@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.
Files changed (92) hide show
  1. package/dist/builder-settings.d.ts +11 -666
  2. package/dist/builder-settings.d.ts.map +1 -1
  3. package/dist/builder-settings.js +2 -1
  4. package/dist/canonical-settings.d.ts +18 -0
  5. package/dist/canonical-settings.d.ts.map +1 -0
  6. package/dist/canonical-settings.js +106 -0
  7. package/dist/custom-pages.d.ts +15 -0
  8. package/dist/custom-pages.d.ts.map +1 -0
  9. package/dist/custom-pages.js +40 -0
  10. package/dist/dedicated-pages.d.ts +15 -0
  11. package/dist/dedicated-pages.d.ts.map +1 -0
  12. package/dist/dedicated-pages.js +142 -0
  13. package/dist/events.d.ts +35 -240
  14. package/dist/events.d.ts.map +1 -1
  15. package/dist/events.js +7 -0
  16. package/dist/fields.d.ts +229 -10
  17. package/dist/fields.d.ts.map +1 -1
  18. package/dist/fields.js +27 -0
  19. package/dist/index.d.ts +10 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +10 -0
  22. package/dist/legacy-manifest.d.ts +18 -0
  23. package/dist/legacy-manifest.d.ts.map +1 -1
  24. package/dist/legacy-manifest.js +137 -22
  25. package/dist/migrations.d.ts.map +1 -1
  26. package/dist/migrations.js +55 -6
  27. package/dist/persistence.d.ts +7 -0
  28. package/dist/persistence.d.ts.map +1 -0
  29. package/dist/persistence.js +58 -0
  30. package/dist/preview-boot.d.ts +68 -0
  31. package/dist/preview-boot.d.ts.map +1 -0
  32. package/dist/preview-boot.js +38 -0
  33. package/dist/preview-protocol.d.ts +227 -459
  34. package/dist/preview-protocol.d.ts.map +1 -1
  35. package/dist/preview-protocol.js +112 -0
  36. package/dist/preview-session-resolve.d.ts +115 -0
  37. package/dist/preview-session-resolve.d.ts.map +1 -0
  38. package/dist/preview-session-resolve.js +25 -0
  39. package/dist/preview-trusted-origins.d.ts +4 -0
  40. package/dist/preview-trusted-origins.d.ts.map +1 -0
  41. package/dist/preview-trusted-origins.js +28 -0
  42. package/dist/storefront-initial-data-html.d.ts +17 -0
  43. package/dist/storefront-initial-data-html.d.ts.map +1 -0
  44. package/dist/storefront-initial-data-html.js +83 -0
  45. package/dist/storefront-typography-fonts.d.ts +18 -0
  46. package/dist/storefront-typography-fonts.d.ts.map +1 -0
  47. package/dist/storefront-typography-fonts.js +89 -0
  48. package/dist/style-slots.d.ts +50 -152
  49. package/dist/style-slots.d.ts.map +1 -1
  50. package/dist/style-slots.js +80 -32
  51. package/dist/theme-manifest.d.ts +287 -456
  52. package/dist/theme-manifest.d.ts.map +1 -1
  53. package/dist/theme-manifest.js +92 -0
  54. package/dist/theme-schemes.d.ts +10 -0
  55. package/dist/theme-schemes.d.ts.map +1 -0
  56. package/dist/theme-schemes.js +25 -0
  57. package/dist/validation.d.ts +1 -1
  58. package/dist/validation.d.ts.map +1 -1
  59. package/dist/validation.js +23 -12
  60. package/package.json +43 -1
  61. package/src/builder-contracts.test.ts +416 -3
  62. package/src/builder-settings.ts +4 -1
  63. package/src/canonical-settings.ts +156 -0
  64. package/src/custom-pages.test.ts +74 -0
  65. package/src/custom-pages.ts +70 -0
  66. package/src/dedicated-pages.test.ts +88 -0
  67. package/src/dedicated-pages.ts +173 -0
  68. package/src/events.ts +8 -0
  69. package/src/fields.ts +30 -0
  70. package/src/index.ts +10 -0
  71. package/src/legacy-manifest.ts +147 -23
  72. package/src/migrations.ts +70 -6
  73. package/src/persistence.ts +77 -0
  74. package/src/preview-boot.test.ts +72 -0
  75. package/src/preview-boot.ts +49 -0
  76. package/src/preview-protocol.test.ts +132 -0
  77. package/src/preview-protocol.ts +122 -0
  78. package/src/preview-session-resolve.test.ts +37 -0
  79. package/src/preview-session-resolve.ts +34 -0
  80. package/src/preview-trusted-origins.test.ts +24 -0
  81. package/src/preview-trusted-origins.ts +35 -0
  82. package/src/storefront-initial-data-html.test.ts +73 -0
  83. package/src/storefront-initial-data-html.ts +112 -0
  84. package/src/storefront-typography-fonts.test.ts +48 -0
  85. package/src/storefront-typography-fonts.ts +108 -0
  86. package/src/style-slots.ts +102 -34
  87. package/src/theme-manifest.ts +118 -1
  88. package/src/theme-schemes.ts +34 -0
  89. package/src/validation.ts +32 -13
  90. package/dist/builder-contracts.test.d.ts +0 -2
  91. package/dist/builder-contracts.test.d.ts.map +0 -1
  92. 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('accepts full and short block setting keys declared by the manifest', () => {
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 issues = validateBuilderSettingsAgainstManifest(settings, {
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
- expect(issues).toEqual([]);
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
  });
@@ -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: z.string().min(1).regex(/^[a-z0-9][a-z0-9-_/]*$/),
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
+ }