@shoppexio/builder-runtime 0.1.0 → 0.1.1

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.
@@ -9,6 +9,7 @@ import {
9
9
  getBuilderContentList,
10
10
  getBuilderContentString,
11
11
  getPageBlocks,
12
+ getThemePageBlockOrderFromManifest,
12
13
  resolveBlockSettings,
13
14
  resolveStyleSlotValue,
14
15
  } from './index.js';
@@ -40,6 +41,7 @@ function createSettings(): BuilderSettings {
40
41
  style_slots: {
41
42
  'button.radius': { base: 8, md: 12 },
42
43
  'color.primary': '#ff5500',
44
+ 'theme.hero.overlay.opacity': 0.72,
43
45
  },
44
46
  pages: [],
45
47
  terms: {},
@@ -95,6 +97,27 @@ describe('@shoppex/builder-runtime', () => {
95
97
  expect(getPageBlocks(settings, 'missing')).toEqual([]);
96
98
  });
97
99
 
100
+ test('reads canonical default page block order from a manifest', () => {
101
+ expect(getThemePageBlockOrderFromManifest({
102
+ pages: {
103
+ home: {
104
+ allowedBlocks: ['hero', 'faq'],
105
+ defaultBlocks: [{ type: 'hero' }],
106
+ },
107
+ product: {
108
+ allowedBlocks: ['gallery', 'buy-box'],
109
+ },
110
+ },
111
+ }, 'home')).toEqual(['hero']);
112
+ expect(getThemePageBlockOrderFromManifest({
113
+ pages: {
114
+ product: {
115
+ allowedBlocks: ['gallery', 'buy-box'],
116
+ },
117
+ },
118
+ }, 'product')).toEqual(['gallery', 'buy-box']);
119
+ });
120
+
98
121
  test('resolves style slots with breakpoint fallback and block override', () => {
99
122
  const settings = createSettings();
100
123
  const block = settings.theme.layout.home.blocks[0];
@@ -108,6 +131,7 @@ describe('@shoppex/builder-runtime', () => {
108
131
 
109
132
  expect(css).toContain('--builder-button-radius: 8px;');
110
133
  expect(css).toContain('--builder-color-primary: #ff5500;');
134
+ expect(css).toContain('--builder-theme-hero-overlay-opacity: 0.72;');
111
135
  expect(css).toContain('@media (min-width: 768px)');
112
136
  expect(css).toContain('--builder-button-radius: 12px;');
113
137
  });
package/src/css-vars.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BuilderSettings, Breakpoint, StyleSlotId, StyleSlots } from '@shoppex/builder-contracts';
1
+ import type { BuilderSettings, Breakpoint, CoreStyleSlotId, StyleSlotId, StyleSlots } from '@shoppex/builder-contracts';
2
2
  import { CORE_STYLE_SLOT_IDS } from '@shoppex/builder-contracts';
3
3
  import { isResponsiveRecord } from './style-slots.js';
4
4
 
@@ -9,7 +9,7 @@ const BREAKPOINT_MEDIA: Record<Exclude<Breakpoint, 'base'>, string> = {
9
9
  xl: '(min-width: 1280px)',
10
10
  };
11
11
 
12
- const STYLE_SLOT_CSS_VARIABLES: Record<StyleSlotId, { name: string; unit?: string }> = {
12
+ const STYLE_SLOT_CSS_VARIABLES: Record<CoreStyleSlotId, { name: string; unit?: string }> = {
13
13
  'button.radius': { name: '--builder-button-radius', unit: 'px' },
14
14
  'button.background': { name: '--builder-button-background' },
15
15
  'button.foreground': { name: '--builder-button-foreground' },
@@ -37,19 +37,19 @@ const STYLE_SLOT_CSS_VARIABLES: Record<StyleSlotId, { name: string; unit?: strin
37
37
  };
38
38
 
39
39
  export function getStyleSlotCssVariable(slotId: StyleSlotId): string {
40
- return STYLE_SLOT_CSS_VARIABLES[slotId].name;
40
+ return getStyleSlotCssVariableConfig(slotId).name;
41
41
  }
42
42
 
43
43
  export function createStyleSlotCssVariables(slots: StyleSlots): Record<string, string> {
44
44
  const variables: Record<string, string> = {};
45
45
 
46
- for (const slotId of CORE_STYLE_SLOT_IDS) {
46
+ for (const slotId of getRenderableStyleSlotIds(slots)) {
47
47
  const value = slots[slotId];
48
48
  if (value === undefined || isResponsiveRecord(value)) {
49
49
  continue;
50
50
  }
51
51
 
52
- variables[STYLE_SLOT_CSS_VARIABLES[slotId].name] = formatStyleSlotValue(slotId, value);
52
+ variables[getStyleSlotCssVariable(slotId)] = formatStyleSlotValue(slotId, value);
53
53
  }
54
54
 
55
55
  return variables;
@@ -68,13 +68,13 @@ export function createStyleSlotsCss(slots: StyleSlots, selector = ':root'): stri
68
68
  xl: [],
69
69
  };
70
70
 
71
- for (const slotId of CORE_STYLE_SLOT_IDS) {
71
+ for (const slotId of getRenderableStyleSlotIds(slots)) {
72
72
  const value = slots[slotId];
73
73
  if (value === undefined) {
74
74
  continue;
75
75
  }
76
76
 
77
- const cssVariable = STYLE_SLOT_CSS_VARIABLES[slotId].name;
77
+ const cssVariable = getStyleSlotCssVariable(slotId);
78
78
 
79
79
  if (!isResponsiveRecord(value)) {
80
80
  baseDeclarations.push(`${cssVariable}: ${formatStyleSlotValue(slotId, value)};`);
@@ -114,7 +114,7 @@ export function createStyleSlotsCss(slots: StyleSlots, selector = ':root'): stri
114
114
  }
115
115
 
116
116
  function formatStyleSlotValue(slotId: StyleSlotId, value: unknown): string {
117
- const unit = STYLE_SLOT_CSS_VARIABLES[slotId].unit;
117
+ const unit = getStyleSlotCssVariableConfig(slotId).unit;
118
118
 
119
119
  if (typeof value === 'number') {
120
120
  return unit ? `${value}${unit}` : `${value}`;
@@ -122,3 +122,24 @@ function formatStyleSlotValue(slotId: StyleSlotId, value: unknown): string {
122
122
 
123
123
  return String(value);
124
124
  }
125
+
126
+ function getStyleSlotCssVariableConfig(slotId: StyleSlotId): { name: string; unit?: string } {
127
+ return isCoreStyleSlotId(slotId)
128
+ ? STYLE_SLOT_CSS_VARIABLES[slotId]
129
+ : { name: `--builder-${slotId.split('.').join('-')}` };
130
+ }
131
+
132
+ function getRenderableStyleSlotIds(slots: StyleSlots): StyleSlotId[] {
133
+ const declared = new Set<StyleSlotId>();
134
+ for (const slotId of CORE_STYLE_SLOT_IDS) {
135
+ declared.add(slotId);
136
+ }
137
+ for (const slotId of Object.keys(slots)) {
138
+ declared.add(slotId as StyleSlotId);
139
+ }
140
+ return [...declared];
141
+ }
142
+
143
+ function isCoreStyleSlotId(slotId: StyleSlotId): slotId is CoreStyleSlotId {
144
+ return (CORE_STYLE_SLOT_IDS as readonly string[]).includes(slotId);
145
+ }
package/src/layout.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import type { BlockInstance, BuilderSettings, PageLayout, ThemeManifest } from '@shoppex/builder-contracts';
2
2
 
3
+ export type ThemePageBlockOrderManifest = {
4
+ pages?: Record<string, {
5
+ allowedBlocks?: string[];
6
+ defaultBlocks?: Array<{ type?: string }>;
7
+ }>;
8
+ };
9
+
3
10
  export function getPageLayout(settings: BuilderSettings, pageId: string): PageLayout {
4
11
  return settings.theme.layout[pageId] ?? { blocks: [] };
5
12
  }
@@ -20,6 +27,22 @@ export function getAllowedBlockTypes(manifest: ThemeManifest, pageId: string): s
20
27
  return manifest.pages[pageId]?.allowedBlocks ?? [];
21
28
  }
22
29
 
30
+ export function getThemePageBlockOrderFromManifest(
31
+ manifest: ThemePageBlockOrderManifest,
32
+ pageId: string,
33
+ ): string[] {
34
+ const page = manifest.pages?.[pageId];
35
+ if (!page) {
36
+ return [];
37
+ }
38
+
39
+ const defaultBlockTypes = (page.defaultBlocks ?? [])
40
+ .map((block) => block.type)
41
+ .filter((blockType): blockType is string => typeof blockType === 'string' && blockType.length > 0);
42
+
43
+ return defaultBlockTypes.length > 0 ? defaultBlockTypes : page.allowedBlocks ?? [];
44
+ }
45
+
23
46
  export function canAddBlock(settings: BuilderSettings, manifest: ThemeManifest, pageId: string, blockType: string): boolean {
24
47
  const page = manifest.pages[pageId];
25
48
  const blockDefinition = manifest.blocks[blockType];
@@ -8,8 +8,10 @@ import {
8
8
  BuilderBlockProvider,
9
9
  BuilderPage,
10
10
  BuilderRuntimePreviewProvider,
11
+ resolvePreviewReloadTarget,
11
12
  useBuilderContent,
12
13
  useBuilderContentRecord,
14
+ useThemePageBlocks,
13
15
  } from './react.js';
14
16
 
15
17
  function createSettings(revision: number, title: string): BuilderSettings {
@@ -96,6 +98,21 @@ function ScopedProbeContent() {
96
98
  );
97
99
  }
98
100
 
101
+ function PageBlocksProbe({ pageId = 'home', defaultOrder = ['hero', 'products'] }: {
102
+ pageId?: string;
103
+ defaultOrder?: string[];
104
+ }) {
105
+ const blocks = useThemePageBlocks(pageId, defaultOrder);
106
+
107
+ return (
108
+ <ol data-testid="page-blocks">
109
+ {blocks.map((block) => (
110
+ <li key={block.id}>{block.id}:{block.type}</li>
111
+ ))}
112
+ </ol>
113
+ );
114
+ }
115
+
99
116
  function HeroBlock({ block }: { block: BlockInstance }) {
100
117
  const title = useBuilderContent('hero.title', '');
101
118
 
@@ -106,6 +123,26 @@ function HeroBlock({ block }: { block: BlockInstance }) {
106
123
  );
107
124
  }
108
125
 
126
+ describe('resolvePreviewReloadTarget', () => {
127
+ test('reloads through the worker session route when one was injected', () => {
128
+ expect(
129
+ resolvePreviewReloadTarget(
130
+ { search: '?shoppex-preview-mode=theme', hash: '#hero' },
131
+ '/s/session-1/draft-1/products/example',
132
+ ),
133
+ ).toBe('/s/session-1/draft-1/products/example?shoppex-preview-mode=theme#hero');
134
+ });
135
+
136
+ test('keeps normal browser reload behavior outside preview sessions', () => {
137
+ expect(
138
+ resolvePreviewReloadTarget(
139
+ { search: '?shoppex-preview-mode=theme', hash: '' },
140
+ '/products/example',
141
+ ),
142
+ ).toBeNull();
143
+ });
144
+ });
145
+
109
146
  describe('BuilderRuntimePreviewProvider', () => {
110
147
  let dom: JSDOM;
111
148
  let root: Root;
@@ -184,10 +221,47 @@ describe('BuilderRuntimePreviewProvider', () => {
184
221
  );
185
222
  });
186
223
 
187
- expect(postedMessages).toContainEqual({ type: 'READY', revision: 3 });
224
+ expect(postedMessages).toContainEqual({
225
+ type: 'READY',
226
+ revision: 3,
227
+ health: {
228
+ reactMounted: true,
229
+ builderRuntimeProvider: true,
230
+ protocolVersion: 2,
231
+ },
232
+ });
188
233
  expect(dom.window.document.body.textContent).toContain('Initial title');
189
234
  });
190
235
 
236
+ test('sends READY when the trusted parent origin is provided explicitly without a referrer', async () => {
237
+ dom.reconfigure({
238
+ url: 'https://preview.shoppex.test/?shoppex-preview-mode=theme&shoppex-preview-parent-origin=https%3A%2F%2Fdashboard.shoppex.test',
239
+ });
240
+ Object.defineProperty(dom.window.document, 'referrer', {
241
+ configurable: true,
242
+ value: '',
243
+ });
244
+
245
+ await act(async () => {
246
+ root.render(
247
+ <BuilderRuntimePreviewProvider initialSettings={createSettings(4, 'Explicit origin title')}>
248
+ <Probe />
249
+ </BuilderRuntimePreviewProvider>,
250
+ );
251
+ });
252
+
253
+ expect(postedMessages).toContainEqual({
254
+ type: 'READY',
255
+ revision: 4,
256
+ health: {
257
+ reactMounted: true,
258
+ builderRuntimeProvider: true,
259
+ protocolVersion: 2,
260
+ },
261
+ });
262
+ expect(dom.window.document.body.textContent).toContain('Explicit origin title');
263
+ });
264
+
191
265
  test('normalizes mixed initial builder settings before strict validation', async () => {
192
266
  await act(async () => {
193
267
  root.render(
@@ -222,7 +296,15 @@ describe('BuilderRuntimePreviewProvider', () => {
222
296
  );
223
297
  });
224
298
 
225
- expect(postedMessages).toContainEqual({ type: 'READY', revision: 9 });
299
+ expect(postedMessages).toContainEqual({
300
+ type: 'READY',
301
+ revision: 9,
302
+ health: {
303
+ reactMounted: true,
304
+ builderRuntimeProvider: true,
305
+ protocolVersion: 2,
306
+ },
307
+ });
226
308
  expect(dom.window.document.body.textContent).toContain('Mixed title');
227
309
  });
228
310
 
@@ -261,7 +343,39 @@ describe('BuilderRuntimePreviewProvider', () => {
261
343
  );
262
344
  });
263
345
 
264
- expect(postedMessages).toEqual([{ type: 'READY', revision: 3 }]);
346
+ expect(postedMessages).toEqual([{
347
+ type: 'READY',
348
+ revision: 3,
349
+ health: {
350
+ reactMounted: true,
351
+ builderRuntimeProvider: true,
352
+ protocolVersion: 2,
353
+ },
354
+ }]);
355
+ });
356
+
357
+ test('reports iframe runtime errors to the builder parent', async () => {
358
+ await act(async () => {
359
+ root.render(
360
+ <BuilderRuntimePreviewProvider initialSettings={createSettings(6, 'Initial title')}>
361
+ <Probe />
362
+ </BuilderRuntimePreviewProvider>,
363
+ );
364
+ });
365
+
366
+ postedMessages.length = 0;
367
+
368
+ dom.window.dispatchEvent(new dom.window.ErrorEvent('error', {
369
+ error: new Error('Preview crashed'),
370
+ message: 'Preview crashed',
371
+ }));
372
+
373
+ expect(postedMessages).toEqual([expect.objectContaining({
374
+ type: 'PREVIEW_ERROR',
375
+ revision: 6,
376
+ message: 'Preview crashed',
377
+ source: 'error',
378
+ })]);
265
379
  });
266
380
 
267
381
  test('applies preview state and acknowledges the exact revision', async () => {
@@ -313,6 +427,62 @@ describe('BuilderRuntimePreviewProvider', () => {
313
427
  expect(dom.window.document.body.textContent).toContain('Registry title');
314
428
  });
315
429
 
430
+ test('returns visible page blocks or manifest default order from the runtime helper', async () => {
431
+ await act(async () => {
432
+ root.render(
433
+ <BuilderRuntimePreviewProvider initialSettings={createEmptyBuilderSettings(1)}>
434
+ <PageBlocksProbe />
435
+ </BuilderRuntimePreviewProvider>,
436
+ );
437
+ });
438
+
439
+ expect(dom.window.document.querySelector('[data-testid="page-blocks"]')?.textContent).toBe('hero:heroproducts:products');
440
+
441
+ await act(async () => {
442
+ dom.window.dispatchEvent(
443
+ new dom.window.MessageEvent('message', {
444
+ origin: 'https://dashboard.shoppex.test',
445
+ source: parentWindow as Window,
446
+ data: {
447
+ type: 'APPLY_STATE',
448
+ revision: 2,
449
+ state: {
450
+ ...createEmptyBuilderSettings(2),
451
+ theme: {
452
+ ...createEmptyBuilderSettings(2).theme,
453
+ layout: {
454
+ home: {
455
+ blocks: [
456
+ { id: 'hero-1', type: 'hero', visible: true, settings: {} },
457
+ { id: 'faq-1', type: 'faq', visible: false, settings: {} },
458
+ ],
459
+ },
460
+ },
461
+ },
462
+ },
463
+ },
464
+ }),
465
+ );
466
+ });
467
+
468
+ expect(dom.window.document.querySelector('[data-testid="page-blocks"]')?.textContent).toBe('hero-1:hero');
469
+ });
470
+
471
+ test('shows missing registry blocks inside trusted builder preview', async () => {
472
+ await act(async () => {
473
+ root.render(
474
+ <BuilderRuntimePreviewProvider initialSettings={createSettings(3, 'Registry title')}>
475
+ <BuilderPage pageId="home" registry={{}} context={{}} />
476
+ </BuilderRuntimePreviewProvider>,
477
+ );
478
+ });
479
+
480
+ const missingBlock = dom.window.document.querySelector('[data-builder-runtime-error="missing-block-component"]');
481
+
482
+ expect(missingBlock?.getAttribute('data-builder-block')).toBe('hero-1');
483
+ expect(missingBlock?.textContent).toContain('Missing Builder component for block "hero".');
484
+ });
485
+
316
486
  test('prefers scoped block settings over global builder content inside a block provider', async () => {
317
487
  await act(async () => {
318
488
  root.render(