@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.
- package/dist/css-vars.d.ts.map +1 -1
- package/dist/css-vars.js +24 -6
- package/dist/layout.d.ts +9 -0
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +10 -0
- package/dist/react.d.ts +5 -222
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +506 -27
- package/package.json +1 -1
- package/src/builder-runtime.test.ts +24 -0
- package/src/css-vars.ts +29 -8
- package/src/layout.ts +23 -0
- package/src/react-runtime.test.tsx +173 -3
- package/src/react.tsx +579 -24
- package/dist/builder-runtime.test.d.ts +0 -2
- package/dist/builder-runtime.test.d.ts.map +0 -1
- package/dist/builder-runtime.test.js +0 -115
- package/dist/react-runtime.test.d.ts +0 -2
- package/dist/react-runtime.test.d.ts.map +0 -1
- package/dist/react-runtime.test.js +0 -292
|
@@ -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<
|
|
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
|
|
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
|
|
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[
|
|
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
|
|
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 =
|
|
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 =
|
|
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({
|
|
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({
|
|
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([{
|
|
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(
|