@shoppexio/builder-runtime 0.1.0 → 0.1.2

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.
Files changed (53) hide show
  1. package/dist/css-vars.d.ts.map +1 -1
  2. package/dist/css-vars.js +24 -6
  3. package/dist/index.d.ts +4 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +4 -0
  6. package/dist/layout.d.ts +5 -1
  7. package/dist/layout.d.ts.map +1 -1
  8. package/dist/layout.js +2 -0
  9. package/dist/preview-fixtures.d.ts +16 -0
  10. package/dist/preview-fixtures.d.ts.map +1 -0
  11. package/dist/preview-fixtures.js +40 -0
  12. package/dist/product-page.d.ts +13 -0
  13. package/dist/product-page.d.ts.map +1 -0
  14. package/dist/product-page.js +18 -0
  15. package/dist/react.d.ts +36 -224
  16. package/dist/react.d.ts.map +1 -1
  17. package/dist/react.js +606 -47
  18. package/dist/search-bar-settings.d.ts +33 -0
  19. package/dist/search-bar-settings.d.ts.map +1 -0
  20. package/dist/search-bar-settings.js +99 -0
  21. package/dist/standard-product-blocks.d.ts +48 -0
  22. package/dist/standard-product-blocks.d.ts.map +1 -0
  23. package/dist/standard-product-blocks.js +45 -0
  24. package/dist/standard-product-page.d.ts +69 -0
  25. package/dist/standard-product-page.d.ts.map +1 -0
  26. package/dist/standard-product-page.js +89 -0
  27. package/dist/storefront-google-fonts.d.ts +2 -0
  28. package/dist/storefront-google-fonts.d.ts.map +1 -0
  29. package/dist/storefront-google-fonts.js +28 -0
  30. package/package.json +3 -3
  31. package/src/builder-runtime.test.ts +57 -0
  32. package/src/css-vars.ts +29 -8
  33. package/src/index.ts +4 -0
  34. package/src/layout.ts +14 -1
  35. package/src/preview-fixtures.ts +56 -0
  36. package/src/product-page.test.ts +37 -0
  37. package/src/product-page.ts +32 -0
  38. package/src/react-runtime.test.tsx +215 -3
  39. package/src/react.tsx +769 -45
  40. package/src/search-bar-settings.test.ts +72 -0
  41. package/src/search-bar-settings.ts +176 -0
  42. package/src/standard-product-blocks.test.tsx +93 -0
  43. package/src/standard-product-blocks.tsx +121 -0
  44. package/src/standard-product-page.test.ts +171 -0
  45. package/src/standard-product-page.ts +169 -0
  46. package/src/storefront-google-fonts.test.ts +31 -0
  47. package/src/storefront-google-fonts.ts +43 -0
  48. package/dist/builder-runtime.test.d.ts +0 -2
  49. package/dist/builder-runtime.test.d.ts.map +0 -1
  50. package/dist/builder-runtime.test.js +0 -115
  51. package/dist/react-runtime.test.d.ts +0 -2
  52. package/dist/react-runtime.test.d.ts.map +0 -1
  53. package/dist/react-runtime.test.js +0 -292
