@zalify/storefront-kit 0.3.1 → 0.4.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 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
+ }
@@ -0,0 +1 @@
1
+ export declare function resolveEditorOrigin(origins: readonly string[], win?: Window): string | null;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Which editor, if any, is framing this document.
3
+ *
4
+ * `document.referrer` names the editor only on the first document the editor
5
+ * loads. In `interact` mode the bridge navigates the frame with
6
+ * `location.replace`, so the next document's referrer is the storefront
7
+ * itself. The trusted origin is therefore remembered for the tab on first
8
+ * contact, and later documents accept it as long as the editor-mode param is
9
+ * still present — always checked against the allowlist, never trusted bare.
10
+ */
11
+ const STORAGE_KEY = "zalify-editor-origin";
12
+ export function resolveEditorOrigin(origins, win = window) {
13
+ if (!origins.length || win.self === win.top)
14
+ return null;
15
+ try {
16
+ if (new URL(win.location.href).searchParams.get("zalify-editor") !== "1")
17
+ return null;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ let referrer = null;
23
+ try {
24
+ referrer = new URL(win.document.referrer).origin;
25
+ }
26
+ catch {
27
+ referrer = null;
28
+ }
29
+ if (referrer && origins.includes(referrer)) {
30
+ try {
31
+ win.sessionStorage.setItem(STORAGE_KEY, referrer);
32
+ }
33
+ catch {
34
+ /* private mode: the first document still works */
35
+ }
36
+ return referrer;
37
+ }
38
+ try {
39
+ const remembered = win.sessionStorage.getItem(STORAGE_KEY);
40
+ return remembered && origins.includes(remembered) ? remembered : null;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
@@ -36,4 +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
+ export { resolveEditorOrigin } from './editor-origin';
@@ -37,4 +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
+ export { resolveEditorOrigin } from './editor-origin';
@@ -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
+ /** Editors allowed to frame a storefront unless the app narrows the list. */
6
+ export declare const ZALIFY_EDITOR_ORIGINS: readonly string[];
5
7
  type Options = {
6
- origins: readonly string[];
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;
@@ -1,25 +1,39 @@
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";
5
+ /** Editors allowed to frame a storefront unless the app narrows the list. */
6
+ export const ZALIFY_EDITOR_ORIGINS = [
7
+ "https://app.zalify.com",
8
+ "http://localhost:3000",
9
+ ];
10
+ /** Cap for `--editor-viewport-height`: a tall editor pane is not a tall phone. */
11
+ const MAX_EDITOR_VIEWPORT_HEIGHT = 1000;
12
+ /**
13
+ * Design mode is the SDK's to declare, not each storefront's: the editor
14
+ * sizes the iframe to its content, so `100vh` inside it grows with every
15
+ * height report. Themes read `--editor-viewport-height` under
16
+ * `html[data-z-design-mode='1']` instead. Fixed per mount on purpose.
17
+ */
18
+ function enterDesignMode() {
19
+ const root = document.documentElement;
20
+ root.style.setProperty("--editor-viewport-height", `${Math.min(window.innerHeight, MAX_EDITOR_VIEWPORT_HEIGHT)}px`);
21
+ root.setAttribute("data-z-design-mode", "1");
22
+ return () => {
23
+ root.removeAttribute("data-z-design-mode");
24
+ root.style.removeProperty("--editor-viewport-height");
25
+ };
26
+ }
4
27
  /** No editor code or schema is fetched outside an explicitly allowed preview. */
5
28
  export function useEditorTemplate(name, options) {
6
29
  const [draft, setDraft] = useState(null);
7
30
  const controller = useRef(null);
8
31
  useEffect(() => {
9
32
  setDraft(null);
10
- if (!options.origins.length ||
11
- window.self === window.top ||
12
- new URL(window.location.href).searchParams.get("zalify-editor") !== "1")
13
- return;
14
- let parentOrigin;
15
- try {
16
- parentOrigin = new URL(document.referrer).origin;
17
- }
18
- catch {
19
- return;
20
- }
21
- if (!options.origins.includes(parentOrigin))
33
+ const parentOrigin = resolveEditorOrigin(options.origins ?? ZALIFY_EDITOR_ORIGINS);
34
+ if (!parentOrigin)
22
35
  return;
36
+ const leaveDesignMode = enterDesignMode();
23
37
  let disposed = false;
24
38
  const owner = getThemeStore();
25
39
  void Promise.all([
@@ -102,6 +116,7 @@ export function useEditorTemplate(name, options) {
102
116
  });
103
117
  return () => {
104
118
  disposed = true;
119
+ leaveDesignMode();
105
120
  controller.current?.unmount();
106
121
  controller.current = null;
107
122
  setThemePreview(owner, null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalify/storefront-kit",
3
- "version": "0.3.1",
3
+ "version": "0.4.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",
@@ -35,7 +35,12 @@
35
35
  "default": "./dist/react/server.js"
36
36
  }
37
37
  },
38
+ "bin": {
39
+ "storefront-kit": "./bin/storefront-kit.mjs"
40
+ },
38
41
  "files": [
42
+ "bin",
43
+ "skills",
39
44
  "dist",
40
45
  "src",
41
46
  "!src/**/*.test.ts"
@@ -78,7 +83,7 @@
78
83
  ],
79
84
  "scripts": {
80
85
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
81
- "test": "node --test \"src/schemas/*.test.ts\" \"src/editor/*.test.ts\"",
86
+ "test": "node --test \"src/schemas/*.test.ts\" \"src/editor/*.test.ts\" \"src/react/*.test.ts\"",
82
87
  "typecheck": "tsc -p tsconfig.json"
83
88
  }
84
89
  }
@@ -0,0 +1,123 @@
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
+
27
+ If the kit cannot express what the storefront needs, the fix is a kit change
28
+ (open a PR on `zalify/zalify-theme-2026`, bump the version), not a local
29
+ workaround. A local workaround silently diverges from every other storefront
30
+ and from the editor.
31
+
32
+ ## 1. Templates
33
+
34
+ - Names follow Shopify: `<type>` or `<type>.<suffix>` — `product`,
35
+ `product.drawing-projector`, `page.about`. The type is the part before the
36
+ first dot and must be one of `index, product, collection, list-collections,
37
+ page, blog, article, cart, search, 404, password, gift_card`. A name like
38
+ `product-drawing-projector` or `luka-features` is an unknown type: the editor
39
+ cannot group it, icon it, or offer an entity picker for it. Landing pages are
40
+ `page.<suffix>`.
41
+ - A type that has alternates also registers its default (`page` next to
42
+ `page.about`). The editor nests alternates under the default.
43
+ - `writePath` is `<templates dir>/<name>.json`; the file on disk has the same
44
+ name. Renaming a template renames the file and every route that loads it.
45
+ - Only advertise a preview route the app can really render. Resource routes
46
+ (`product`, `collection`, `page`, `blog`, `article`) are added by the hook
47
+ when visited; never fabricate handles.
48
+ - `disabled: true` on a section or block means "kept in the template, not
49
+ rendered". The editor shows it as hidden and can toggle it. Do not use it as
50
+ a feature flag the merchant is not supposed to see.
51
+
52
+ ## 2. Schema, template and rendering agree
53
+
54
+ - Components read section/block settings; a schema whose fields the component
55
+ ignores is a bug. Cover text, images, alt, button label/link, repeated items.
56
+ - Clearing a value stays cleared: no silent fallback to a default, never an
57
+ empty `src`.
58
+ - Repeated items have stable ids and `block_order`. After insert / delete /
59
+ duplicate / reorder, the serialized data and the DOM paths still match.
60
+ - `data-z-path` matches the template structure for every block, including ones
61
+ added later — not just the default blocks.
62
+ - Different structures get different block types (`feature-card`,
63
+ `timeline-entry`, `faq-item`), never a shared `item`.
64
+ - Labels name the purpose ("Primary button link"), not the default value.
65
+ - The first name-like setting (`heading`, `title`, `name`, `label`) is what the
66
+ editor shows in Layers. Give sections one.
67
+
68
+ ## 3. Links and images
69
+
70
+ | Value | Meaning |
71
+ | --- | --- |
72
+ | `#contact` | in-page href, not an element id field |
73
+ | `/products/example` | store-relative link |
74
+ | full HTTPS URL | by the field's purpose |
75
+ | `/images/example.jpg` | asset in `public/`; keep it root-relative |
76
+ | empty image | render no image node |
77
+
78
+ No preview or local origins in template defaults.
79
+
80
+ ## 4. Design mode (inside the editor)
81
+
82
+ The editor sizes the iframe to the page's content. Therefore:
83
+
84
+ - Any height derived from `100vh` / `100dvh` / `min-height: 100vh` grows with
85
+ every height report. Under `html[data-z-design-mode='1']`, base those heights
86
+ on `var(--editor-viewport-height)` instead. This applies to heroes, product
87
+ galleries, sticky columns, full-screen drawers — check every `vh` in the CSS.
88
+ - Never clip the page (`max-height`, `overflow: hidden` on the root) to hide a
89
+ problem: later sections become unreachable.
90
+ - Content revealed only by scroll (IntersectionObserver, scroll animations,
91
+ lazy sections) needs a visible, editable presentation in design mode.
92
+ - Do not change the normal site's scrolling or animations to fix the preview.
93
+
94
+ ## 5. Editor mode must not leak into the business
95
+
96
+ - Analytics, pixels and ad tags are skipped in editor mode (use
97
+ `resolveEditorOrigin` on the client, the `x-zalify-editor` header on the
98
+ server). An editing session is not a visitor.
99
+ - Forms keep their real submit logic; styling changes never alter attribution,
100
+ subscription, validation or payment requests.
101
+ - Drafts never reach public caches or production data. No unauthenticated
102
+ write endpoints.
103
+ - The storefront works with the editor unreachable; editor code is lazy-loaded
104
+ only after `resolveEditorOrigin` returns an origin.
105
+
106
+ ## 6. Verify before shipping
107
+
108
+ - Per route (home, product, collection, content, legal, landing pages): right
109
+ page, images loaded, real page height and section boundaries — not a
110
+ screenshot squeezed to match.
111
+ - Per block type: edit, clear, insert, duplicate, delete, reorder; check the
112
+ DOM and the saved JSON.
113
+ - Desktop and mobile. Normal visit and inside the editor.
114
+ - Report what was run, what was skipped, and what is still a risk. Unverified
115
+ is not passed.
116
+
117
+ ## 7. Upgrading the kit
118
+
119
+ 1. Bump `@zalify/storefront-kit`, install, confirm the resolved version in the
120
+ lockfile.
121
+ 2. `pnpm exec storefront-kit skills sync` and commit the result.
122
+ 3. Delete any local code the new version makes redundant (see section 0).
123
+ 4. Typecheck, test, build; open the site inside the editor once.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Which editor, if any, is framing this document.
3
+ *
4
+ * `document.referrer` names the editor only on the first document the editor
5
+ * loads. In `interact` mode the bridge navigates the frame with
6
+ * `location.replace`, so the next document's referrer is the storefront
7
+ * itself. The trusted origin is therefore remembered for the tab on first
8
+ * contact, and later documents accept it as long as the editor-mode param is
9
+ * still present — always checked against the allowlist, never trusted bare.
10
+ */
11
+ const STORAGE_KEY = "zalify-editor-origin";
12
+
13
+ export function resolveEditorOrigin(
14
+ origins: readonly string[],
15
+ win: Window = window,
16
+ ): string | null {
17
+ if (!origins.length || win.self === win.top) return null;
18
+ try {
19
+ if (new URL(win.location.href).searchParams.get("zalify-editor") !== "1")
20
+ return null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ let referrer: string | null = null;
25
+ try {
26
+ referrer = new URL(win.document.referrer).origin;
27
+ } catch {
28
+ referrer = null;
29
+ }
30
+ if (referrer && origins.includes(referrer)) {
31
+ try {
32
+ win.sessionStorage.setItem(STORAGE_KEY, referrer);
33
+ } catch {
34
+ /* private mode: the first document still works */
35
+ }
36
+ return referrer;
37
+ }
38
+ try {
39
+ const remembered = win.sessionStorage.getItem(STORAGE_KEY);
40
+ return remembered && origins.includes(remembered) ? remembered : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
@@ -78,4 +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';
85
+ export {resolveEditorOrigin} from './editor-origin';
@@ -6,9 +6,39 @@ import type { FrameBridgeController } from "../editor/frame";
6
6
  import type { PreviewContext } from "../schemas/bridge";
7
7
  import type { EditorDocuments } from "../editor/bootstrap";
8
8
  import { getThemeStore, setThemePreview } from "./engine/store";
9
+ import { resolveEditorOrigin } from "./editor-origin";
10
+
11
+ /** Editors allowed to frame a storefront unless the app narrows the list. */
12
+ export const ZALIFY_EDITOR_ORIGINS: readonly string[] = [
13
+ "https://app.zalify.com",
14
+ "http://localhost:3000",
15
+ ];
16
+
17
+ /** Cap for `--editor-viewport-height`: a tall editor pane is not a tall phone. */
18
+ const MAX_EDITOR_VIEWPORT_HEIGHT = 1000;
19
+
20
+ /**
21
+ * Design mode is the SDK's to declare, not each storefront's: the editor
22
+ * sizes the iframe to its content, so `100vh` inside it grows with every
23
+ * height report. Themes read `--editor-viewport-height` under
24
+ * `html[data-z-design-mode='1']` instead. Fixed per mount on purpose.
25
+ */
26
+ function enterDesignMode(): () => void {
27
+ const root = document.documentElement;
28
+ root.style.setProperty(
29
+ "--editor-viewport-height",
30
+ `${Math.min(window.innerHeight, MAX_EDITOR_VIEWPORT_HEIGHT)}px`,
31
+ );
32
+ root.setAttribute("data-z-design-mode", "1");
33
+ return () => {
34
+ root.removeAttribute("data-z-design-mode");
35
+ root.style.removeProperty("--editor-viewport-height");
36
+ };
37
+ }
9
38
 
10
39
  type Options = {
11
- origins: readonly string[];
40
+ /** Defaults to {@link ZALIFY_EDITOR_ORIGINS}. */
41
+ origins?: readonly string[];
12
42
  loadManifest: () => Promise<ThemeEditorManifest>;
13
43
  /** Authoritative, repository-relative merchant data targets. */
14
44
  paths: EditorDocuments;
@@ -28,19 +58,11 @@ export function useEditorTemplate(
28
58
  const controller = useRef<FrameBridgeController | null>(null);
29
59
  useEffect(() => {
30
60
  setDraft(null);
31
- if (
32
- !options.origins.length ||
33
- window.self === window.top ||
34
- new URL(window.location.href).searchParams.get("zalify-editor") !== "1"
35
- )
36
- return;
37
- let parentOrigin: string;
38
- try {
39
- parentOrigin = new URL(document.referrer).origin;
40
- } catch {
41
- return;
42
- }
43
- if (!options.origins.includes(parentOrigin)) return;
61
+ const parentOrigin = resolveEditorOrigin(
62
+ options.origins ?? ZALIFY_EDITOR_ORIGINS,
63
+ );
64
+ if (!parentOrigin) return;
65
+ const leaveDesignMode = enterDesignMode();
44
66
  let disposed = false;
45
67
  const owner = getThemeStore();
46
68
  void Promise.all([
@@ -124,6 +146,7 @@ export function useEditorTemplate(
124
146
  });
125
147
  return () => {
126
148
  disposed = true;
149
+ leaveDesignMode();
127
150
  controller.current?.unmount();
128
151
  controller.current = null;
129
152
  setThemePreview(owner, null);