@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.
@@ -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
+ }
@@ -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 neutralizes the page's own interactivity: clicks,
10
- * double/middle clicks, form submits, and context menus are captured
11
- * at the document root and suppressed, so selecting a node never
12
- * navigates a link, opens a drawer, or submits a form — a click is
13
- * only ever a selection. (Hover stays live for highlight tracking.)
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`). */
@@ -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 neutralizes the page's own interactivity: clicks,
10
- * double/middle clicks, form submits, and context menus are captured
11
- * at the document root and suppressed, so selecting a node never
12
- * navigates a link, opens a drawer, or submits a form — a click is
13
- * only ever a selection. (Hover stays live for highlight tracking.)
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 instanceof Element))
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
- // Editor mode owns clicks: no navigation, no dialogs.
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
- event.stopPropagation();
250
- if (!node)
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
- const path = node.getAttribute(DATA_PATH_ATTR);
253
- setSelection(path);
254
- post({ type: 'block:clicked', payload: { path, rect: rectOf(node) } });
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
- // Both previews are fixed device viewports. Let ordinary wheel gestures
258
- // scroll the storefront; pinch/Ctrl+wheel remains a canvas zoom gesture.
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: "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
+ 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('[storefront-kit] installTheme() has not been called. Import your app\'s ' +
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
- void Promise.all([import("../editor/frame"), options.loadManifest()])
24
- .then(([bridge, manifest]) => {
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(() => {
@@ -121,6 +121,7 @@ export type FrameMessage = BridgeEnvelope<'bridge:ready', {
121
121
  deltaX: number;
122
122
  deltaY: number;
123
123
  ctrlKey: boolean;
124
+ shiftKey?: boolean;
124
125
  clientX: number;
125
126
  clientY: number;
126
127
  }> | BridgeEnvelope<'navigation', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalify/storefront-kit",
3
- "version": "0.1.13",
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
+ }
@@ -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 neutralizes the page's own interactivity: clicks,
10
- * double/middle clicks, form submits, and context menus are captured
11
- * at the document root and suppressed, so selecting a node never
12
- * navigates a link, opens a drawer, or submits a form — a click is
13
- * only ever a selection. (Hover stays live for highlight tracking.)
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 instanceof Element)) return null;
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
- // Editor mode owns clicks: no navigation, no dialogs.
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
- event.stopPropagation();
322
- if (!node) return;
323
- const path = node.getAttribute(DATA_PATH_ATTR)!;
324
- setSelection(path);
325
- post({type: 'block:clicked', payload: {path, rect: rectOf(node)}});
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
- // Both previews are fixed device viewports. Let ordinary wheel gestures
330
- // scroll the storefront; pinch/Ctrl+wheel remains a canvas zoom gesture.
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 {getThemeStoreVersion} from './store';
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(` --font-body--style: ${bodyGoogle ? 'normal' : bodyFont.style};`);
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(` --style-border-radius-inputs: ${s.input_corner_radius ?? 0}px;`);
56
- lines.push(` --style-border-radius-buttons: ${s.button_corner_radius ?? 0}px;`);
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(` --style-badge-background: ${s.card_badge_background ?? '#F9EDEF'};`);
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(` --style-badge-sale-text: ${s.card_badge_sale_text ?? '#FFFFFF'};`);
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="preload"
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 nonce={nonce} dangerouslySetInnerHTML={{__html: FONT_CSS_LOADER}} />
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 {getSectionGroup, getTemplate, getThemeStore} from './store';
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<{name: string; settings?: Array<Record<string, unknown>>}>;
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
- '[storefront-kit] installTheme() has not been called. Import your app\'s ' +
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
- void Promise.all([import("../editor/frame"), options.loadManifest()])
39
- .then(([bridge, manifest]) => {
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(() => {
@@ -185,6 +185,7 @@ export type FrameMessage =
185
185
  deltaX: number;
186
186
  deltaY: number;
187
187
  ctrlKey: boolean;
188
+ shiftKey?: boolean;
188
189
  clientX: number;
189
190
  clientY: number;
190
191
  }