@zalify/storefront-kit 0.1.13 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/editor/bootstrap.d.ts +15 -0
- package/dist/editor/bootstrap.js +42 -0
- package/dist/editor/draft.d.ts +17 -0
- package/dist/editor/draft.js +32 -0
- package/dist/editor/frame.d.ts +33 -5
- package/dist/editor/frame.js +105 -22
- package/dist/react/engine/CssVariables.js +5 -4
- package/dist/react/engine/render.js +3 -1
- package/dist/react/engine/store.d.ts +6 -0
- package/dist/react/engine/store.js +34 -3
- package/dist/react/useEditorTemplate.d.ts +6 -0
- package/dist/react/useEditorTemplate.js +63 -2
- package/dist/schemas/bridge.d.ts +1 -0
- package/package.json +1 -1
- package/src/editor/bootstrap.ts +61 -0
- package/src/editor/draft.ts +54 -0
- package/src/editor/frame.ts +112 -21
- package/src/react/engine/CssVariables.tsx +34 -10
- package/src/react/engine/render.tsx +14 -1
- package/src/react/engine/store.ts +43 -4
- package/src/react/useEditorTemplate.ts +72 -2
- package/src/schemas/bridge.ts +1 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { EditorBootstrap, PreviewContext } from '../schemas/bridge.ts';
|
|
2
|
+
import type { ThemeEditorManifest } from '../schemas/manifest.ts';
|
|
3
|
+
import type { DraftSchema } from './draft.ts';
|
|
4
|
+
export interface EditorDocuments {
|
|
5
|
+
templates: string;
|
|
6
|
+
groups: string;
|
|
7
|
+
settings: string;
|
|
8
|
+
}
|
|
9
|
+
/** Only list routes the app can actually render; never fabricate handles. */
|
|
10
|
+
export declare function createEditorBootstrap(options: {
|
|
11
|
+
schema: DraftSchema;
|
|
12
|
+
manifest: ThemeEditorManifest;
|
|
13
|
+
paths: EditorDocuments;
|
|
14
|
+
previews: Record<string, PreviewContext>;
|
|
15
|
+
}): EditorBootstrap;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Only list routes the app can actually render; never fabricate handles. */
|
|
2
|
+
export function createEditorBootstrap(options) {
|
|
3
|
+
const { schema, manifest, paths, previews } = options;
|
|
4
|
+
for (const path of [paths.templates, paths.groups, paths.settings]) {
|
|
5
|
+
if (!path ||
|
|
6
|
+
path.startsWith('/') ||
|
|
7
|
+
path.includes('\\') ||
|
|
8
|
+
path.split('/').some((p) => p === '..' || p === '.')) {
|
|
9
|
+
throw new Error('Editor write paths must be repository-relative');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const templates = {
|
|
13
|
+
...schema.templates,
|
|
14
|
+
...Object.fromEntries(Object.entries(schema.customerTemplates ?? {}).map(([name, data]) => [
|
|
15
|
+
`customers/${name}`,
|
|
16
|
+
data,
|
|
17
|
+
])),
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
revision: manifest.hash,
|
|
21
|
+
manifest,
|
|
22
|
+
templates: Object.entries(templates)
|
|
23
|
+
.filter(([name]) => previews[name])
|
|
24
|
+
.map(([name, data]) => ({
|
|
25
|
+
name,
|
|
26
|
+
data: structuredClone(data),
|
|
27
|
+
writePath: `${paths.templates}/${name}.json`,
|
|
28
|
+
preview: previews[name],
|
|
29
|
+
})),
|
|
30
|
+
groups: Object.entries(schema.sectionGroups).map(([name, data]) => ({
|
|
31
|
+
name,
|
|
32
|
+
data: structuredClone(data),
|
|
33
|
+
writePath: `${paths.groups}/${name}.json`,
|
|
34
|
+
})),
|
|
35
|
+
settings: {
|
|
36
|
+
writePath: paths.settings,
|
|
37
|
+
schema: manifest.settingsSchema,
|
|
38
|
+
resolvedData: structuredClone(schema.settingsData),
|
|
39
|
+
},
|
|
40
|
+
previewContexts: {},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SectionGroupData, SettingsData, TemplateData } from '../schemas/data.ts';
|
|
2
|
+
export interface DraftSchema {
|
|
3
|
+
templates: Record<string, TemplateData>;
|
|
4
|
+
customerTemplates?: Record<string, TemplateData>;
|
|
5
|
+
sectionGroups: Record<string, SectionGroupData>;
|
|
6
|
+
settingsData: SettingsData;
|
|
7
|
+
}
|
|
8
|
+
export interface PreviewApply {
|
|
9
|
+
templateName: string;
|
|
10
|
+
template: TemplateData;
|
|
11
|
+
groups?: Record<string, SectionGroupData>;
|
|
12
|
+
settingsData?: SettingsData;
|
|
13
|
+
}
|
|
14
|
+
/** Immutable, per-preview data. Never writes into an installed source schema. */
|
|
15
|
+
export declare function createEditorDraft(source: DraftSchema): {
|
|
16
|
+
apply(payload: PreviewApply): DraftSchema;
|
|
17
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Immutable, per-preview data. Never writes into an installed source schema. */
|
|
2
|
+
export function createEditorDraft(source) {
|
|
3
|
+
let current = structuredClone(source);
|
|
4
|
+
return {
|
|
5
|
+
apply(payload) {
|
|
6
|
+
const template = structuredClone(payload.template);
|
|
7
|
+
const customer = payload.templateName.startsWith('customers/');
|
|
8
|
+
current = {
|
|
9
|
+
...current,
|
|
10
|
+
...(customer
|
|
11
|
+
? {
|
|
12
|
+
customerTemplates: {
|
|
13
|
+
...current.customerTemplates,
|
|
14
|
+
[payload.templateName.slice(10)]: template,
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
: {
|
|
18
|
+
templates: {
|
|
19
|
+
...current.templates,
|
|
20
|
+
[payload.templateName]: template,
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
sectionGroups: {
|
|
24
|
+
...current.sectionGroups,
|
|
25
|
+
...structuredClone(payload.groups ?? {}),
|
|
26
|
+
},
|
|
27
|
+
settingsData: structuredClone(payload.settingsData ?? current.settingsData),
|
|
28
|
+
};
|
|
29
|
+
return current;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
package/dist/editor/frame.d.ts
CHANGED
|
@@ -6,11 +6,20 @@
|
|
|
6
6
|
* sync — and hands app-specific concerns (template hot-apply, device
|
|
7
7
|
* emulation) to callbacks.
|
|
8
8
|
*
|
|
9
|
-
* Editor mode
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* Editor mode keeps the page interactive — buttons, drawers, variant
|
|
10
|
+
* pickers, forms all work, so any state can be previewed — while the
|
|
11
|
+
* bridge takes over two things a click must never do on its own:
|
|
12
|
+
*
|
|
13
|
+
* - Leave the storefront. Same-origin links and GET forms are turned
|
|
14
|
+
* into `location.replace` navigations that keep the editor-mode
|
|
15
|
+
* query param (so the next document mounts the bridge again) and
|
|
16
|
+
* add no browser-history entries; off-site links, new-tab links,
|
|
17
|
+
* modifier-clicks and off-site paths (checkout, account) are blocked.
|
|
18
|
+
* - Escape selection: every click still selects the enclosing block.
|
|
19
|
+
*
|
|
20
|
+
* Double/middle clicks and context menus stay suppressed. Each mount
|
|
21
|
+
* reports its URL as a `navigation`, which is how the editor follows
|
|
22
|
+
* in-preview browsing.
|
|
14
23
|
*
|
|
15
24
|
* Security: the first `bridge:init` pins the editor origin; every
|
|
16
25
|
* later message must match it, and nothing but `bridge:ready` is ever
|
|
@@ -47,6 +56,25 @@ export interface FrameBridgeOptions {
|
|
|
47
56
|
}
|
|
48
57
|
/** True when this document should mount the editor bridge. */
|
|
49
58
|
export declare function isEditorMode(win?: Window): boolean;
|
|
59
|
+
export type NavigationDecision = {
|
|
60
|
+
kind: 'allow';
|
|
61
|
+
url: string;
|
|
62
|
+
} | {
|
|
63
|
+
kind: 'block';
|
|
64
|
+
reason: 'off-origin' | 'new-tab' | 'modifier' | 'off-site' | 'download';
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Where a click on `anchor` may take the preview. Same-origin page loads are
|
|
68
|
+
* allowed (with the editor-mode param re-attached); anything that would leave
|
|
69
|
+
* the sandbox or open another window is blocked.
|
|
70
|
+
*/
|
|
71
|
+
export declare function decideNavigation(href: string, base: string, flags: {
|
|
72
|
+
target?: string | null;
|
|
73
|
+
download?: boolean;
|
|
74
|
+
modifier?: boolean;
|
|
75
|
+
}): NavigationDecision;
|
|
76
|
+
/** The storefront path the editor should remember: no editor-mode param. */
|
|
77
|
+
export declare function previewPathOf(href: string): string;
|
|
50
78
|
export interface FrameBridgeController {
|
|
51
79
|
unmount: () => void;
|
|
52
80
|
/** Report that a persisted write round-tripped (HMR applied `hash`). */
|
package/dist/editor/frame.js
CHANGED
|
@@ -6,11 +6,20 @@
|
|
|
6
6
|
* sync — and hands app-specific concerns (template hot-apply, device
|
|
7
7
|
* emulation) to callbacks.
|
|
8
8
|
*
|
|
9
|
-
* Editor mode
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* Editor mode keeps the page interactive — buttons, drawers, variant
|
|
10
|
+
* pickers, forms all work, so any state can be previewed — while the
|
|
11
|
+
* bridge takes over two things a click must never do on its own:
|
|
12
|
+
*
|
|
13
|
+
* - Leave the storefront. Same-origin links and GET forms are turned
|
|
14
|
+
* into `location.replace` navigations that keep the editor-mode
|
|
15
|
+
* query param (so the next document mounts the bridge again) and
|
|
16
|
+
* add no browser-history entries; off-site links, new-tab links,
|
|
17
|
+
* modifier-clicks and off-site paths (checkout, account) are blocked.
|
|
18
|
+
* - Escape selection: every click still selects the enclosing block.
|
|
19
|
+
*
|
|
20
|
+
* Double/middle clicks and context menus stay suppressed. Each mount
|
|
21
|
+
* reports its URL as a `navigation`, which is how the editor follows
|
|
22
|
+
* in-preview browsing.
|
|
14
23
|
*
|
|
15
24
|
* Security: the first `bridge:init` pins the editor origin; every
|
|
16
25
|
* later message must match it, and nothing but `bridge:ready` is ever
|
|
@@ -49,10 +58,51 @@ function visibleRectOf(element) {
|
|
|
49
58
|
return { x: left, y: top, width: right - left, height: bottom - top };
|
|
50
59
|
}
|
|
51
60
|
function pathNodeOf(target) {
|
|
52
|
-
if (!(target
|
|
61
|
+
if (!isElement(target))
|
|
53
62
|
return null;
|
|
54
63
|
return target.closest(`[${DATA_PATH_ATTR}]`);
|
|
55
64
|
}
|
|
65
|
+
function isElement(target) {
|
|
66
|
+
return (typeof Element !== 'undefined' && target instanceof Element);
|
|
67
|
+
}
|
|
68
|
+
/** Storefront paths that hand off to Shopify-hosted pages; never previewable. */
|
|
69
|
+
const OFF_SITE_PATHS = [/^\/checkout(?:s)?(?:\/|$)/, /^\/cart\/c\//, /^\/account(?:\/|$)/, /^\/admin(?:\/|$)/];
|
|
70
|
+
/**
|
|
71
|
+
* Where a click on `anchor` may take the preview. Same-origin page loads are
|
|
72
|
+
* allowed (with the editor-mode param re-attached); anything that would leave
|
|
73
|
+
* the sandbox or open another window is blocked.
|
|
74
|
+
*/
|
|
75
|
+
export function decideNavigation(href, base, flags) {
|
|
76
|
+
if (flags.modifier)
|
|
77
|
+
return { kind: 'block', reason: 'modifier' };
|
|
78
|
+
if (flags.download)
|
|
79
|
+
return { kind: 'block', reason: 'download' };
|
|
80
|
+
if (flags.target && flags.target !== '_self')
|
|
81
|
+
return { kind: 'block', reason: 'new-tab' };
|
|
82
|
+
let url;
|
|
83
|
+
try {
|
|
84
|
+
url = new URL(href, base);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return { kind: 'block', reason: 'off-origin' };
|
|
88
|
+
}
|
|
89
|
+
if (url.origin !== new URL(base).origin)
|
|
90
|
+
return { kind: 'block', reason: 'off-origin' };
|
|
91
|
+
if (OFF_SITE_PATHS.some((pattern) => pattern.test(url.pathname))) {
|
|
92
|
+
return { kind: 'block', reason: 'off-site' };
|
|
93
|
+
}
|
|
94
|
+
url.searchParams.set(EDITOR_MODE_PARAM, '1');
|
|
95
|
+
return { kind: 'allow', url: url.toString() };
|
|
96
|
+
}
|
|
97
|
+
/** The storefront path the editor should remember: no editor-mode param. */
|
|
98
|
+
export function previewPathOf(href) {
|
|
99
|
+
const url = new URL(href);
|
|
100
|
+
url.searchParams.delete(EDITOR_MODE_PARAM);
|
|
101
|
+
return url.pathname + url.search;
|
|
102
|
+
}
|
|
103
|
+
function anchorOf(target) {
|
|
104
|
+
return isElement(target) ? target.closest('a[href]') : null;
|
|
105
|
+
}
|
|
56
106
|
/**
|
|
57
107
|
* Mount the frame bridge. Returns a controller with an `unmount`
|
|
58
108
|
* function. Call only when `isEditorMode()` is true.
|
|
@@ -82,6 +132,15 @@ export function mountFrameBridge(options) {
|
|
|
82
132
|
return;
|
|
83
133
|
win.parent.postMessage({ z: BRIDGE_NAMESPACE, v: CONTRACT_VERSION, ...message }, target);
|
|
84
134
|
};
|
|
135
|
+
// Every mount tells the editor where the preview is now: after an
|
|
136
|
+
// in-preview link or search the host would otherwise keep showing the
|
|
137
|
+
// page it originally asked for. Sent once the editor origin is pinned.
|
|
138
|
+
const reportNavigation = () => {
|
|
139
|
+
post({
|
|
140
|
+
type: 'navigation',
|
|
141
|
+
payload: { templateName: options.templateName, url: previewPathOf(win.location.href) },
|
|
142
|
+
});
|
|
143
|
+
};
|
|
85
144
|
const setUnique = (attr, path) => {
|
|
86
145
|
for (const node of doc.querySelectorAll(`[${attr}]`)) {
|
|
87
146
|
node.removeAttribute(attr);
|
|
@@ -171,6 +230,7 @@ export function mountFrameBridge(options) {
|
|
|
171
230
|
queueMicrotask(() => {
|
|
172
231
|
lastHeight = 0;
|
|
173
232
|
measure();
|
|
233
|
+
reportNavigation();
|
|
174
234
|
});
|
|
175
235
|
break;
|
|
176
236
|
}
|
|
@@ -244,20 +304,45 @@ export function mountFrameBridge(options) {
|
|
|
244
304
|
};
|
|
245
305
|
const handleClick = (event) => {
|
|
246
306
|
const node = pathNodeOf(event.target);
|
|
247
|
-
|
|
307
|
+
if (node) {
|
|
308
|
+
const path = node.getAttribute(DATA_PATH_ATTR);
|
|
309
|
+
setSelection(path);
|
|
310
|
+
post({ type: 'block:clicked', payload: { path, rect: rectOf(node) } });
|
|
311
|
+
}
|
|
312
|
+
// The page keeps its interactivity; only navigation is policed.
|
|
313
|
+
const anchor = anchorOf(event.target);
|
|
314
|
+
if (!anchor)
|
|
315
|
+
return;
|
|
316
|
+
const decision = decideNavigation(anchor.getAttribute('href') ?? '', win.location.href, {
|
|
317
|
+
target: anchor.getAttribute('target'),
|
|
318
|
+
download: anchor.hasAttribute('download'),
|
|
319
|
+
modifier: event.metaKey || event.ctrlKey || event.shiftKey || event.altKey,
|
|
320
|
+
});
|
|
248
321
|
event.preventDefault();
|
|
249
|
-
|
|
250
|
-
|
|
322
|
+
if (decision.kind === 'allow')
|
|
323
|
+
win.location.replace(decision.url);
|
|
324
|
+
};
|
|
325
|
+
const handleSubmit = (event) => {
|
|
326
|
+
const form = event.target;
|
|
327
|
+
if (!(form instanceof win.HTMLFormElement))
|
|
328
|
+
return;
|
|
329
|
+
if ((form.getAttribute('method') ?? 'get').toLowerCase() !== 'get')
|
|
251
330
|
return;
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
331
|
+
// A GET form is a navigation: route it like a link so the editor-mode
|
|
332
|
+
// param survives and history stays clean. POST forms run as they are.
|
|
333
|
+
const action = new URL(form.getAttribute('action') || win.location.pathname, win.location.href);
|
|
334
|
+
for (const [key, value] of new FormData(form)) {
|
|
335
|
+
if (typeof value === 'string')
|
|
336
|
+
action.searchParams.set(key, value);
|
|
337
|
+
}
|
|
338
|
+
const decision = decideNavigation(action.toString(), win.location.href, {});
|
|
339
|
+
event.preventDefault();
|
|
340
|
+
if (decision.kind === 'allow')
|
|
341
|
+
win.location.replace(decision.url);
|
|
255
342
|
};
|
|
256
343
|
const handleWheel = (event) => {
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
if (!event.ctrlKey)
|
|
260
|
-
return;
|
|
344
|
+
// Cross-origin iframe wheel events never reach the host canvas. Forward
|
|
345
|
+
// wheel gestures so the editor can pan its canvas.
|
|
261
346
|
event.preventDefault();
|
|
262
347
|
post({
|
|
263
348
|
type: 'viewport:wheel',
|
|
@@ -265,6 +350,7 @@ export function mountFrameBridge(options) {
|
|
|
265
350
|
deltaX: event.deltaX,
|
|
266
351
|
deltaY: event.deltaY,
|
|
267
352
|
ctrlKey: event.ctrlKey,
|
|
353
|
+
shiftKey: event.shiftKey,
|
|
268
354
|
clientX: event.clientX,
|
|
269
355
|
clientY: event.clientY,
|
|
270
356
|
},
|
|
@@ -275,12 +361,7 @@ export function mountFrameBridge(options) {
|
|
|
275
361
|
event.preventDefault();
|
|
276
362
|
event.stopPropagation();
|
|
277
363
|
};
|
|
278
|
-
const SUPPRESSED_EVENTS = [
|
|
279
|
-
'dblclick',
|
|
280
|
-
'auxclick',
|
|
281
|
-
'submit',
|
|
282
|
-
'contextmenu',
|
|
283
|
-
];
|
|
364
|
+
const SUPPRESSED_EVENTS = ['dblclick', 'auxclick', 'contextmenu'];
|
|
284
365
|
const handleMouseMove = (() => {
|
|
285
366
|
let lastPath = null;
|
|
286
367
|
return (event) => {
|
|
@@ -316,6 +397,7 @@ export function mountFrameBridge(options) {
|
|
|
316
397
|
win.addEventListener('resize', syncSelectionHighlight);
|
|
317
398
|
win.addEventListener('scroll', syncSelectionHighlight, true);
|
|
318
399
|
doc.addEventListener('click', handleClick, true);
|
|
400
|
+
doc.addEventListener('submit', handleSubmit, true);
|
|
319
401
|
doc.addEventListener('mousemove', handleMouseMove, true);
|
|
320
402
|
doc.addEventListener('wheel', handleWheel, { capture: true, passive: false });
|
|
321
403
|
for (const type of SUPPRESSED_EVENTS) {
|
|
@@ -337,6 +419,7 @@ export function mountFrameBridge(options) {
|
|
|
337
419
|
win.removeEventListener('resize', syncSelectionHighlight);
|
|
338
420
|
win.removeEventListener('scroll', syncSelectionHighlight, true);
|
|
339
421
|
doc.removeEventListener('click', handleClick, true);
|
|
422
|
+
doc.removeEventListener('submit', handleSubmit, true);
|
|
340
423
|
doc.removeEventListener('mousemove', handleMouseMove, true);
|
|
341
424
|
doc.removeEventListener('wheel', handleWheel, true);
|
|
342
425
|
for (const type of SUPPRESSED_EVENTS) {
|
|
@@ -8,9 +8,9 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
8
8
|
* Static token scales (spacing, type, radii) live in the app's
|
|
9
9
|
* critical.css (synced from the theme).
|
|
10
10
|
*/
|
|
11
|
-
import { useMemo } from 'react';
|
|
11
|
+
import { useMemo, useSyncExternalStore } from 'react';
|
|
12
12
|
import { colorSchemes, themeSettings } from './settings';
|
|
13
|
-
import { getThemeStoreVersion } from './store';
|
|
13
|
+
import { getThemeStoreVersion, subscribePreview, getPreviewVersion, getServerPreviewVersion, hasThemePreview, } from './store';
|
|
14
14
|
import { googleFontsHrefsFromSettings, parseFontHandle, } from "../../commerce/google-fonts.js";
|
|
15
15
|
function buildCss() {
|
|
16
16
|
const s = themeSettings;
|
|
@@ -107,14 +107,15 @@ const FONT_CSS_LOADER = [
|
|
|
107
107
|
'})(l[i])}',
|
|
108
108
|
].join('');
|
|
109
109
|
export function CssVariables({ nonce, fonts, } = {}) {
|
|
110
|
+
useSyncExternalStore(subscribePreview, getPreviewVersion, getServerPreviewVersion);
|
|
110
111
|
const themeVersion = getThemeStoreVersion();
|
|
111
112
|
const { css, links } = useMemo(() => ({ css: buildCss(), links: googleFontsHrefs() }), [themeVersion]);
|
|
112
113
|
// Server-resolved fonts (the next/font pattern): @font-face rules are
|
|
113
114
|
// inlined and the latin woff2 files preloaded, so the font downloads
|
|
114
115
|
// with the HTML and usually beats first paint — no async stylesheet,
|
|
115
116
|
// no FOUT, no request to fonts.googleapis.com at all.
|
|
116
|
-
if (fonts) {
|
|
117
|
+
if (fonts && !hasThemePreview()) {
|
|
117
118
|
return (_jsxs(_Fragment, { children: [_jsx("link", { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" }), fonts.preloadUrls.map((href) => (_jsx("link", { rel: "preload", as: "font", type: "font/woff2", href: href, crossOrigin: "anonymous" }, href))), _jsx("style", { dangerouslySetInnerHTML: { __html: fonts.css } }), _jsx("style", { dangerouslySetInnerHTML: { __html: css } })] }));
|
|
118
119
|
}
|
|
119
|
-
return (_jsxs(_Fragment, { children: [_jsx("link", { rel: "preconnect", href: "https://fonts.googleapis.com" }), _jsx("link", { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" }), links.map((href) => (_jsx("link", { rel:
|
|
120
|
+
return (_jsxs(_Fragment, { children: [_jsx("link", { rel: "preconnect", href: "https://fonts.googleapis.com" }), _jsx("link", { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" }), links.map((href) => (_jsx("link", { rel: hasThemePreview() ? 'stylesheet' : 'preload', as: "style", href: href, "data-zfy-font-css": "" }, href))), _jsx("script", { nonce: nonce, dangerouslySetInnerHTML: { __html: FONT_CSS_LOADER } }), _jsx("noscript", { children: links.map((href) => (_jsx("link", { rel: "stylesheet", href: href }, href))) }), _jsx("style", { dangerouslySetInnerHTML: { __html: css } })] }));
|
|
120
121
|
}
|
|
@@ -16,7 +16,8 @@ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
|
16
16
|
* emitted unconditionally so server and client markup stay identical.
|
|
17
17
|
*/
|
|
18
18
|
import { formatThemePath, DATA_PATH_ATTR } from "../../schemas/index.js";
|
|
19
|
-
import { getSectionGroup, getTemplate, getThemeStore } from './store';
|
|
19
|
+
import { getSectionGroup, getTemplate, getThemeStore, subscribePreview, getPreviewVersion, getServerPreviewVersion, } from './store';
|
|
20
|
+
import { useSyncExternalStore } from 'react';
|
|
20
21
|
import { SectionProvider, TemplateProvider } from './context';
|
|
21
22
|
const warned = new Set();
|
|
22
23
|
function warnMissing(kind, type) {
|
|
@@ -82,6 +83,7 @@ export function ThemeTemplate({ name, resources = {}, sectionData = {}, }) {
|
|
|
82
83
|
}
|
|
83
84
|
/** Mirror of `{% sections 'header-group' %}` in layout/theme.liquid. */
|
|
84
85
|
export function SectionGroup({ name, sectionData = {}, }) {
|
|
86
|
+
useSyncExternalStore(subscribePreview, getPreviewVersion, getServerPreviewVersion);
|
|
85
87
|
const group = getSectionGroup(name);
|
|
86
88
|
return (_jsx(TemplateProvider, { value: { name, kind: 'group', resources: {}, sectionData }, children: _jsx(RenderSections, { template: group, scope: { kind: 'group', name } }) }));
|
|
87
89
|
}
|
|
@@ -49,6 +49,12 @@ export interface InstallThemeOptions {
|
|
|
49
49
|
}
|
|
50
50
|
interface ThemeStore extends InstallThemeOptions {
|
|
51
51
|
}
|
|
52
|
+
export declare function subscribePreview(listener: () => void): () => void;
|
|
53
|
+
export declare function getPreviewVersion(): number;
|
|
54
|
+
export declare function getServerPreviewVersion(): number;
|
|
55
|
+
export declare function hasThemePreview(): boolean;
|
|
56
|
+
/** Browser-only overlay; the installed schema and SSR state remain untouched. */
|
|
57
|
+
export declare function setThemePreview(owner: ThemeStore, schema: ThemeSchema | null): void;
|
|
52
58
|
export declare function installTheme(options: InstallThemeOptions): void;
|
|
53
59
|
/** Monotonic install counter — lets derived caches detect re-installs. */
|
|
54
60
|
export declare function getThemeStoreVersion(): number;
|
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
let store = null;
|
|
2
2
|
let version = 0;
|
|
3
|
+
let preview = null;
|
|
4
|
+
let previewVersion = 0;
|
|
5
|
+
const previewListeners = new Set();
|
|
6
|
+
export function subscribePreview(listener) {
|
|
7
|
+
previewListeners.add(listener);
|
|
8
|
+
return () => {
|
|
9
|
+
previewListeners.delete(listener);
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function getPreviewVersion() {
|
|
13
|
+
return previewVersion;
|
|
14
|
+
}
|
|
15
|
+
export function getServerPreviewVersion() {
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
export function hasThemePreview() {
|
|
19
|
+
return (typeof window !== 'undefined' && preview !== null && preview.owner === store);
|
|
20
|
+
}
|
|
21
|
+
/** Browser-only overlay; the installed schema and SSR state remain untouched. */
|
|
22
|
+
export function setThemePreview(owner, schema) {
|
|
23
|
+
if (typeof window === 'undefined' || owner !== store)
|
|
24
|
+
return;
|
|
25
|
+
preview = schema
|
|
26
|
+
? { owner, value: { ...owner, schema, settingsOverride: undefined } }
|
|
27
|
+
: null;
|
|
28
|
+
previewVersion++;
|
|
29
|
+
for (const listener of previewListeners)
|
|
30
|
+
listener();
|
|
31
|
+
}
|
|
3
32
|
export function installTheme(options) {
|
|
4
33
|
store = options;
|
|
5
34
|
// Bump so derived caches (resolved settings) recompute — multi-tenant
|
|
@@ -8,14 +37,16 @@ export function installTheme(options) {
|
|
|
8
37
|
}
|
|
9
38
|
/** Monotonic install counter — lets derived caches detect re-installs. */
|
|
10
39
|
export function getThemeStoreVersion() {
|
|
11
|
-
return version;
|
|
40
|
+
return version + (typeof window === 'undefined' ? 0 : previewVersion);
|
|
12
41
|
}
|
|
13
42
|
export function getThemeStore() {
|
|
14
43
|
if (!store) {
|
|
15
|
-
throw new Error(
|
|
44
|
+
throw new Error("[storefront-kit] installTheme() has not been called. Import your app's " +
|
|
16
45
|
'theme-setup module (which calls installTheme) before rendering.');
|
|
17
46
|
}
|
|
18
|
-
return store
|
|
47
|
+
return typeof window !== 'undefined' && preview?.owner === store
|
|
48
|
+
? preview.value
|
|
49
|
+
: store;
|
|
19
50
|
}
|
|
20
51
|
/** Look up a page template ("index", "product", "customers/login"…). */
|
|
21
52
|
export function getTemplate(name) {
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type { TemplateData } from "../schemas/data";
|
|
2
2
|
import type { ThemeEditorManifest } from "../schemas/manifest";
|
|
3
|
+
import type { PreviewContext } from "../schemas/bridge";
|
|
4
|
+
import type { EditorDocuments } from "../editor/bootstrap";
|
|
3
5
|
type Options = {
|
|
4
6
|
origins: readonly string[];
|
|
5
7
|
loadManifest: () => Promise<ThemeEditorManifest>;
|
|
8
|
+
/** Authoritative, repository-relative merchant data targets. */
|
|
9
|
+
paths: EditorDocuments;
|
|
10
|
+
/** Real route targets keyed by template name. */
|
|
11
|
+
previews: Record<string, PreviewContext>;
|
|
6
12
|
};
|
|
7
13
|
/** No editor code or schema is fetched outside an explicitly allowed preview. */
|
|
8
14
|
export declare function useEditorTemplate(name: string, options: Options): TemplateData | null;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { getThemeStore, setThemePreview } from "./engine/store";
|
|
3
4
|
/** No editor code or schema is fetched outside an explicitly allowed preview. */
|
|
4
5
|
export function useEditorTemplate(name, options) {
|
|
5
6
|
const [draft, setDraft] = useState(null);
|
|
@@ -20,18 +21,77 @@ export function useEditorTemplate(name, options) {
|
|
|
20
21
|
if (!options.origins.includes(parentOrigin))
|
|
21
22
|
return;
|
|
22
23
|
let disposed = false;
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
const owner = getThemeStore();
|
|
25
|
+
void Promise.all([
|
|
26
|
+
import("../editor/frame"),
|
|
27
|
+
options.loadManifest(),
|
|
28
|
+
import("../editor/bootstrap"),
|
|
29
|
+
import("../editor/draft"),
|
|
30
|
+
])
|
|
31
|
+
.then(([bridge, manifest, bootstrapModule, draftModule]) => {
|
|
25
32
|
if (disposed)
|
|
26
33
|
return;
|
|
34
|
+
const settings = owner.schema.settingsData;
|
|
35
|
+
const current = typeof settings.current === "string"
|
|
36
|
+
? (settings.presets?.[settings.current] ?? {})
|
|
37
|
+
: settings.current;
|
|
38
|
+
const schema = {
|
|
39
|
+
...owner.schema,
|
|
40
|
+
settingsData: {
|
|
41
|
+
...settings,
|
|
42
|
+
current: { ...current, ...owner.settingsOverride },
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
const previews = { ...options.previews };
|
|
46
|
+
// The currently rendered route is also a verified context, including locale.
|
|
47
|
+
const url = new URL(window.location.href);
|
|
48
|
+
url.searchParams.delete("zalify-editor");
|
|
49
|
+
const kind = name.split(".")[0];
|
|
50
|
+
const resourceType = ([
|
|
51
|
+
"index",
|
|
52
|
+
"product",
|
|
53
|
+
"collection",
|
|
54
|
+
"page",
|
|
55
|
+
"blog",
|
|
56
|
+
"article",
|
|
57
|
+
"cart",
|
|
58
|
+
"search",
|
|
59
|
+
"list-collections",
|
|
60
|
+
"404",
|
|
61
|
+
].includes(kind)
|
|
62
|
+
? kind
|
|
63
|
+
: "page");
|
|
64
|
+
previews[name] = {
|
|
65
|
+
...previews[name],
|
|
66
|
+
id: name,
|
|
67
|
+
title: previews[name]?.title ?? name,
|
|
68
|
+
resourceType,
|
|
69
|
+
url: url.pathname + url.search,
|
|
70
|
+
};
|
|
71
|
+
const bootstrap = bootstrapModule.createEditorBootstrap({
|
|
72
|
+
schema,
|
|
73
|
+
manifest,
|
|
74
|
+
paths: options.paths,
|
|
75
|
+
previews,
|
|
76
|
+
});
|
|
77
|
+
const draft = draftModule.createEditorDraft(schema);
|
|
27
78
|
controller.current = bridge.mountFrameBridge({
|
|
28
79
|
editorOrigin: parentOrigin,
|
|
29
80
|
templateName: name,
|
|
30
81
|
hash: manifest.hash,
|
|
31
82
|
getManifest: () => manifest,
|
|
83
|
+
capabilities: [
|
|
84
|
+
"editor-bootstrap-v1",
|
|
85
|
+
"apply-template-v1",
|
|
86
|
+
"apply-groups-v1",
|
|
87
|
+
"apply-settings-v1",
|
|
88
|
+
"preview-navigation-v1",
|
|
89
|
+
],
|
|
90
|
+
getBootstrap: () => bootstrap,
|
|
32
91
|
applyTemplate(payload) {
|
|
33
92
|
if (payload.templateName !== name)
|
|
34
93
|
return false;
|
|
94
|
+
setThemePreview(owner, { ...schema, ...draft.apply(payload) });
|
|
35
95
|
setDraft({ name, template: payload.template });
|
|
36
96
|
return true;
|
|
37
97
|
},
|
|
@@ -44,6 +104,7 @@ export function useEditorTemplate(name, options) {
|
|
|
44
104
|
disposed = true;
|
|
45
105
|
controller.current?.unmount();
|
|
46
106
|
controller.current = null;
|
|
107
|
+
setThemePreview(owner, null);
|
|
47
108
|
};
|
|
48
109
|
}, [name, options]);
|
|
49
110
|
useEffect(() => {
|
package/dist/schemas/bridge.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zalify/storefront-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The Zalify storefront SDK: framework-agnostic commerce logic (/commerce), the theme contract types and validators (/schemas), the canvas-editor bridge (/editor), and the React theme engine + shared components (/ui, /react/server). Consumed as TypeScript source inside the zalify-storefronts monorepo; published as compiled ESM + d.ts.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type {EditorBootstrap, PreviewContext} from '../schemas/bridge.ts';
|
|
2
|
+
import type {ThemeEditorManifest} from '../schemas/manifest.ts';
|
|
3
|
+
import type {DraftSchema} from './draft.ts';
|
|
4
|
+
|
|
5
|
+
export interface EditorDocuments {
|
|
6
|
+
templates: string;
|
|
7
|
+
groups: string;
|
|
8
|
+
settings: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Only list routes the app can actually render; never fabricate handles. */
|
|
12
|
+
export function createEditorBootstrap(options: {
|
|
13
|
+
schema: DraftSchema;
|
|
14
|
+
manifest: ThemeEditorManifest;
|
|
15
|
+
paths: EditorDocuments;
|
|
16
|
+
previews: Record<string, PreviewContext>;
|
|
17
|
+
}): EditorBootstrap {
|
|
18
|
+
const {schema, manifest, paths, previews} = options;
|
|
19
|
+
for (const path of [paths.templates, paths.groups, paths.settings]) {
|
|
20
|
+
if (
|
|
21
|
+
!path ||
|
|
22
|
+
path.startsWith('/') ||
|
|
23
|
+
path.includes('\\') ||
|
|
24
|
+
path.split('/').some((p) => p === '..' || p === '.')
|
|
25
|
+
) {
|
|
26
|
+
throw new Error('Editor write paths must be repository-relative');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const templates = {
|
|
30
|
+
...schema.templates,
|
|
31
|
+
...Object.fromEntries(
|
|
32
|
+
Object.entries(schema.customerTemplates ?? {}).map(([name, data]) => [
|
|
33
|
+
`customers/${name}`,
|
|
34
|
+
data,
|
|
35
|
+
]),
|
|
36
|
+
),
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
revision: manifest.hash,
|
|
40
|
+
manifest,
|
|
41
|
+
templates: Object.entries(templates)
|
|
42
|
+
.filter(([name]) => previews[name])
|
|
43
|
+
.map(([name, data]) => ({
|
|
44
|
+
name,
|
|
45
|
+
data: structuredClone(data),
|
|
46
|
+
writePath: `${paths.templates}/${name}.json`,
|
|
47
|
+
preview: previews[name],
|
|
48
|
+
})),
|
|
49
|
+
groups: Object.entries(schema.sectionGroups).map(([name, data]) => ({
|
|
50
|
+
name,
|
|
51
|
+
data: structuredClone(data),
|
|
52
|
+
writePath: `${paths.groups}/${name}.json`,
|
|
53
|
+
})),
|
|
54
|
+
settings: {
|
|
55
|
+
writePath: paths.settings,
|
|
56
|
+
schema: manifest.settingsSchema,
|
|
57
|
+
resolvedData: structuredClone(schema.settingsData),
|
|
58
|
+
},
|
|
59
|
+
previewContexts: {},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SectionGroupData,
|
|
3
|
+
SettingsData,
|
|
4
|
+
TemplateData,
|
|
5
|
+
} from '../schemas/data.ts';
|
|
6
|
+
|
|
7
|
+
export interface DraftSchema {
|
|
8
|
+
templates: Record<string, TemplateData>;
|
|
9
|
+
customerTemplates?: Record<string, TemplateData>;
|
|
10
|
+
sectionGroups: Record<string, SectionGroupData>;
|
|
11
|
+
settingsData: SettingsData;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PreviewApply {
|
|
15
|
+
templateName: string;
|
|
16
|
+
template: TemplateData;
|
|
17
|
+
groups?: Record<string, SectionGroupData>;
|
|
18
|
+
settingsData?: SettingsData;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Immutable, per-preview data. Never writes into an installed source schema. */
|
|
22
|
+
export function createEditorDraft(source: DraftSchema) {
|
|
23
|
+
let current = structuredClone(source);
|
|
24
|
+
return {
|
|
25
|
+
apply(payload: PreviewApply): DraftSchema {
|
|
26
|
+
const template = structuredClone(payload.template);
|
|
27
|
+
const customer = payload.templateName.startsWith('customers/');
|
|
28
|
+
current = {
|
|
29
|
+
...current,
|
|
30
|
+
...(customer
|
|
31
|
+
? {
|
|
32
|
+
customerTemplates: {
|
|
33
|
+
...current.customerTemplates,
|
|
34
|
+
[payload.templateName.slice(10)]: template,
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
: {
|
|
38
|
+
templates: {
|
|
39
|
+
...current.templates,
|
|
40
|
+
[payload.templateName]: template,
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
sectionGroups: {
|
|
44
|
+
...current.sectionGroups,
|
|
45
|
+
...structuredClone(payload.groups ?? {}),
|
|
46
|
+
},
|
|
47
|
+
settingsData: structuredClone(
|
|
48
|
+
payload.settingsData ?? current.settingsData,
|
|
49
|
+
),
|
|
50
|
+
};
|
|
51
|
+
return current;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
package/src/editor/frame.ts
CHANGED
|
@@ -6,11 +6,20 @@
|
|
|
6
6
|
* sync — and hands app-specific concerns (template hot-apply, device
|
|
7
7
|
* emulation) to callbacks.
|
|
8
8
|
*
|
|
9
|
-
* Editor mode
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* Editor mode keeps the page interactive — buttons, drawers, variant
|
|
10
|
+
* pickers, forms all work, so any state can be previewed — while the
|
|
11
|
+
* bridge takes over two things a click must never do on its own:
|
|
12
|
+
*
|
|
13
|
+
* - Leave the storefront. Same-origin links and GET forms are turned
|
|
14
|
+
* into `location.replace` navigations that keep the editor-mode
|
|
15
|
+
* query param (so the next document mounts the bridge again) and
|
|
16
|
+
* add no browser-history entries; off-site links, new-tab links,
|
|
17
|
+
* modifier-clicks and off-site paths (checkout, account) are blocked.
|
|
18
|
+
* - Escape selection: every click still selects the enclosing block.
|
|
19
|
+
*
|
|
20
|
+
* Double/middle clicks and context menus stay suppressed. Each mount
|
|
21
|
+
* reports its URL as a `navigation`, which is how the editor follows
|
|
22
|
+
* in-preview browsing.
|
|
14
23
|
*
|
|
15
24
|
* Security: the first `bridge:init` pins the editor origin; every
|
|
16
25
|
* later message must match it, and nothing but `bridge:ready` is ever
|
|
@@ -102,10 +111,61 @@ function visibleRectOf(element: Element): DOMRectLike | null {
|
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
function pathNodeOf(target: EventTarget | null): HTMLElement | null {
|
|
105
|
-
if (!(target
|
|
114
|
+
if (!isElement(target)) return null;
|
|
106
115
|
return target.closest<HTMLElement>(`[${DATA_PATH_ATTR}]`);
|
|
107
116
|
}
|
|
108
117
|
|
|
118
|
+
function isElement(target: EventTarget | null): target is Element {
|
|
119
|
+
return (
|
|
120
|
+
typeof Element !== 'undefined' && target instanceof Element
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Storefront paths that hand off to Shopify-hosted pages; never previewable. */
|
|
125
|
+
const OFF_SITE_PATHS = [/^\/checkout(?:s)?(?:\/|$)/, /^\/cart\/c\//, /^\/account(?:\/|$)/, /^\/admin(?:\/|$)/];
|
|
126
|
+
|
|
127
|
+
export type NavigationDecision =
|
|
128
|
+
| {kind: 'allow'; url: string}
|
|
129
|
+
| {kind: 'block'; reason: 'off-origin' | 'new-tab' | 'modifier' | 'off-site' | 'download'};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Where a click on `anchor` may take the preview. Same-origin page loads are
|
|
133
|
+
* allowed (with the editor-mode param re-attached); anything that would leave
|
|
134
|
+
* the sandbox or open another window is blocked.
|
|
135
|
+
*/
|
|
136
|
+
export function decideNavigation(
|
|
137
|
+
href: string,
|
|
138
|
+
base: string,
|
|
139
|
+
flags: {target?: string | null; download?: boolean; modifier?: boolean},
|
|
140
|
+
): NavigationDecision {
|
|
141
|
+
if (flags.modifier) return {kind: 'block', reason: 'modifier'};
|
|
142
|
+
if (flags.download) return {kind: 'block', reason: 'download'};
|
|
143
|
+
if (flags.target && flags.target !== '_self') return {kind: 'block', reason: 'new-tab'};
|
|
144
|
+
let url: URL;
|
|
145
|
+
try {
|
|
146
|
+
url = new URL(href, base);
|
|
147
|
+
} catch {
|
|
148
|
+
return {kind: 'block', reason: 'off-origin'};
|
|
149
|
+
}
|
|
150
|
+
if (url.origin !== new URL(base).origin) return {kind: 'block', reason: 'off-origin'};
|
|
151
|
+
if (OFF_SITE_PATHS.some((pattern) => pattern.test(url.pathname))) {
|
|
152
|
+
return {kind: 'block', reason: 'off-site'};
|
|
153
|
+
}
|
|
154
|
+
url.searchParams.set(EDITOR_MODE_PARAM, '1');
|
|
155
|
+
return {kind: 'allow', url: url.toString()};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The storefront path the editor should remember: no editor-mode param. */
|
|
159
|
+
export function previewPathOf(href: string): string {
|
|
160
|
+
const url = new URL(href);
|
|
161
|
+
url.searchParams.delete(EDITOR_MODE_PARAM);
|
|
162
|
+
return url.pathname + url.search;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function anchorOf(target: EventTarget | null): HTMLAnchorElement | null {
|
|
166
|
+
return isElement(target) ? target.closest<HTMLAnchorElement>('a[href]') : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
109
169
|
export interface FrameBridgeController {
|
|
110
170
|
unmount: () => void;
|
|
111
171
|
/** Report that a persisted write round-tripped (HMR applied `hash`). */
|
|
@@ -152,6 +212,16 @@ export function mountFrameBridge(
|
|
|
152
212
|
);
|
|
153
213
|
};
|
|
154
214
|
|
|
215
|
+
// Every mount tells the editor where the preview is now: after an
|
|
216
|
+
// in-preview link or search the host would otherwise keep showing the
|
|
217
|
+
// page it originally asked for. Sent once the editor origin is pinned.
|
|
218
|
+
const reportNavigation = (): void => {
|
|
219
|
+
post({
|
|
220
|
+
type: 'navigation',
|
|
221
|
+
payload: {templateName: options.templateName, url: previewPathOf(win.location.href)},
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
|
|
155
225
|
const setUnique = (attr: string, path: string | null): void => {
|
|
156
226
|
for (const node of doc.querySelectorAll(`[${attr}]`)) {
|
|
157
227
|
node.removeAttribute(attr);
|
|
@@ -243,6 +313,7 @@ export function mountFrameBridge(
|
|
|
243
313
|
queueMicrotask(() => {
|
|
244
314
|
lastHeight = 0;
|
|
245
315
|
measure();
|
|
316
|
+
reportNavigation();
|
|
246
317
|
});
|
|
247
318
|
break;
|
|
248
319
|
}
|
|
@@ -316,19 +387,41 @@ export function mountFrameBridge(
|
|
|
316
387
|
|
|
317
388
|
const handleClick = (event: MouseEvent): void => {
|
|
318
389
|
const node = pathNodeOf(event.target);
|
|
319
|
-
|
|
390
|
+
if (node) {
|
|
391
|
+
const path = node.getAttribute(DATA_PATH_ATTR)!;
|
|
392
|
+
setSelection(path);
|
|
393
|
+
post({type: 'block:clicked', payload: {path, rect: rectOf(node)}});
|
|
394
|
+
}
|
|
395
|
+
// The page keeps its interactivity; only navigation is policed.
|
|
396
|
+
const anchor = anchorOf(event.target);
|
|
397
|
+
if (!anchor) return;
|
|
398
|
+
const decision = decideNavigation(anchor.getAttribute('href') ?? '', win.location.href, {
|
|
399
|
+
target: anchor.getAttribute('target'),
|
|
400
|
+
download: anchor.hasAttribute('download'),
|
|
401
|
+
modifier: event.metaKey || event.ctrlKey || event.shiftKey || event.altKey,
|
|
402
|
+
});
|
|
320
403
|
event.preventDefault();
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
404
|
+
if (decision.kind === 'allow') win.location.replace(decision.url);
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const handleSubmit = (event: Event): void => {
|
|
408
|
+
const form = event.target;
|
|
409
|
+
if (!(form instanceof win.HTMLFormElement)) return;
|
|
410
|
+
if ((form.getAttribute('method') ?? 'get').toLowerCase() !== 'get') return;
|
|
411
|
+
// A GET form is a navigation: route it like a link so the editor-mode
|
|
412
|
+
// param survives and history stays clean. POST forms run as they are.
|
|
413
|
+
const action = new URL(form.getAttribute('action') || win.location.pathname, win.location.href);
|
|
414
|
+
for (const [key, value] of new FormData(form)) {
|
|
415
|
+
if (typeof value === 'string') action.searchParams.set(key, value);
|
|
416
|
+
}
|
|
417
|
+
const decision = decideNavigation(action.toString(), win.location.href, {});
|
|
418
|
+
event.preventDefault();
|
|
419
|
+
if (decision.kind === 'allow') win.location.replace(decision.url);
|
|
326
420
|
};
|
|
327
421
|
|
|
328
422
|
const handleWheel = (event: WheelEvent): void => {
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
if (!event.ctrlKey) return;
|
|
423
|
+
// Cross-origin iframe wheel events never reach the host canvas. Forward
|
|
424
|
+
// wheel gestures so the editor can pan its canvas.
|
|
332
425
|
event.preventDefault();
|
|
333
426
|
post({
|
|
334
427
|
type: 'viewport:wheel',
|
|
@@ -336,6 +429,7 @@ export function mountFrameBridge(
|
|
|
336
429
|
deltaX: event.deltaX,
|
|
337
430
|
deltaY: event.deltaY,
|
|
338
431
|
ctrlKey: event.ctrlKey,
|
|
432
|
+
shiftKey: event.shiftKey,
|
|
339
433
|
clientX: event.clientX,
|
|
340
434
|
clientY: event.clientY,
|
|
341
435
|
},
|
|
@@ -347,12 +441,7 @@ export function mountFrameBridge(
|
|
|
347
441
|
event.preventDefault();
|
|
348
442
|
event.stopPropagation();
|
|
349
443
|
};
|
|
350
|
-
const SUPPRESSED_EVENTS = [
|
|
351
|
-
'dblclick',
|
|
352
|
-
'auxclick',
|
|
353
|
-
'submit',
|
|
354
|
-
'contextmenu',
|
|
355
|
-
] as const;
|
|
444
|
+
const SUPPRESSED_EVENTS = ['dblclick', 'auxclick', 'contextmenu'] as const;
|
|
356
445
|
|
|
357
446
|
const handleMouseMove = (() => {
|
|
358
447
|
let lastPath: string | null = null;
|
|
@@ -390,6 +479,7 @@ export function mountFrameBridge(
|
|
|
390
479
|
win.addEventListener('resize', syncSelectionHighlight);
|
|
391
480
|
win.addEventListener('scroll', syncSelectionHighlight, true);
|
|
392
481
|
doc.addEventListener('click', handleClick, true);
|
|
482
|
+
doc.addEventListener('submit', handleSubmit, true);
|
|
393
483
|
doc.addEventListener('mousemove', handleMouseMove, true);
|
|
394
484
|
doc.addEventListener('wheel', handleWheel, {capture: true, passive: false});
|
|
395
485
|
for (const type of SUPPRESSED_EVENTS) {
|
|
@@ -413,6 +503,7 @@ export function mountFrameBridge(
|
|
|
413
503
|
win.removeEventListener('resize', syncSelectionHighlight);
|
|
414
504
|
win.removeEventListener('scroll', syncSelectionHighlight, true);
|
|
415
505
|
doc.removeEventListener('click', handleClick, true);
|
|
506
|
+
doc.removeEventListener('submit', handleSubmit, true);
|
|
416
507
|
doc.removeEventListener('mousemove', handleMouseMove, true);
|
|
417
508
|
doc.removeEventListener('wheel', handleWheel, true);
|
|
418
509
|
for (const type of SUPPRESSED_EVENTS) {
|
|
@@ -7,9 +7,15 @@
|
|
|
7
7
|
* Static token scales (spacing, type, radii) live in the app's
|
|
8
8
|
* critical.css (synced from the theme).
|
|
9
9
|
*/
|
|
10
|
-
import {useMemo} from 'react';
|
|
10
|
+
import {useMemo, useSyncExternalStore} from 'react';
|
|
11
11
|
import {colorSchemes, themeSettings} from './settings';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
getThemeStoreVersion,
|
|
14
|
+
subscribePreview,
|
|
15
|
+
getPreviewVersion,
|
|
16
|
+
getServerPreviewVersion,
|
|
17
|
+
hasThemePreview,
|
|
18
|
+
} from './store';
|
|
13
19
|
import {
|
|
14
20
|
googleFontsHrefsFromSettings,
|
|
15
21
|
parseFontHandle,
|
|
@@ -35,7 +41,9 @@ function buildCss(): string {
|
|
|
35
41
|
const lines: string[] = [];
|
|
36
42
|
lines.push(':root {');
|
|
37
43
|
lines.push(` --font-body--family: ${bodyFamily};`);
|
|
38
|
-
lines.push(
|
|
44
|
+
lines.push(
|
|
45
|
+
` --font-body--style: ${bodyGoogle ? 'normal' : bodyFont.style};`,
|
|
46
|
+
);
|
|
39
47
|
lines.push(` --font-body--weight: ${bodyGoogle ? 400 : bodyFont.weight};`);
|
|
40
48
|
lines.push(` --font-heading--family: ${headingFamily};`);
|
|
41
49
|
if (monoGoogle) {
|
|
@@ -52,15 +60,23 @@ function buildCss(): string {
|
|
|
52
60
|
}
|
|
53
61
|
lines.push(` --page-width: ${s.max_page_width ?? '90rem'};`);
|
|
54
62
|
lines.push(` --page-margin: ${s.min_page_margin ?? 20}px;`);
|
|
55
|
-
lines.push(
|
|
56
|
-
|
|
63
|
+
lines.push(
|
|
64
|
+
` --style-border-radius-inputs: ${s.input_corner_radius ?? 0}px;`,
|
|
65
|
+
);
|
|
66
|
+
lines.push(
|
|
67
|
+
` --style-border-radius-buttons: ${s.button_corner_radius ?? 0}px;`,
|
|
68
|
+
);
|
|
57
69
|
lines.push(` --style-border-radius-cards: ${s.card_corner_radius ?? 0}px;`);
|
|
58
|
-
lines.push(
|
|
70
|
+
lines.push(
|
|
71
|
+
` --style-badge-background: ${s.card_badge_background ?? '#F9EDEF'};`,
|
|
72
|
+
);
|
|
59
73
|
lines.push(` --style-badge-text: ${s.card_badge_text ?? '#1A1A1A'};`);
|
|
60
74
|
lines.push(
|
|
61
75
|
` --style-badge-sale-background: ${s.card_badge_sale_background ?? '#F26B8A'};`,
|
|
62
76
|
);
|
|
63
|
-
lines.push(
|
|
77
|
+
lines.push(
|
|
78
|
+
` --style-badge-sale-text: ${s.card_badge_sale_text ?? '#FFFFFF'};`,
|
|
79
|
+
);
|
|
64
80
|
lines.push(
|
|
65
81
|
` --style-badge-1-background: ${s.badge_style_1_background ?? '#1A1A1A'};`,
|
|
66
82
|
);
|
|
@@ -137,6 +153,11 @@ export function CssVariables({
|
|
|
137
153
|
nonce,
|
|
138
154
|
fonts,
|
|
139
155
|
}: {nonce?: string; fonts?: ResolvedGoogleFontCss | null} = {}) {
|
|
156
|
+
useSyncExternalStore(
|
|
157
|
+
subscribePreview,
|
|
158
|
+
getPreviewVersion,
|
|
159
|
+
getServerPreviewVersion,
|
|
160
|
+
);
|
|
140
161
|
const themeVersion = getThemeStoreVersion();
|
|
141
162
|
const {css, links} = useMemo(
|
|
142
163
|
() => ({css: buildCss(), links: googleFontsHrefs()}),
|
|
@@ -147,7 +168,7 @@ export function CssVariables({
|
|
|
147
168
|
// inlined and the latin woff2 files preloaded, so the font downloads
|
|
148
169
|
// with the HTML and usually beats first paint — no async stylesheet,
|
|
149
170
|
// no FOUT, no request to fonts.googleapis.com at all.
|
|
150
|
-
if (fonts) {
|
|
171
|
+
if (fonts && !hasThemePreview()) {
|
|
151
172
|
return (
|
|
152
173
|
<>
|
|
153
174
|
<link
|
|
@@ -182,13 +203,16 @@ export function CssVariables({
|
|
|
182
203
|
{links.map((href) => (
|
|
183
204
|
<link
|
|
184
205
|
key={href}
|
|
185
|
-
rel=
|
|
206
|
+
rel={hasThemePreview() ? 'stylesheet' : 'preload'}
|
|
186
207
|
as="style"
|
|
187
208
|
href={href}
|
|
188
209
|
data-zfy-font-css=""
|
|
189
210
|
/>
|
|
190
211
|
))}
|
|
191
|
-
<script
|
|
212
|
+
<script
|
|
213
|
+
nonce={nonce}
|
|
214
|
+
dangerouslySetInnerHTML={{__html: FONT_CSS_LOADER}}
|
|
215
|
+
/>
|
|
192
216
|
<noscript>
|
|
193
217
|
{links.map((href) => (
|
|
194
218
|
<link key={href} rel="stylesheet" href={href} />
|
|
@@ -15,7 +15,15 @@
|
|
|
15
15
|
* emitted unconditionally so server and client markup stay identical.
|
|
16
16
|
*/
|
|
17
17
|
import {formatThemePath, DATA_PATH_ATTR} from '../../schemas/index.ts';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
getSectionGroup,
|
|
20
|
+
getTemplate,
|
|
21
|
+
getThemeStore,
|
|
22
|
+
subscribePreview,
|
|
23
|
+
getPreviewVersion,
|
|
24
|
+
getServerPreviewVersion,
|
|
25
|
+
} from './store';
|
|
26
|
+
import {useSyncExternalStore} from 'react';
|
|
19
27
|
import {SectionProvider, TemplateProvider} from './context';
|
|
20
28
|
import type {BlockData, SectionData, TemplateData} from './types';
|
|
21
29
|
|
|
@@ -181,6 +189,11 @@ export function SectionGroup({
|
|
|
181
189
|
name: string;
|
|
182
190
|
sectionData?: Record<string, unknown>;
|
|
183
191
|
}) {
|
|
192
|
+
useSyncExternalStore(
|
|
193
|
+
subscribePreview,
|
|
194
|
+
getPreviewVersion,
|
|
195
|
+
getServerPreviewVersion,
|
|
196
|
+
);
|
|
184
197
|
const group = getSectionGroup(name);
|
|
185
198
|
return (
|
|
186
199
|
<TemplateProvider value={{name, kind: 'group', resources: {}, sectionData}}>
|
|
@@ -26,7 +26,10 @@ export interface ThemeSchema {
|
|
|
26
26
|
/** sections/*-group.json keyed by file name ("header-group"…). */
|
|
27
27
|
sectionGroups: Record<string, SectionGroupData>;
|
|
28
28
|
/** config/settings_schema.json. */
|
|
29
|
-
settingsSchema: Array<{
|
|
29
|
+
settingsSchema: Array<{
|
|
30
|
+
name: string;
|
|
31
|
+
settings?: Array<Record<string, unknown>>;
|
|
32
|
+
}>;
|
|
30
33
|
/** config/settings_data.json. */
|
|
31
34
|
settingsData: {
|
|
32
35
|
current: string | Record<string, unknown>;
|
|
@@ -57,6 +60,40 @@ interface ThemeStore extends InstallThemeOptions {}
|
|
|
57
60
|
|
|
58
61
|
let store: ThemeStore | null = null;
|
|
59
62
|
let version = 0;
|
|
63
|
+
let preview: {owner: ThemeStore; value: ThemeStore} | null = null;
|
|
64
|
+
let previewVersion = 0;
|
|
65
|
+
const previewListeners = new Set<() => void>();
|
|
66
|
+
|
|
67
|
+
export function subscribePreview(listener: () => void): () => void {
|
|
68
|
+
previewListeners.add(listener);
|
|
69
|
+
return () => {
|
|
70
|
+
previewListeners.delete(listener);
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function getPreviewVersion(): number {
|
|
74
|
+
return previewVersion;
|
|
75
|
+
}
|
|
76
|
+
export function getServerPreviewVersion(): number {
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
export function hasThemePreview(): boolean {
|
|
80
|
+
return (
|
|
81
|
+
typeof window !== 'undefined' && preview !== null && preview.owner === store
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Browser-only overlay; the installed schema and SSR state remain untouched. */
|
|
86
|
+
export function setThemePreview(
|
|
87
|
+
owner: ThemeStore,
|
|
88
|
+
schema: ThemeSchema | null,
|
|
89
|
+
): void {
|
|
90
|
+
if (typeof window === 'undefined' || owner !== store) return;
|
|
91
|
+
preview = schema
|
|
92
|
+
? {owner, value: {...owner, schema, settingsOverride: undefined}}
|
|
93
|
+
: null;
|
|
94
|
+
previewVersion++;
|
|
95
|
+
for (const listener of previewListeners) listener();
|
|
96
|
+
}
|
|
60
97
|
|
|
61
98
|
export function installTheme(options: InstallThemeOptions): void {
|
|
62
99
|
store = options;
|
|
@@ -67,17 +104,19 @@ export function installTheme(options: InstallThemeOptions): void {
|
|
|
67
104
|
|
|
68
105
|
/** Monotonic install counter — lets derived caches detect re-installs. */
|
|
69
106
|
export function getThemeStoreVersion(): number {
|
|
70
|
-
return version;
|
|
107
|
+
return version + (typeof window === 'undefined' ? 0 : previewVersion);
|
|
71
108
|
}
|
|
72
109
|
|
|
73
110
|
export function getThemeStore(): ThemeStore {
|
|
74
111
|
if (!store) {
|
|
75
112
|
throw new Error(
|
|
76
|
-
|
|
113
|
+
"[storefront-kit] installTheme() has not been called. Import your app's " +
|
|
77
114
|
'theme-setup module (which calls installTheme) before rendering.',
|
|
78
115
|
);
|
|
79
116
|
}
|
|
80
|
-
return store
|
|
117
|
+
return typeof window !== 'undefined' && preview?.owner === store
|
|
118
|
+
? preview.value
|
|
119
|
+
: store;
|
|
81
120
|
}
|
|
82
121
|
|
|
83
122
|
/** Look up a page template ("index", "product", "customers/login"…). */
|
|
@@ -3,10 +3,17 @@ import { useEffect, useRef, useState } from "react";
|
|
|
3
3
|
import type { TemplateData } from "../schemas/data";
|
|
4
4
|
import type { ThemeEditorManifest } from "../schemas/manifest";
|
|
5
5
|
import type { FrameBridgeController } from "../editor/frame";
|
|
6
|
+
import type { PreviewContext } from "../schemas/bridge";
|
|
7
|
+
import type { EditorDocuments } from "../editor/bootstrap";
|
|
8
|
+
import { getThemeStore, setThemePreview } from "./engine/store";
|
|
6
9
|
|
|
7
10
|
type Options = {
|
|
8
11
|
origins: readonly string[];
|
|
9
12
|
loadManifest: () => Promise<ThemeEditorManifest>;
|
|
13
|
+
/** Authoritative, repository-relative merchant data targets. */
|
|
14
|
+
paths: EditorDocuments;
|
|
15
|
+
/** Real route targets keyed by template name. */
|
|
16
|
+
previews: Record<string, PreviewContext>;
|
|
10
17
|
};
|
|
11
18
|
|
|
12
19
|
/** No editor code or schema is fetched outside an explicitly allowed preview. */
|
|
@@ -35,16 +42,78 @@ export function useEditorTemplate(
|
|
|
35
42
|
}
|
|
36
43
|
if (!options.origins.includes(parentOrigin)) return;
|
|
37
44
|
let disposed = false;
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
const owner = getThemeStore();
|
|
46
|
+
void Promise.all([
|
|
47
|
+
import("../editor/frame"),
|
|
48
|
+
options.loadManifest(),
|
|
49
|
+
import("../editor/bootstrap"),
|
|
50
|
+
import("../editor/draft"),
|
|
51
|
+
])
|
|
52
|
+
.then(([bridge, manifest, bootstrapModule, draftModule]) => {
|
|
40
53
|
if (disposed) return;
|
|
54
|
+
const settings = owner.schema.settingsData;
|
|
55
|
+
const current =
|
|
56
|
+
typeof settings.current === "string"
|
|
57
|
+
? (settings.presets?.[settings.current] ?? {})
|
|
58
|
+
: settings.current;
|
|
59
|
+
const schema = {
|
|
60
|
+
...owner.schema,
|
|
61
|
+
settingsData: {
|
|
62
|
+
...settings,
|
|
63
|
+
current: { ...current, ...owner.settingsOverride },
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const previews = { ...options.previews };
|
|
67
|
+
// The currently rendered route is also a verified context, including locale.
|
|
68
|
+
const url = new URL(window.location.href);
|
|
69
|
+
url.searchParams.delete("zalify-editor");
|
|
70
|
+
const kind = name.split(".")[0];
|
|
71
|
+
const resourceType = (
|
|
72
|
+
[
|
|
73
|
+
"index",
|
|
74
|
+
"product",
|
|
75
|
+
"collection",
|
|
76
|
+
"page",
|
|
77
|
+
"blog",
|
|
78
|
+
"article",
|
|
79
|
+
"cart",
|
|
80
|
+
"search",
|
|
81
|
+
"list-collections",
|
|
82
|
+
"404",
|
|
83
|
+
].includes(kind)
|
|
84
|
+
? kind
|
|
85
|
+
: "page"
|
|
86
|
+
) as PreviewContext["resourceType"];
|
|
87
|
+
previews[name] = {
|
|
88
|
+
...previews[name],
|
|
89
|
+
id: name,
|
|
90
|
+
title: previews[name]?.title ?? name,
|
|
91
|
+
resourceType,
|
|
92
|
+
url: url.pathname + url.search,
|
|
93
|
+
};
|
|
94
|
+
const bootstrap = bootstrapModule.createEditorBootstrap({
|
|
95
|
+
schema,
|
|
96
|
+
manifest,
|
|
97
|
+
paths: options.paths,
|
|
98
|
+
previews,
|
|
99
|
+
});
|
|
100
|
+
const draft = draftModule.createEditorDraft(schema);
|
|
41
101
|
controller.current = bridge.mountFrameBridge({
|
|
42
102
|
editorOrigin: parentOrigin,
|
|
43
103
|
templateName: name,
|
|
44
104
|
hash: manifest.hash,
|
|
45
105
|
getManifest: () => manifest,
|
|
106
|
+
capabilities: [
|
|
107
|
+
"editor-bootstrap-v1",
|
|
108
|
+
"apply-template-v1",
|
|
109
|
+
"apply-groups-v1",
|
|
110
|
+
"apply-settings-v1",
|
|
111
|
+
"preview-navigation-v1",
|
|
112
|
+
],
|
|
113
|
+
getBootstrap: () => bootstrap,
|
|
46
114
|
applyTemplate(payload) {
|
|
47
115
|
if (payload.templateName !== name) return false;
|
|
116
|
+
setThemePreview(owner, { ...schema, ...draft.apply(payload) });
|
|
48
117
|
setDraft({ name, template: payload.template });
|
|
49
118
|
return true;
|
|
50
119
|
},
|
|
@@ -57,6 +126,7 @@ export function useEditorTemplate(
|
|
|
57
126
|
disposed = true;
|
|
58
127
|
controller.current?.unmount();
|
|
59
128
|
controller.current = null;
|
|
129
|
+
setThemePreview(owner, null);
|
|
60
130
|
};
|
|
61
131
|
}, [name, options]);
|
|
62
132
|
useEffect(() => {
|