@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.
@@ -1,16 +1,19 @@
1
1
  "use client";
2
2
  import { useEffect, useRef, useState } from "react";
3
3
  import { getThemeStore, setThemePreview } from "./engine/store";
4
- import { resolveEditorOrigin } from "./editor-origin";
4
+ import { resolveEditorOrigin, ZALIFY_EDITOR_ORIGINS } from "./editor-origin";
5
+ export { ZALIFY_EDITOR_ORIGINS };
6
+ import { enterDesignMode } from "./design-mode";
5
7
  /** No editor code or schema is fetched outside an explicitly allowed preview. */
6
8
  export function useEditorTemplate(name, options) {
7
9
  const [draft, setDraft] = useState(null);
8
10
  const controller = useRef(null);
9
11
  useEffect(() => {
10
12
  setDraft(null);
11
- const parentOrigin = resolveEditorOrigin(options.origins);
13
+ const parentOrigin = resolveEditorOrigin(options.origins ?? ZALIFY_EDITOR_ORIGINS);
12
14
  if (!parentOrigin)
13
15
  return;
16
+ const leaveDesignMode = enterDesignMode();
14
17
  let disposed = false;
15
18
  const owner = getThemeStore();
16
19
  void Promise.all([
@@ -93,6 +96,7 @@ export function useEditorTemplate(name, options) {
93
96
  });
94
97
  return () => {
95
98
  disposed = true;
99
+ leaveDesignMode();
96
100
  controller.current?.unmount();
97
101
  controller.current = null;
98
102
  setThemePreview(owner, null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalify/storefront-kit",
3
- "version": "0.3.2",
3
+ "version": "0.5.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",
@@ -18,6 +18,10 @@
18
18
  "types": "./dist/editor/index.d.ts",
19
19
  "default": "./dist/editor/index.js"
20
20
  },
21
+ "./editor/bootstrap": {
22
+ "types": "./dist/editor/bootstrap.d.ts",
23
+ "default": "./dist/editor/bootstrap.js"
24
+ },
21
25
  "./editor/frame": {
22
26
  "types": "./dist/editor/frame.d.ts",
23
27
  "default": "./dist/editor/frame.js"
@@ -26,16 +30,29 @@
26
30
  "types": "./dist/editor/host.d.ts",
27
31
  "default": "./dist/editor/host.js"
28
32
  },
33
+ "./editor/selection": {
34
+ "types": "./dist/editor/selection.d.ts",
35
+ "default": "./dist/editor/selection.js"
36
+ },
29
37
  "./react": {
30
38
  "types": "./dist/react/index.d.ts",
31
39
  "default": "./dist/react/index.js"
32
40
  },
41
+ "./react/preview": {
42
+ "types": "./dist/react/server-preview.d.ts",
43
+ "default": "./dist/react/server-preview.js"
44
+ },
33
45
  "./react/server": {
34
46
  "types": "./dist/react/server.d.ts",
35
47
  "default": "./dist/react/server.js"
36
48
  }
37
49
  },
50
+ "bin": {
51
+ "storefront-kit": "./bin/storefront-kit.mjs"
52
+ },
38
53
  "files": [
54
+ "bin",
55
+ "skills",
39
56
  "dist",
40
57
  "src",
41
58
  "!src/**/*.test.ts"
@@ -44,17 +61,22 @@
44
61
  "access": "public"
45
62
  },
46
63
  "peerDependencies": {
47
- "react": ">=18"
64
+ "react": ">=18",
65
+ "react-dom": ">=18"
48
66
  },
49
67
  "peerDependenciesMeta": {
50
68
  "react": {
51
69
  "optional": true
70
+ },
71
+ "react-dom": {
72
+ "optional": true
52
73
  }
53
74
  },
54
75
  "devDependencies": {
55
76
  "@types/node": "^24.0.0",
56
77
  "@types/react": "^18.3.28",
57
- "typescript": "^5.9.2"
78
+ "typescript": "^5.9.2",
79
+ "@types/react-dom": "^18.3.7"
58
80
  },
59
81
  "repository": {
60
82
  "type": "git",
@@ -0,0 +1,137 @@
1
+ ---
2
+ name: zalify-storefront-editor
3
+ description: Contract between a Zalify headless storefront and the Zalify Site Editor. Use before touching templates, section/block schemas, template routing, editor/preview integration, or anything under theme/ — and before writing any "editor glue" in a storefront repo.
4
+ ---
5
+
6
+ # Zalify storefront ↔ editor contract
7
+
8
+ This file ships inside `@zalify/storefront-kit` and is copied into the repo by
9
+ `pnpm exec storefront-kit skills sync`. Do not edit the copy: change it in the
10
+ kit and re-sync. Its version is the kit version in `package.json`.
11
+
12
+ ## 0. Infrastructure lives in the kit, not in this repo
13
+
14
+ A storefront repo holds **content and brand**: templates, section/block
15
+ components, schemas, styles, data loaders. Everything that talks to the editor
16
+ is SDK code and must be imported, never re-implemented:
17
+
18
+ | Need | Use | Never |
19
+ | --- | --- | --- |
20
+ | Editor bootstrap | `createEditorBootstrap` (via `useEditorTemplate`) | a hand-built `EditorBootstrap` object or template catalog |
21
+ | Frame bridge, selection, rects, navigation, interaction modes | `useEditorTemplate` / `mountFrameBridge` | custom `postMessage` handlers |
22
+ | Is this document inside the editor? | `resolveEditorOrigin` | reading `document.referrer` or the query param yourself |
23
+ | Allowed editor origins | `ZALIFY_EDITOR_ORIGINS` (default) | a copied origin list |
24
+ | Design-mode flag + `--editor-viewport-height` | set by `useEditorTemplate` | a per-repo effect that sets them |
25
+ | Draft application | `createEditorDraft` | mutating template JSON in components |
26
+ | Preview for server-rendered (RSC) storefronts | `EditorPreviewProvider`, `EditorTemplateRegion`, `EditorGroupRegion`, `useEditorPreview`, `useEditorSelection` from `@zalify/storefront-kit/react/preview` | a local provider, selection listener or visible-path observer |
27
+
28
+ Two integration shapes exist, pick by how the storefront renders:
29
+
30
+ - **Client theme engine** (`installTheme` + `ThemeTemplate`): call
31
+ `useEditorTemplate(name, {paths, previews, loadManifest})`.
32
+ - **Server-rendered templates** (RSC): wrap the layout in
33
+ `<EditorPreviewProvider getBootstrap={…} renderTemplate={…}>`, where both
34
+ props are server functions of the app — `getBootstrap` returns
35
+ `createEditorBootstrap(...)`, `renderTemplate` renders a draft read-only —
36
+ and wrap each page in `<EditorTemplateRegion name="…">`, each header/footer
37
+ in `<EditorGroupRegion name="…">`.
38
+
39
+ If the kit cannot express what the storefront needs, the fix is a kit change
40
+ (open a PR on `zalify/zalify-theme-2026`, bump the version), not a local
41
+ workaround. A local workaround silently diverges from every other storefront
42
+ and from the editor.
43
+
44
+ ## 1. Templates
45
+
46
+ - Names follow Shopify: `<type>` or `<type>.<suffix>` — `product`,
47
+ `product.drawing-projector`, `page.about`. The type is the part before the
48
+ first dot and must be one of `index, product, collection, list-collections,
49
+ page, blog, article, cart, search, 404, password, gift_card`. A name like
50
+ `product-drawing-projector` or `luka-features` is an unknown type: the editor
51
+ cannot group it, icon it, or offer an entity picker for it. Landing pages are
52
+ `page.<suffix>`.
53
+ - Assert `templateNameProblems(names)` (from
54
+ `@zalify/storefront-kit/editor/bootstrap`) is empty in the repo's tests.
55
+ - A type that has alternates also registers its default (`page` next to
56
+ `page.about`). The editor nests alternates under the default.
57
+ - `writePath` is `<templates dir>/<name>.json`; the file on disk has the same
58
+ name. Renaming a template renames the file and every route that loads it.
59
+ - Only advertise a preview route the app can really render. Resource routes
60
+ (`product`, `collection`, `page`, `blog`, `article`) are added by the hook
61
+ when visited; never fabricate handles.
62
+ - `disabled: true` on a section or block means "kept in the template, not
63
+ rendered". The editor shows it as hidden and can toggle it. Do not use it as
64
+ a feature flag the merchant is not supposed to see.
65
+
66
+ ## 2. Schema, template and rendering agree
67
+
68
+ - Components read section/block settings; a schema whose fields the component
69
+ ignores is a bug. Cover text, images, alt, button label/link, repeated items.
70
+ - Clearing a value stays cleared: no silent fallback to a default, never an
71
+ empty `src`.
72
+ - Repeated items have stable ids and `block_order`. After insert / delete /
73
+ duplicate / reorder, the serialized data and the DOM paths still match.
74
+ - `data-z-path` matches the template structure for every block, including ones
75
+ added later — not just the default blocks.
76
+ - Different structures get different block types (`feature-card`,
77
+ `timeline-entry`, `faq-item`), never a shared `item`.
78
+ - Labels name the purpose ("Primary button link"), not the default value.
79
+ - The first name-like setting (`heading`, `title`, `name`, `label`) is what the
80
+ editor shows in Layers. Give sections one.
81
+
82
+ ## 3. Links and images
83
+
84
+ | Value | Meaning |
85
+ | --- | --- |
86
+ | `#contact` | in-page href, not an element id field |
87
+ | `/products/example` | store-relative link |
88
+ | full HTTPS URL | by the field's purpose |
89
+ | `/images/example.jpg` | asset in `public/`; keep it root-relative |
90
+ | empty image | render no image node |
91
+
92
+ No preview or local origins in template defaults.
93
+
94
+ ## 4. Design mode (inside the editor)
95
+
96
+ The editor sizes the iframe to the page's content. Therefore:
97
+
98
+ - Any height derived from `100vh` / `100dvh` / `min-height: 100vh` grows with
99
+ every height report. Under `html[data-z-design-mode='1']`, base those heights
100
+ on `var(--editor-viewport-height)` instead. This applies to heroes, product
101
+ galleries, sticky columns, full-screen drawers — check every `vh` in the CSS.
102
+ - Never clip the page (`max-height`, `overflow: hidden` on the root) to hide a
103
+ problem: later sections become unreachable.
104
+ - Content revealed only by scroll (IntersectionObserver, scroll animations,
105
+ lazy sections) needs a visible, editable presentation in design mode.
106
+ - Do not change the normal site's scrolling or animations to fix the preview.
107
+
108
+ ## 5. Editor mode must not leak into the business
109
+
110
+ - Analytics, pixels and ad tags are skipped in editor mode (use
111
+ `resolveEditorOrigin` on the client, the `x-zalify-editor` header on the
112
+ server). An editing session is not a visitor.
113
+ - Forms keep their real submit logic; styling changes never alter attribution,
114
+ subscription, validation or payment requests.
115
+ - Drafts never reach public caches or production data. No unauthenticated
116
+ write endpoints.
117
+ - The storefront works with the editor unreachable; editor code is lazy-loaded
118
+ only after `resolveEditorOrigin` returns an origin.
119
+
120
+ ## 6. Verify before shipping
121
+
122
+ - Per route (home, product, collection, content, legal, landing pages): right
123
+ page, images loaded, real page height and section boundaries — not a
124
+ screenshot squeezed to match.
125
+ - Per block type: edit, clear, insert, duplicate, delete, reorder; check the
126
+ DOM and the saved JSON.
127
+ - Desktop and mobile. Normal visit and inside the editor.
128
+ - Report what was run, what was skipped, and what is still a risk. Unverified
129
+ is not passed.
130
+
131
+ ## 7. Upgrading the kit
132
+
133
+ 1. Bump `@zalify/storefront-kit`, install, confirm the resolved version in the
134
+ lockfile.
135
+ 2. `pnpm exec storefront-kit skills sync` and commit the result.
136
+ 3. Delete any local code the new version makes redundant (see section 0).
137
+ 4. Typecheck, test, build; open the site inside the editor once.
@@ -8,6 +8,48 @@ export interface EditorDocuments {
8
8
  settings: string;
9
9
  }
10
10
 
11
+ /** Template types the editor can group, icon and offer an entity picker for. */
12
+ export const TEMPLATE_TYPES = [
13
+ 'index',
14
+ 'product',
15
+ 'collection',
16
+ 'list-collections',
17
+ 'page',
18
+ 'blog',
19
+ 'article',
20
+ 'cart',
21
+ 'search',
22
+ '404',
23
+ 'password',
24
+ 'gift_card',
25
+ ] as const;
26
+
27
+ /**
28
+ * What is wrong with a set of template names, for a storefront's own tests:
29
+ * names must be `<type>` or `<type>.<suffix>`, and a type that has alternates
30
+ * must also have its default. Empty means the editor can lay them out.
31
+ */
32
+ export function templateNameProblems(names: readonly string[]): string[] {
33
+ const problems: string[] = [];
34
+ const known = new Set(names);
35
+ const needsDefault = new Set<string>();
36
+ for (const name of names) {
37
+ if (name.startsWith('customers/')) continue;
38
+ const type = name.split('.')[0];
39
+ if (!(TEMPLATE_TYPES as readonly string[]).includes(type)) {
40
+ problems.push(
41
+ `"${name}": unknown template type "${type}" — use <type>.<suffix>, e.g. "page.${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}"`,
42
+ );
43
+ } else if (name !== type && !known.has(type)) {
44
+ needsDefault.add(type);
45
+ }
46
+ }
47
+ for (const type of needsDefault) {
48
+ problems.push(`"${type}.*" alternates exist but the default "${type}" template is not registered`);
49
+ }
50
+ return problems;
51
+ }
52
+
11
53
  /** Only list routes the app can actually render; never fabricate handles. */
12
54
  export function createEditorBootstrap(options: {
13
55
  schema: DraftSchema;
@@ -0,0 +1,52 @@
1
+ import {
2
+ BRIDGE_NAMESPACE,
3
+ CONTRACT_VERSION,
4
+ isCompatibleVersion,
5
+ } from "../schemas/index.ts";
6
+
7
+ /** Observe the same authenticated parent selection as the SDK. Components can
8
+ * reveal the selected tab/menu before the SDK measures its DOM node. */
9
+ export function mountEditorSelection(
10
+ win: Window,
11
+ onSelect: (path: string | null) => void,
12
+ ) {
13
+ let origin: string | undefined;
14
+ const listener = (event: MessageEvent) => {
15
+ if (event.source !== win.parent) return;
16
+ const data = event.data;
17
+ if (
18
+ !data ||
19
+ data.z !== BRIDGE_NAMESPACE ||
20
+ typeof data.v !== "string" ||
21
+ !isCompatibleVersion(CONTRACT_VERSION, data.v) ||
22
+ !data.payload
23
+ )
24
+ return;
25
+ if (
26
+ !origin &&
27
+ data.type === "bridge:init" &&
28
+ event.origin === data.payload.editorOrigin
29
+ )
30
+ origin = event.origin;
31
+ if (!origin || event.origin !== origin) return;
32
+ const path =
33
+ data.type === "bridge:init"
34
+ ? data.payload.selectedPath
35
+ : data.type === "block:select"
36
+ ? data.payload.path
37
+ : undefined;
38
+ if (path === null || typeof path === "string") onSelect(path);
39
+ };
40
+ const click = (event: Event) => {
41
+ const target = event.target as Element | null;
42
+ const path =
43
+ target?.closest?.("[data-z-path]")?.getAttribute("data-z-path") ?? null;
44
+ if (path) onSelect(path);
45
+ };
46
+ win.addEventListener("message", listener);
47
+ win.document.addEventListener("click", click, true);
48
+ return () => {
49
+ win.removeEventListener("message", listener);
50
+ win.document.removeEventListener("click", click, true);
51
+ };
52
+ }
@@ -0,0 +1,99 @@
1
+ const PATH = "data-z-path";
2
+ const HIDDEN_PATH = "data-z-editor-hidden-path";
3
+
4
+ /** The SDK selects the first matching path. Responsive copies must not let a
5
+ * hidden desktop node shadow its visible mobile counterpart (or vice versa). */
6
+ export function syncVisiblePaths(doc: Document) {
7
+ for (const node of doc.querySelectorAll(`[${PATH}], [${HIDDEN_PATH}]`)) {
8
+ const path = node.getAttribute(PATH) ?? node.getAttribute(HIDDEN_PATH);
9
+ if (!path) continue;
10
+ // display:contents has no own box; a visible child still makes it selectable.
11
+ const visible =
12
+ node.getClientRects().length > 0 ||
13
+ Array.from(node.children).some(
14
+ (child) => child.getClientRects().length > 0,
15
+ );
16
+ if (visible) {
17
+ if (node.getAttribute(PATH) !== path) node.setAttribute(PATH, path);
18
+ if (node.getAttribute(HIDDEN_PATH) !== null)
19
+ node.removeAttribute(HIDDEN_PATH);
20
+ } else {
21
+ if (node.getAttribute(HIDDEN_PATH) !== path)
22
+ node.setAttribute(HIDDEN_PATH, path);
23
+ if (node.getAttribute(PATH) !== null) node.removeAttribute(PATH);
24
+ }
25
+ }
26
+ }
27
+
28
+ export function restoreHiddenPaths(doc: Document) {
29
+ for (const node of doc.querySelectorAll(`[${HIDDEN_PATH}]`)) {
30
+ const path = node.getAttribute(HIDDEN_PATH);
31
+ if (path && !node.hasAttribute(PATH)) node.setAttribute(PATH, path);
32
+ node.removeAttribute(HIDDEN_PATH);
33
+ }
34
+ }
35
+
36
+ /** Mounted only in the editor, before the SDK's message/resize listeners. */
37
+ export function mountVisiblePaths(win: Window, onChange: () => void) {
38
+ let frame = 0;
39
+ const sync = () => syncVisiblePaths(win.document);
40
+ const schedule = () => {
41
+ if (frame) 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: string | null) =>
51
+ ["display", "content-visibility"]
52
+ .map(
53
+ (property) =>
54
+ new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "i")
55
+ .exec(style ?? "")?.[1]
56
+ .trim() ?? "",
57
+ )
58
+ .join("|");
59
+ const observer = new MutationObserver((records) => {
60
+ if (
61
+ records.some((record) => {
62
+ const target = record.target as Element;
63
+ if (target.id === "zalify-editor-highlight") return false;
64
+ if (record.type === "attributes" && record.attributeName === "style")
65
+ return (
66
+ boxStyle(record.oldValue) !== boxStyle(target.getAttribute("style"))
67
+ );
68
+ return true;
69
+ })
70
+ )
71
+ schedule();
72
+ });
73
+ observer.observe(win.document.documentElement, {
74
+ subtree: true,
75
+ childList: true,
76
+ attributes: true,
77
+ attributeOldValue: true,
78
+ attributeFilter: [PATH, "class", "style", "hidden"],
79
+ });
80
+ const onMessage = (event: MessageEvent) => {
81
+ if (
82
+ event.data?.z === "zalify-editor-bridge" &&
83
+ ["bridge:init", "block:select", "device:set", "template:apply"].includes(
84
+ event.data.type,
85
+ )
86
+ )
87
+ sync();
88
+ };
89
+ win.addEventListener("resize", sync);
90
+ win.addEventListener("message", onMessage);
91
+ sync();
92
+ return () => {
93
+ observer.disconnect();
94
+ win.cancelAnimationFrame(frame);
95
+ win.removeEventListener("resize", sync);
96
+ win.removeEventListener("message", onMessage);
97
+ restoreHiddenPaths(win.document);
98
+ };
99
+ }
@@ -0,0 +1,21 @@
1
+ /** Cap for `--editor-viewport-height`: a tall editor pane is not a tall phone. */
2
+ const MAX_EDITOR_VIEWPORT_HEIGHT = 1000;
3
+
4
+ /**
5
+ * Design mode is the SDK's to declare, not each storefront's: the editor
6
+ * sizes the iframe to its content, so `100vh` inside it grows with every
7
+ * height report. Themes read `--editor-viewport-height` under
8
+ * `html[data-z-design-mode='1']` instead. Fixed per mount on purpose.
9
+ */
10
+ export function enterDesignMode(): () => void {
11
+ const root = document.documentElement;
12
+ root.style.setProperty(
13
+ "--editor-viewport-height",
14
+ `${Math.min(window.innerHeight, MAX_EDITOR_VIEWPORT_HEIGHT)}px`,
15
+ );
16
+ root.setAttribute("data-z-design-mode", "1");
17
+ return () => {
18
+ root.removeAttribute("data-z-design-mode");
19
+ root.style.removeProperty("--editor-viewport-height");
20
+ };
21
+ }
@@ -10,6 +10,13 @@
10
10
  */
11
11
  const STORAGE_KEY = "zalify-editor-origin";
12
12
 
13
+ /** Editors allowed to frame a storefront unless the app narrows the list. */
14
+ export const ZALIFY_EDITOR_ORIGINS: readonly string[] = [
15
+ "https://app.zalify.com",
16
+ "http://localhost:3000",
17
+ ];
18
+
19
+
13
20
  export function resolveEditorOrigin(
14
21
  origins: readonly string[],
15
22
  win: Window = window,
@@ -78,5 +78,8 @@ export {
78
78
  // Server-only loaders live in '@zalify/storefront-kit/react/server'.
79
79
  export {builtinSections, builtinBlocks} from './registries';
80
80
 
81
- export {useEditorTemplate} from './useEditorTemplate';
81
+ export {
82
+ useEditorTemplate,
83
+ ZALIFY_EDITOR_ORIGINS,
84
+ } from './useEditorTemplate';
82
85
  export {resolveEditorOrigin} from './editor-origin';