Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion assets/js/spark-platform.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
* What is intentionally dropped:
* - jquery.validate.min.js - Use HTML5 validation instead
* - core.variant.init() - Was empty function, Spark has its own variant picker
* - core.preview_theme / core.theme_settings_preview - Admin-only features
* - core.theme_settings_preview - Theme-editor iframe behavior remains platform-owned
*
* Preview-session visibility is provided separately by spark-preview.js.
*/

(function() {
Expand Down
80 changes: 80 additions & 0 deletions assets/js/spark-preview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/** Accessible preview-session indicator. Ordinary storefront visits are untouched. */
(function(root) {
'use strict';

function readPreviewTheme(cookie) {
var match = String(cookie || '').match(/(?:^|;\s*)preview_theme=([^;]+)/);
if (!match) return null;
try {
var value = decodeURIComponent(match[1]);
return /^[A-Za-z0-9_-]+$/.test(value) ? value : null;
} catch (error) {
return null;
}
}

function buildExitPreviewUrl(location) {
var pathname = String(location && location.pathname || '/');
var search = String(location && location.search || '').replace(/^\?/, '');
var hash = String(location && location.hash || '');
var params = new root.URLSearchParams(search);

params.delete('preview_theme');
params.set('deactivate-theme', 'true');

var query = params.toString();
return pathname + (query ? '?' + query : '') + hash;
}

function createPreviewIndicator(doc, themeId, location) {
var existing = doc.getElementById('spark-preview-indicator');
if (existing) return existing;

var indicator = doc.createElement('aside');
indicator.id = 'spark-preview-indicator';
indicator.className = 'spark-preview-indicator';
indicator.setAttribute('role', 'status');
indicator.setAttribute('aria-live', 'polite');
indicator.setAttribute('aria-label', 'Theme preview session');

var message = doc.createElement('span');
message.className = 'spark-preview-indicator__message';
message.textContent = 'Previewing theme ' + themeId;

var exitLink = doc.createElement('a');
exitLink.className = 'spark-preview-indicator__exit';
exitLink.href = buildExitPreviewUrl(location);
exitLink.textContent = 'Exit preview';

indicator.appendChild(message);
indicator.appendChild(exitLink);
doc.body.appendChild(indicator);
return indicator;
}

function initPreviewIndicator(doc, location) {
if (!doc || !doc.body) return null;
var themeId = readPreviewTheme(doc.cookie);
return themeId ? createPreviewIndicator(doc, themeId, location) : null;
}

var api = {
readPreviewTheme: readPreviewTheme,
buildExitPreviewUrl: buildExitPreviewUrl,
createPreviewIndicator: createPreviewIndicator,
initPreviewIndicator: initPreviewIndicator
};

if (typeof module !== 'undefined' && module.exports) module.exports = api;
root.SparkPreview = api;

if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
initPreviewIndicator(document, root.location);
}, { once: true });
} else {
initPreviewIndicator(document, root.location);
}
}
})(typeof window !== 'undefined' ? window : globalThis);
2 changes: 1 addition & 1 deletion assets/main.css

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions css/input.css
Original file line number Diff line number Diff line change
Expand Up @@ -1282,3 +1282,59 @@
transition: all 200ms ease-out;
z-index: 80;
}

/* Preview sessions are explicit without changing ordinary storefront visits. */
.spark-preview-indicator {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 100;
display: flex;
align-items: center;
gap: 0.75rem;
max-width: calc(100vw - 2rem);
padding: 0.75rem 1rem;
color: #fff;
background: #0f172a;
border: 1px solid rgb(255 255 255 / 20%);
border-radius: 0.75rem;
box-shadow: 0 12px 32px rgb(15 23 42 / 28%);
font-size: 0.875rem;
line-height: 1.25rem;
}

.spark-preview-indicator__message {
font-weight: 600;
}

.spark-preview-indicator__exit {
padding: 0.25rem 0.5rem;
color: #0f172a;
background: #fff;
border-radius: 0.375rem;
font-weight: 700;
text-decoration: none;
}

.spark-preview-indicator__exit:focus-visible {
outline: 3px solid #fbbf24;
outline-offset: 3px;
}

@media (hover: hover) {
.spark-preview-indicator__exit:hover {
color: #0f172a;
background: #e2e8f0;
opacity: 1;
}
}

@media (width < 30rem) {
.spark-preview-indicator {
right: 0.75rem;
bottom: 0.75rem;
left: 0.75rem;
justify-content: space-between;
max-width: none;
}
}
22 changes: 17 additions & 5 deletions layouts/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,11 @@

{% block extrastyles %}{% endblock extrastyles %}

{% if settings.custom_css %}
<style>{{ settings.custom_css|safe }}</style>
{% endif %}
{% block custom_css %}
{% if settings.custom_css %}
<style>{{ settings.custom_css|safe }}</style>
{% endif %}
{% endblock custom_css %}
{% block extrahead %}{% endblock extrahead %}

