@zalify/storefront-kit 0.3.2 → 0.5.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/README.md +14 -0
- package/bin/storefront-kit.mjs +66 -0
- package/dist/editor/bootstrap.d.ts +8 -0
- package/dist/editor/bootstrap.js +40 -0
- package/dist/editor/selection.d.ts +3 -0
- package/dist/editor/selection.js +42 -0
- package/dist/editor/visible-paths.d.ts +6 -0
- package/dist/editor/visible-paths.js +88 -0
- package/dist/react/design-mode.d.ts +7 -0
- package/dist/react/design-mode.js +17 -0
- package/dist/react/editor-origin.d.ts +2 -0
- package/dist/react/editor-origin.js +5 -0
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.js +1 -1
- package/dist/react/server-preview.d.ts +55 -0
- package/dist/react/server-preview.js +216 -0
- package/dist/react/useEditorTemplate.d.ts +4 -2
- package/dist/react/useEditorTemplate.js +6 -2
- package/package.json +25 -3
- package/skills/zalify-storefront-editor/SKILL.md +137 -0
- package/src/editor/bootstrap.ts +42 -0
- package/src/editor/selection.ts +52 -0
- package/src/editor/visible-paths.ts +99 -0
- package/src/react/design-mode.ts +21 -0
- package/src/react/editor-origin.ts +7 -0
- package/src/react/index.ts +4 -1
- package/src/react/server-preview.tsx +333 -0
- package/src/react/useEditorTemplate.ts +11 -3
package/README.md
CHANGED
|
@@ -61,3 +61,17 @@ the TypeScript source included (`src/`) for reference and sourcemaps.
|
|
|
61
61
|
Source-available under the [Zalify Source Available License](./LICENSE.md):
|
|
62
62
|
build, modify, and operate storefronts freely (including for clients);
|
|
63
63
|
don't redistribute the SDK or use it to build competing theme products.
|
|
64
|
+
|
|
65
|
+
## Agent skills
|
|
66
|
+
|
|
67
|
+
The storefront ↔ editor contract ships with the package as an agent skill, so
|
|
68
|
+
every storefront repo carries the rules for the kit version it has installed:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pnpm exec storefront-kit skills sync # copy into .agents/skills and .claude/skills
|
|
72
|
+
pnpm exec storefront-kit skills check # CI: fail when the copies are stale
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Run `sync` after every kit upgrade and commit the result. Editor plumbing
|
|
76
|
+
(bootstrap, bridge, editor-mode detection, design-mode flags) belongs in this
|
|
77
|
+
package — a storefront repo holds content and brand only.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Installs the agent skills that ship with this version of the kit into the
|
|
3
|
+
// storefront repo, so whoever (or whatever) edits the storefront works from
|
|
4
|
+
// the same contract the SDK implements.
|
|
5
|
+
//
|
|
6
|
+
// storefront-kit skills sync copy skills into .agents/skills and .claude/skills
|
|
7
|
+
// storefront-kit skills check exit 1 when the repo's copies are missing or stale
|
|
8
|
+
import {cpSync, existsSync, readdirSync, readFileSync, rmSync} from 'node:fs';
|
|
9
|
+
import {dirname, join, relative} from 'node:path';
|
|
10
|
+
import {fileURLToPath} from 'node:url';
|
|
11
|
+
|
|
12
|
+
const source = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
|
|
13
|
+
const TARGETS = ['.agents/skills', '.claude/skills'];
|
|
14
|
+
|
|
15
|
+
function files(dir, base = dir) {
|
|
16
|
+
return readdirSync(dir, {withFileTypes: true}).flatMap((entry) => {
|
|
17
|
+
const path = join(dir, entry.name);
|
|
18
|
+
return entry.isDirectory() ? files(path, base) : [relative(base, path)];
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stale(root) {
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const target of TARGETS) {
|
|
25
|
+
for (const file of files(source)) {
|
|
26
|
+
const copy = join(root, target, file);
|
|
27
|
+
if (
|
|
28
|
+
!existsSync(copy) ||
|
|
29
|
+
!readFileSync(copy).equals(readFileSync(join(source, file)))
|
|
30
|
+
) {
|
|
31
|
+
out.push(join(target, file));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const [group, command] = process.argv.slice(2);
|
|
39
|
+
const root = process.cwd();
|
|
40
|
+
|
|
41
|
+
if (group !== 'skills' || !['sync', 'check'].includes(command)) {
|
|
42
|
+
console.error('usage: storefront-kit skills <sync|check>');
|
|
43
|
+
process.exit(2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (command === 'check') {
|
|
47
|
+
const out = stale(root);
|
|
48
|
+
if (out.length) {
|
|
49
|
+
console.error(
|
|
50
|
+
`Kit skills are missing or out of date:\n${out.map((f) => ` ${f}`).join('\n')}\nRun: pnpm exec storefront-kit skills sync`,
|
|
51
|
+
);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
console.log('Kit skills are up to date.');
|
|
55
|
+
} else {
|
|
56
|
+
for (const target of TARGETS) {
|
|
57
|
+
for (const skill of readdirSync(source)) {
|
|
58
|
+
// Whole-directory replace: a file the kit dropped must not linger.
|
|
59
|
+
rmSync(join(root, target, skill), {recursive: true, force: true});
|
|
60
|
+
cpSync(join(source, skill), join(root, target, skill), {recursive: true});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
console.log(
|
|
64
|
+
`Synced ${readdirSync(source).join(', ')} into ${TARGETS.join(' and ')}.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -6,6 +6,14 @@ export interface EditorDocuments {
|
|
|
6
6
|
groups: string;
|
|
7
7
|
settings: string;
|
|
8
8
|
}
|
|
9
|
+
/** Template types the editor can group, icon and offer an entity picker for. */
|
|
10
|
+
export declare const TEMPLATE_TYPES: readonly ["index", "product", "collection", "list-collections", "page", "blog", "article", "cart", "search", "404", "password", "gift_card"];
|
|
11
|
+
/**
|
|
12
|
+
* What is wrong with a set of template names, for a storefront's own tests:
|
|
13
|
+
* names must be `<type>` or `<type>.<suffix>`, and a type that has alternates
|
|
14
|
+
* must also have its default. Empty means the editor can lay them out.
|
|
15
|
+
*/
|
|
16
|
+
export declare function templateNameProblems(names: readonly string[]): string[];
|
|
9
17
|
/** Only list routes the app can actually render; never fabricate handles. */
|
|
10
18
|
export declare function createEditorBootstrap(options: {
|
|
11
19
|
schema: DraftSchema;
|
package/dist/editor/bootstrap.js
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
+
/** Template types the editor can group, icon and offer an entity picker for. */
|
|
2
|
+
export const TEMPLATE_TYPES = [
|
|
3
|
+
'index',
|
|
4
|
+
'product',
|
|
5
|
+
'collection',
|
|
6
|
+
'list-collections',
|
|
7
|
+
'page',
|
|
8
|
+
'blog',
|
|
9
|
+
'article',
|
|
10
|
+
'cart',
|
|
11
|
+
'search',
|
|
12
|
+
'404',
|
|
13
|
+
'password',
|
|
14
|
+
'gift_card',
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* What is wrong with a set of template names, for a storefront's own tests:
|
|
18
|
+
* names must be `<type>` or `<type>.<suffix>`, and a type that has alternates
|
|
19
|
+
* must also have its default. Empty means the editor can lay them out.
|
|
20
|
+
*/
|
|
21
|
+
export function templateNameProblems(names) {
|
|
22
|
+
const problems = [];
|
|
23
|
+
const known = new Set(names);
|
|
24
|
+
const needsDefault = new Set();
|
|
25
|
+
for (const name of names) {
|
|
26
|
+
if (name.startsWith('customers/'))
|
|
27
|
+
continue;
|
|
28
|
+
const type = name.split('.')[0];
|
|
29
|
+
if (!TEMPLATE_TYPES.includes(type)) {
|
|
30
|
+
problems.push(`"${name}": unknown template type "${type}" — use <type>.<suffix>, e.g. "page.${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}"`);
|
|
31
|
+
}
|
|
32
|
+
else if (name !== type && !known.has(type)) {
|
|
33
|
+
needsDefault.add(type);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const type of needsDefault) {
|
|
37
|
+
problems.push(`"${type}.*" alternates exist but the default "${type}" template is not registered`);
|
|
38
|
+
}
|
|
39
|
+
return problems;
|
|
40
|
+
}
|
|
1
41
|
/** Only list routes the app can actually render; never fabricate handles. */
|
|
2
42
|
export function createEditorBootstrap(options) {
|
|
3
43
|
const { schema, manifest, paths, previews } = options;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { BRIDGE_NAMESPACE, CONTRACT_VERSION, isCompatibleVersion, } from "../schemas/index.js";
|
|
2
|
+
/** Observe the same authenticated parent selection as the SDK. Components can
|
|
3
|
+
* reveal the selected tab/menu before the SDK measures its DOM node. */
|
|
4
|
+
export function mountEditorSelection(win, onSelect) {
|
|
5
|
+
let origin;
|
|
6
|
+
const listener = (event) => {
|
|
7
|
+
if (event.source !== win.parent)
|
|
8
|
+
return;
|
|
9
|
+
const data = event.data;
|
|
10
|
+
if (!data ||
|
|
11
|
+
data.z !== BRIDGE_NAMESPACE ||
|
|
12
|
+
typeof data.v !== "string" ||
|
|
13
|
+
!isCompatibleVersion(CONTRACT_VERSION, data.v) ||
|
|
14
|
+
!data.payload)
|
|
15
|
+
return;
|
|
16
|
+
if (!origin &&
|
|
17
|
+
data.type === "bridge:init" &&
|
|
18
|
+
event.origin === data.payload.editorOrigin)
|
|
19
|
+
origin = event.origin;
|
|
20
|
+
if (!origin || event.origin !== origin)
|
|
21
|
+
return;
|
|
22
|
+
const path = data.type === "bridge:init"
|
|
23
|
+
? data.payload.selectedPath
|
|
24
|
+
: data.type === "block:select"
|
|
25
|
+
? data.payload.path
|
|
26
|
+
: undefined;
|
|
27
|
+
if (path === null || typeof path === "string")
|
|
28
|
+
onSelect(path);
|
|
29
|
+
};
|
|
30
|
+
const click = (event) => {
|
|
31
|
+
const target = event.target;
|
|
32
|
+
const path = target?.closest?.("[data-z-path]")?.getAttribute("data-z-path") ?? null;
|
|
33
|
+
if (path)
|
|
34
|
+
onSelect(path);
|
|
35
|
+
};
|
|
36
|
+
win.addEventListener("message", listener);
|
|
37
|
+
win.document.addEventListener("click", click, true);
|
|
38
|
+
return () => {
|
|
39
|
+
win.removeEventListener("message", listener);
|
|
40
|
+
win.document.removeEventListener("click", click, true);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The SDK selects the first matching path. Responsive copies must not let a
|
|
2
|
+
* hidden desktop node shadow its visible mobile counterpart (or vice versa). */
|
|
3
|
+
export declare function syncVisiblePaths(doc: Document): void;
|
|
4
|
+
export declare function restoreHiddenPaths(doc: Document): void;
|
|
5
|
+
/** Mounted only in the editor, before the SDK's message/resize listeners. */
|
|
6
|
+
export declare function mountVisiblePaths(win: Window, onChange: () => void): () => void;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const PATH = "data-z-path";
|
|
2
|
+
const HIDDEN_PATH = "data-z-editor-hidden-path";
|
|
3
|
+
/** The SDK selects the first matching path. Responsive copies must not let a
|
|
4
|
+
* hidden desktop node shadow its visible mobile counterpart (or vice versa). */
|
|
5
|
+
export function syncVisiblePaths(doc) {
|
|
6
|
+
for (const node of doc.querySelectorAll(`[${PATH}], [${HIDDEN_PATH}]`)) {
|
|
7
|
+
const path = node.getAttribute(PATH) ?? node.getAttribute(HIDDEN_PATH);
|
|
8
|
+
if (!path)
|
|
9
|
+
continue;
|
|
10
|
+
// display:contents has no own box; a visible child still makes it selectable.
|
|
11
|
+
const visible = node.getClientRects().length > 0 ||
|
|
12
|
+
Array.from(node.children).some((child) => child.getClientRects().length > 0);
|
|
13
|
+
if (visible) {
|
|
14
|
+
if (node.getAttribute(PATH) !== path)
|
|
15
|
+
node.setAttribute(PATH, path);
|
|
16
|
+
if (node.getAttribute(HIDDEN_PATH) !== null)
|
|
17
|
+
node.removeAttribute(HIDDEN_PATH);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
if (node.getAttribute(HIDDEN_PATH) !== path)
|
|
21
|
+
node.setAttribute(HIDDEN_PATH, path);
|
|
22
|
+
if (node.getAttribute(PATH) !== null)
|
|
23
|
+
node.removeAttribute(PATH);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function restoreHiddenPaths(doc) {
|
|
28
|
+
for (const node of doc.querySelectorAll(`[${HIDDEN_PATH}]`)) {
|
|
29
|
+
const path = node.getAttribute(HIDDEN_PATH);
|
|
30
|
+
if (path && !node.hasAttribute(PATH))
|
|
31
|
+
node.setAttribute(PATH, path);
|
|
32
|
+
node.removeAttribute(HIDDEN_PATH);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Mounted only in the editor, before the SDK's message/resize listeners. */
|
|
36
|
+
export function mountVisiblePaths(win, onChange) {
|
|
37
|
+
let frame = 0;
|
|
38
|
+
const sync = () => syncVisiblePaths(win.document);
|
|
39
|
+
const schedule = () => {
|
|
40
|
+
if (frame)
|
|
41
|
+
return;
|
|
42
|
+
frame = win.requestAnimationFrame(() => {
|
|
43
|
+
frame = 0;
|
|
44
|
+
sync();
|
|
45
|
+
onChange();
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
// Transform/opacity animations do not change which responsive copy has a box.
|
|
49
|
+
// Scanning every path for each animation frame makes long previews expensive.
|
|
50
|
+
const boxStyle = (style) => ["display", "content-visibility"]
|
|
51
|
+
.map((property) => new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "i")
|
|
52
|
+
.exec(style ?? "")?.[1]
|
|
53
|
+
.trim() ?? "")
|
|
54
|
+
.join("|");
|
|
55
|
+
const observer = new MutationObserver((records) => {
|
|
56
|
+
if (records.some((record) => {
|
|
57
|
+
const target = record.target;
|
|
58
|
+
if (target.id === "zalify-editor-highlight")
|
|
59
|
+
return false;
|
|
60
|
+
if (record.type === "attributes" && record.attributeName === "style")
|
|
61
|
+
return (boxStyle(record.oldValue) !== boxStyle(target.getAttribute("style")));
|
|
62
|
+
return true;
|
|
63
|
+
}))
|
|
64
|
+
schedule();
|
|
65
|
+
});
|
|
66
|
+
observer.observe(win.document.documentElement, {
|
|
67
|
+
subtree: true,
|
|
68
|
+
childList: true,
|
|
69
|
+
attributes: true,
|
|
70
|
+
attributeOldValue: true,
|
|
71
|
+
attributeFilter: [PATH, "class", "style", "hidden"],
|
|
72
|
+
});
|
|
73
|
+
const onMessage = (event) => {
|
|
74
|
+
if (event.data?.z === "zalify-editor-bridge" &&
|
|
75
|
+
["bridge:init", "block:select", "device:set", "template:apply"].includes(event.data.type))
|
|
76
|
+
sync();
|
|
77
|
+
};
|
|
78
|
+
win.addEventListener("resize", sync);
|
|
79
|
+
win.addEventListener("message", onMessage);
|
|
80
|
+
sync();
|
|
81
|
+
return () => {
|
|
82
|
+
observer.disconnect();
|
|
83
|
+
win.cancelAnimationFrame(frame);
|
|
84
|
+
win.removeEventListener("resize", sync);
|
|
85
|
+
win.removeEventListener("message", onMessage);
|
|
86
|
+
restoreHiddenPaths(win.document);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design mode is the SDK's to declare, not each storefront's: the editor
|
|
3
|
+
* sizes the iframe to its content, so `100vh` inside it grows with every
|
|
4
|
+
* height report. Themes read `--editor-viewport-height` under
|
|
5
|
+
* `html[data-z-design-mode='1']` instead. Fixed per mount on purpose.
|
|
6
|
+
*/
|
|
7
|
+
export declare function enterDesignMode(): () => void;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Cap for `--editor-viewport-height`: a tall editor pane is not a tall phone. */
|
|
2
|
+
const MAX_EDITOR_VIEWPORT_HEIGHT = 1000;
|
|
3
|
+
/**
|
|
4
|
+
* Design mode is the SDK's to declare, not each storefront's: the editor
|
|
5
|
+
* sizes the iframe to its content, so `100vh` inside it grows with every
|
|
6
|
+
* height report. Themes read `--editor-viewport-height` under
|
|
7
|
+
* `html[data-z-design-mode='1']` instead. Fixed per mount on purpose.
|
|
8
|
+
*/
|
|
9
|
+
export function enterDesignMode() {
|
|
10
|
+
const root = document.documentElement;
|
|
11
|
+
root.style.setProperty("--editor-viewport-height", `${Math.min(window.innerHeight, MAX_EDITOR_VIEWPORT_HEIGHT)}px`);
|
|
12
|
+
root.setAttribute("data-z-design-mode", "1");
|
|
13
|
+
return () => {
|
|
14
|
+
root.removeAttribute("data-z-design-mode");
|
|
15
|
+
root.style.removeProperty("--editor-viewport-height");
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* still present — always checked against the allowlist, never trusted bare.
|
|
10
10
|
*/
|
|
11
11
|
const STORAGE_KEY = "zalify-editor-origin";
|
|
12
|
+
/** Editors allowed to frame a storefront unless the app narrows the list. */
|
|
13
|
+
export const ZALIFY_EDITOR_ORIGINS = [
|
|
14
|
+
"https://app.zalify.com",
|
|
15
|
+
"http://localhost:3000",
|
|
16
|
+
];
|
|
12
17
|
export function resolveEditorOrigin(origins, win = window) {
|
|
13
18
|
if (!origins.length || win.self === win.top)
|
|
14
19
|
return null;
|
package/dist/react/index.d.ts
CHANGED
|
@@ -36,5 +36,5 @@ export { ProductCard, PRODUCT_CARD_FRAGMENT } from './components/ProductCard';
|
|
|
36
36
|
export * from './components/VideoModal';
|
|
37
37
|
export { Facets, COLLECTION_SORT_OPTIONS, SEARCH_SORT_OPTIONS, } from './components/Facets';
|
|
38
38
|
export { builtinSections, builtinBlocks } from './registries';
|
|
39
|
-
export { useEditorTemplate } from './useEditorTemplate';
|
|
39
|
+
export { useEditorTemplate, ZALIFY_EDITOR_ORIGINS, } from './useEditorTemplate';
|
|
40
40
|
export { resolveEditorOrigin } from './editor-origin';
|
package/dist/react/index.js
CHANGED
|
@@ -37,5 +37,5 @@ export { Facets, COLLECTION_SORT_OPTIONS, SEARCH_SORT_OPTIONS, } from './compone
|
|
|
37
37
|
// Built-in registries (spread into installTheme with app extras).
|
|
38
38
|
// Server-only loaders live in '@zalify/storefront-kit/react/server'.
|
|
39
39
|
export { builtinSections, builtinBlocks } from './registries';
|
|
40
|
-
export { useEditorTemplate } from './useEditorTemplate';
|
|
40
|
+
export { useEditorTemplate, ZALIFY_EDITOR_ORIGINS, } from './useEditorTemplate';
|
|
41
41
|
export { resolveEditorOrigin } from './editor-origin';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editor preview for storefronts that render templates on the server (React
|
|
3
|
+
* Server Components) instead of through the client theme engine.
|
|
4
|
+
*
|
|
5
|
+
* The app supplies two server functions — one returning its bootstrap, one
|
|
6
|
+
* rendering a draft template to React nodes — and wraps each editable region.
|
|
7
|
+
* Everything else (trust, bridge, design mode, selection, responsive-copy
|
|
8
|
+
* paths, error surface) is here, so a storefront repo never writes it again.
|
|
9
|
+
*
|
|
10
|
+
* <EditorPreviewProvider getBootstrap={…} renderTemplate={…}>
|
|
11
|
+
* <EditorGroupRegion name="header-group">…</EditorGroupRegion>
|
|
12
|
+
* <EditorTemplateRegion name="page.about">…</EditorTemplateRegion>
|
|
13
|
+
* </EditorPreviewProvider>
|
|
14
|
+
*/
|
|
15
|
+
import { type ReactNode } from "react";
|
|
16
|
+
import type { EditorBootstrap } from "../schemas/bridge";
|
|
17
|
+
import type { SectionGroupData, SettingsData, TemplateData } from "../schemas/data";
|
|
18
|
+
export interface RenderTemplateInput {
|
|
19
|
+
templateName: string;
|
|
20
|
+
template: TemplateData;
|
|
21
|
+
groups?: Record<string, SectionGroupData>;
|
|
22
|
+
/** Path + query of the page being previewed, without the editor param. */
|
|
23
|
+
pathname: string;
|
|
24
|
+
}
|
|
25
|
+
/** Keys are `template:<name>` and `group:<name>`. */
|
|
26
|
+
export type RenderedRegions = Record<string, ReactNode>;
|
|
27
|
+
export interface EditorPreviewProviderProps {
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
/** Server function: the app's bootstrap (see `createEditorBootstrap`). */
|
|
30
|
+
getBootstrap: () => Promise<EditorBootstrap>;
|
|
31
|
+
/** Server function: render a draft. Must be read-only. */
|
|
32
|
+
renderTemplate: (input: RenderTemplateInput) => Promise<RenderedRegions>;
|
|
33
|
+
/** Map draft theme settings onto the page (CSS variables, usually). */
|
|
34
|
+
applySettings?: (settings: SettingsData, root: HTMLElement) => void;
|
|
35
|
+
/** Defaults to {@link ZALIFY_EDITOR_ORIGINS}. */
|
|
36
|
+
origins?: readonly string[];
|
|
37
|
+
/** A render slower than this fails the edit instead of hanging it. */
|
|
38
|
+
renderTimeoutMs?: number;
|
|
39
|
+
}
|
|
40
|
+
/** No bootstrap payload or bridge code is downloaded by ordinary visitors. */
|
|
41
|
+
export declare function EditorPreviewProvider({ children, getBootstrap, renderTemplate, applySettings, origins, renderTimeoutMs, }: EditorPreviewProviderProps): import("react").JSX.Element;
|
|
42
|
+
/** The page's template. Also tells the provider which template this route is. */
|
|
43
|
+
export declare function EditorTemplateRegion({ name, children, }: {
|
|
44
|
+
name: string;
|
|
45
|
+
children: ReactNode;
|
|
46
|
+
}): ReactNode;
|
|
47
|
+
/** A section group (header-group, footer-group). */
|
|
48
|
+
export declare function EditorGroupRegion({ name, children, }: {
|
|
49
|
+
name: string;
|
|
50
|
+
children: ReactNode;
|
|
51
|
+
}): ReactNode;
|
|
52
|
+
/** True inside a trusted editor frame, after hydration. */
|
|
53
|
+
export declare function useEditorPreview(): boolean;
|
|
54
|
+
/** The editor's selected `data-z-path`, so a component can reveal it. */
|
|
55
|
+
export declare function useEditorSelection(): string | null;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* Editor preview for storefronts that render templates on the server (React
|
|
5
|
+
* Server Components) instead of through the client theme engine.
|
|
6
|
+
*
|
|
7
|
+
* The app supplies two server functions — one returning its bootstrap, one
|
|
8
|
+
* rendering a draft template to React nodes — and wraps each editable region.
|
|
9
|
+
* Everything else (trust, bridge, design mode, selection, responsive-copy
|
|
10
|
+
* paths, error surface) is here, so a storefront repo never writes it again.
|
|
11
|
+
*
|
|
12
|
+
* <EditorPreviewProvider getBootstrap={…} renderTemplate={…}>
|
|
13
|
+
* <EditorGroupRegion name="header-group">…</EditorGroupRegion>
|
|
14
|
+
* <EditorTemplateRegion name="page.about">…</EditorTemplateRegion>
|
|
15
|
+
* </EditorPreviewProvider>
|
|
16
|
+
*/
|
|
17
|
+
import { createContext, useContext, useEffect, useRef, useState, } from "react";
|
|
18
|
+
import { flushSync } from "react-dom";
|
|
19
|
+
import { enterDesignMode } from "./design-mode";
|
|
20
|
+
import { resolveEditorOrigin, ZALIFY_EDITOR_ORIGINS } from "./editor-origin";
|
|
21
|
+
const PreviewContext = createContext(null);
|
|
22
|
+
const CONNECT_TIMEOUT_MS = 30_000;
|
|
23
|
+
const GUARDED_EVENTS = ["click", "auxclick", "dblclick", "submit"];
|
|
24
|
+
async function withDeadline(request, ms) {
|
|
25
|
+
let timer;
|
|
26
|
+
try {
|
|
27
|
+
return await Promise.race([
|
|
28
|
+
request,
|
|
29
|
+
new Promise((_, reject) => {
|
|
30
|
+
timer = setTimeout(() => reject(new Error("Preview update timed out. Retry the edit.")), ms);
|
|
31
|
+
}),
|
|
32
|
+
]);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** No bootstrap payload or bridge code is downloaded by ordinary visitors. */
|
|
39
|
+
export function EditorPreviewProvider({ children, getBootstrap, renderTemplate, applySettings, origins = ZALIFY_EDITOR_ORIGINS, renderTimeoutMs = 20_000, }) {
|
|
40
|
+
const [editorOrigin, setEditorOrigin] = useState(null);
|
|
41
|
+
const [selectedPath, setSelectedPath] = useState(null);
|
|
42
|
+
const [templateName, setTemplateName] = useState(null);
|
|
43
|
+
const [regions, setRegions] = useState({});
|
|
44
|
+
const [error, setError] = useState(null);
|
|
45
|
+
const [retry, setRetry] = useState(0);
|
|
46
|
+
const controllerRef = useRef(null);
|
|
47
|
+
const sequenceRef = useRef(0);
|
|
48
|
+
// Server functions and callbacks may be new identities on every render;
|
|
49
|
+
// they must not reconnect the bridge.
|
|
50
|
+
const handlers = useRef({ getBootstrap, renderTemplate, applySettings });
|
|
51
|
+
handlers.current = { getBootstrap, renderTemplate, applySettings };
|
|
52
|
+
const enabled = editorOrigin !== null;
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
setEditorOrigin(resolveEditorOrigin(origins));
|
|
55
|
+
}, [origins]);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!enabled)
|
|
58
|
+
return;
|
|
59
|
+
const guard = (event) => {
|
|
60
|
+
if (event.type === "click" &&
|
|
61
|
+
event.target instanceof Element &&
|
|
62
|
+
event.target.closest("[data-z-editor-retry]")) {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
event.stopImmediatePropagation();
|
|
65
|
+
setRetry((value) => value + 1);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// The bridge owns clicks once connected. Until then nothing in the shop
|
|
69
|
+
// may run; and a preview never submits a real lead, order or login.
|
|
70
|
+
if (event.type === "submit" || !controllerRef.current) {
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
event.stopImmediatePropagation();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
for (const type of GUARDED_EVENTS)
|
|
76
|
+
document.addEventListener(type, guard, true);
|
|
77
|
+
return () => {
|
|
78
|
+
for (const type of GUARDED_EVENTS)
|
|
79
|
+
document.removeEventListener(type, guard, true);
|
|
80
|
+
};
|
|
81
|
+
}, [enabled]);
|
|
82
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: retry intentionally tears down and reconnects the bridge.
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!editorOrigin || !templateName)
|
|
85
|
+
return;
|
|
86
|
+
let disposed = false;
|
|
87
|
+
let unmountObservers = () => { };
|
|
88
|
+
const leaveDesignMode = enterDesignMode();
|
|
89
|
+
setError(null);
|
|
90
|
+
const timeout = window.setTimeout(() => setError("Editor connection timed out. Retry the preview connection."), CONNECT_TIMEOUT_MS);
|
|
91
|
+
void (async () => {
|
|
92
|
+
const [{ mountFrameBridge }, selection, visiblePaths, bootstrap] = await Promise.all([
|
|
93
|
+
import("../editor/frame"),
|
|
94
|
+
import("../editor/selection"),
|
|
95
|
+
import("../editor/visible-paths"),
|
|
96
|
+
handlers.current.getBootstrap(),
|
|
97
|
+
]);
|
|
98
|
+
if (disposed)
|
|
99
|
+
return;
|
|
100
|
+
// Both must listen before the bridge does: selection so a component can
|
|
101
|
+
// reveal the selected tab before it is measured, visible paths so a
|
|
102
|
+
// hidden responsive copy never shadows the visible one.
|
|
103
|
+
const unmountSelection = selection.mountEditorSelection(window, (path) => flushSync(() => setSelectedPath(path)));
|
|
104
|
+
const unmountVisiblePaths = visiblePaths.mountVisiblePaths(window, () => controllerRef.current?.reportRects());
|
|
105
|
+
unmountObservers = () => {
|
|
106
|
+
unmountVisiblePaths();
|
|
107
|
+
unmountSelection();
|
|
108
|
+
};
|
|
109
|
+
window.clearTimeout(timeout);
|
|
110
|
+
setError(null);
|
|
111
|
+
controllerRef.current = mountFrameBridge({
|
|
112
|
+
editorOrigin,
|
|
113
|
+
templateName,
|
|
114
|
+
hash: bootstrap.manifest.hash,
|
|
115
|
+
capabilities: [
|
|
116
|
+
"editor-bootstrap-v1",
|
|
117
|
+
"apply-template-v1",
|
|
118
|
+
"apply-groups-v1",
|
|
119
|
+
"apply-settings-v1",
|
|
120
|
+
"preview-navigation-v1",
|
|
121
|
+
],
|
|
122
|
+
getManifest: () => bootstrap.manifest,
|
|
123
|
+
getBootstrap: () => bootstrap,
|
|
124
|
+
applyTemplate: async (payload) => {
|
|
125
|
+
if (payload.templateName !== templateName)
|
|
126
|
+
return false;
|
|
127
|
+
const sequence = ++sequenceRef.current;
|
|
128
|
+
try {
|
|
129
|
+
const content = await withDeadline(handlers.current.renderTemplate({
|
|
130
|
+
templateName,
|
|
131
|
+
template: payload.template,
|
|
132
|
+
groups: payload.groups,
|
|
133
|
+
pathname: window.location.pathname + window.location.search,
|
|
134
|
+
}), renderTimeoutMs);
|
|
135
|
+
// A newer edit is already rendering: this result is stale.
|
|
136
|
+
if (disposed || sequence !== sequenceRef.current)
|
|
137
|
+
return true;
|
|
138
|
+
setRegions((current) => ({ ...current, ...content }));
|
|
139
|
+
if (payload.settingsData)
|
|
140
|
+
handlers.current.applySettings?.(payload.settingsData, document.documentElement);
|
|
141
|
+
setError(null);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
catch (cause) {
|
|
145
|
+
if (!disposed && sequence === sequenceRef.current)
|
|
146
|
+
setError(cause instanceof Error
|
|
147
|
+
? cause.message
|
|
148
|
+
: "Preview could not update");
|
|
149
|
+
throw cause;
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
})().catch((cause) => {
|
|
154
|
+
window.clearTimeout(timeout);
|
|
155
|
+
if (!disposed)
|
|
156
|
+
setError(cause instanceof Error ? cause.message : "Editor connection failed");
|
|
157
|
+
});
|
|
158
|
+
return () => {
|
|
159
|
+
disposed = true;
|
|
160
|
+
sequenceRef.current++;
|
|
161
|
+
window.clearTimeout(timeout);
|
|
162
|
+
controllerRef.current?.unmount();
|
|
163
|
+
controllerRef.current = null;
|
|
164
|
+
unmountObservers();
|
|
165
|
+
leaveDesignMode();
|
|
166
|
+
};
|
|
167
|
+
}, [editorOrigin, templateName, retry, renderTimeoutMs]);
|
|
168
|
+
// Measure after each committed region replacement.
|
|
169
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: regions is the trigger.
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
controllerRef.current?.reportRects();
|
|
172
|
+
}, [regions]);
|
|
173
|
+
const register = (name) => {
|
|
174
|
+
setTemplateName(name);
|
|
175
|
+
return () => setTemplateName((current) => (current === name ? null : current));
|
|
176
|
+
};
|
|
177
|
+
return (_jsxs(PreviewContext.Provider, { value: { enabled, selectedPath, regions, register }, children: [children, enabled && error ? (_jsxs("div", { role: "alert", style: {
|
|
178
|
+
position: "fixed",
|
|
179
|
+
left: 16,
|
|
180
|
+
right: 16,
|
|
181
|
+
bottom: 16,
|
|
182
|
+
zIndex: 2147483647,
|
|
183
|
+
padding: 16,
|
|
184
|
+
background: "#fff1f0",
|
|
185
|
+
color: "#8b1b16",
|
|
186
|
+
border: "1px solid #c63225",
|
|
187
|
+
}, children: [error, " ", _jsx("button", { type: "button", "data-z-editor-retry": true, onClick: () => setRetry((value) => value + 1), children: "Retry" })] })) : null] }));
|
|
188
|
+
}
|
|
189
|
+
function useRegion(key, children) {
|
|
190
|
+
const context = useContext(PreviewContext);
|
|
191
|
+
return context?.enabled && Object.hasOwn(context.regions, key)
|
|
192
|
+
? context.regions[key]
|
|
193
|
+
: children;
|
|
194
|
+
}
|
|
195
|
+
/** The page's template. Also tells the provider which template this route is. */
|
|
196
|
+
export function EditorTemplateRegion({ name, children, }) {
|
|
197
|
+
const context = useContext(PreviewContext);
|
|
198
|
+
// Registration follows route identity, not every update of preview state.
|
|
199
|
+
const registerRef = useRef(context?.register);
|
|
200
|
+
registerRef.current = context?.register;
|
|
201
|
+
useEffect(() => registerRef.current?.(name), [name]);
|
|
202
|
+
return useRegion(`template:${name}`, children);
|
|
203
|
+
}
|
|
204
|
+
/** A section group (header-group, footer-group). */
|
|
205
|
+
export function EditorGroupRegion({ name, children, }) {
|
|
206
|
+
return useRegion(`group:${name}`, children);
|
|
207
|
+
}
|
|
208
|
+
/** True inside a trusted editor frame, after hydration. */
|
|
209
|
+
export function useEditorPreview() {
|
|
210
|
+
return useContext(PreviewContext)?.enabled ?? false;
|
|
211
|
+
}
|
|
212
|
+
/** The editor's selected `data-z-path`, so a component can reveal it. */
|
|
213
|
+
export function useEditorSelection() {
|
|
214
|
+
const context = useContext(PreviewContext);
|
|
215
|
+
return context?.enabled ? context.selectedPath : null;
|
|
216
|
+
}
|
|
@@ -2,8 +2,11 @@ import type { TemplateData } from "../schemas/data";
|
|
|
2
2
|
import type { ThemeEditorManifest } from "../schemas/manifest";
|
|
3
3
|
import type { PreviewContext } from "../schemas/bridge";
|
|
4
4
|
import type { EditorDocuments } from "../editor/bootstrap";
|
|
5
|
+
import { ZALIFY_EDITOR_ORIGINS } from "./editor-origin";
|
|
6
|
+
export { ZALIFY_EDITOR_ORIGINS };
|
|
5
7
|
type Options = {
|
|
6
|
-
|
|
8
|
+
/** Defaults to {@link ZALIFY_EDITOR_ORIGINS}. */
|
|
9
|
+
origins?: readonly string[];
|
|
7
10
|
loadManifest: () => Promise<ThemeEditorManifest>;
|
|
8
11
|
/** Authoritative, repository-relative merchant data targets. */
|
|
9
12
|
paths: EditorDocuments;
|
|
@@ -12,4 +15,3 @@ type Options = {
|
|
|
12
15
|
};
|
|
13
16
|
/** No editor code or schema is fetched outside an explicitly allowed preview. */
|
|
14
17
|
export declare function useEditorTemplate(name: string, options: Options): TemplateData | null;
|
|
15
|
-
export {};
|