@shoppexio/builder-contracts 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/builder-contracts.test.js +71 -1
- package/dist/builder-settings.d.ts +9 -666
- package/dist/builder-settings.d.ts.map +1 -1
- package/dist/canonical-settings.d.ts +18 -0
- package/dist/canonical-settings.d.ts.map +1 -0
- package/dist/canonical-settings.js +106 -0
- package/dist/events.d.ts +35 -240
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +7 -0
- package/dist/fields.d.ts +169 -8
- package/dist/fields.d.ts.map +1 -1
- package/dist/fields.js +27 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/legacy-manifest.d.ts +11 -0
- package/dist/legacy-manifest.d.ts.map +1 -1
- package/dist/legacy-manifest.js +106 -16
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +50 -4
- package/dist/persistence.d.ts +7 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +58 -0
- package/dist/preview-boot.d.ts +68 -0
- package/dist/preview-boot.d.ts.map +1 -0
- package/dist/preview-boot.js +36 -0
- package/dist/preview-protocol.d.ts +227 -459
- package/dist/preview-protocol.d.ts.map +1 -1
- package/dist/preview-protocol.js +112 -0
- package/dist/preview-session-resolve.d.ts +115 -0
- package/dist/preview-session-resolve.d.ts.map +1 -0
- package/dist/preview-session-resolve.js +25 -0
- package/dist/preview-trusted-origins.d.ts +4 -0
- package/dist/preview-trusted-origins.d.ts.map +1 -0
- package/dist/preview-trusted-origins.js +26 -0
- package/dist/storefront-initial-data-html.d.ts +17 -0
- package/dist/storefront-initial-data-html.d.ts.map +1 -0
- package/dist/storefront-initial-data-html.js +83 -0
- package/dist/style-slots.d.ts +49 -151
- package/dist/style-slots.d.ts.map +1 -1
- package/dist/style-slots.js +75 -29
- package/dist/theme-manifest.d.ts +229 -454
- package/dist/theme-manifest.d.ts.map +1 -1
- package/dist/theme-manifest.js +92 -0
- package/dist/theme-schemes.d.ts +10 -0
- package/dist/theme-schemes.d.ts.map +1 -0
- package/dist/theme-schemes.js +24 -0
- package/dist/validation.d.ts +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +18 -9
- package/package.json +43 -1
- package/src/builder-contracts.test.ts +398 -3
- package/src/canonical-settings.ts +156 -0
- package/src/events.ts +8 -0
- package/src/fields.ts +30 -0
- package/src/index.ts +7 -0
- package/src/legacy-manifest.ts +107 -16
- package/src/migrations.ts +65 -4
- package/src/persistence.ts +77 -0
- package/src/preview-boot.ts +47 -0
- package/src/preview-protocol.test.ts +132 -0
- package/src/preview-protocol.ts +122 -0
- package/src/preview-session-resolve.ts +34 -0
- package/src/preview-trusted-origins.test.ts +24 -0
- package/src/preview-trusted-origins.ts +35 -0
- package/src/storefront-initial-data-html.test.ts +63 -0
- package/src/storefront-initial-data-html.ts +112 -0
- package/src/style-slots.ts +96 -31
- package/src/theme-manifest.ts +118 -1
- package/src/theme-schemes.ts +33 -0
- package/src/validation.ts +27 -10
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import {
|
|
3
3
|
BuilderEventSchema,
|
|
4
4
|
BuilderSettingsSchema,
|
|
5
|
+
PreviewResponseSchema,
|
|
5
6
|
PreviewMessageSchema,
|
|
6
7
|
StyleSlotsSchema,
|
|
7
8
|
ThemeManifestSchema,
|
|
@@ -10,7 +11,11 @@ import {
|
|
|
10
11
|
createBlockInstance,
|
|
11
12
|
createEmptyBuilderSettings,
|
|
12
13
|
convertLegacyThemeManifest,
|
|
14
|
+
extractPersistedBuilderSettings,
|
|
15
|
+
canonicalizeBuilderSettingsForManifestWithReport,
|
|
16
|
+
mergeBuilderSettingsIntoThemeSettings,
|
|
13
17
|
migrateLegacyBuilderSettings,
|
|
18
|
+
listThemeManifestPresets,
|
|
14
19
|
} from './index.ts';
|
|
15
20
|
|
|
16
21
|
describe('@shoppex/builder-contracts', () => {
|
|
@@ -39,6 +44,16 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
39
44
|
expect(result.success).toBe(false);
|
|
40
45
|
});
|
|
41
46
|
|
|
47
|
+
test('accepts theme-scoped style slots', () => {
|
|
48
|
+
const result = StyleSlotsSchema.safeParse({
|
|
49
|
+
'button.radius': { base: 10 },
|
|
50
|
+
'theme.hero.overlay.opacity': 0.72,
|
|
51
|
+
'theme.product.badge.background': '#111827',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(result.success).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
42
57
|
test('rejects invalid colors', () => {
|
|
43
58
|
const result = StyleSlotsSchema.safeParse({
|
|
44
59
|
'color.primary': 'tomato',
|
|
@@ -93,6 +108,32 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
93
108
|
expect(result.success).toBe(true);
|
|
94
109
|
});
|
|
95
110
|
|
|
111
|
+
test('accepts preview READY health and runtime error responses', () => {
|
|
112
|
+
expect(PreviewResponseSchema.safeParse({
|
|
113
|
+
type: 'READY',
|
|
114
|
+
revision: 3,
|
|
115
|
+
health: {
|
|
116
|
+
reactMounted: true,
|
|
117
|
+
builderRuntimeProvider: true,
|
|
118
|
+
protocolVersion: 2,
|
|
119
|
+
},
|
|
120
|
+
}).success).toBe(true);
|
|
121
|
+
|
|
122
|
+
expect(PreviewResponseSchema.safeParse({
|
|
123
|
+
type: 'PREVIEW_ERROR',
|
|
124
|
+
revision: 3,
|
|
125
|
+
message: 'useBuilderRuntime must be used inside BuilderRuntimeProvider',
|
|
126
|
+
stack: 'Error: useBuilderRuntime must be used inside BuilderRuntimeProvider',
|
|
127
|
+
source: 'error',
|
|
128
|
+
}).success).toBe(true);
|
|
129
|
+
|
|
130
|
+
expect(PreviewResponseSchema.safeParse({
|
|
131
|
+
type: 'PREVIEW_ERROR',
|
|
132
|
+
message: 'Theme bootstrap failed before React mounted',
|
|
133
|
+
source: 'bootstrap',
|
|
134
|
+
}).success).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
96
137
|
test('accepts block add events', () => {
|
|
97
138
|
const result = BuilderEventSchema.safeParse({
|
|
98
139
|
id: 'evt_1',
|
|
@@ -112,6 +153,20 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
112
153
|
expect(result.success).toBe(true);
|
|
113
154
|
});
|
|
114
155
|
|
|
156
|
+
test('accepts preset apply events', () => {
|
|
157
|
+
const result = BuilderEventSchema.safeParse({
|
|
158
|
+
id: 'evt_preset',
|
|
159
|
+
type: 'preset.apply',
|
|
160
|
+
revision: 7,
|
|
161
|
+
createdAt: '2026-05-16T12:00:00.000Z',
|
|
162
|
+
source: 'dashboard',
|
|
163
|
+
pageId: 'home',
|
|
164
|
+
presetId: 'launch',
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(result.success).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
115
170
|
test('migrates legacy content and token overrides', () => {
|
|
116
171
|
const migrated = migrateLegacyBuilderSettings(
|
|
117
172
|
{
|
|
@@ -122,6 +177,14 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
122
177
|
tokens_override: {
|
|
123
178
|
'buttons.borderRadius': 12,
|
|
124
179
|
'colors.primary': '#ff5500',
|
|
180
|
+
typography: {
|
|
181
|
+
fontSize: 18,
|
|
182
|
+
fontFamily: 'Manrope, sans-serif',
|
|
183
|
+
headingFont: 'Georgia, serif',
|
|
184
|
+
},
|
|
185
|
+
header: {
|
|
186
|
+
height: 64,
|
|
187
|
+
},
|
|
125
188
|
},
|
|
126
189
|
},
|
|
127
190
|
},
|
|
@@ -132,6 +195,68 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
132
195
|
expect(migrated.theme.content['hero.title']).toBe('Summer sale');
|
|
133
196
|
expect(migrated.theme.style_slots['button.radius']).toEqual({ base: 12 });
|
|
134
197
|
expect(migrated.theme.style_slots['color.primary']).toBe('#ff5500');
|
|
198
|
+
expect(migrated.theme.style_slots['typography.body.size']).toEqual({ base: 18 });
|
|
199
|
+
expect(migrated.theme.style_slots['theme.typography.font.family']).toBe('Manrope, sans-serif');
|
|
200
|
+
expect(migrated.theme.style_slots['theme.typography.heading.font']).toBe('Georgia, serif');
|
|
201
|
+
expect(migrated.theme.style_slots['theme.header.height']).toEqual({ base: 64 });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('persists builder v2 state inside theme settings without losing non-builder siblings', () => {
|
|
205
|
+
const settings = createEmptyBuilderSettings(4);
|
|
206
|
+
const persisted = mergeBuilderSettingsIntoThemeSettings({
|
|
207
|
+
builder_settings: {
|
|
208
|
+
seo: {
|
|
209
|
+
default_title: 'Keep this title',
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
}, settings);
|
|
213
|
+
|
|
214
|
+
expect((persisted.builder_settings as Record<string, unknown>).seo).toEqual({
|
|
215
|
+
default_title: 'Keep this title',
|
|
216
|
+
});
|
|
217
|
+
expect(extractPersistedBuilderSettings(persisted)).toEqual(settings);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('removes reserved content keys from persisted builder v2 state', () => {
|
|
221
|
+
const settings = BuilderSettingsSchema.parse({
|
|
222
|
+
...createEmptyBuilderSettings(5),
|
|
223
|
+
theme: {
|
|
224
|
+
...createEmptyBuilderSettings(5).theme,
|
|
225
|
+
content: {
|
|
226
|
+
layout: {
|
|
227
|
+
home: {
|
|
228
|
+
blocks: [
|
|
229
|
+
{
|
|
230
|
+
id: 'old-hero',
|
|
231
|
+
type: 'hero',
|
|
232
|
+
visible: true,
|
|
233
|
+
settings: { title: 'Old title' },
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
'hero.eyebrow': 'Fresh content',
|
|
239
|
+
},
|
|
240
|
+
layout: {
|
|
241
|
+
home: {
|
|
242
|
+
blocks: [
|
|
243
|
+
{
|
|
244
|
+
id: 'new-hero',
|
|
245
|
+
type: 'hero',
|
|
246
|
+
visible: true,
|
|
247
|
+
settings: { title: 'New title' },
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const persisted = mergeBuilderSettingsIntoThemeSettings({}, settings);
|
|
256
|
+
const extracted = extractPersistedBuilderSettings(persisted);
|
|
257
|
+
|
|
258
|
+
expect(extracted?.theme.content).toEqual({ 'hero.eyebrow': 'Fresh content' });
|
|
259
|
+
expect(extracted?.theme.layout.home?.blocks[0]?.settings).toEqual({ title: 'New title' });
|
|
135
260
|
});
|
|
136
261
|
|
|
137
262
|
test('converts legacy theme manifests into the v2 manifest contract', () => {
|
|
@@ -139,6 +264,17 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
139
264
|
id: 'default',
|
|
140
265
|
name: 'Default Theme',
|
|
141
266
|
version: '1.0.0',
|
|
267
|
+
author: 'Shoppex',
|
|
268
|
+
description: 'Legacy theme metadata',
|
|
269
|
+
preview: 'preview.png',
|
|
270
|
+
features: ['Builder-ready'],
|
|
271
|
+
techStack: ['Vite', 'React'],
|
|
272
|
+
templates: ['Home'],
|
|
273
|
+
createdAt: '2026-05-16',
|
|
274
|
+
demoUrl: 'https://demo.example',
|
|
275
|
+
audience: 'Digital sellers',
|
|
276
|
+
tags: ['minimal'],
|
|
277
|
+
hotfixPaths: ['src/hooks/**'],
|
|
142
278
|
presets: [
|
|
143
279
|
{
|
|
144
280
|
id: 'gaming',
|
|
@@ -192,16 +328,53 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
192
328
|
],
|
|
193
329
|
},
|
|
194
330
|
},
|
|
331
|
+
linkGroups: [
|
|
332
|
+
{
|
|
333
|
+
path: 'navigation.header.links',
|
|
334
|
+
label: 'Header links',
|
|
335
|
+
pageIds: ['navigation'],
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
defaultLinkItems: {
|
|
339
|
+
'navigation.header.links': [
|
|
340
|
+
{ label: 'FAQ', href: '/faq' },
|
|
341
|
+
],
|
|
342
|
+
},
|
|
195
343
|
},
|
|
196
344
|
});
|
|
197
345
|
|
|
198
346
|
expect(manifest.pages.home.allowedBlocks).toEqual(['hero', 'faq']);
|
|
347
|
+
expect(manifest).toMatchObject({
|
|
348
|
+
author: 'Shoppex',
|
|
349
|
+
description: 'Legacy theme metadata',
|
|
350
|
+
preview: 'preview.png',
|
|
351
|
+
features: ['Builder-ready'],
|
|
352
|
+
techStack: ['Vite', 'React'],
|
|
353
|
+
templates: ['Home'],
|
|
354
|
+
createdAt: '2026-05-16',
|
|
355
|
+
demoUrl: 'https://demo.example',
|
|
356
|
+
audience: 'Digital sellers',
|
|
357
|
+
tags: ['minimal'],
|
|
358
|
+
hotfixPaths: ['src/hooks/**'],
|
|
359
|
+
});
|
|
199
360
|
expect(manifest.pages.home.defaultBlocks).toEqual([{ type: 'hero' }, { type: 'faq' }]);
|
|
200
361
|
expect(manifest.blocks.hero.settings['hero.title']).toMatchObject({ type: 'text', label: 'Title' });
|
|
201
362
|
expect(manifest.blocks.hero.settings['hero.style']).toMatchObject({ type: 'select' });
|
|
202
363
|
expect(manifest.blocks.faq.settings['faq.items']).toMatchObject({ type: 'list' });
|
|
203
364
|
expect(manifest.blocks['page-contact-page'].settings['pages.contact.title']).toMatchObject({ type: 'text' });
|
|
204
365
|
expect(manifest.pages['contact-page'].allowedBlocks).toEqual(['page-contact-page']);
|
|
366
|
+
expect(manifest.linkGroups).toEqual([
|
|
367
|
+
{
|
|
368
|
+
path: 'navigation.header.links',
|
|
369
|
+
label: 'Header links',
|
|
370
|
+
pageIds: ['navigation'],
|
|
371
|
+
},
|
|
372
|
+
]);
|
|
373
|
+
expect(manifest.defaultLinkItems).toEqual({
|
|
374
|
+
'navigation.header.links': [
|
|
375
|
+
{ label: 'FAQ', href: '/faq' },
|
|
376
|
+
],
|
|
377
|
+
});
|
|
205
378
|
expect(manifest.presets.gaming).toMatchObject({
|
|
206
379
|
label: 'Gaming Store',
|
|
207
380
|
description: 'Gaming copy preset',
|
|
@@ -211,6 +384,117 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
211
384
|
});
|
|
212
385
|
});
|
|
213
386
|
|
|
387
|
+
test('accepts canonical theme manifests without legacy conversion', () => {
|
|
388
|
+
const manifest = convertLegacyThemeManifest({
|
|
389
|
+
id: 'default',
|
|
390
|
+
name: 'Default Theme',
|
|
391
|
+
version: '2.0.0',
|
|
392
|
+
author: 'Shoppex',
|
|
393
|
+
description: 'Canonical theme metadata',
|
|
394
|
+
preview: 'preview.png',
|
|
395
|
+
pages: {
|
|
396
|
+
home: {
|
|
397
|
+
label: 'Home',
|
|
398
|
+
previewPath: '/',
|
|
399
|
+
allowedBlocks: ['hero'],
|
|
400
|
+
defaultBlocks: [{ type: 'hero' }],
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
blocks: {
|
|
404
|
+
hero: {
|
|
405
|
+
label: 'Hero',
|
|
406
|
+
settings: {
|
|
407
|
+
title: { type: 'text', label: 'Title' },
|
|
408
|
+
},
|
|
409
|
+
variants: [],
|
|
410
|
+
exposedStyleSlots: [],
|
|
411
|
+
presets: [],
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
styleSlots: {},
|
|
415
|
+
presets: {},
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
expect(manifest.pages.home.allowedBlocks).toEqual(['hero']);
|
|
419
|
+
expect(manifest.author).toBe('Shoppex');
|
|
420
|
+
expect(manifest.description).toBe('Canonical theme metadata');
|
|
421
|
+
expect(manifest.preview).toBe('preview.png');
|
|
422
|
+
expect(manifest.blocks.hero.settings.title).toMatchObject({
|
|
423
|
+
type: 'text',
|
|
424
|
+
label: 'Title',
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test('normalizes canonical and legacy theme presets for theme library readers', () => {
|
|
429
|
+
expect(listThemeManifestPresets({
|
|
430
|
+
id: 'default',
|
|
431
|
+
name: 'Default Theme',
|
|
432
|
+
version: '2.0.0',
|
|
433
|
+
pages: {
|
|
434
|
+
home: {
|
|
435
|
+
label: 'Home',
|
|
436
|
+
previewPath: '/',
|
|
437
|
+
allowedBlocks: ['hero'],
|
|
438
|
+
defaultBlocks: [{ type: 'hero' }],
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
blocks: {
|
|
442
|
+
hero: {
|
|
443
|
+
label: 'Hero',
|
|
444
|
+
settings: {},
|
|
445
|
+
variants: [],
|
|
446
|
+
exposedStyleSlots: [],
|
|
447
|
+
presets: [],
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
styleSlots: {},
|
|
451
|
+
presets: {
|
|
452
|
+
launch: {
|
|
453
|
+
label: 'Launch Store',
|
|
454
|
+
description: 'Launch copy',
|
|
455
|
+
preview: 'presets/launch.png',
|
|
456
|
+
content: { 'hero.title': 'Instant access' },
|
|
457
|
+
layout: {},
|
|
458
|
+
style_slots: { 'button.radius': { base: 12 } },
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
})).toEqual([
|
|
462
|
+
{
|
|
463
|
+
id: 'launch',
|
|
464
|
+
name: 'Launch Store',
|
|
465
|
+
description: 'Launch copy',
|
|
466
|
+
preview: 'presets/launch.png',
|
|
467
|
+
overrides: {
|
|
468
|
+
content: { 'hero.title': 'Instant access' },
|
|
469
|
+
style_slots: { 'button.radius': { base: 12 } },
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
]);
|
|
473
|
+
|
|
474
|
+
expect(listThemeManifestPresets({
|
|
475
|
+
id: 'legacy',
|
|
476
|
+
name: 'Legacy Theme',
|
|
477
|
+
version: '1.0.0',
|
|
478
|
+
presets: [
|
|
479
|
+
{
|
|
480
|
+
id: 'tokens',
|
|
481
|
+
name: 'Tokens',
|
|
482
|
+
overrides: {
|
|
483
|
+
tokens: { buttons: { borderRadius: 18 } },
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
})).toEqual([
|
|
488
|
+
{
|
|
489
|
+
id: 'tokens',
|
|
490
|
+
name: 'Tokens',
|
|
491
|
+
overrides: {
|
|
492
|
+
tokens: { buttons: { borderRadius: 18 } },
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
]);
|
|
496
|
+
});
|
|
497
|
+
|
|
214
498
|
test('applies manifest default blocks to empty builder settings', () => {
|
|
215
499
|
const settings = applyManifestDefaultLayout(createEmptyBuilderSettings(2), {
|
|
216
500
|
id: 'default',
|
|
@@ -347,7 +631,48 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
347
631
|
expect(issues.map((issue) => issue.code)).toContain('unexposed_style_slot');
|
|
348
632
|
});
|
|
349
633
|
|
|
350
|
-
test('
|
|
634
|
+
test('requires theme-scoped global style slots to be declared by the manifest', () => {
|
|
635
|
+
const settings = BuilderSettingsSchema.parse({
|
|
636
|
+
version: 2,
|
|
637
|
+
revision: 1,
|
|
638
|
+
theme: {
|
|
639
|
+
content: {},
|
|
640
|
+
layout: {},
|
|
641
|
+
style_slots: {
|
|
642
|
+
'color.primary': '#ff5500',
|
|
643
|
+
'theme.hero.overlay.opacity': 0.72,
|
|
644
|
+
},
|
|
645
|
+
pages: [],
|
|
646
|
+
terms: {},
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
const manifest = {
|
|
650
|
+
id: 'default',
|
|
651
|
+
name: 'Default',
|
|
652
|
+
version: '2.0.0',
|
|
653
|
+
pages: {},
|
|
654
|
+
blocks: {},
|
|
655
|
+
styleSlots: {},
|
|
656
|
+
presets: {},
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
expect(validateBuilderSettingsAgainstManifest(settings, manifest)).toEqual([
|
|
660
|
+
{
|
|
661
|
+
code: 'unknown_style_slot',
|
|
662
|
+
path: 'theme.style_slots.theme.hero.overlay.opacity',
|
|
663
|
+
message: 'Theme style slot "theme.hero.overlay.opacity" is not declared by the manifest',
|
|
664
|
+
},
|
|
665
|
+
]);
|
|
666
|
+
|
|
667
|
+
expect(validateBuilderSettingsAgainstManifest(settings, {
|
|
668
|
+
...manifest,
|
|
669
|
+
styleSlots: {
|
|
670
|
+
'theme.hero.overlay.opacity': 0.5,
|
|
671
|
+
},
|
|
672
|
+
})).toEqual([]);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test('canonicalizes unique short block setting aliases before strict validation', () => {
|
|
351
676
|
const settings = BuilderSettingsSchema.parse({
|
|
352
677
|
version: 2,
|
|
353
678
|
revision: 1,
|
|
@@ -374,7 +699,7 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
374
699
|
},
|
|
375
700
|
});
|
|
376
701
|
|
|
377
|
-
const
|
|
702
|
+
const manifest = {
|
|
378
703
|
id: 'default',
|
|
379
704
|
name: 'Default',
|
|
380
705
|
version: '2.0.0',
|
|
@@ -398,8 +723,78 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
398
723
|
},
|
|
399
724
|
styleSlots: {},
|
|
400
725
|
presets: {},
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
const result = canonicalizeBuilderSettingsForManifestWithReport(settings, manifest);
|
|
729
|
+
|
|
730
|
+
expect(result.migratedAliases).toBe(0);
|
|
731
|
+
expect(result.removedAliases).toBe(1);
|
|
732
|
+
expect(result.conflicts).toEqual([]);
|
|
733
|
+
expect(result.settings.theme.layout.home.blocks[0].settings).toEqual({
|
|
734
|
+
'hero.title': 'Launch sale',
|
|
735
|
+
});
|
|
736
|
+
expect(validateBuilderSettingsAgainstManifest(result.settings, manifest)).toEqual([]);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test('canonicalizes block-prefixed aliases when the manifest declares short block fields', () => {
|
|
740
|
+
const settings = BuilderSettingsSchema.parse({
|
|
741
|
+
version: 2,
|
|
742
|
+
revision: 1,
|
|
743
|
+
theme: {
|
|
744
|
+
content: {},
|
|
745
|
+
layout: {
|
|
746
|
+
home: {
|
|
747
|
+
blocks: [
|
|
748
|
+
{
|
|
749
|
+
id: 'home-hero-1',
|
|
750
|
+
type: 'hero',
|
|
751
|
+
visible: true,
|
|
752
|
+
settings: {
|
|
753
|
+
'hero.title': 'Launch sale',
|
|
754
|
+
},
|
|
755
|
+
},
|
|
756
|
+
],
|
|
757
|
+
},
|
|
758
|
+
},
|
|
759
|
+
style_slots: {},
|
|
760
|
+
pages: [],
|
|
761
|
+
terms: {},
|
|
762
|
+
},
|
|
401
763
|
});
|
|
402
764
|
|
|
403
|
-
|
|
765
|
+
const manifest = {
|
|
766
|
+
id: 'starlight',
|
|
767
|
+
name: 'Starlight',
|
|
768
|
+
version: '1.0.0',
|
|
769
|
+
pages: {
|
|
770
|
+
home: {
|
|
771
|
+
label: 'Home',
|
|
772
|
+
allowedBlocks: ['hero'],
|
|
773
|
+
defaultBlocks: [{ type: 'hero' }],
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
blocks: {
|
|
777
|
+
hero: {
|
|
778
|
+
label: 'Hero',
|
|
779
|
+
settings: {
|
|
780
|
+
title: { type: 'text', label: 'Title' },
|
|
781
|
+
},
|
|
782
|
+
variants: [],
|
|
783
|
+
exposedStyleSlots: [],
|
|
784
|
+
presets: [],
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
styleSlots: {},
|
|
788
|
+
presets: {},
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
const result = canonicalizeBuilderSettingsForManifestWithReport(settings, manifest);
|
|
792
|
+
|
|
793
|
+
expect(result.migratedAliases).toBe(1);
|
|
794
|
+
expect(result.removedAliases).toBe(1);
|
|
795
|
+
expect(result.settings.theme.layout.home.blocks[0].settings).toEqual({
|
|
796
|
+
title: 'Launch sale',
|
|
797
|
+
});
|
|
798
|
+
expect(validateBuilderSettingsAgainstManifest(result.settings, manifest)).toEqual([]);
|
|
404
799
|
});
|
|
405
800
|
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { BlockInstance, BuilderSettings } from './builder-settings.ts';
|
|
2
|
+
import { sanitizeBuilderSettingsState } from './persistence.ts';
|
|
3
|
+
import type { ThemeManifest } from './theme-manifest.ts';
|
|
4
|
+
|
|
5
|
+
export type BuilderSettingsCanonicalizationConflict = {
|
|
6
|
+
pageId: string;
|
|
7
|
+
blockId: string;
|
|
8
|
+
blockType: string;
|
|
9
|
+
alias: string;
|
|
10
|
+
canonicalPath: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type BuilderSettingsCanonicalizationResult = {
|
|
14
|
+
settings: BuilderSettings;
|
|
15
|
+
migratedAliases: number;
|
|
16
|
+
removedAliases: number;
|
|
17
|
+
conflicts: BuilderSettingsCanonicalizationConflict[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function canonicalizeBuilderSettingsForManifest(
|
|
21
|
+
settings: BuilderSettings,
|
|
22
|
+
manifest: ThemeManifest,
|
|
23
|
+
): BuilderSettings {
|
|
24
|
+
return canonicalizeBuilderSettingsForManifestWithReport(settings, manifest).settings;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function canonicalizeBuilderSettingsForManifestWithReport(
|
|
28
|
+
settings: BuilderSettings,
|
|
29
|
+
manifest: ThemeManifest,
|
|
30
|
+
): BuilderSettingsCanonicalizationResult {
|
|
31
|
+
const next = sanitizeBuilderSettingsState(cloneBuilderSettings(settings));
|
|
32
|
+
const conflicts: BuilderSettingsCanonicalizationConflict[] = [];
|
|
33
|
+
let migratedAliases = 0;
|
|
34
|
+
let removedAliases = 0;
|
|
35
|
+
|
|
36
|
+
for (const [pageId, layout] of Object.entries(next.theme.layout)) {
|
|
37
|
+
for (const block of layout.blocks) {
|
|
38
|
+
const blockDefinition = manifest.blocks[block.type];
|
|
39
|
+
if (!blockDefinition) continue;
|
|
40
|
+
|
|
41
|
+
const result = canonicalizeBlockSettings(pageId, block, Object.keys(blockDefinition.settings));
|
|
42
|
+
migratedAliases += result.migratedAliases;
|
|
43
|
+
removedAliases += result.removedAliases;
|
|
44
|
+
conflicts.push(...result.conflicts);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
settings: next,
|
|
50
|
+
migratedAliases,
|
|
51
|
+
removedAliases,
|
|
52
|
+
conflicts,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function canonicalizeBlockSettings(
|
|
57
|
+
pageId: string,
|
|
58
|
+
block: BlockInstance,
|
|
59
|
+
settingPaths: string[],
|
|
60
|
+
): Omit<BuilderSettingsCanonicalizationResult, 'settings'> {
|
|
61
|
+
const aliases = buildAliasMap(block.type, settingPaths);
|
|
62
|
+
const conflicts: BuilderSettingsCanonicalizationConflict[] = [];
|
|
63
|
+
let migratedAliases = 0;
|
|
64
|
+
let removedAliases = 0;
|
|
65
|
+
|
|
66
|
+
for (const [alias, canonicalPaths] of aliases.entries()) {
|
|
67
|
+
if (!Object.prototype.hasOwnProperty.call(block.settings, alias)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const aliasValue = block.settings[alias];
|
|
72
|
+
if (canonicalPaths.length === 1) {
|
|
73
|
+
const canonicalPath = canonicalPaths[0];
|
|
74
|
+
if (!Object.prototype.hasOwnProperty.call(block.settings, canonicalPath)) {
|
|
75
|
+
block.settings[canonicalPath] = aliasValue;
|
|
76
|
+
migratedAliases += 1;
|
|
77
|
+
} else if (!isJsonEqual(block.settings[canonicalPath], aliasValue)) {
|
|
78
|
+
conflicts.push({
|
|
79
|
+
pageId,
|
|
80
|
+
blockId: block.id,
|
|
81
|
+
blockType: block.type,
|
|
82
|
+
alias,
|
|
83
|
+
canonicalPath,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
delete block.settings[alias];
|
|
88
|
+
removedAliases += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const everyCanonicalExists = canonicalPaths.every((canonicalPath) =>
|
|
93
|
+
Object.prototype.hasOwnProperty.call(block.settings, canonicalPath),
|
|
94
|
+
);
|
|
95
|
+
if (everyCanonicalExists) {
|
|
96
|
+
delete block.settings[alias];
|
|
97
|
+
removedAliases += 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
migratedAliases,
|
|
103
|
+
removedAliases,
|
|
104
|
+
conflicts,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildAliasMap(blockType: string, settingPaths: string[]): Map<string, string[]> {
|
|
109
|
+
const aliases = new Map<string, Set<string>>();
|
|
110
|
+
const canonicalPaths = new Set(settingPaths);
|
|
111
|
+
|
|
112
|
+
for (const canonicalPath of settingPaths) {
|
|
113
|
+
for (const alias of getLegacyAliases(blockType, canonicalPath)) {
|
|
114
|
+
if (alias === canonicalPath || canonicalPaths.has(alias)) continue;
|
|
115
|
+
const canonicalAliases = aliases.get(alias) ?? new Set<string>();
|
|
116
|
+
canonicalAliases.add(canonicalPath);
|
|
117
|
+
aliases.set(alias, canonicalAliases);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return new Map(
|
|
122
|
+
Array.from(aliases.entries()).map(([alias, paths]) => [alias, Array.from(paths).sort()]),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function getLegacyAliases(blockType: string, canonicalPath: string): string[] {
|
|
127
|
+
const aliases = new Set<string>();
|
|
128
|
+
const shortKey = getShortSettingKey(canonicalPath);
|
|
129
|
+
|
|
130
|
+
if (shortKey && shortKey !== canonicalPath) {
|
|
131
|
+
aliases.add(shortKey);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!canonicalPath.includes('.')) {
|
|
135
|
+
aliases.add(`${blockType}.${canonicalPath}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return Array.from(aliases);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function getShortSettingKey(path: string): string {
|
|
142
|
+
const parts = path.split('.').filter(Boolean);
|
|
143
|
+
return parts.at(-1) ?? path;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function cloneBuilderSettings(settings: BuilderSettings): BuilderSettings {
|
|
147
|
+
if (typeof structuredClone === 'function') {
|
|
148
|
+
return structuredClone(settings);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return JSON.parse(JSON.stringify(settings)) as BuilderSettings;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isJsonEqual(left: unknown, right: unknown): boolean {
|
|
155
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
156
|
+
}
|
package/src/events.ts
CHANGED
|
@@ -102,6 +102,13 @@ const TermsSetEventSchema = EventBaseSchema.extend({
|
|
|
102
102
|
value: z.string(),
|
|
103
103
|
}).strict();
|
|
104
104
|
|
|
105
|
+
const PresetApplyEventSchema = EventBaseSchema.extend({
|
|
106
|
+
type: z.literal('preset.apply'),
|
|
107
|
+
pageId: PageIdSchema,
|
|
108
|
+
presetId: z.string().min(1),
|
|
109
|
+
blockId: z.string().min(1).optional(),
|
|
110
|
+
}).strict();
|
|
111
|
+
|
|
105
112
|
export const BuilderEventSchema = z.discriminatedUnion('type', [
|
|
106
113
|
ContentSetEventSchema,
|
|
107
114
|
ListSetEventSchema,
|
|
@@ -117,5 +124,6 @@ export const BuilderEventSchema = z.discriminatedUnion('type', [
|
|
|
117
124
|
PageUpsertEventSchema,
|
|
118
125
|
PageRemoveEventSchema,
|
|
119
126
|
TermsSetEventSchema,
|
|
127
|
+
PresetApplyEventSchema,
|
|
120
128
|
]);
|
|
121
129
|
export type BuilderEvent = z.infer<typeof BuilderEventSchema>;
|