@@ -0,0 +1,169 @@
1
+ import type { BlockInstance } from '@shoppex/builder-contracts';
2
+ import { getBuilderBlockSettingText } from './product-page.js';
3
+
4
+ export const STANDARD_PRODUCT_BLOCK_TYPES = [
5
+ 'gallery',
6
+ 'buy-box',
7
+ 'details',
8
+ 'related-products',
9
+ ] as const;
10
+
11
+ export type StandardProductBlockType = (typeof STANDARD_PRODUCT_BLOCK_TYPES)[number];
12
+
13
+ export const STANDARD_PRODUCT_PRIMARY_BLOCK_TYPES = ['gallery', 'buy-box'] as const;
14
+ export const STANDARD_PRODUCT_SECONDARY_BLOCK_TYPES = ['details', 'related-products'] as const;
15
+
16
+ export type StandardProductSettingScope = 'buyBox' | 'details' | 'relatedProducts';
17
+
18
+ const SCOPE_PREFIX: Record<StandardProductSettingScope, string> = {
19
+ buyBox: 'product.buyBox',
20
+ details: 'product.details',
21
+ relatedProducts: 'product.relatedProducts',
22
+ };
23
+
24
+ export function splitStandardProductPageBlocks(blocks: BlockInstance[]) {
25
+ return {
26
+ primary: blocks.filter((block) => block.type === 'gallery' || block.type === 'buy-box'),
27
+ secondary: blocks.filter((block) => block.type === 'details' || block.type === 'related-products'),
28
+ };
29
+ }
30
+
31
+ export function resolveScopedBlockSettingText(
32
+ block: Pick<BlockInstance, 'settings'>,
33
+ scope: StandardProductSettingScope,
34
+ key: string,
35
+ ): string | null {
36
+ const prefixed = getBuilderBlockSettingText(block, `${SCOPE_PREFIX[scope]}.${key}`);
37
+ if (prefixed) return prefixed;
38
+ return getBuilderBlockSettingText(block, key);
39
+ }
40
+
41
+ export type StandardBuyBoxLabels = {
42
+ variantLabel: string | null;
43
+ addonsLabel: string | null;
44
+ primaryActionLabel: string | null;
45
+ buyNowLabel: string | null;
46
+ quantityLabel: string | null;
47
+ reviewsLabel: string | null;
48
+ noReviewsLabel: string | null;
49
+ };
50
+
51
+ export function resolveStandardBuyBoxLabels(
52
+ block: Pick<BlockInstance, 'settings'>,
53
+ options?: { useScopedKeys?: boolean },
54
+ ): StandardBuyBoxLabels {
55
+ const read = (key: string) =>
56
+ options?.useScopedKeys
57
+ ? resolveScopedBlockSettingText(block, 'buyBox', key)
58
+ : getBuilderBlockSettingText(block, key);
59
+
60
+ return {
61
+ variantLabel: read('variantLabel'),
62
+ addonsLabel: read('addonsLabel'),
63
+ primaryActionLabel: read('primaryActionLabel'),
64
+ buyNowLabel: read('buyNowLabel'),
65
+ quantityLabel: read('quantityLabel'),
66
+ reviewsLabel: read('reviewsLabel'),
67
+ noReviewsLabel: read('noReviewsLabel'),
68
+ };
69
+ }
70
+
71
+ export type StandardDetailsLabels = {
72
+ descriptionTabLabel: string | null;
73
+ reviewsTabLabel: string | null;
74
+ shopReviewsTabLabel: string | null;
75
+ faqTabLabel: string | null;
76
+ emptyDescriptionLabel: string | null;
77
+ emptyReviewsTitle: string | null;
78
+ emptyReviewsDescription: string | null;
79
+ emptyFaqLabel: string | null;
80
+ };
81
+
82
+ export function resolveStandardDetailsLabels(
83
+ block: Pick<BlockInstance, 'settings'>,
84
+ options?: { useScopedKeys?: boolean },
85
+ ): StandardDetailsLabels {
86
+ const read = (key: string) =>
87
+ options?.useScopedKeys
88
+ ? resolveScopedBlockSettingText(block, 'details', key)
89
+ : getBuilderBlockSettingText(block, key);
90
+
91
+ return {
92
+ descriptionTabLabel: read('descriptionTabLabel'),
93
+ reviewsTabLabel: read('reviewsTabLabel'),
94
+ shopReviewsTabLabel: read('shopReviewsTabLabel'),
95
+ faqTabLabel: read('faqTabLabel'),
96
+ emptyDescriptionLabel: read('emptyDescriptionLabel'),
97
+ emptyReviewsTitle: read('emptyReviewsTitle'),
98
+ emptyReviewsDescription: read('emptyReviewsDescription'),
99
+ emptyFaqLabel: read('emptyFaqLabel'),
100
+ };
101
+ }
102
+
103
+ export type StandardProductTabSpec = {
104
+ id: 'description' | 'reviews' | 'faq';
105
+ label: string;
106
+ content?: string;
107
+ badge?: string;
108
+ };
109
+
110
+ export function buildStandardProductInfoTabs(input: {
111
+ labels: StandardDetailsLabels;
112
+ filteredDescription: string;
113
+ faqCount: number;
114
+ reviewCount: number;
115
+ reviewSource?: 'shop' | 'product';
116
+ includeReviewsTab?: boolean;
117
+ useShopReviewTabLabel?: boolean;
118
+ }): StandardProductTabSpec[] {
119
+ const {
120
+ labels,
121
+ filteredDescription,
122
+ faqCount,
123
+ reviewCount,
124
+ reviewSource = 'product',
125
+ includeReviewsTab = true,
126
+ useShopReviewTabLabel = false,
127
+ } = input;
128
+
129
+ const tabs: StandardProductTabSpec[] = [
130
+ {
131
+ id: 'description',
132
+ label: labels.descriptionTabLabel ?? 'Description',
133
+ content: filteredDescription,
134
+ },
135
+ ];
136
+
137
+ if (includeReviewsTab) {
138
+ const reviewsLabel =
139
+ useShopReviewTabLabel && reviewSource === 'shop'
140
+ ? (labels.shopReviewsTabLabel ?? labels.reviewsTabLabel ?? 'Shop Reviews')
141
+ : (labels.reviewsTabLabel ?? 'Reviews');
142
+
143
+ tabs.push({
144
+ id: 'reviews',
145
+ label: reviewsLabel,
146
+ badge: String(reviewCount),
147
+ });
148
+ }
149
+
150
+ if (faqCount > 0) {
151
+ tabs.push({
152
+ id: 'faq',
153
+ label: labels.faqTabLabel ?? 'FAQ',
154
+ badge: String(faqCount),
155
+ });
156
+ }
157
+
158
+ return tabs;
159
+ }
160
+
161
+ export function resolveStandardRelatedProductsTitle(
162
+ block: Pick<BlockInstance, 'settings'>,
163
+ options?: { useScopedKeys?: boolean },
164
+ ): string | null {
165
+ if (options?.useScopedKeys) {
166
+ return resolveScopedBlockSettingText(block, 'relatedProducts', 'title');
167
+ }
168
+ return getBuilderBlockSettingText(block, 'title');
169
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { JSDOM } from 'jsdom';
3
+ import { syncStorefrontGoogleFontStylesheets } from './storefront-google-fonts.ts';
4
+
5
+ describe('syncStorefrontGoogleFontStylesheets', () => {
6
+ test('injects and removes managed google font stylesheets', () => {
7
+ const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>');
8
+ const { document } = dom.window;
9
+ const originalDocument = globalThis.document;
10
+ // @ts-expect-error test harness replaces document
11
+ globalThis.document = document;
12
+
13
+ try {
14
+ const cleanup = syncStorefrontGoogleFontStylesheets([
15
+ 'https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap',
16
+ ]);
17
+
18
+ expect(
19
+ document.head.querySelectorAll('link[data-shoppex-storefront-google-font="true"]').length,
20
+ ).toBe(1);
21
+
22
+ cleanup();
23
+
24
+ expect(
25
+ document.head.querySelectorAll('link[data-shoppex-storefront-google-font="true"]').length,
26
+ ).toBe(0);
27
+ } finally {
28
+ globalThis.document = originalDocument;
29
+ }
30
+ });
31
+ });
@@ -0,0 +1,43 @@
1
+ const STOREFRONT_GOOGLE_FONT_MARKER = 'data-shoppex-storefront-google-font';
2
+
3
+ export function syncStorefrontGoogleFontStylesheets(hrefs: string[]): () => void {
4
+ if (typeof document === 'undefined') {
5
+ return () => {};
6
+ }
7
+
8
+ const nextHrefs = [...new Set(hrefs.filter((href) => href.trim().length > 0))];
9
+ const existing = Array.from(
10
+ document.head.querySelectorAll<HTMLLinkElement>(
11
+ `link[rel="stylesheet"][${STOREFRONT_GOOGLE_FONT_MARKER}]`,
12
+ ),
13
+ );
14
+
15
+ for (const link of existing) {
16
+ if (!nextHrefs.includes(link.href)) {
17
+ link.remove();
18
+ }
19
+ }
20
+
21
+ for (const href of nextHrefs) {
22
+ const alreadyPresent = Array.from(
23
+ document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'),
24
+ ).some((link) => link.href === href);
25
+ if (alreadyPresent) continue;
26
+
27
+ const link = document.createElement('link');
28
+ link.rel = 'stylesheet';
29
+ link.href = href;
30
+ link.setAttribute(STOREFRONT_GOOGLE_FONT_MARKER, 'true');
31
+ document.head.appendChild(link);
32
+ }
33
+
34
+ return () => {
35
+ for (const link of Array.from(
36
+ document.head.querySelectorAll<HTMLLinkElement>(
37
+ `link[rel="stylesheet"][${STOREFRONT_GOOGLE_FONT_MARKER}]`,
38
+ ),
39
+ )) {
40
+ link.remove();
41
+ }
42
+ };
43
+ }
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=builder-runtime.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"builder-runtime.test.d.ts","sourceRoot":"","sources":["../src/builder-runtime.test.ts"],"names":[],"mappings":""}
@@ -1,115 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { createBlockInstance, createEmptyBuilderSettings } from '@shoppex/builder-contracts';
3
- import { builderBlock, builderContent, builderSlot, canAddBlock, createBuilderCss, getBuilderContentList, getBuilderContentString, getPageBlocks, resolveBlockSettings, resolveStyleSlotValue, } from './index.js';
4
- function createSettings() {
5
- return {
6
- ...createEmptyBuilderSettings(1),
7
- theme: {
8
- content: {
9
- 'hero.title': 'Launch sale',
10
- faq: {
11
- items: [{ question: 'Can I edit sections?', answer: 'Yes' }],
12
- },
13
- },
14
- layout: {
15
- home: {
16
- blocks: [
17
- createBlockInstance({
18
- id: 'hero-1',
19
- type: 'hero',
20
- settings: { title: 'Hero block' },
21
- style_overrides: {
22
- 'button.radius': { base: 14 },
23
- },
24
- }),
25
- ],
26
- },
27
- },
28
- style_slots: {
29
- 'button.radius': { base: 8, md: 12 },
30
- 'color.primary': '#ff5500',
31
- },
32
- pages: [],
33
- terms: {},
34
- },
35
- };
36
- }
37
- const manifest = {
38
- id: 'default',
39
- name: 'Default',
40
- version: '2.0.0',
41
- pages: {
42
- home: {
43
- label: 'Home',
44
- allowedBlocks: ['hero'],
45
- defaultBlocks: [],
46
- },
47
- },
48
- blocks: {
49
- hero: {
50
- label: 'Hero',
51
- variants: [],
52
- settings: {
53
- title: { type: 'text', label: 'Headline', defaultValue: 'Default hero' },
54
- },
55
- exposedStyleSlots: ['button.radius'],
56
- presets: [],
57
- },
58
- },
59
- styleSlots: {},
60
- presets: {},
61
- };
62
- describe('@shoppex/builder-runtime', () => {
63
- test('reads direct and nested content values', () => {
64
- const settings = createSettings();
65
- expect(getBuilderContentString(settings, 'hero.title')).toBe('Launch sale');
66
- expect(getBuilderContentList(settings, 'faq.items')).toEqual([{ question: 'Can I edit sections?', answer: 'Yes' }]);
67
- });
68
- test('preserves intentionally empty content strings', () => {
69
- const settings = createSettings();
70
- settings.theme.content['hero.subtitle'] = '';
71
- expect(getBuilderContentString(settings, 'hero.subtitle', 'Default subtitle')).toBe('');
72
- });
73
- test('resolves page blocks', () => {
74
- const settings = createSettings();
75
- expect(getPageBlocks(settings, 'home')).toHaveLength(1);
76
- expect(getPageBlocks(settings, 'missing')).toEqual([]);
77
- });
78
- test('resolves style slots with breakpoint fallback and block override', () => {
79
- const settings = createSettings();
80
- const block = settings.theme.layout.home.blocks[0];
81
- expect(resolveStyleSlotValue(settings, 'button.radius', { breakpoint: 'lg' })).toBe(12);
82
- expect(resolveStyleSlotValue(settings, 'button.radius', { block, breakpoint: 'md' })).toBe(14);
83
- });
84
- test('emits CSS variables with responsive media blocks', () => {
85
- const css = createBuilderCss(createSettings());
86
- expect(css).toContain('--builder-button-radius: 8px;');
87
- expect(css).toContain('--builder-color-primary: #ff5500;');
88
- expect(css).toContain('@media (min-width: 768px)');
89
- expect(css).toContain('--builder-button-radius: 12px;');
90
- });
91
- test('checks block limits against the manifest', () => {
92
- const settings = createSettings();
93
- expect(canAddBlock(settings, manifest, 'home', 'hero')).toBe(true);
94
- expect(canAddBlock(settings, manifest, 'home', 'faq')).toBe(false);
95
- });
96
- test('merges block defaults from the manifest', () => {
97
- const block = createBlockInstance({
98
- id: 'hero-2',
99
- type: 'hero',
100
- settings: {},
101
- });
102
- expect(resolveBlockSettings(block, manifest)).toEqual({ title: 'Default hero' });
103
- });
104
- test('creates the three supported builder attributes', () => {
105
- expect(builderContent('hero.title')).toEqual({ 'data-builder-content': 'hero.title' });
106
- expect(builderSlot('button.radius', { blockId: 'hero-1' })).toEqual({
107
- 'data-builder-slot': 'button.radius',
108
- 'data-builder-block': 'hero-1',
109
- });
110
- expect(builderBlock('hero-1', 'hero')).toEqual({
111
- 'data-builder-block': 'hero-1',
112
- 'data-builder-block-type': 'hero',
113
- });
114
- });
115
- });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=react-runtime.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"react-runtime.test.d.ts","sourceRoot":"","sources":["../src/react-runtime.test.tsx"],"names":[],"mappings":""}
@@ -1,292 +0,0 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
3
- import { createEmptyBuilderSettings } from '@shoppex/builder-contracts';
4
- import { JSDOM } from 'jsdom';
5
- import { act } from 'react';
6
- import { createRoot } from 'react-dom/client';
7
- import { BuilderBlockFrame, BuilderBlockProvider, BuilderPage, BuilderRuntimePreviewProvider, useBuilderContent, useBuilderContentRecord, } from './react.js';
8
- function createSettings(revision, title) {
9
- return {
10
- ...createEmptyBuilderSettings(revision),
11
- theme: {
12
- content: {
13
- 'hero.title': title,
14
- },
15
- layout: {
16
- home: {
17
- blocks: [
18
- {
19
- id: 'hero-1',
20
- type: 'hero',
21
- visible: true,
22
- settings: {},
23
- },
24
- ],
25
- },
26
- },
27
- style_slots: {},
28
- pages: [],
29
- terms: {},
30
- },
31
- };
32
- }
33
- function Probe() {
34
- const title = useBuilderContent('hero.title', '');
35
- return (_jsx("section", { "data-builder-block": "hero-1", "data-builder-block-type": "hero", "data-page-id": "home", children: _jsx("h1", { "data-builder-content": "hero.title", children: title }) }));
36
- }
37
- function FallbackProbe() {
38
- const title = useBuilderContent('hero.title', 'Default title');
39
- return _jsx("h1", { "data-builder-content": "hero.title", children: title });
40
- }
41
- function SlotButtonProbe() {
42
- return (_jsx("section", { "data-builder-block": "hero-1", "data-builder-block-type": "hero", "data-page-id": "home", children: _jsx("button", { type: "button", "data-builder-slot": "button.background", children: "Buy now" }) }));
43
- }
44
- function ScopedProbe() {
45
- return (_jsx(BuilderBlockProvider, { block: {
46
- id: 'hero-1',
47
- type: 'hero',
48
- visible: true,
49
- settings: {
50
- title: 'Scoped title',
51
- subtitle: 'Scoped subtitle',
52
- },
53
- }, children: _jsx(ScopedProbeContent, {}) }));
54
- }
55
- function ScopedProbeContent() {
56
- const title = useBuilderContent('hero.title', '');
57
- const subtitle = useBuilderContent('hero.subtitle', '');
58
- const contentRecord = useBuilderContentRecord();
59
- return (_jsxs(_Fragment, { children: [_jsx("h1", { "data-testid": "scoped-title", children: title }), _jsx("p", { "data-testid": "scoped-subtitle", children: subtitle }), _jsx("span", { "data-testid": "scoped-record", children: String(contentRecord.hero.title) })] }));
60
- }
61
- function HeroBlock({ block }) {
62
- const title = useBuilderContent('hero.title', '');
63
- return (_jsx(BuilderBlockFrame, { as: "section", pageId: "home", block: block, children: _jsx("h1", { "data-builder-content": "hero.title", children: title }) }));
64
- }
65
- describe('BuilderRuntimePreviewProvider', () => {
66
- let dom;
67
- let root;
68
- let postedMessages;
69
- let parentWindow;
70
- beforeEach(() => {
71
- postedMessages = [];
72
- parentWindow = {
73
- postMessage: (message) => {
74
- postedMessages.push(message);
75
- },
76
- };
77
- dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
78
- url: 'https://preview.shoppex.test/?shoppex-preview-mode=theme',
79
- referrer: 'https://dashboard.shoppex.test/theme/editor',
80
- });
81
- Object.defineProperty(dom.window, 'parent', {
82
- configurable: true,
83
- value: parentWindow,
84
- });
85
- Object.assign(globalThis, {
86
- window: dom.window,
87
- document: dom.window.document,
88
- navigator: dom.window.navigator,
89
- Element: dom.window.Element,
90
- HTMLElement: dom.window.HTMLElement,
91
- HTMLImageElement: dom.window.HTMLImageElement,
92
- Node: dom.window.Node,
93
- MouseEvent: dom.window.MouseEvent,
94
- MessageEvent: dom.window.MessageEvent,
95
- CustomEvent: dom.window.CustomEvent,
96
- CSS: {
97
- escape: (value) => value.replaceAll('"', '\\"'),
98
- },
99
- IS_REACT_ACT_ENVIRONMENT: true,
100
- });
101
- root = createRoot(dom.window.document.getElementById('root'));
102
- });
103
- afterEach(async () => {
104
- await act(async () => {
105
- root.unmount();
106
- });
107
- dom.window.close();
108
- const globals = globalThis;
109
- for (const key of [
110
- 'window',
111
- 'document',
112
- 'navigator',
113
- 'Element',
114
- 'HTMLElement',
115
- 'HTMLImageElement',
116
- 'Node',
117
- 'MouseEvent',
118
- 'MessageEvent',
119
- 'CustomEvent',
120
- 'CSS',
121
- 'IS_REACT_ACT_ENVIRONMENT',
122
- ]) {
123
- delete globals[key];
124
- }
125
- });
126
- test('sends READY with the initial revision', async () => {
127
- await act(async () => {
128
- root.render(_jsx(BuilderRuntimePreviewProvider, { initialSettings: createSettings(3, 'Initial title'), children: _jsx(Probe, {}) }));
129
- });
130
- expect(postedMessages).toContainEqual({ type: 'READY', revision: 3 });
131
- expect(dom.window.document.body.textContent).toContain('Initial title');
132
- });
133
- test('normalizes mixed initial builder settings before strict validation', async () => {
134
- await act(async () => {
135
- root.render(_jsx(BuilderRuntimePreviewProvider, { initialSettings: {
136
- version: 2,
137
- revision: 9,
138
- theme: {
139
- content: {
140
- 'hero.title': 'Mixed title',
141
- },
142
- layout: {
143
- home: {
144
- sections: [
145
- {
146
- id: 'hero',
147
- visible: true,
148
- },
149
- ],
150
- },
151
- },
152
- tokens_override: {
153
- colors: {
154
- primary: '#111827',
155
- },
156
- },
157
- },
158
- }, children: _jsx(Probe, {}) }));
159
- });
160
- expect(postedMessages).toContainEqual({ type: 'READY', revision: 9 });
161
- expect(dom.window.document.body.textContent).toContain('Mixed title');
162
- });
163
- test('preserves intentionally empty content strings in hooks', async () => {
164
- await act(async () => {
165
- root.render(_jsx(BuilderRuntimePreviewProvider, { initialSettings: createSettings(4, ''), children: _jsx(FallbackProbe, {}) }));
166
- });
167
- expect(dom.window.document.body.textContent).toBe('');
168
- });
169
- test('responds to explicit READY requests when the parent missed the initial message', async () => {
170
- await act(async () => {
171
- root.render(_jsx(BuilderRuntimePreviewProvider, { initialSettings: createSettings(3, 'Initial title'), children: _jsx(Probe, {}) }));
172
- });
173
- postedMessages.length = 0;
174
- await act(async () => {
175
- dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
176
- origin: 'https://dashboard.shoppex.test',
177
- source: parentWindow,
178
- data: {
179
- type: 'REQUEST_READY',
180
- },
181
- }));
182
- });
183
- expect(postedMessages).toEqual([{ type: 'READY', revision: 3 }]);
184
- });
185
- test('applies preview state and acknowledges the exact revision', async () => {
186
- await act(async () => {
187
- root.render(_jsx(BuilderRuntimePreviewProvider, { initialSettings: createSettings(3, 'Initial title'), children: _jsx(Probe, {}) }));
188
- });
189
- await act(async () => {
190
- dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
191
- origin: 'https://dashboard.shoppex.test',
192
- source: parentWindow,
193
- data: {
194
- type: 'APPLY_STATE',
195
- revision: 4,
196
- state: createSettings(4, 'Updated title'),
197
- },
198
- }));
199
- });
200
- expect(postedMessages).toContainEqual({ type: 'APPLIED', revision: 4 });
201
- expect(dom.window.document.body.textContent).toContain('Updated title');
202
- });
203
- test('renders page blocks through a typed registry and shared block frame', async () => {
204
- await act(async () => {
205
- root.render(_jsx(BuilderRuntimePreviewProvider, { initialSettings: createSettings(3, 'Registry title'), children: _jsx(BuilderPage, { pageId: "home", registry: {
206
- hero: HeroBlock,
207
- }, context: {} }) }));
208
- });
209
- const renderedBlock = dom.window.document.querySelector('[data-builder-block="hero-1"]');
210
- expect(renderedBlock?.getAttribute('data-page-id')).toBe('home');
211
- expect(renderedBlock?.getAttribute('data-builder-block-type')).toBe('hero');
212
- expect(dom.window.document.body.textContent).toContain('Registry title');
213
- });
214
- test('prefers scoped block settings over global builder content inside a block provider', async () => {
215
- await act(async () => {
216
- root.render(_jsx(BuilderRuntimePreviewProvider, { initialSettings: createSettings(3, 'Global title'), children: _jsx(ScopedProbe, {}) }));
217
- });
218
- expect(dom.window.document.querySelector('[data-testid="scoped-title"]')?.textContent).toBe('Scoped title');
219
- expect(dom.window.document.querySelector('[data-testid="scoped-subtitle"]')?.textContent).toBe('Scoped subtitle');
220
- expect(dom.window.document.querySelector('[data-testid="scoped-record"]')?.textContent).toBe('Scoped title');
221
- });
222
- test('rejects stale preview revisions without changing rendered content', async () => {
223
- await act(async () => {
224
- root.render(_jsx(BuilderRuntimePreviewProvider, { initialSettings: createSettings(5, 'Current title'), children: _jsx(Probe, {}) }));
225
- });
226
- await act(async () => {
227
- dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
228
- origin: 'https://dashboard.shoppex.test',
229
- source: parentWindow,
230
- data: {
231
- type: 'APPLY_STATE',
232
- revision: 4,
233
- state: createSettings(4, 'Stale title'),
234
- },
235
- }));
236
- });
237
- expect(postedMessages).toContainEqual({
238
- type: 'APPLY_FAILED',
239
- revision: 4,
240
- error: 'Stale builder revision',
241
- });
242
- expect(dom.window.document.body.textContent).toContain('Current title');
243
- });
244
- test('emits block selection details from iframe clicks', async () => {
245
- await act(async () => {
246
- root.render(_jsx(BuilderRuntimePreviewProvider, { initialSettings: createSettings(7, 'Clickable title'), children: _jsx(Probe, {}) }));
247
- });
248
- const heading = dom.window.document.querySelector('h1');
249
- heading?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }));
250
- expect(postedMessages).toContainEqual({
251
- type: 'ELEMENT_CLICKED',
252
- revision: 7,
253
- selection: {
254
- pageId: 'home',
255
- blockId: 'hero-1',
256
- blockType: 'hero',
257
- contentPath: 'hero.title',
258
- elementType: 'text',
259
- },
260
- });
261
- });
262
- test('classifies buttons before style slots for inspector clicks', async () => {
263
- await act(async () => {
264
- root.render(_jsx(BuilderRuntimePreviewProvider, { initialSettings: createSettings(7, 'Clickable title'), children: _jsx(SlotButtonProbe, {}) }));
265
- });
266
- const button = dom.window.document.querySelector('button');
267
- button?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }));
268
- expect(postedMessages).toContainEqual({
269
- type: 'ELEMENT_CLICKED',
270
- revision: 7,
271
- selection: {
272
- pageId: 'home',
273
- blockId: 'hero-1',
274
- blockType: 'hero',
275
- slotId: 'button.background',
276
- elementType: 'button',
277
- },
278
- });
279
- });
280
- test('does not intercept clicks outside trusted builder preview embeds', async () => {
281
- dom.reconfigure({
282
- url: 'https://preview.shoppex.test/',
283
- });
284
- await act(async () => {
285
- root.render(_jsx(BuilderRuntimePreviewProvider, { initialSettings: createSettings(7, 'Clickable title'), children: _jsx(Probe, {}) }));
286
- });
287
- postedMessages.length = 0;
288
- const heading = dom.window.document.querySelector('h1');
289
- heading?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }));
290
- expect(postedMessages).toEqual([]);
291
- });
292
- });