@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.
Files changed (71) hide show
  1. package/dist/builder-contracts.test.js +71 -1
  2. package/dist/builder-settings.d.ts +9 -666
  3. package/dist/builder-settings.d.ts.map +1 -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/events.d.ts +35 -240
  8. package/dist/events.d.ts.map +1 -1
  9. package/dist/events.js +7 -0
  10. package/dist/fields.d.ts +169 -8
  11. package/dist/fields.d.ts.map +1 -1
  12. package/dist/fields.js +27 -0
  13. package/dist/index.d.ts +7 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +7 -0
  16. package/dist/legacy-manifest.d.ts +11 -0
  17. package/dist/legacy-manifest.d.ts.map +1 -1
  18. package/dist/legacy-manifest.js +106 -16
  19. package/dist/migrations.d.ts.map +1 -1
  20. package/dist/migrations.js +50 -4
  21. package/dist/persistence.d.ts +7 -0
  22. package/dist/persistence.d.ts.map +1 -0
  23. package/dist/persistence.js +58 -0
  24. package/dist/preview-boot.d.ts +68 -0
  25. package/dist/preview-boot.d.ts.map +1 -0
  26. package/dist/preview-boot.js +36 -0
  27. package/dist/preview-protocol.d.ts +227 -459
  28. package/dist/preview-protocol.d.ts.map +1 -1
  29. package/dist/preview-protocol.js +112 -0
  30. package/dist/preview-session-resolve.d.ts +115 -0
  31. package/dist/preview-session-resolve.d.ts.map +1 -0
  32. package/dist/preview-session-resolve.js +25 -0
  33. package/dist/preview-trusted-origins.d.ts +4 -0
  34. package/dist/preview-trusted-origins.d.ts.map +1 -0
  35. package/dist/preview-trusted-origins.js +26 -0
  36. package/dist/storefront-initial-data-html.d.ts +17 -0
  37. package/dist/storefront-initial-data-html.d.ts.map +1 -0
  38. package/dist/storefront-initial-data-html.js +83 -0
  39. package/dist/style-slots.d.ts +49 -151
  40. package/dist/style-slots.d.ts.map +1 -1
  41. package/dist/style-slots.js +75 -29
  42. package/dist/theme-manifest.d.ts +229 -454
  43. package/dist/theme-manifest.d.ts.map +1 -1
  44. package/dist/theme-manifest.js +92 -0
  45. package/dist/theme-schemes.d.ts +10 -0
  46. package/dist/theme-schemes.d.ts.map +1 -0
  47. package/dist/theme-schemes.js +24 -0
  48. package/dist/validation.d.ts +1 -1
  49. package/dist/validation.d.ts.map +1 -1
  50. package/dist/validation.js +18 -9
  51. package/package.json +43 -1
  52. package/src/builder-contracts.test.ts +398 -3
  53. package/src/canonical-settings.ts +156 -0
  54. package/src/events.ts +8 -0
  55. package/src/fields.ts +30 -0
  56. package/src/index.ts +7 -0
  57. package/src/legacy-manifest.ts +107 -16
  58. package/src/migrations.ts +65 -4
  59. package/src/persistence.ts +77 -0
  60. package/src/preview-boot.ts +47 -0
  61. package/src/preview-protocol.test.ts +132 -0
  62. package/src/preview-protocol.ts +122 -0
  63. package/src/preview-session-resolve.ts +34 -0
  64. package/src/preview-trusted-origins.test.ts +24 -0
  65. package/src/preview-trusted-origins.ts +35 -0
  66. package/src/storefront-initial-data-html.test.ts +63 -0
  67. package/src/storefront-initial-data-html.ts +112 -0
  68. package/src/style-slots.ts +96 -31
  69. package/src/theme-manifest.ts +118 -1
  70. package/src/theme-schemes.ts +33 -0
  71. 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('accepts full and short block setting keys declared by the manifest', () => {
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 issues = validateBuilderSettingsAgainstManifest(settings, {
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
- expect(issues).toEqual([]);
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>;