@zalify/storefront-kit 0.2.0 → 0.3.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.
@@ -6,11 +6,20 @@
6
6
  * sync — and hands app-specific concerns (template hot-apply, device
7
7
  * emulation) to callbacks.
8
8
  *
9
- * Editor mode neutralizes the page's own interactivity: clicks,
10
- * double/middle clicks, form submits, and context menus are captured
11
- * at the document root and suppressed, so selecting a node never
12
- * navigates a link, opens a drawer, or submits a form — a click is
13
- * only ever a selection. (Hover stays live for highlight tracking.)
9
+ * Editor mode keeps the page interactive buttons, drawers, variant
10
+ * pickers, forms all work, so any state can be previewed — while the
11
+ * bridge takes over two things a click must never do on its own:
12
+ *
13
+ * - Leave the storefront. Same-origin links and GET forms are turned
14
+ * into `location.replace` navigations that keep the editor-mode
15
+ * query param (so the next document mounts the bridge again) and
16
+ * add no browser-history entries; off-site links, new-tab links,
17
+ * modifier-clicks and off-site paths (checkout, account) are blocked.
18
+ * - Escape selection: every click still selects the enclosing block.
19
+ *
20
+ * Double/middle clicks and context menus stay suppressed. Each mount
21
+ * reports its URL as a `navigation`, which is how the editor follows
22
+ * in-preview browsing.
14
23
  *
15
24
  * Security: the first `bridge:init` pins the editor origin; every
16
25
  * later message must match it, and nothing but `bridge:ready` is ever
@@ -47,6 +56,25 @@ export interface FrameBridgeOptions {
47
56
  }
48
57
  /** True when this document should mount the editor bridge. */
49
58
  export declare function isEditorMode(win?: Window): boolean;
