@zalify/storefront-kit 0.1.13 → 0.2.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
+ }
@@ -254,10 +254,8 @@ export function mountFrameBridge(options) {
254
254
  post({ type: 'block:clicked', payload: { path, rect: rectOf(node) } });
255
255
  };
256
256
  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;
257
+ // Cross-origin iframe wheel events never reach the host canvas. Forward
258
+ // wheel gestures so the editor can pan its canvas.
261
259
  event.preventDefault();
262
260
  post({
263
261
  type: 'viewport:wheel',
@@ -265,6 +263,7 @@ export function mountFrameBridge(options) {
265
263
  deltaX: event.deltaX,
266
264
  deltaY: event.deltaY,
267
265
  ctrlKey: event.ctrlKey,
266
+ shiftKey: event.shiftKey,
268
267
  clientX: event.clientX,
269
268
  clientY: event.clientY,
270
269
  },
@@ -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.2.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
+ }
@@ -326,9 +326,8 @@ export function mountFrameBridge(
326
326
  };
327
327
 
328
328
  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;
329
+ // Cross-origin iframe wheel events never reach the host canvas. Forward
330
+ // wheel gestures so the editor can pan its canvas.
332
331
  event.preventDefault();
333
332
  post({
334
333
  type: 'viewport:wheel',
@@ -336,6 +335,7 @@ export function mountFrameBridge(
336
335
  deltaX: event.deltaX,
337
336
  deltaY: event.deltaY,
338
337
  ctrlKey: event.ctrlKey,
338
+ shiftKey: event.shiftKey,
339
339
  clientX: event.clientX,
340
340
  clientY: event.clientY,
341
341
  },
@@ -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
  }