@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 +14 -0
- package/bin/storefront-kit.mjs +66 -0
- package/dist/react/editor-origin.d.ts +1 -0
- package/dist/react/editor-origin.js +45 -0
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +2 -1
- package/dist/react/useEditorTemplate.d.ts +4 -1
- package/dist/react/useEditorTemplate.js +27 -12
- package/package.json +7 -2
- package/skills/zalify-storefront-editor/SKILL.md +123 -0
- package/src/react/editor-origin.ts +44 -0
- package/src/react/index.ts +5 -1
- package/src/react/useEditorTemplate.ts +37 -14
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
|
+
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/react/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
+
"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
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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);
|