59
+ export type NavigationDecision = {
60
+ kind: 'allow';
61
+ url: string;
62
+ } | {
63
+ kind: 'block';
64
+ reason: 'off-origin' | 'new-tab' | 'modifier' | 'off-site' | 'download';
65
+ };
66
+ /**
67
+ * Where a click on `anchor` may take the preview. Same-origin page loads are
68
+ * allowed (with the editor-mode param re-attached); anything that would leave
69
+ * the sandbox or open another window is blocked.
70
+ */
71
+ export declare function decideNavigation(href: string, base: string, flags: {
72
+ target?: string | null;
73
+ download?: boolean;
74
+ modifier?: boolean;
75
+ }): NavigationDecision;
76
+ /** The storefront path the editor should remember: no editor-mode param. */
77
+ export declare function previewPathOf(href: string): string;
50
78
  export interface FrameBridgeController {
51
79
  unmount: () => void;
52
80
  /** Report that a persisted write round-tripped (HMR applied `hash`). */
@@ -6,11 +6,20 @@
6
6
  * sync — and hands app-specific concerns (template hot-apply, device
7
7
  * emulation) to callbacks.
8
8
  *
9
- * Editor mode neutralizes the page's own interactivity: clicks,
10
- * double/middle clicks, form submits, and context menus are captured
11
- * at the document root and suppressed, so selecting a node never
12
- * navigates a link, opens a drawer, or submits a form — a click is
13
- * only ever a selection. (Hover stays live for highlight tracking.)
9
+ * Editor mode keeps the page interactive buttons, drawers, variant
10
+ * pickers, forms all work, so any state can be previewed — while the
11
+ * bridge takes over two things a click must never do on its own:
12
+ *
13
+ * - Leave the storefront. Same-origin links and GET forms are turned
14
+ * into `location.replace` navigations that keep the editor-mode
15
+ * query param (so the next document mounts the bridge again) and
16
+ * add no browser-history entries; off-site links, new-tab links,
17
+ * modifier-clicks and off-site paths (checkout, account) are blocked.
18
+ * - Escape selection: every click still selects the enclosing block.
19
+ *
20
+ * Double/middle clicks and context menus stay suppressed. Each mount
21
+ * reports its URL as a `navigation`, which is how the editor follows
22
+ * in-preview browsing.
14
23
  *
15
24
  * Security: the first `bridge:init` pins the editor origin; every
16
25
  * later message must match it, and nothing but `bridge:ready` is ever
@@ -49,10 +58,51 @@ function visibleRectOf(element) {
49
58
  return { x: left, y: top, width: right - left, height: bottom - top };
50
59
  }
51
60
  function pathNodeOf(target) {
52
- if (!(target instanceof Element))
61
+ if (!isElement(target))
53
62
  return null;
54
63
  return target.closest(`[${DATA_PATH_ATTR}]`);
55
64
  }
65
+ function isElement(target) {
66
+ return (typeof Element !== 'undefined' && target instanceof Element);
67
+ }
68
+ /** Storefront paths that hand off to Shopify-hosted pages; never previewable. */
69
+ const OFF_SITE_PATHS = [/^\/checkout(?:s)?(?:\/|$)/, /^\/cart\/c\//, /^\/account(?:\/|$)/, /^\/admin(?:\/|$)/];
70
+ /**
71
+ * Where a click on `anchor` may take the preview. Same-origin page loads are
72
+ * allowed (with the editor-mode param re-attached); anything that would leave
73
+ * the sandbox or open another window is blocked.
74
+ */
75
+ export function decideNavigation(href, base, flags) {
76
+ if (flags.modifier)
77
+ return { kind: 'block', reason: 'modifier' };
78
+ if (flags.download)
79
+ return { kind: 'block', reason: 'download' };
80
+ if (flags.target && flags.target !== '_self')
81
+ return { kind: 'block', reason: 'new-tab' };
82
+ let url;
83
+ try {
84
+ url = new URL(href, base);
85
+ }
86
+ catch {
87
+ return { kind: 'block', reason: 'off-origin' };
88
+ }
89
+ if (url.origin !== new URL(base).origin)
90
+ return { kind: 'block', reason: 'off-origin' };
91
+ if (OFF_SITE_PATHS.some((pattern) => pattern.test(url.pathname))) {
92
+ return { kind: 'block', reason: 'off-site' };
93
+ }
94
+ url.searchParams.set(EDITOR_MODE_PARAM, '1');
95
+ return { kind: 'allow', url: url.toString() };
96
+ }
97
+ /** The storefront path the editor should remember: no editor-mode param. */
98
+ export function previewPathOf(href) {
99
+ const url = new URL(href);
100
+ url.searchParams.delete(EDITOR_MODE_PARAM);
101
+ return url.pathname + url.search;
102
+ }
103
+ function anchorOf(target) {
104
+ return isElement(target) ? target.closest('a[href]') : null;
105
+ }
56
106
  /**
57
107
  * Mount the frame bridge. Returns a controller with an `unmount`
58
108
  * function. Call only when `isEditorMode()` is true.
@@ -82,6 +132,15 @@ export function mountFrameBridge(options) {
82
132
  return;
83
133
  win.parent.postMessage({ z: BRIDGE_NAMESPACE, v: CONTRACT_VERSION, ...message }, target);
84
134
  };
135
+ // Every mount tells the editor where the preview is now: after an
136
+ // in-preview link or search the host would otherwise keep showing the
137
+ // page it originally asked for. Sent once the editor origin is pinned.
138
+ const reportNavigation = () => {
139
+ post({
140
+ type: 'navigation',
141
+ payload: { templateName: options.templateName, url: previewPathOf(win.location.href) },
142
+ });
143
+ };
85
144
  const setUnique = (attr, path) => {
86
145
  for (const node of doc.querySelectorAll(`[${attr}]`)) {
87
146
  node.removeAttribute(attr);
@@ -171,6 +230,7 @@ export function mountFrameBridge(options) {
171
230
  queueMicrotask(() => {
172
231
  lastHeight = 0;
173
232
  measure();
233
+ reportNavigation();
174
234
  });
175
235
  break;
176
236
  }
@@ -244,14 +304,41 @@ export function mountFrameBridge(options) {
244
304
  };
245
305
  const handleClick = (event) => {
246
306
  const node = pathNodeOf(event.target);
247
- // Editor mode owns clicks: no navigation, no dialogs.
307
+ if (node) {
308
+ const path = node.getAttribute(DATA_PATH_ATTR);
309
+ setSelection(path);
310
+ post({ type: 'block:clicked', payload: { path, rect: rectOf(node) } });
311
+ }
312
+ // The page keeps its interactivity; only navigation is policed.
313
+ const anchor = anchorOf(event.target);
314
+ if (!anchor)
315
+ return;
316
+ const decision = decideNavigation(anchor.getAttribute('href') ?? '', win.location.href, {
317
+ target: anchor.getAttribute('target'),
318
+ download: anchor.hasAttribute('download'),
319
+ modifier: event.metaKey || event.ctrlKey || event.shiftKey || event.altKey,
320
+ });
248
321
  event.preventDefault();
249
- event.stopPropagation();
250
- if (!node)
322
+ if (decision.kind === 'allow')
323
+ win.location.replace(decision.url);
324
+ };
325
+ const handleSubmit = (event) => {
326
+ const form = event.target;
327
+ if (!(form instanceof win.HTMLFormElement))
251
328
  return;
252
- const path = node.getAttribute(DATA_PATH_ATTR);
253
- setSelection(path);
254
- post({ type: 'block:clicked', payload: { path, rect: rectOf(node) } });
329
+ if ((form.getAttribute('method') ?? 'get').toLowerCase() !== 'get')
330
+ return;
331
+ // A GET form is a navigation: route it like a link so the editor-mode
332
+ // param survives and history stays clean. POST forms run as they are.
333
+ const action = new URL(form.getAttribute('action') || win.location.pathname, win.location.href);
334
+ for (const [key, value] of new FormData(form)) {
335
+ if (typeof value === 'string')
336
+ action.searchParams.set(key, value);
337
+ }
338
+ const decision = decideNavigation(action.toString(), win.location.href, {});
339
+ event.preventDefault();
340
+ if (decision.kind === 'allow')
341
+ win.location.replace(decision.url);
255
342
  };
256
343
  const handleWheel = (event) => {
257
344
  // Cross-origin iframe wheel events never reach the host canvas. Forward
@@ -274,12 +361,7 @@ export function mountFrameBridge(options) {
274
361
  event.preventDefault();
275
362
  event.stopPropagation();
276
363
  };
277
- const SUPPRESSED_EVENTS = [
278
- 'dblclick',
279
- 'auxclick',
280
- 'submit',
281
- 'contextmenu',
282
- ];
364
+ const SUPPRESSED_EVENTS = ['dblclick', 'auxclick', 'contextmenu'];
283
365
  const handleMouseMove = (() => {
284
366
  let lastPath = null;
285
367
  return (event) => {
@@ -315,6 +397,7 @@ export function mountFrameBridge(options) {
315
397
  win.addEventListener('resize', syncSelectionHighlight);
316
398
  win.addEventListener('scroll', syncSelectionHighlight, true);
317
399
  doc.addEventListener('click', handleClick, true);
400
+ doc.addEventListener('submit', handleSubmit, true);
318
401
  doc.addEventListener('mousemove', handleMouseMove, true);
319
402
  doc.addEventListener('wheel', handleWheel, { capture: true, passive: false });
320
403
  for (const type of SUPPRESSED_EVENTS) {
@@ -336,6 +419,7 @@ export function mountFrameBridge(options) {
336
419
  win.removeEventListener('resize', syncSelectionHighlight);
337
420
  win.removeEventListener('scroll', syncSelectionHighlight, true);
338
421
  doc.removeEventListener('click', handleClick, true);
422
+ doc.removeEventListener('submit', handleSubmit, true);
339
423
  doc.removeEventListener('mousemove', handleMouseMove, true);
340
424
  doc.removeEventListener('wheel', handleWheel, true);
341
425
  for (const type of SUPPRESSED_EVENTS) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalify/storefront-kit",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -6,11 +6,20 @@
6
6
  * sync — and hands app-specific concerns (template hot-apply, device
7
7
  * emulation) to callbacks.
8
8
  *
9
- * Editor mode neutralizes the page's own interactivity: clicks,
10
- * double/middle clicks, form submits, and context menus are captured
11
- * at the document root and suppressed, so selecting a node never
12
- * navigates a link, opens a drawer, or submits a form — a click is
13
- * only ever a selection. (Hover stays live for highlight tracking.)
9
+ * Editor mode keeps the page interactive buttons, drawers, variant
10
+ * pickers, forms all work, so any state can be previewed — while the
11
+ * bridge takes over two things a click must never do on its own:
12
+ *
13
+ * - Leave the storefront. Same-origin links and GET forms are turned
14
+ * into `location.replace` navigations that keep the editor-mode
15
+ * query param (so the next document mounts the bridge again) and
16
+ * add no browser-history entries; off-site links, new-tab links,
17
+ * modifier-clicks and off-site paths (checkout, account) are blocked.
18
+ * - Escape selection: every click still selects the enclosing block.
19
+ *
20
+ * Double/middle clicks and context menus stay suppressed. Each mount
21
+ * reports its URL as a `navigation`, which is how the editor follows
22
+ * in-preview browsing.
14
23
  *
15
24
  * Security: the first `bridge:init` pins the editor origin; every
16
25
  * later message must match it, and nothing but `bridge:ready` is ever
@@ -102,10 +111,61 @@ function visibleRectOf(element: Element): DOMRectLike | null {
102
111
  }
103
112
 
104
113
  function pathNodeOf(target: EventTarget | null): HTMLElement | null {
105
- if (!(target instanceof Element)) return null;
114
+ if (!isElement(target)) return null;
106
115
  return target.closest<HTMLElement>(`[${DATA_PATH_ATTR}]`);
107
116
  }
108
117
 
118
+ function isElement(target: EventTarget | null): target is Element {
119
+ return (
120
+ typeof Element !== 'undefined' && target instanceof Element
121
+ );
122
+ }
123
+
124
+ /** Storefront paths that hand off to Shopify-hosted pages; never previewable. */
125
+ const OFF_SITE_PATHS = [/^\/checkout(?:s)?(?:\/|$)/, /^\/cart\/c\//, /^\/account(?:\/|$)/, /^\/admin(?:\/|$)/];
126
+
127
+ export type NavigationDecision =
128
+ | {kind: 'allow'; url: string}
129
+ | {kind: 'block'; reason: 'off-origin' | 'new-tab' | 'modifier' | 'off-site' | 'download'};
130
+
131
+ /**
132
+ * Where a click on `anchor` may take the preview. Same-origin page loads are
133
+ * allowed (with the editor-mode param re-attached); anything that would leave
134
+ * the sandbox or open another window is blocked.
135
+ */
136
+ export function decideNavigation(
137
+ href: string,
138
+ base: string,
139
+ flags: {target?: string | null; download?: boolean; modifier?: boolean},
140
+ ): NavigationDecision {
141
+ if (flags.modifier) return {kind: 'block', reason: 'modifier'};
142
+ if (flags.download) return {kind: 'block', reason: 'download'};
143
+ if (flags.target && flags.target !== '_self') return {kind: 'block', reason: 'new-tab'};
144
+ let url: URL;
145
+ try {
146
+ url = new URL(href, base);
147
+ } catch {
148
+ return {kind: 'block', reason: 'off-origin'};
149
+ }
150
+ if (url.origin !== new URL(base).origin) return {kind: 'block', reason: 'off-origin'};
151
+ if (OFF_SITE_PATHS.some((pattern) => pattern.test(url.pathname))) {
152
+ return {kind: 'block', reason: 'off-site'};
153
+ }
154
+ url.searchParams.set(EDITOR_MODE_PARAM, '1');
155
+ return {kind: 'allow', url: url.toString()};
156
+ }
157
+
158
+ /** The storefront path the editor should remember: no editor-mode param. */
159
+ export function previewPathOf(href: string): string {
160
+ const url = new URL(href);
161
+ url.searchParams.delete(EDITOR_MODE_PARAM);
162
+ return url.pathname + url.search;
163
+ }
164
+
165
+ function anchorOf(target: EventTarget | null): HTMLAnchorElement | null {
166
+ return isElement(target) ? target.closest<HTMLAnchorElement>('a[href]') : null;
167
+ }
168
+
109
169
  export interface FrameBridgeController {
110
170
  unmount: () => void;
111
171
  /** Report that a persisted write round-tripped (HMR applied `hash`). */
@@ -152,6 +212,16 @@ export function mountFrameBridge(
152
212
  );
153
213
  };
154
214
 
215
+ // Every mount tells the editor where the preview is now: after an
216
+ // in-preview link or search the host would otherwise keep showing the
217
+ // page it originally asked for. Sent once the editor origin is pinned.
218
+ const reportNavigation = (): void => {
219
+ post({
220
+ type: 'navigation',
221
+ payload: {templateName: options.templateName, url: previewPathOf(win.location.href)},
222
+ });
223
+ };
224
+
155
225
  const setUnique = (attr: string, path: string | null): void => {
156
226
  for (const node of doc.querySelectorAll(`[${attr}]`)) {
157
227
  node.removeAttribute(attr);
@@ -243,6 +313,7 @@ export function mountFrameBridge(
243
313
  queueMicrotask(() => {
244
314
  lastHeight = 0;
245
315
  measure();
316
+ reportNavigation();
246
317
  });
247
318
  break;
248
319
  }
@@ -316,13 +387,36 @@ export function mountFrameBridge(
316
387
 
317
388
  const handleClick = (event: MouseEvent): void => {
318
389
  const node = pathNodeOf(event.target);
319
- // Editor mode owns clicks: no navigation, no dialogs.
390
+ if (node) {
391
+ const path = node.getAttribute(DATA_PATH_ATTR)!;
392
+ setSelection(path);
393
+ post({type: 'block:clicked', payload: {path, rect: rectOf(node)}});
394
+ }
395
+ // The page keeps its interactivity; only navigation is policed.
396
+ const anchor = anchorOf(event.target);
397
+ if (!anchor) return;
398
+ const decision = decideNavigation(anchor.getAttribute('href') ?? '', win.location.href, {
399
+ target: anchor.getAttribute('target'),
400
+ download: anchor.hasAttribute('download'),
401
+ modifier: event.metaKey || event.ctrlKey || event.shiftKey || event.altKey,
402
+ });
320
403
  event.preventDefault();
321
- event.stopPropagation();
322
- if (!node) return;
323
- const path = node.getAttribute(DATA_PATH_ATTR)!;
324
- setSelection(path);
325
- post({type: 'block:clicked', payload: {path, rect: rectOf(node)}});
404
+ if (decision.kind === 'allow') win.location.replace(decision.url);
405
+ };
406
+
407
+ const handleSubmit = (event: Event): void => {
408
+ const form = event.target;
409
+ if (!(form instanceof win.HTMLFormElement)) return;
410
+ if ((form.getAttribute('method') ?? 'get').toLowerCase() !== 'get') return;
411
+ // A GET form is a navigation: route it like a link so the editor-mode
412
+ // param survives and history stays clean. POST forms run as they are.
413
+ const action = new URL(form.getAttribute('action') || win.location.pathname, win.location.href);
414
+ for (const [key, value] of new FormData(form)) {
415
+ if (typeof value === 'string') action.searchParams.set(key, value);
416
+ }
417
+ const decision = decideNavigation(action.toString(), win.location.href, {});
418
+ event.preventDefault();
419
+ if (decision.kind === 'allow') win.location.replace(decision.url);
326
420
  };
327
421
 
328
422
  const handleWheel = (event: WheelEvent): void => {
@@ -347,12 +441,7 @@ export function mountFrameBridge(
347
441
  event.preventDefault();
348
442
  event.stopPropagation();
349
443
  };
350
- const SUPPRESSED_EVENTS = [
351
- 'dblclick',
352
- 'auxclick',
353
- 'submit',
354
- 'contextmenu',
355
- ] as const;
444
+ const SUPPRESSED_EVENTS = ['dblclick', 'auxclick', 'contextmenu'] as const;
356
445
 
357
446
  const handleMouseMove = (() => {
358
447
  let lastPath: string | null = null;
@@ -390,6 +479,7 @@ export function mountFrameBridge(
390
479
  win.addEventListener('resize', syncSelectionHighlight);
391
480
  win.addEventListener('scroll', syncSelectionHighlight, true);
392
481
  doc.addEventListener('click', handleClick, true);
482
+ doc.addEventListener('submit', handleSubmit, true);
393
483
  doc.addEventListener('mousemove', handleMouseMove, true);
394
484
  doc.addEventListener('wheel', handleWheel, {capture: true, passive: false});
395
485
  for (const type of SUPPRESSED_EVENTS) {
@@ -413,6 +503,7 @@ export function mountFrameBridge(
413
503
  win.removeEventListener('resize', syncSelectionHighlight);
414
504
  win.removeEventListener('scroll', syncSelectionHighlight, true);
415
505
  doc.removeEventListener('click', handleClick, true);
506
+ doc.removeEventListener('submit', handleSubmit, true);
416
507
  doc.removeEventListener('mousemove', handleMouseMove, true);
417
508
  doc.removeEventListener('wheel', handleWheel, true);
418
509
  for (const type of SUPPRESSED_EVENTS) {