{# Head app hook is for platform-critical head snippets only. Prefer footer hooks. #}
Expand Down Expand Up @@ -217,12 +219,21 @@

{% block component_scripts %}{% endblock component_scripts %}

{# Platform compatibility — replaces jQuery-dependent core_js with vanilla JS #}
<script type="text/javascript" src="{{ 'js/spark-platform.js'|asset_url }}"></script>
{% block platform_compatibility %}
{# Platform compatibility — replaces jQuery-dependent core_js with vanilla JS #}
<script type="text/javascript" src="{{ 'js/spark-platform.js'|asset_url }}"></script>
{% endblock platform_compatibility %}

{% block preview_indicator %}
{% if request.COOKIES.preview_theme %}
<script type="text/javascript" src="{{ 'js/spark-preview.js'|asset_url }}"></script>
{% endif %}
{% endblock preview_indicator %}

{# Additional JS #}
{% block extrascripts %}{% endblock %}

{% block delight_scripts %}
{# Delight: skeleton dismiss + keyboard nav #}
<script>
(function(){
Expand Down Expand Up @@ -264,6 +275,7 @@

})();
</script>
{% endblock delight_scripts %}

{# Non-critical apps and tracking run after content and theme JS. #}
{% block footer_app_hooks %}
Expand Down
69 changes: 69 additions & 0 deletions scripts/check-templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@
r"{%\s*endverbatim(?:\s+.*?)?\s*%}",
re.DOTALL,
)
BLOCK_TAG_RE = re.compile(
r"{%\s*(?P<tag>endblock|block)\b(?:\s+(?P<name>[\w-]+))?[^%]*%}"
)
REQUIRED_BASE_BLOCKS = {
"announcement_bar",
"nav_header",
"content_wrapper",
"footer",
"side_cart",
"custom_css",
"platform_compatibility",
"preview_indicator",
"delight_scripts",
"footer_app_hooks",
"tracking",
}


def mask_match(match):
Expand All @@ -41,6 +57,47 @@ def mask_ignored_regions(text):
return VERBATIM_BLOCK_RE.sub(mask_match, mask_comments(text))


def inspect_block_structure(text):
block_names = set()
stack = []
errors = []

for match in BLOCK_TAG_RE.finditer(text):
tag_name = match.group("tag")
block_name = match.group("name")
line_number = text.count("\n", 0, match.start()) + 1

if tag_name == "block":
if not block_name:
errors.append(f"block on line {line_number} has no name")
continue
if block_name in block_names:
errors.append(
f"duplicate block {block_name!r} on line {line_number}"
)
block_names.add(block_name)
stack.append((block_name, line_number))
continue

if not stack:
errors.append(f"endblock on line {line_number} has no opening block")
continue

expected_name, opening_line = stack.pop()
if block_name and block_name != expected_name:
errors.append(
f"block {expected_name!r} opened on line {opening_line} "
f"closes as {block_name!r} on line {line_number}"
)

for block_name, opening_line in stack:
errors.append(
f"block {block_name!r} opened on line {opening_line} is unclosed"
)

return block_names, errors


def load_allowlist(path):
names = set()
with path.open(encoding="utf-8") as handle:
Expand All @@ -63,6 +120,14 @@ def missing_default_inventory(root, paths):
base_template = root / "layouts" / "base.html"
if not base_template.is_file():
missing.append("required template layouts/base.html")
else:
try:
base_text = mask_ignored_regions(base_template.read_text(encoding="utf-8"))
base_blocks, _ = inspect_block_structure(base_text)
for block_name in sorted(REQUIRED_BASE_BLOCKS - base_blocks):
missing.append(f"overridable base block {block_name!r}")
except OSError as error:
missing.append(f"readable layouts/base.html ({error})")

for directory in TEMPLATE_DIRECTORIES:
directory_root = root / directory
Expand Down Expand Up @@ -91,6 +156,10 @@ def inspect_templates(root, allowlist):

masked = mask_ignored_regions(text)
relative_path = path.relative_to(root)
_, block_errors = inspect_block_structure(masked)
for error in block_errors:
violations.append(f"[block-structure] {relative_path}: {error}")

for match in TAG_RE.finditer(masked):
tag_name = match.group(1)
body = match.group("body")
Expand Down
56 changes: 56 additions & 0 deletions tests/js/spark-preview.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use strict';

const assert = require('node:assert/strict');
const preview = require('../../assets/js/spark-preview.js');

function fakeDocument(cookie) {
const appended = [];
return {
cookie,
appended,
body: { appendChild(node) { appended.push(node); } },
getElementById() { return null; },
createElement(tagName) {
return {
tagName,
children: [],
attributes: {},
appendChild(node) { this.children.push(node); },
setAttribute(name, value) { this.attributes[name] = value; }
};
}
};
}

assert.equal(preview.readPreviewTheme('session=x; preview_theme=42'), '42');
assert.equal(preview.readPreviewTheme('preview_theme=%3Cscript%3E'), null);
assert.equal(
preview.buildExitPreviewUrl({
pathname: '/products/widget/',
search: '?preview_theme=theme_42&variant=blue',
hash: '#reviews'
}),
'/products/widget/?variant=blue&deactivate-theme=true#reviews'
);

const ordinary = fakeDocument('session=abc');
assert.equal(preview.initPreviewIndicator(ordinary), null);
assert.equal(ordinary.appended.length, 0);

const active = fakeDocument('preview_theme=theme_42');
const indicator = preview.initPreviewIndicator(active, {
pathname: '/products/widget/',
search: '?preview_theme=theme_42&variant=blue',
hash: '#reviews'
});
assert.equal(active.appended.length, 1);
assert.equal(indicator.attributes.role, 'status');
assert.equal(indicator.attributes['aria-live'], 'polite');
assert.equal(indicator.children[0].textContent, 'Previewing theme theme_42');
assert.equal(
indicator.children[1].href,
'/products/widget/?variant=blue&deactivate-theme=true#reviews'
);
assert.equal(indicator.children[1].textContent, 'Exit preview');

console.log('spark-preview tests passed');
Loading
Loading