@shoppexio/builder-contracts 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.
- package/dist/builder-settings.d.ts +11 -666
- package/dist/builder-settings.d.ts.map +1 -1
- package/dist/builder-settings.js +2 -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/custom-pages.d.ts +15 -0
- package/dist/custom-pages.d.ts.map +1 -0
- package/dist/custom-pages.js +40 -0
- package/dist/dedicated-pages.d.ts +15 -0
- package/dist/dedicated-pages.d.ts.map +1 -0
- package/dist/dedicated-pages.js +142 -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 +229 -10
- package/dist/fields.d.ts.map +1 -1
- package/dist/fields.js +27 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/legacy-manifest.d.ts +18 -0
- package/dist/legacy-manifest.d.ts.map +1 -1
- package/dist/legacy-manifest.js +137 -22
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +55 -6
- 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 +38 -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 +28 -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/storefront-typography-fonts.d.ts +18 -0
- package/dist/storefront-typography-fonts.d.ts.map +1 -0
- package/dist/storefront-typography-fonts.js +89 -0
- package/dist/style-slots.d.ts +50 -152
- package/dist/style-slots.d.ts.map +1 -1
- package/dist/style-slots.js +80 -32
- package/dist/theme-manifest.d.ts +287 -456
- 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 +25 -0
- package/dist/validation.d.ts +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +23 -12
- package/package.json +43 -1
- package/src/builder-contracts.test.ts +416 -3
- package/src/builder-settings.ts +4 -1
- package/src/canonical-settings.ts +156 -0
- package/src/custom-pages.test.ts +74 -0
- package/src/custom-pages.ts +70 -0
- package/src/dedicated-pages.test.ts +88 -0
- package/src/dedicated-pages.ts +173 -0
- package/src/events.ts +8 -0
- package/src/fields.ts +30 -0
- package/src/index.ts +10 -0
- package/src/legacy-manifest.ts +147 -23
- package/src/migrations.ts +70 -6
- package/src/persistence.ts +77 -0
- package/src/preview-boot.test.ts +72 -0
- package/src/preview-boot.ts +49 -0
- package/src/preview-protocol.test.ts +132 -0
- package/src/preview-protocol.ts +122 -0
- package/src/preview-session-resolve.test.ts +37 -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 +73 -0
- package/src/storefront-initial-data-html.ts +112 -0
- package/src/storefront-typography-fonts.test.ts +48 -0
- package/src/storefront-typography-fonts.ts +108 -0
- package/src/style-slots.ts +102 -34
- package/src/theme-manifest.ts +118 -1
- package/src/theme-schemes.ts +34 -0
- package/src/validation.ts +32 -13
- package/dist/builder-contracts.test.d.ts +0 -2
- package/dist/builder-contracts.test.d.ts.map +0 -1
- package/dist/builder-contracts.test.js +0 -361
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import {
|
|
3
5
|
BuilderEventSchema,
|
|
4
6
|
BuilderSettingsSchema,
|
|
7
|
+
PreviewResponseSchema,
|
|
5
8
|
PreviewMessageSchema,
|
|
6
9
|
StyleSlotsSchema,
|
|
7
10
|
ThemeManifestSchema,
|
|
@@ -10,8 +13,13 @@ import {
|
|
|
10
13
|
createBlockInstance,
|
|
11
14
|
createEmptyBuilderSettings,
|
|
12
15
|
convertLegacyThemeManifest,
|
|
16
|
+
extractPersistedBuilderSettings,
|
|
17
|
+
canonicalizeBuilderSettingsForManifestWithReport,
|
|
18
|
+
mergeBuilderSettingsIntoThemeSettings,
|
|
13
19
|
migrateLegacyBuilderSettings,
|
|
20
|
+
listThemeManifestPresets,
|
|
14
21
|
} from './index.ts';
|
|
22
|
+
import { ColorSchema } from './style-slots.ts';
|
|
15
23
|
|
|
16
24
|
describe('@shoppex/builder-contracts', () => {
|
|
17
25
|
test('accepts an empty builder settings document', () => {
|
|
@@ -39,6 +47,16 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
39
47
|
expect(result.success).toBe(false);
|
|
40
48
|
});
|
|
41
49
|
|
|
50
|
+
test('accepts theme-scoped style slots', () => {
|
|
51
|
+
const result = StyleSlotsSchema.safeParse({
|
|
52
|
+
'button.radius': { base: 10 },
|
|
53
|
+
'theme.hero.overlay.opacity': 0.72,
|
|
54
|
+
'theme.product.badge.background': '#111827',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(result.success).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
42
60
|
test('rejects invalid colors', () => {
|
|
43
61
|
const result = StyleSlotsSchema.safeParse({
|
|
44
62
|
'color.primary': 'tomato',
|
|
@@ -93,6 +111,32 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
93
111
|
expect(result.success).toBe(true);
|
|
94
112
|
});
|
|
95
113
|
|
|
114
|
+
test('accepts preview READY health and runtime error responses', () => {
|
|
115
|
+
expect(PreviewResponseSchema.safeParse({
|
|
116
|
+
type: 'READY',
|
|
117
|
+
revision: 3,
|
|
118
|
+
health: {
|
|
119
|
+
reactMounted: true,
|
|
120
|
+
builderRuntimeProvider: true,
|
|
121
|
+
protocolVersion: 2,
|
|
122
|
+
},
|
|
123
|
+
}).success).toBe(true);
|
|
124
|
+
|
|
125
|
+
expect(PreviewResponseSchema.safeParse({
|
|
126
|
+
type: 'PREVIEW_ERROR',
|
|
127
|
+
revision: 3,
|
|
128
|
+
message: 'useBuilderRuntime must be used inside BuilderRuntimeProvider',
|
|
129
|
+
stack: 'Error: useBuilderRuntime must be used inside BuilderRuntimeProvider',
|
|
130
|
+
source: 'error',
|
|
131
|
+
}).success).toBe(true);
|
|
132
|
+
|
|
133
|
+
expect(PreviewResponseSchema.safeParse({
|
|
134
|
+
type: 'PREVIEW_ERROR',
|
|
135
|
+
message: 'Theme bootstrap failed before React mounted',
|
|
136
|
+
source: 'bootstrap',
|
|
137
|
+
}).success).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
96
140
|
test('accepts block add events', () => {
|
|
97
141
|
const result = BuilderEventSchema.safeParse({
|
|
98
142
|
id: 'evt_1',
|
|
@@ -112,6 +156,20 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
112
156
|
expect(result.success).toBe(true);
|
|
113
157
|
});
|
|
114
158
|
|
|
159
|
+
test('accepts preset apply events', () => {
|
|
160
|
+
const result = BuilderEventSchema.safeParse({
|
|
161
|
+
id: 'evt_preset',
|
|
162
|
+
type: 'preset.apply',
|
|
163
|
+
revision: 7,
|
|
164
|
+
createdAt: '2026-05-16T12:00:00.000Z',
|
|
165
|
+
source: 'dashboard',
|
|
166
|
+
pageId: 'home',
|
|
167
|
+
presetId: 'launch',
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(result.success).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
115
173
|
test('migrates legacy content and token overrides', () => {
|
|
116
174
|
const migrated = migrateLegacyBuilderSettings(
|
|
117
175
|
{
|
|
@@ -122,6 +180,14 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
122
180
|
tokens_override: {
|
|
123
181
|
'buttons.borderRadius': 12,
|
|
124
182
|
'colors.primary': '#ff5500',
|
|
183
|
+
typography: {
|
|
184
|
+
fontSize: 18,
|
|
185
|
+
fontFamily: 'Manrope, sans-serif',
|
|
186
|
+
headingFont: 'Georgia, serif',
|
|
187
|
+
},
|
|
188
|
+
header: {
|
|
189
|
+
height: 64,
|
|
190
|
+
},
|
|
125
191
|
},
|
|
126
192
|
},
|
|
127
193
|
},
|
|
@@ -132,6 +198,68 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
132
198
|
expect(migrated.theme.content['hero.title']).toBe('Summer sale');
|
|
133
199
|
expect(migrated.theme.style_slots['button.radius']).toEqual({ base: 12 });
|
|
134
200
|
expect(migrated.theme.style_slots['color.primary']).toBe('#ff5500');
|
|
201
|
+
expect(migrated.theme.style_slots['typography.body.size']).toEqual({ base: 18 });
|
|
202
|
+
expect(migrated.theme.style_slots['theme.typography.font.family']).toBe('Manrope, sans-serif');
|
|
203
|
+
expect(migrated.theme.style_slots['theme.typography.heading.font']).toBe('Georgia, serif');
|
|
204
|
+
expect(migrated.theme.style_slots['theme.header.height']).toEqual({ base: 64 });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('persists builder v2 state inside theme settings without losing non-builder siblings', () => {
|
|
208
|
+
const settings = createEmptyBuilderSettings(4);
|
|
209
|
+
const persisted = mergeBuilderSettingsIntoThemeSettings({
|
|
210
|
+
builder_settings: {
|
|
211
|
+
seo: {
|
|
212
|
+
default_title: 'Keep this title',
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
}, settings);
|
|
216
|
+
|
|
217
|
+
expect((persisted.builder_settings as Record<string, unknown>).seo).toEqual({
|
|
218
|
+
default_title: 'Keep this title',
|
|
219
|
+
});
|
|
220
|
+
expect(extractPersistedBuilderSettings(persisted)).toEqual(settings);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('removes reserved content keys from persisted builder v2 state', () => {
|
|
224
|
+
const settings = BuilderSettingsSchema.parse({
|
|
225
|
+
...createEmptyBuilderSettings(5),
|
|
226
|
+
theme: {
|
|
227
|
+
...createEmptyBuilderSettings(5).theme,
|
|
228
|
+
content: {
|
|
229
|
+
layout: {
|
|
230
|
+
home: {
|
|
231
|
+
blocks: [
|
|
232
|
+
{
|
|
233
|
+
id: 'old-hero',
|
|
234
|
+
type: 'hero',
|
|
235
|
+
visible: true,
|
|
236
|
+
settings: { title: 'Old title' },
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
'hero.eyebrow': 'Fresh content',
|
|
242
|
+
},
|
|
243
|
+
layout: {
|
|
244
|
+
home: {
|
|
245
|
+
blocks: [
|
|
246
|
+
{
|
|
247
|
+
id: 'new-hero',
|
|
248
|
+
type: 'hero',
|
|
249
|
+
visible: true,
|
|
250
|
+
settings: { title: 'New title' },
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const persisted = mergeBuilderSettingsIntoThemeSettings({}, settings);
|
|
259
|
+
const extracted = extractPersistedBuilderSettings(persisted);
|
|
260
|
+
|
|
261
|
+
expect(extracted?.theme.content).toEqual({ 'hero.eyebrow': 'Fresh content' });
|
|
262
|
+
expect(extracted?.theme.layout.home?.blocks[0]?.settings).toEqual({ title: 'New title' });
|
|
135
263
|
});
|
|
136
264
|
|
|
137
265
|
test('converts legacy theme manifests into the v2 manifest contract', () => {
|
|
@@ -139,6 +267,17 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
139
267
|
id: 'default',
|
|
140
268
|
name: 'Default Theme',
|
|
141
269
|
version: '1.0.0',
|
|
270
|
+
author: 'Shoppex',
|
|
271
|
+
description: 'Legacy theme metadata',
|
|
272
|
+
preview: 'preview.png',
|
|
273
|
+
features: ['Builder-ready'],
|
|
274
|
+
techStack: ['Vite', 'React'],
|
|
275
|
+
templates: ['Home'],
|
|
276
|
+
createdAt: '2026-05-16',
|
|
277
|
+
demoUrl: 'https://demo.example',
|
|
278
|
+
audience: 'Digital sellers',
|
|
279
|
+
tags: ['minimal'],
|
|
280
|
+
hotfixPaths: ['src/hooks/**'],
|
|
142
281
|
presets: [
|
|
143
282
|
{
|
|
144
283
|
id: 'gaming',
|
|
@@ -192,16 +331,53 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
192
331
|
],
|
|
193
332
|
},
|
|
194
333
|
},
|
|
334
|
+
linkGroups: [
|
|
335
|
+
{
|
|
336
|
+
path: 'navigation.header.links',
|
|
337
|
+
label: 'Header links',
|
|
338
|
+
pageIds: ['navigation'],
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
defaultLinkItems: {
|
|
342
|
+
'navigation.header.links': [
|
|
343
|
+
{ label: 'FAQ', href: '/faq' },
|
|
344
|
+
],
|
|
345
|
+
},
|
|
195
346
|
},
|
|
196
347
|
});
|
|
197
348
|
|
|
198
349
|
expect(manifest.pages.home.allowedBlocks).toEqual(['hero', 'faq']);
|
|
350
|
+
expect(manifest).toMatchObject({
|
|
351
|
+
author: 'Shoppex',
|
|
352
|
+
description: 'Legacy theme metadata',
|
|
353
|
+
preview: 'preview.png',
|
|
354
|
+
features: ['Builder-ready'],
|
|
355
|
+
techStack: ['Vite', 'React'],
|
|
356
|
+
templates: ['Home'],
|
|
357
|
+
createdAt: '2026-05-16',
|
|
358
|
+
demoUrl: 'https://demo.example',
|
|
359
|
+
audience: 'Digital sellers',
|
|
360
|
+
tags: ['minimal'],
|
|
361
|
+
hotfixPaths: ['src/hooks/**'],
|
|
362
|
+
});
|
|
199
363
|
expect(manifest.pages.home.defaultBlocks).toEqual([{ type: 'hero' }, { type: 'faq' }]);
|
|
200
364
|
expect(manifest.blocks.hero.settings['hero.title']).toMatchObject({ type: 'text', label: 'Title' });
|
|
201
365
|
expect(manifest.blocks.hero.settings['hero.style']).toMatchObject({ type: 'select' });
|
|
202
366
|
expect(manifest.blocks.faq.settings['faq.items']).toMatchObject({ type: 'list' });
|
|
203
367
|
expect(manifest.blocks['page-contact-page'].settings['pages.contact.title']).toMatchObject({ type: 'text' });
|
|
204
368
|
expect(manifest.pages['contact-page'].allowedBlocks).toEqual(['page-contact-page']);
|
|
369
|
+
expect(manifest.linkGroups).toEqual([
|
|
370
|
+
{
|
|
371
|
+
path: 'navigation.header.links',
|
|
372
|
+
label: 'Header links',
|
|
373
|
+
pageIds: ['navigation'],
|
|
374
|
+
},
|
|
375
|
+
]);
|
|
376
|
+
expect(manifest.defaultLinkItems).toEqual({
|
|
377
|
+
'navigation.header.links': [
|
|
378
|
+
{ label: 'FAQ', href: '/faq' },
|
|
379
|
+
],
|
|
380
|
+
});
|
|
205
381
|
expect(manifest.presets.gaming).toMatchObject({
|
|
206
382
|
label: 'Gaming Store',
|
|
207
383
|
description: 'Gaming copy preset',
|
|
@@ -211,6 +387,117 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
211
387
|
});
|
|
212
388
|
});
|
|
213
389
|
|
|
390
|
+
test('accepts canonical theme manifests without legacy conversion', () => {
|
|
391
|
+
const manifest = convertLegacyThemeManifest({
|
|
392
|
+
id: 'default',
|
|
393
|
+
name: 'Default Theme',
|
|
394
|
+
version: '2.0.0',
|
|
395
|
+
author: 'Shoppex',
|
|
396
|
+
description: 'Canonical theme metadata',
|
|
397
|
+
preview: 'preview.png',
|
|
398
|
+
pages: {
|
|
399
|
+
home: {
|
|
400
|
+
label: 'Home',
|
|
401
|
+
previewPath: '/',
|
|
402
|
+
allowedBlocks: ['hero'],
|
|
403
|
+
defaultBlocks: [{ type: 'hero' }],
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
blocks: {
|
|
407
|
+
hero: {
|
|
408
|
+
label: 'Hero',
|
|
409
|
+
settings: {
|
|
410
|
+
title: { type: 'text', label: 'Title' },
|
|
411
|
+
},
|
|
412
|
+
variants: [],
|
|
413
|
+
exposedStyleSlots: [],
|
|
414
|
+
presets: [],
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
styleSlots: {},
|
|
418
|
+
presets: {},
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
expect(manifest.pages.home.allowedBlocks).toEqual(['hero']);
|
|
422
|
+
expect(manifest.author).toBe('Shoppex');
|
|
423
|
+
expect(manifest.description).toBe('Canonical theme metadata');
|
|
424
|
+
expect(manifest.preview).toBe('preview.png');
|
|
425
|
+
expect(manifest.blocks.hero.settings.title).toMatchObject({
|
|
426
|
+
type: 'text',
|
|
427
|
+
label: 'Title',
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('normalizes canonical and legacy theme presets for theme library readers', () => {
|
|
432
|
+
expect(listThemeManifestPresets({
|
|
433
|
+
id: 'default',
|
|
434
|
+
name: 'Default Theme',
|
|
435
|
+
version: '2.0.0',
|
|
436
|
+
pages: {
|
|
437
|
+
home: {
|
|
438
|
+
label: 'Home',
|
|
439
|
+
previewPath: '/',
|
|
440
|
+
allowedBlocks: ['hero'],
|
|
441
|
+
defaultBlocks: [{ type: 'hero' }],
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
blocks: {
|
|
445
|
+
hero: {
|
|
446
|
+
label: 'Hero',
|
|
447
|
+
settings: {},
|
|
448
|
+
variants: [],
|
|
449
|
+
exposedStyleSlots: [],
|
|
450
|
+
presets: [],
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
styleSlots: {},
|
|
454
|
+
presets: {
|
|
455
|
+
launch: {
|
|
456
|
+
label: 'Launch Store',
|
|
457
|
+
description: 'Launch copy',
|
|
458
|
+
preview: 'presets/launch.png',
|
|
459
|
+
content: { 'hero.title': 'Instant access' },
|
|
460
|
+
layout: {},
|
|
461
|
+
style_slots: { 'button.radius': { base: 12 } },
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
})).toEqual([
|
|
465
|
+
{
|
|
466
|
+
id: 'launch',
|
|
467
|
+
name: 'Launch Store',
|
|
468
|
+
description: 'Launch copy',
|
|
469
|
+
preview: 'presets/launch.png',
|
|
470
|
+
overrides: {
|
|
471
|
+
content: { 'hero.title': 'Instant access' },
|
|
472
|
+
style_slots: { 'button.radius': { base: 12 } },
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
]);
|
|
476
|
+
|
|
477
|
+
expect(listThemeManifestPresets({
|
|
478
|
+
id: 'legacy',
|
|
479
|
+
name: 'Legacy Theme',
|
|
480
|
+
version: '1.0.0',
|
|
481
|
+
presets: [
|
|
482
|
+
{
|
|
483
|
+
id: 'tokens',
|
|
484
|
+
name: 'Tokens',
|
|
485
|
+
overrides: {
|
|
486
|
+
tokens: { buttons: { borderRadius: 18 } },
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
})).toEqual([
|
|
491
|
+
{
|
|
492
|
+
id: 'tokens',
|
|
493
|
+
name: 'Tokens',
|
|
494
|
+
overrides: {
|
|
495
|
+
tokens: { buttons: { borderRadius: 18 } },
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
]);
|
|
499
|
+
});
|
|
500
|
+
|
|
214
501
|
test('applies manifest default blocks to empty builder settings', () => {
|
|
215
502
|
const settings = applyManifestDefaultLayout(createEmptyBuilderSettings(2), {
|
|
216
503
|
id: 'default',
|
|
@@ -347,7 +634,48 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
347
634
|
expect(issues.map((issue) => issue.code)).toContain('unexposed_style_slot');
|
|
348
635
|
});
|
|
349
636
|
|
|
350
|
-
test('
|
|
637
|
+
test('requires theme-scoped global style slots to be declared by the manifest', () => {
|
|
638
|
+
const settings = BuilderSettingsSchema.parse({
|
|
639
|
+
version: 2,
|
|
640
|
+
revision: 1,
|
|
641
|
+
theme: {
|
|
642
|
+
content: {},
|
|
643
|
+
layout: {},
|
|
644
|
+
style_slots: {
|
|
645
|
+
'color.primary': '#ff5500',
|
|
646
|
+
'theme.hero.overlay.opacity': 0.72,
|
|
647
|
+
},
|
|
648
|
+
pages: [],
|
|
649
|
+
terms: {},
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
const manifest = {
|
|
653
|
+
id: 'default',
|
|
654
|
+
name: 'Default',
|
|
655
|
+
version: '2.0.0',
|
|
656
|
+
pages: {},
|
|
657
|
+
blocks: {},
|
|
658
|
+
styleSlots: {},
|
|
659
|
+
presets: {},
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
expect(validateBuilderSettingsAgainstManifest(settings, manifest)).toEqual([
|
|
663
|
+
{
|
|
664
|
+
code: 'unknown_style_slot',
|
|
665
|
+
path: 'theme.style_slots.theme.hero.overlay.opacity',
|
|
666
|
+
message: 'Theme style slot "theme.hero.overlay.opacity" is not declared by the manifest',
|
|
667
|
+
},
|
|
668
|
+
]);
|
|
669
|
+
|
|
670
|
+
expect(validateBuilderSettingsAgainstManifest(settings, {
|
|
671
|
+
...manifest,
|
|
672
|
+
styleSlots: {
|
|
673
|
+
'theme.hero.overlay.opacity': 0.5,
|
|
674
|
+
},
|
|
675
|
+
})).toEqual([]);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test('canonicalizes unique short block setting aliases before strict validation', () => {
|
|
351
679
|
const settings = BuilderSettingsSchema.parse({
|
|
352
680
|
version: 2,
|
|
353
681
|
revision: 1,
|
|
@@ -374,7 +702,7 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
374
702
|
},
|
|
375
703
|
});
|
|
376
704
|
|
|
377
|
-
const
|
|
705
|
+
const manifest = {
|
|
378
706
|
id: 'default',
|
|
379
707
|
name: 'Default',
|
|
380
708
|
version: '2.0.0',
|
|
@@ -398,8 +726,93 @@ describe('@shoppex/builder-contracts', () => {
|
|
|
398
726
|
},
|
|
399
727
|
styleSlots: {},
|
|
400
728
|
presets: {},
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const result = canonicalizeBuilderSettingsForManifestWithReport(settings, manifest);
|
|
732
|
+
|
|
733
|
+
expect(result.migratedAliases).toBe(0);
|
|
734
|
+
expect(result.removedAliases).toBe(1);
|
|
735
|
+
expect(result.conflicts).toEqual([]);
|
|
736
|
+
expect(result.settings.theme.layout.home.blocks[0].settings).toEqual({
|
|
737
|
+
'hero.title': 'Launch sale',
|
|
401
738
|
});
|
|
739
|
+
expect(validateBuilderSettingsAgainstManifest(result.settings, manifest)).toEqual([]);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test('canonicalizes block-prefixed aliases when the manifest declares short block fields', () => {
|
|
743
|
+
const settings = BuilderSettingsSchema.parse({
|
|
744
|
+
version: 2,
|
|
745
|
+
revision: 1,
|
|
746
|
+
theme: {
|
|
747
|
+
content: {},
|
|
748
|
+
layout: {
|
|
749
|
+
home: {
|
|
750
|
+
blocks: [
|
|
751
|
+
{
|
|
752
|
+
id: 'home-hero-1',
|
|
753
|
+
type: 'hero',
|
|
754
|
+
visible: true,
|
|
755
|
+
settings: {
|
|
756
|
+
'hero.title': 'Launch sale',
|
|
757
|
+
},
|
|
758
|
+
},
|
|
759
|
+
],
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
style_slots: {},
|
|
763
|
+
pages: [],
|
|
764
|
+
terms: {},
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
const manifest = {
|
|
769
|
+
id: 'starlight',
|
|
770
|
+
name: 'Starlight',
|
|
771
|
+
version: '1.0.0',
|
|
772
|
+
pages: {
|
|
773
|
+
home: {
|
|
774
|
+
label: 'Home',
|
|
775
|
+
allowedBlocks: ['hero'],
|
|
776
|
+
defaultBlocks: [{ type: 'hero' }],
|
|
777
|
+
},
|
|
778
|
+
},
|
|
779
|
+
blocks: {
|
|
780
|
+
hero: {
|
|
781
|
+
label: 'Hero',
|
|
782
|
+
settings: {
|
|
783
|
+
title: { type: 'text', label: 'Title' },
|
|
784
|
+
},
|
|
785
|
+
variants: [],
|
|
786
|
+
exposedStyleSlots: [],
|
|
787
|
+
presets: [],
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
styleSlots: {},
|
|
791
|
+
presets: {},
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
const result = canonicalizeBuilderSettingsForManifestWithReport(settings, manifest);
|
|
795
|
+
|
|
796
|
+
expect(result.migratedAliases).toBe(1);
|
|
797
|
+
expect(result.removedAliases).toBe(1);
|
|
798
|
+
expect(result.settings.theme.layout.home.blocks[0].settings).toEqual({
|
|
799
|
+
title: 'Launch sale',
|
|
800
|
+
});
|
|
801
|
+
expect(validateBuilderSettingsAgainstManifest(result.settings, manifest)).toEqual([]);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
test('accepts transparent color literals used by theme manifests', () => {
|
|
805
|
+
expect(ColorSchema.parse('transparent')).toBe('transparent');
|
|
806
|
+
expect(ColorSchema.parse('#ffffff')).toBe('#ffffff');
|
|
807
|
+
expect(ColorSchema.parse('#00000080')).toBe('#00000080');
|
|
808
|
+
});
|
|
402
809
|
|
|
403
|
-
|
|
810
|
+
test('parses official theme manifests with transparent contact-form backgrounds', () => {
|
|
811
|
+
const repoRoot = join(import.meta.dir, '../../..');
|
|
812
|
+
for (const theme of ['default', 'nebula', 'classic', 'pulse', 'starlight', 'vault', 'phantom', 'apex']) {
|
|
813
|
+
const manifestPath = join(repoRoot, 'themes', theme, 'theme.manifest.json');
|
|
814
|
+
const raw = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
815
|
+
expect(ThemeManifestSchema.safeParse(raw).success, `${theme} manifest should validate`).toBe(true);
|
|
816
|
+
}
|
|
404
817
|
});
|
|
405
818
|
});
|
package/src/builder-settings.ts
CHANGED
|
@@ -31,11 +31,14 @@ export const PageLayoutSchema = z
|
|
|
31
31
|
.strict();
|
|
32
32
|
export type PageLayout = z.infer<typeof PageLayoutSchema>;
|
|
33
33
|
|
|
34
|
+
export const CustomPageSlugSchema = z.string().min(1).regex(/^[a-z0-9][a-z0-9-_]*$/);
|
|
35
|
+
export type CustomPageSlug = z.infer<typeof CustomPageSlugSchema>;
|
|
36
|
+
|
|
34
37
|
export const CustomPageSchema = z
|
|
35
38
|
.object({
|
|
36
39
|
id: PageIdSchema,
|
|
37
40
|
title: z.string().min(1),
|
|
38
|
-
slug:
|
|
41
|
+
slug: CustomPageSlugSchema,
|
|
39
42
|
visible: z.boolean().default(true),
|
|
40
43
|
layout: PageLayoutSchema.default({ blocks: [] }),
|
|
41
44
|
seo: z
|
|
@@ -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
|
+
}
|