@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.
- 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.js +3 -4
- 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 +3 -3
- 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.js
CHANGED
|
@@ -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
|
-
//
|
|
258
|
-
//
|
|
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:
|
|
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.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
|
+
}
|
package/src/editor/frame.ts
CHANGED
|
@@ -326,9 +326,8 @@ export function mountFrameBridge(
|
|
|
326
326
|
};
|
|
327
327
|
|
|
328
328
|
const handleWheel = (event: WheelEvent): void => {
|
|
329
|
-
//
|
|
330
|
-
//
|
|
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 {
|
|
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(() => {
|