@zalify/storefront-kit 0.1.9 → 0.1.11

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.
@@ -16,7 +16,7 @@
16
16
  * later message must match it, and nothing but `bridge:ready` is ever
17
17
  * posted before the pin.
18
18
  */
19
- import { type Device, type SectionGroupData, type SettingsBag, type TemplateData, type ThemeEditorManifest } from '../schemas/index.ts';
19
+ import { type Device, type EditorBootstrap, type EditorCapability, type SectionGroupData, type SettingsData, type TemplateData, type ThemeEditorManifest } from '../schemas/index.ts';
20
20
  export declare const DATA_HOVERED_ATTR = "data-z-hovered";
21
21
  export interface FrameBridgeOptions {
22
22
  /** Template currently rendered ("index", "product", …). */
@@ -25,6 +25,10 @@ export interface FrameBridgeOptions {
25
25
  hash: string;
26
26
  /** Serve the editor manifest (static import or dynamic build). */
27
27
  getManifest: () => ThemeEditorManifest | Promise<ThemeEditorManifest>;
28
+ /** Capabilities supported by this deployed storefront runtime. */
29
+ capabilities?: EditorCapability[];
30
+ /** Serve the complete Site Editor bootstrap payload. */
31
+ getBootstrap?: () => EditorBootstrap | Promise<EditorBootstrap>;
28
32
  /**
29
33
  * Hot-apply an updated template in memory (optimistic edit). Return
30
34
  * false (or throw) if the app cannot apply without a reload.
@@ -33,7 +37,7 @@ export interface FrameBridgeOptions {
33
37
  templateName: string;
34
38
  template: TemplateData;
35
39
  groups?: Record<string, SectionGroupData>;
36
- settingsData?: SettingsBag;
40
+ settingsData?: SettingsData;
37
41
  }) => boolean | Promise<boolean>;
38
42
  onDeviceChange?: (device: Device) => void;
39
43
  /** Overrides for tests / non-browser hosts. */
@@ -33,6 +33,21 @@ function rectOf(element) {
33
33
  const rect = element.getBoundingClientRect();
34
34
  return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
35
35
  }
36
+ function visibleRectOf(element) {
37
+ const ownRect = rectOf(element);
38
+ if (ownRect.width > 0 && ownRect.height > 0)
39
+ return ownRect;
40
+ const childRects = [...element.children]
41
+ .map(rectOf)
42
+ .filter((rect) => rect.width > 0 && rect.height > 0);
43
+ if (childRects.length === 0)
44
+ return null;
45
+ const left = Math.min(...childRects.map((rect) => rect.x));
46
+ const top = Math.min(...childRects.map((rect) => rect.y));
47
+ const right = Math.max(...childRects.map((rect) => rect.x + rect.width));
48
+ const bottom = Math.max(...childRects.map((rect) => rect.y + rect.height));
49
+ return { x: left, y: top, width: right - left, height: bottom - top };
50
+ }
36
51
  function pathNodeOf(target) {
37
52
  if (!(target instanceof Element))
38
53
  return null;
@@ -47,6 +62,19 @@ export function mountFrameBridge(options) {
47
62
  const doc = win.document;
48
63
  let editorOrigin = null;
49
64
  let lastHeight = 0;
65
+ let selectedPath = null;
66
+ const selectionHighlight = doc.createElement('div');
67
+ selectionHighlight.id = 'zalify-editor-highlight';
68
+ selectionHighlight.setAttribute('aria-hidden', 'true');
69
+ Object.assign(selectionHighlight.style, {
70
+ position: 'fixed',
71
+ display: 'none',
72
+ pointerEvents: 'none',
73
+ boxSizing: 'border-box',
74
+ border: '2px solid #2563eb',
75
+ zIndex: '2147483647',
76
+ });
77
+ (doc.body ?? doc.documentElement).append(selectionHighlight);
50
78
  const post = (message) => {
51
79
  // bridge:ready is the only pre-pin message; everything else waits.
52
80
  const target = editorOrigin ?? '*';
@@ -63,6 +91,43 @@ export function mountFrameBridge(options) {
63
91
  const selector = `[${DATA_PATH_ATTR}="${CSS.escape(path)}"]`;
64
92
  doc.querySelector(selector)?.setAttribute(attr, '1');
65
93
  };
94
+ const syncSelectionHighlight = () => {
95
+ if (selectedPath === null) {
96
+ selectionHighlight.style.display = 'none';
97
+ return;
98
+ }
99
+ const selector = `[${DATA_PATH_ATTR}="${CSS.escape(selectedPath)}"]`;
100
+ const node = doc.querySelector(selector);
101
+ const rect = node ? visibleRectOf(node) : null;
102
+ if (!rect) {
103
+ selectionHighlight.style.display = 'none';
104
+ return;
105
+ }
106
+ Object.assign(selectionHighlight.style, {
107
+ display: 'block',
108
+ left: `${rect.x}px`,
109
+ top: `${rect.y}px`,
110
+ width: `${rect.width}px`,
111
+ height: `${rect.height}px`,
112
+ });
113
+ };
114
+ const selectionResizeObserver = new win.ResizeObserver(syncSelectionHighlight);
115
+ const setSelection = (path) => {
116
+ setUnique(DATA_SELECTED_ATTR, path);
117
+ selectedPath = path;
118
+ selectionResizeObserver.disconnect();
119
+ if (path !== null) {
120
+ const selector = `[${DATA_PATH_ATTR}="${CSS.escape(path)}"]`;
121
+ const node = doc.querySelector(selector);
122
+ if (node) {
123
+ selectionResizeObserver.observe(node);
124
+ for (const child of node.children) {
125
+ selectionResizeObserver.observe(child);
126
+ }
127
+ }
128
+ }
129
+ syncSelectionHighlight();
130
+ };
66
131
  const reportRects = () => {
67
132
  const rects = [...doc.querySelectorAll(`[${DATA_PATH_ATTR}]`)].map((node) => ({
68
133
  path: node.getAttribute(DATA_PATH_ATTR),
@@ -95,11 +160,11 @@ export function mountFrameBridge(options) {
95
160
  editorOrigin = event.origin;
96
161
  }
97
162
  options.onDeviceChange?.(message.payload.device);
98
- setUnique(DATA_SELECTED_ATTR, message.payload.selectedPath);
163
+ setSelection(message.payload.selectedPath);
99
164
  break;
100
165
  }
101
166
  case 'block:select':
102
- setUnique(DATA_SELECTED_ATTR, message.payload.path);
167
+ setSelection(message.payload.path);
103
168
  // Rects let the host scroll its full-height canvas to the
104
169
  // selection (the frame itself has no scrollbar in that mode).
105
170
  reportRects();
@@ -149,6 +214,21 @@ export function mountFrameBridge(options) {
149
214
  post({ type: 'manifest:response', payload: { manifest } });
150
215
  break;
151
216
  }
217
+ case 'editor:bootstrap:request': {
218
+ if (!options.getBootstrap) {
219
+ post({
220
+ type: 'bridge:error',
221
+ payload: {
222
+ code: 'bootstrap-unsupported',
223
+ message: 'frame cannot provide Site Editor bootstrap data',
224
+ },
225
+ });
226
+ break;
227
+ }
228
+ const bootstrap = await options.getBootstrap();
229
+ post({ type: 'editor:bootstrap:response', payload: { bootstrap } });
230
+ break;
231
+ }
152
232
  }
153
233
  };
154
234
  const handleClick = (event) => {
@@ -159,9 +239,26 @@ export function mountFrameBridge(options) {
159
239
  if (!node)
160
240
  return;
161
241
  const path = node.getAttribute(DATA_PATH_ATTR);
162
- setUnique(DATA_SELECTED_ATTR, path);
242
+ setSelection(path);
163
243
  post({ type: 'block:clicked', payload: { path, rect: rectOf(node) } });
164
244
  };
245
+ const handleWheel = (event) => {
246
+ // Both previews are fixed device viewports. Let ordinary wheel gestures
247
+ // scroll the storefront; pinch/Ctrl+wheel remains a canvas zoom gesture.
248
+ if (!event.ctrlKey)
249
+ return;
250
+ event.preventDefault();
251
+ post({
252
+ type: 'viewport:wheel',
253
+ payload: {
254
+ deltaX: event.deltaX,
255
+ deltaY: event.deltaY,
256
+ ctrlKey: event.ctrlKey,
257
+ clientX: event.clientX,
258
+ clientY: event.clientY,
259
+ },
260
+ });
261
+ };
165
262
  /** Suppress the page's own interactivity (capture phase, doc root). */
166
263
  const suppress = (event) => {
167
264
  event.preventDefault();
@@ -197,6 +294,7 @@ export function mountFrameBridge(options) {
197
294
  if (height === lastHeight || height <= 0)
198
295
  return;
199
296
  lastHeight = height;
297
+ syncSelectionHighlight();
200
298
  post({ type: 'height:changed', payload: { height } });
201
299
  };
202
300
  const resizeObserver = new win.ResizeObserver(measure);
@@ -204,8 +302,11 @@ export function mountFrameBridge(options) {
204
302
  if (doc.body)
205
303
  resizeObserver.observe(doc.body);
206
304
  win.addEventListener('message', handleMessage);
305
+ win.addEventListener('resize', syncSelectionHighlight);
306
+ win.addEventListener('scroll', syncSelectionHighlight, true);
207
307
  doc.addEventListener('click', handleClick, true);
208
308
  doc.addEventListener('mousemove', handleMouseMove, true);
309
+ doc.addEventListener('wheel', handleWheel, { capture: true, passive: false });
209
310
  for (const type of SUPPRESSED_EVENTS) {
210
311
  doc.addEventListener(type, suppress, true);
211
312
  }
@@ -215,20 +316,26 @@ export function mountFrameBridge(options) {
215
316
  contractVersion: CONTRACT_VERSION,
216
317
  templateName: options.templateName,
217
318
  hash: options.hash,
319
+ capabilities: options.capabilities ?? [],
218
320
  },
219
321
  });
220
322
  measure();
221
323
  return {
222
324
  unmount: () => {
223
325
  win.removeEventListener('message', handleMessage);
326
+ win.removeEventListener('resize', syncSelectionHighlight);
327
+ win.removeEventListener('scroll', syncSelectionHighlight, true);
224
328
  doc.removeEventListener('click', handleClick, true);
225
329
  doc.removeEventListener('mousemove', handleMouseMove, true);
330
+ doc.removeEventListener('wheel', handleWheel, true);
226
331
  for (const type of SUPPRESSED_EVENTS) {
227
332
  doc.removeEventListener(type, suppress, true);
228
333
  }
229
334
  resizeObserver.disconnect();
230
- setUnique(DATA_SELECTED_ATTR, null);
335
+ selectionResizeObserver.disconnect();
336
+ setSelection(null);
231
337
  setUnique(DATA_HOVERED_ATTR, null);
338
+ selectionHighlight.remove();
232
339
  },
233
340
  notifySynced: (hash) => {
234
341
  post({ type: 'template:synced', payload: { hash } });
@@ -5,7 +5,7 @@
5
5
  * when the iframe reloads (sandbox HMR hard-refresh, crash recovery),
6
6
  * and exposes typed send/subscribe surfaces.
7
7
  */
8
- import { type Device, type FrameMessage, type HostMessage } from '../schemas/index.ts';
8
+ import { type Device, type EditorCapability, type FrameMessage, type HostMessage } from '../schemas/index.ts';
9
9
  export interface HostBridgeOptions {
10
10
  iframe: HTMLIFrameElement;
11
11
  /** Origin the preview is served from (the sandbox preview URL). */
@@ -16,6 +16,10 @@ export interface HostBridgeOptions {
16
16
  onMessage?: (message: FrameMessage) => void;
17
17
  /** Called when the frame speaks an incompatible contract version. */
18
18
  onIncompatible?: (frameVersion: string) => void;
19
+ /** Capabilities that must be present before the frame becomes writable. */
20
+ requiredCapabilities?: EditorCapability[];
21
+ /** Called with the required capabilities missing from bridge:ready. */
22
+ onMissingCapabilities?: (capabilities: EditorCapability[]) => void;
19
23
  /** Overrides for tests / non-browser hosts. */
20
24
  window?: Window;
21
25
  }
@@ -38,6 +38,13 @@ export function createHostBridge(options) {
38
38
  }
39
39
  const message = data;
40
40
  if (message.type === 'bridge:ready') {
41
+ const offered = new Set(message.payload.capabilities ?? []);
42
+ const missing = (options.requiredCapabilities ?? []).filter((capability) => !offered.has(capability));
43
+ if (missing.length) {
44
+ ready = false;
45
+ options.onMissingCapabilities?.(missing);
46
+ return;
47
+ }
41
48
  // First load and every iframe reload land here: (re)init.
42
49
  ready = true;
43
50
  post({
@@ -10,6 +10,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
10
10
  */
11
11
  import { useMemo } from 'react';
12
12
  import { colorSchemes, themeSettings } from './settings';
13
+ import { getThemeStoreVersion } from './store';
13
14
  import { googleFontsHrefsFromSettings, parseFontHandle, } from "../../commerce/google-fonts.js";
14
15
  function buildCss() {
15
16
  const s = themeSettings;
@@ -106,7 +107,8 @@ const FONT_CSS_LOADER = [
106
107
  '})(l[i])}',
107
108
  ].join('');
108
109
  export function CssVariables({ nonce, fonts, } = {}) {
109
- const { css, links } = useMemo(() => ({ css: buildCss(), links: googleFontsHrefs() }), []);
110
+ const themeVersion = getThemeStoreVersion();
111
+ const { css, links } = useMemo(() => ({ css: buildCss(), links: googleFontsHrefs() }), [themeVersion]);
110
112
  // Server-resolved fonts (the next/font pattern): @font-face rules are
111
113
  // inlined and the latin woff2 files preloaded, so the font downloads
112
114
  // with the HTML and usually beats first paint — no async stylesheet,
@@ -13,7 +13,7 @@
13
13
  * Server loaders: import {loadSectionData} from '@zalify/storefront-kit/react/server'.
14
14
  */
15
15
  export * from './engine/types';
16
- export { installTheme, getTemplate, getSectionGroup, parseThemeJson, } from './engine/store';
16
+ export { installTheme, getTemplate, getSectionGroup, getThemeStoreVersion, parseThemeJson, } from './engine/store';
17
17
  export type { ThemeSchema, InstallThemeOptions } from './engine/store';
18
18
  export { themeSettings, colorSchemes, getColorScheme } from './engine/settings';
19
19
  export { t } from './engine/translate';
@@ -14,7 +14,7 @@
14
14
  */
15
15
  // Engine
16
16
  export * from './engine/types';
17
- export { installTheme, getTemplate, getSectionGroup, parseThemeJson, } from './engine/store';
17
+ export { installTheme, getTemplate, getSectionGroup, getThemeStoreVersion, parseThemeJson, } from './engine/store';
18
18
  export { themeSettings, colorSchemes, getColorScheme } from './engine/settings';
19
19
  export { t } from './engine/translate';
20
20
  export { ThemeTemplate, SectionGroup, RenderSection, RenderSections, } from './engine/render';
@@ -6,7 +6,7 @@
6
6
  * Both sides validate `event.origin` and the message envelope before
7
7
  * acting; versions negotiate on CONTRACT_VERSION's major component.
8
8
  */
9
- import type { SectionGroupData, SettingsBag, TemplateData } from './data.ts';
9
+ import type { SectionGroupData, SettingsData, TemplateData } from './data.ts';
10
10
  import type { ThemeEditorManifest } from './manifest.ts';
11
11
  export declare const BRIDGE_NAMESPACE = "zalify-editor-bridge";
12
12
  /** Query param that switches a theme preview into editor mode. */
@@ -16,6 +16,35 @@ export declare const DATA_PATH_ATTR = "data-z-path";
16
16
  /** DOM attribute the bridge sets on the selected node. */
17
17
  export declare const DATA_SELECTED_ATTR = "data-z-selected";
18
18
  export type Device = 'desktop' | 'mobile';
19
+ export type EditorCapability = 'editor-bootstrap-v1' | 'apply-template-v1' | 'apply-groups-v1' | 'apply-settings-v1' | 'preview-navigation-v1';
20
+ export type PreviewResourceType = 'index' | 'product' | 'collection' | 'page' | 'blog' | 'article' | 'cart' | 'search' | 'list-collections' | '404';
21
+ export interface PreviewContext {
22
+ id: string;
23
+ title: string;
24
+ url: string;
25
+ resourceType: PreviewResourceType;
26
+ }
27
+ export interface EditorBootstrap {
28
+ revision: string;
29
+ manifest: ThemeEditorManifest;
30
+ templates: Array<{
31
+ name: string;
32
+ writePath: string;
33
+ data: TemplateData;
34
+ preview?: PreviewContext;
35
+ }>;
36
+ groups: Array<{
37
+ name: string;
38
+ writePath: string;
39
+ data: SectionGroupData;
40
+ }>;
41
+ settings: {
42
+ writePath: string;
43
+ schema: ThemeEditorManifest['settingsSchema'];
44
+ resolvedData: SettingsData;
45
+ };
46
+ previewContexts: Partial<Record<'product' | 'collection' | 'page' | 'blog' | 'article', PreviewContext>>;
47
+ }
19
48
  export interface DOMRectLike {
20
49
  x: number;
21
50
  y: number;
@@ -60,14 +89,15 @@ export type HostMessage = BridgeEnvelope<'bridge:init', {
60
89
  templateName: string;
61
90
  template: TemplateData;
62
91
  groups?: Record<string, SectionGroupData>;
63
- settingsData?: SettingsBag;
92
+ settingsData?: SettingsData;
64
93
  }> | BridgeEnvelope<'device:set', {
65
94
  device: Device;
66
- }> | BridgeEnvelope<'manifest:request', Record<string, never>>;
95
+ }> | BridgeEnvelope<'manifest:request', Record<string, never>> | BridgeEnvelope<'editor:bootstrap:request', Record<string, never>>;
67
96
  export type FrameMessage = BridgeEnvelope<'bridge:ready', {
68
97
  contractVersion: string;
69
98
  templateName: string;
70
99
  hash: string;
100
+ capabilities: EditorCapability[];
71
101
  }> | BridgeEnvelope<'block:clicked', {
72
102
  path: string;
73
103
  rect: DOMRectLike;
@@ -81,10 +111,18 @@ export type FrameMessage = BridgeEnvelope<'bridge:ready', {
81
111
  }>;
82
112
  }> | BridgeEnvelope<'manifest:response', {
83
113
  manifest: ThemeEditorManifest;
114
+ }> | BridgeEnvelope<'editor:bootstrap:response', {
115
+ bootstrap: EditorBootstrap;
84
116
  }> | BridgeEnvelope<'template:synced', {
85
117
  hash: string;
86
118
  }> | BridgeEnvelope<'height:changed', {
87
119
  height: number;
120
+ }> | BridgeEnvelope<'viewport:wheel', {
121
+ deltaX: number;
122
+ deltaY: number;
123
+ ctrlKey: boolean;
124
+ clientX: number;
125
+ clientY: number;
88
126
  }> | BridgeEnvelope<'navigation', {
89
127
  templateName: string;
90
128
  url: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalify/storefront-kit",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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",
@@ -26,10 +26,12 @@ import {
26
26
  isCompatibleVersion,
27
27
  type Device,
28
28
  type DOMRectLike,
29
+ type EditorBootstrap,
30
+ type EditorCapability,
29
31
  type FrameMessage,
30
32
  type HostMessage,
31
33
  type SectionGroupData,
32
- type SettingsBag,
34
+ type SettingsData,
33
35
  type TemplateData,
34
36
  type ThemeEditorManifest,
35
37
  } from '../schemas/index.ts';
@@ -45,6 +47,10 @@ export interface FrameBridgeOptions {
45
47
  getManifest: () =>
46
48
  | ThemeEditorManifest
47
49
  | Promise<ThemeEditorManifest>;
50
+ /** Capabilities supported by this deployed storefront runtime. */
51
+ capabilities?: EditorCapability[];
52
+ /** Serve the complete Site Editor bootstrap payload. */
53
+ getBootstrap?: () => EditorBootstrap | Promise<EditorBootstrap>;
48
54
  /**
49
55
  * Hot-apply an updated template in memory (optimistic edit). Return
50
56
  * false (or throw) if the app cannot apply without a reload.
@@ -53,7 +59,7 @@ export interface FrameBridgeOptions {
53
59
  templateName: string;
54
60
  template: TemplateData;
55
61
  groups?: Record<string, SectionGroupData>;
56
- settingsData?: SettingsBag;
62
+ settingsData?: SettingsData;
57
63
  }) => boolean | Promise<boolean>;
58
64
  onDeviceChange?: (device: Device) => void;
59
65
  /** Overrides for tests / non-browser hosts. */
@@ -77,6 +83,22 @@ function rectOf(element: Element): DOMRectLike {
77
83
  return {x: rect.x, y: rect.y, width: rect.width, height: rect.height};
78
84
  }
79
85
 
86
+ function visibleRectOf(element: Element): DOMRectLike | null {
87
+ const ownRect = rectOf(element);
88
+ if (ownRect.width > 0 && ownRect.height > 0) return ownRect;
89
+
90
+ const childRects = [...element.children]
91
+ .map(rectOf)
92
+ .filter((rect) => rect.width > 0 && rect.height > 0);
93
+ if (childRects.length === 0) return null;
94
+
95
+ const left = Math.min(...childRects.map((rect) => rect.x));
96
+ const top = Math.min(...childRects.map((rect) => rect.y));
97
+ const right = Math.max(...childRects.map((rect) => rect.x + rect.width));
98
+ const bottom = Math.max(...childRects.map((rect) => rect.y + rect.height));
99
+ return {x: left, y: top, width: right - left, height: bottom - top};
100
+ }
101
+
80
102
  function pathNodeOf(target: EventTarget | null): HTMLElement | null {
81
103
  if (!(target instanceof Element)) return null;
82
104
  return target.closest<HTMLElement>(`[${DATA_PATH_ATTR}]`);
@@ -103,6 +125,20 @@ export function mountFrameBridge(
103
125
  const doc = win.document;
104
126
  let editorOrigin: string | null = null;
105
127
  let lastHeight = 0;
128
+ let selectedPath: string | null = null;
129
+
130
+ const selectionHighlight = doc.createElement('div');
131
+ selectionHighlight.id = 'zalify-editor-highlight';
132
+ selectionHighlight.setAttribute('aria-hidden', 'true');
133
+ Object.assign(selectionHighlight.style, {
134
+ position: 'fixed',
135
+ display: 'none',
136
+ pointerEvents: 'none',
137
+ boxSizing: 'border-box',
138
+ border: '2px solid #2563eb',
139
+ zIndex: '2147483647',
140
+ });
141
+ (doc.body ?? doc.documentElement).append(selectionHighlight);
106
142
 
107
143
  const post = (message: Omit<FrameMessage, 'z' | 'v'>): void => {
108
144
  // bridge:ready is the only pre-pin message; everything else waits.
@@ -123,6 +159,47 @@ export function mountFrameBridge(
123
159
  doc.querySelector(selector)?.setAttribute(attr, '1');
124
160
  };
125
161
 
162
+ const syncSelectionHighlight = (): void => {
163
+ if (selectedPath === null) {
164
+ selectionHighlight.style.display = 'none';
165
+ return;
166
+ }
167
+ const selector = `[${DATA_PATH_ATTR}="${CSS.escape(selectedPath)}"]`;
168
+ const node = doc.querySelector(selector);
169
+ const rect = node ? visibleRectOf(node) : null;
170
+ if (!rect) {
171
+ selectionHighlight.style.display = 'none';
172
+ return;
173
+ }
174
+ Object.assign(selectionHighlight.style, {
175
+ display: 'block',
176
+ left: `${rect.x}px`,
177
+ top: `${rect.y}px`,
178
+ width: `${rect.width}px`,
179
+ height: `${rect.height}px`,
180
+ });
181
+ };
182
+
183
+ const selectionResizeObserver = new win.ResizeObserver(
184
+ syncSelectionHighlight,
185
+ );
186
+ const setSelection = (path: string | null): void => {
187
+ setUnique(DATA_SELECTED_ATTR, path);
188
+ selectedPath = path;
189
+ selectionResizeObserver.disconnect();
190
+ if (path !== null) {
191
+ const selector = `[${DATA_PATH_ATTR}="${CSS.escape(path)}"]`;
192
+ const node = doc.querySelector(selector);
193
+ if (node) {
194
+ selectionResizeObserver.observe(node);
195
+ for (const child of node.children) {
196
+ selectionResizeObserver.observe(child);
197
+ }
198
+ }
199
+ }
200
+ syncSelectionHighlight();
201
+ };
202
+
126
203
  const reportRects = (): void => {
127
204
  const rects = [...doc.querySelectorAll(`[${DATA_PATH_ATTR}]`)].map(
128
205
  (node) => ({
@@ -155,11 +232,11 @@ export function mountFrameBridge(
155
232
  editorOrigin = event.origin;
156
233
  }
157
234
  options.onDeviceChange?.(message.payload.device);
158
- setUnique(DATA_SELECTED_ATTR, message.payload.selectedPath);
235
+ setSelection(message.payload.selectedPath);
159
236
  break;
160
237
  }
161
238
  case 'block:select':
162
- setUnique(DATA_SELECTED_ATTR, message.payload.path);
239
+ setSelection(message.payload.path);
163
240
  // Rects let the host scroll its full-height canvas to the
164
241
  // selection (the frame itself has no scrollbar in that mode).
165
242
  reportRects();
@@ -208,6 +285,21 @@ export function mountFrameBridge(
208
285
  post({type: 'manifest:response', payload: {manifest}});
209
286
  break;
210
287
  }
288
+ case 'editor:bootstrap:request': {
289
+ if (!options.getBootstrap) {
290
+ post({
291
+ type: 'bridge:error',
292
+ payload: {
293
+ code: 'bootstrap-unsupported',
294
+ message: 'frame cannot provide Site Editor bootstrap data',
295
+ },
296
+ });
297
+ break;
298
+ }
299
+ const bootstrap = await options.getBootstrap();
300
+ post({type: 'editor:bootstrap:response', payload: {bootstrap}});
301
+ break;
302
+ }
211
303
  }
212
304
  };
213
305
 
@@ -218,10 +310,27 @@ export function mountFrameBridge(
218
310
  event.stopPropagation();
219
311
  if (!node) return;
220
312
  const path = node.getAttribute(DATA_PATH_ATTR)!;
221
- setUnique(DATA_SELECTED_ATTR, path);
313
+ setSelection(path);
222
314
  post({type: 'block:clicked', payload: {path, rect: rectOf(node)}});
223
315
  };
224
316
 
317
+ const handleWheel = (event: WheelEvent): void => {
318
+ // Both previews are fixed device viewports. Let ordinary wheel gestures
319
+ // scroll the storefront; pinch/Ctrl+wheel remains a canvas zoom gesture.
320
+ if (!event.ctrlKey) return;
321
+ event.preventDefault();
322
+ post({
323
+ type: 'viewport:wheel',
324
+ payload: {
325
+ deltaX: event.deltaX,
326
+ deltaY: event.deltaY,
327
+ ctrlKey: event.ctrlKey,
328
+ clientX: event.clientX,
329
+ clientY: event.clientY,
330
+ },
331
+ });
332
+ };
333
+
225
334
  /** Suppress the page's own interactivity (capture phase, doc root). */
226
335
  const suppress = (event: Event): void => {
227
336
  event.preventDefault();
@@ -259,6 +368,7 @@ export function mountFrameBridge(
259
368
  );
260
369
  if (height === lastHeight || height <= 0) return;
261
370
  lastHeight = height;
371
+ syncSelectionHighlight();
262
372
  post({type: 'height:changed', payload: {height}});
263
373
  };
264
374
  const resizeObserver = new win.ResizeObserver(measure);
@@ -266,8 +376,11 @@ export function mountFrameBridge(
266
376
  if (doc.body) resizeObserver.observe(doc.body);
267
377
 
268
378
  win.addEventListener('message', handleMessage);
379
+ win.addEventListener('resize', syncSelectionHighlight);
380
+ win.addEventListener('scroll', syncSelectionHighlight, true);
269
381
  doc.addEventListener('click', handleClick, true);
270
382
  doc.addEventListener('mousemove', handleMouseMove, true);
383
+ doc.addEventListener('wheel', handleWheel, {capture: true, passive: false});
271
384
  for (const type of SUPPRESSED_EVENTS) {
272
385
  doc.addEventListener(type, suppress, true);
273
386
  }
@@ -278,6 +391,7 @@ export function mountFrameBridge(
278
391
  contractVersion: CONTRACT_VERSION,
279
392
  templateName: options.templateName,
280
393
  hash: options.hash,
394
+ capabilities: options.capabilities ?? [],
281
395
  },
282
396
  });
283
397
  measure();
@@ -285,14 +399,19 @@ export function mountFrameBridge(
285
399
  return {
286
400
  unmount: () => {
287
401
  win.removeEventListener('message', handleMessage);
402
+ win.removeEventListener('resize', syncSelectionHighlight);
403
+ win.removeEventListener('scroll', syncSelectionHighlight, true);
288
404
  doc.removeEventListener('click', handleClick, true);
289
405
  doc.removeEventListener('mousemove', handleMouseMove, true);
406
+ doc.removeEventListener('wheel', handleWheel, true);
290
407
  for (const type of SUPPRESSED_EVENTS) {
291
408
  doc.removeEventListener(type, suppress, true);
292
409
  }
293
410
  resizeObserver.disconnect();
294
- setUnique(DATA_SELECTED_ATTR, null);
411
+ selectionResizeObserver.disconnect();
412
+ setSelection(null);
295
413
  setUnique(DATA_HOVERED_ATTR, null);
414
+ selectionHighlight.remove();
296
415
  },
297
416
  notifySynced: (hash) => {
298
417
  post({type: 'template:synced', payload: {hash}});
@@ -12,6 +12,7 @@ import {
12
12
  isBridgeMessage,
13
13
  isCompatibleVersion,
14
14
  type Device,
15
+ type EditorCapability,
15
16
  type FrameMessage,
16
17
  type HostMessage,
17
18
  } from '../schemas/index.ts';
@@ -26,6 +27,10 @@ export interface HostBridgeOptions {
26
27
  onMessage?: (message: FrameMessage) => void;
27
28
  /** Called when the frame speaks an incompatible contract version. */
28
29
  onIncompatible?: (frameVersion: string) => void;
30
+ /** Capabilities that must be present before the frame becomes writable. */
31
+ requiredCapabilities?: EditorCapability[];
32
+ /** Called with the required capabilities missing from bridge:ready. */
33
+ onMissingCapabilities?: (capabilities: EditorCapability[]) => void;
29
34
  /** Overrides for tests / non-browser hosts. */
30
35
  window?: Window;
31
36
  }
@@ -71,6 +76,15 @@ export function createHostBridge(options: HostBridgeOptions): HostBridge {
71
76
  }
72
77
  const message = data as FrameMessage;
73
78
  if (message.type === 'bridge:ready') {
79
+ const offered = new Set(message.payload.capabilities ?? []);
80
+ const missing = (options.requiredCapabilities ?? []).filter(
81
+ (capability) => !offered.has(capability),
82
+ );
83
+ if (missing.length) {
84
+ ready = false;
85
+ options.onMissingCapabilities?.(missing);
86
+ return;
87
+ }
74
88
  // First load and every iframe reload land here: (re)init.
75
89
  ready = true;
76
90
  post({
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import {useMemo} from 'react';
11
11
  import {colorSchemes, themeSettings} from './settings';
12
+ import {getThemeStoreVersion} from './store';
12
13
  import {
13
14
  googleFontsHrefsFromSettings,
14
15
  parseFontHandle,
@@ -136,9 +137,10 @@ export function CssVariables({
136
137
  nonce,
137
138
  fonts,
138
139
  }: {nonce?: string; fonts?: ResolvedGoogleFontCss | null} = {}) {
140
+ const themeVersion = getThemeStoreVersion();
139
141
  const {css, links} = useMemo(
140
142
  () => ({css: buildCss(), links: googleFontsHrefs()}),
141
- [],
143
+ [themeVersion],
142
144
  );
143
145
 
144
146
  // Server-resolved fonts (the next/font pattern): @font-face rules are
@@ -19,6 +19,7 @@ export {
19
19
  installTheme,
20
20
  getTemplate,
21
21
  getSectionGroup,
22
+ getThemeStoreVersion,
22
23
  parseThemeJson,
23
24
  } from './engine/store';
24
25
  export type {ThemeSchema, InstallThemeOptions} from './engine/store';
@@ -6,7 +6,11 @@
6
6
  * Both sides validate `event.origin` and the message envelope before
7
7
  * acting; versions negotiate on CONTRACT_VERSION's major component.
8
8
  */
9
- import type {SectionGroupData, SettingsBag, TemplateData} from './data.ts';
9
+ import type {
10
+ SectionGroupData,
11
+ SettingsData,
12
+ TemplateData,
13
+ } from './data.ts';
10
14
  import type {ThemeEditorManifest} from './manifest.ts';
11
15
 
12
16
  export const BRIDGE_NAMESPACE = 'zalify-editor-bridge';
@@ -21,6 +25,56 @@ export const DATA_SELECTED_ATTR = 'data-z-selected';
21
25
 
22
26
  export type Device = 'desktop' | 'mobile';
23
27
 
28
+ export type EditorCapability =
29
+ | 'editor-bootstrap-v1'
30
+ | 'apply-template-v1'
31
+ | 'apply-groups-v1'
32
+ | 'apply-settings-v1'
33
+ | 'preview-navigation-v1';
34
+
35
+ export type PreviewResourceType =
36
+ | 'index'
37
+ | 'product'
38
+ | 'collection'
39
+ | 'page'
40
+ | 'blog'
41
+ | 'article'
42
+ | 'cart'
43
+ | 'search'
44
+ | 'list-collections'
45
+ | '404';
46
+
47
+ export interface PreviewContext {
48
+ id: string;
49
+ title: string;
50
+ url: string;
51
+ resourceType: PreviewResourceType;
52
+ }
53
+
54
+ export interface EditorBootstrap {
55
+ revision: string;
56
+ manifest: ThemeEditorManifest;
57
+ templates: Array<{
58
+ name: string;
59
+ writePath: string;
60
+ data: TemplateData;
61
+ preview?: PreviewContext;
62
+ }>;
63
+ groups: Array<{
64
+ name: string;
65
+ writePath: string;
66
+ data: SectionGroupData;
67
+ }>;
68
+ settings: {
69
+ writePath: string;
70
+ schema: ThemeEditorManifest['settingsSchema'];
71
+ resolvedData: SettingsData;
72
+ };
73
+ previewContexts: Partial<
74
+ Record<'product' | 'collection' | 'page' | 'blog' | 'article', PreviewContext>
75
+ >;
76
+ }
77
+
24
78
  export interface DOMRectLike {
25
79
  x: number;
26
80
  y: number;
@@ -96,18 +150,24 @@ export type HostMessage =
96
150
  templateName: string;
97
151
  template: TemplateData;
98
152
  groups?: Record<string, SectionGroupData>;
99
- settingsData?: SettingsBag;
153
+ settingsData?: SettingsData;
100
154
  }
101
155
  >
102
156
  | BridgeEnvelope<'device:set', {device: Device}>
103
- | BridgeEnvelope<'manifest:request', Record<string, never>>;
157
+ | BridgeEnvelope<'manifest:request', Record<string, never>>
158
+ | BridgeEnvelope<'editor:bootstrap:request', Record<string, never>>;
104
159
 
105
160
  /* ------------------------- frame -> host ---------------------------- */
106
161
 
107
162
  export type FrameMessage =
108
163
  | BridgeEnvelope<
109
164
  'bridge:ready',
110
- {contractVersion: string; templateName: string; hash: string}
165
+ {
166
+ contractVersion: string;
167
+ templateName: string;
168
+ hash: string;
169
+ capabilities: EditorCapability[];
170
+ }
111
171
  >
112
172
  | BridgeEnvelope<'block:clicked', {path: string; rect: DOMRectLike}>
113
173
  | BridgeEnvelope<'block:hovered', {path: string | null; rect?: DOMRectLike}>
@@ -116,8 +176,19 @@ export type FrameMessage =
116
176
  {rects: Array<{path: string; rect: DOMRectLike}>}
117
177
  >
118
178
  | BridgeEnvelope<'manifest:response', {manifest: ThemeEditorManifest}>
179
+ | BridgeEnvelope<'editor:bootstrap:response', {bootstrap: EditorBootstrap}>
119
180
  | BridgeEnvelope<'template:synced', {hash: string}>
120
181
  | BridgeEnvelope<'height:changed', {height: number}>
182
+ | BridgeEnvelope<
183
+ 'viewport:wheel',
184
+ {
185
+ deltaX: number;
186
+ deltaY: number;
187
+ ctrlKey: boolean;
188
+ clientX: number;
189
+ clientY: number;
190
+ }
191
+ >
121
192
  | BridgeEnvelope<'navigation', {templateName: string; url: string}>
122
193
  | BridgeEnvelope<'bridge:error', {code: string; message: string}>;
123
194