@shoppexio/builder-contracts 0.1.0

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 (45) hide show
  1. package/dist/builder-contracts.test.d.ts +2 -0
  2. package/dist/builder-contracts.test.d.ts.map +1 -0
  3. package/dist/builder-contracts.test.js +361 -0
  4. package/dist/builder-settings.d.ts +801 -0
  5. package/dist/builder-settings.d.ts.map +1 -0
  6. package/dist/builder-settings.js +65 -0
  7. package/dist/events.d.ts +512 -0
  8. package/dist/events.d.ts.map +1 -0
  9. package/dist/events.js +104 -0
  10. package/dist/fields.d.ts +300 -0
  11. package/dist/fields.d.ts.map +1 -0
  12. package/dist/fields.js +111 -0
  13. package/dist/index.d.ts +10 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +9 -0
  16. package/dist/legacy-manifest.d.ts +172 -0
  17. package/dist/legacy-manifest.d.ts.map +1 -0
  18. package/dist/legacy-manifest.js +272 -0
  19. package/dist/migrations.d.ts +31 -0
  20. package/dist/migrations.d.ts.map +1 -0
  21. package/dist/migrations.js +175 -0
  22. package/dist/preview-protocol.d.ts +687 -0
  23. package/dist/preview-protocol.d.ts.map +1 -0
  24. package/dist/preview-protocol.js +79 -0
  25. package/dist/style-slots.d.ts +209 -0
  26. package/dist/style-slots.d.ts.map +1 -0
  27. package/dist/style-slots.js +93 -0
  28. package/dist/theme-manifest.d.ts +845 -0
  29. package/dist/theme-manifest.d.ts.map +1 -0
  30. package/dist/theme-manifest.js +119 -0
  31. package/dist/validation.d.ts +16 -0
  32. package/dist/validation.d.ts.map +1 -0
  33. package/dist/validation.js +131 -0
  34. package/package.json +95 -0
  35. package/src/builder-contracts.test.ts +405 -0
  36. package/src/builder-settings.ts +85 -0
  37. package/src/events.ts +121 -0
  38. package/src/fields.ts +134 -0
  39. package/src/index.ts +9 -0
  40. package/src/legacy-manifest.ts +321 -0
  41. package/src/migrations.ts +240 -0
  42. package/src/preview-protocol.ts +93 -0
  43. package/src/style-slots.ts +111 -0
  44. package/src/theme-manifest.ts +140 -0
  45. package/src/validation.ts +196 -0
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=builder-contracts.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"builder-contracts.test.d.ts","sourceRoot":"","sources":["../src/builder-contracts.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,361 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { BuilderEventSchema, BuilderSettingsSchema, PreviewMessageSchema, StyleSlotsSchema, ThemeManifestSchema, applyManifestDefaultLayout, validateBuilderSettingsAgainstManifest, createBlockInstance, createEmptyBuilderSettings, convertLegacyThemeManifest, migrateLegacyBuilderSettings, } from "./index.js";
3
+ describe('@shoppex/builder-contracts', () => {
4
+ test('accepts an empty builder settings document', () => {
5
+ const settings = createEmptyBuilderSettings(7);
6
+ expect(BuilderSettingsSchema.parse(settings)).toEqual({
7
+ version: 2,
8
+ revision: 7,
9
+ theme: {
10
+ content: {},
11
+ layout: {},
12
+ style_slots: {},
13
+ pages: [],
14
+ terms: {},
15
+ },
16
+ });
17
+ });
18
+ test('rejects unknown style slots', () => {
19
+ const result = StyleSlotsSchema.safeParse({
20
+ 'button.radius': { base: 10 },
21
+ 'hero.magic.glow': true,
22
+ });
23
+ expect(result.success).toBe(false);
24
+ });
25
+ test('rejects invalid colors', () => {
26
+ const result = StyleSlotsSchema.safeParse({
27
+ 'color.primary': 'tomato',
28
+ });
29
+ expect(result.success).toBe(false);
30
+ });
31
+ test('validates manifest block references', () => {
32
+ const result = ThemeManifestSchema.safeParse({
33
+ id: 'default',
34
+ name: 'Default',
35
+ version: '2.0.0',
36
+ pages: {
37
+ home: {
38
+ label: 'Home',
39
+ allowedBlocks: ['hero', 'faq'],
40
+ defaultBlocks: [{ type: 'hero', variant: 'split' }],
41
+ },
42
+ },
43
+ blocks: {
44
+ hero: {
45
+ label: 'Hero',
46
+ variants: [{ id: 'split', label: 'Split' }],
47
+ settings: {
48
+ title: { type: 'text', label: 'Headline' },
49
+ },
50
+ exposedStyleSlots: ['button.radius'],
51
+ },
52
+ },
53
+ });
54
+ expect(result.success).toBe(false);
55
+ });
56
+ test('accepts preview APPLY_STATE messages', () => {
57
+ const settings = createEmptyBuilderSettings(3);
58
+ const result = PreviewMessageSchema.safeParse({
59
+ type: 'APPLY_STATE',
60
+ revision: 3,
61
+ state: settings,
62
+ });
63
+ expect(result.success).toBe(true);
64
+ });
65
+ test('accepts preview REQUEST_READY messages', () => {
66
+ const result = PreviewMessageSchema.safeParse({
67
+ type: 'REQUEST_READY',
68
+ });
69
+ expect(result.success).toBe(true);
70
+ });
71
+ test('accepts block add events', () => {
72
+ const result = BuilderEventSchema.safeParse({
73
+ id: 'evt_1',
74
+ type: 'block.add',
75
+ revision: 4,
76
+ createdAt: '2026-04-23T10:00:00.000Z',
77
+ source: 'dashboard',
78
+ pageId: 'home',
79
+ block: createBlockInstance({
80
+ id: 'hero-1',
81
+ type: 'hero',
82
+ settings: { title: 'New drop' },
83
+ }),
84
+ index: 0,
85
+ });
86
+ expect(result.success).toBe(true);
87
+ });
88
+ test('migrates legacy content and token overrides', () => {
89
+ const migrated = migrateLegacyBuilderSettings({
90
+ theme: {
91
+ content: {
92
+ 'hero.title': 'Summer sale',
93
+ },
94
+ tokens_override: {
95
+ 'buttons.borderRadius': 12,
96
+ 'colors.primary': '#ff5500',
97
+ },
98
+ },
99
+ }, 12);
100
+ expect(migrated.revision).toBe(12);
101
+ expect(migrated.theme.content['hero.title']).toBe('Summer sale');
102
+ expect(migrated.theme.style_slots['button.radius']).toEqual({ base: 12 });
103
+ expect(migrated.theme.style_slots['color.primary']).toBe('#ff5500');
104
+ });
105
+ test('converts legacy theme manifests into the v2 manifest contract', () => {
106
+ const manifest = convertLegacyThemeManifest({
107
+ id: 'default',
108
+ name: 'Default Theme',
109
+ version: '1.0.0',
110
+ presets: [
111
+ {
112
+ id: 'gaming',
113
+ name: 'Gaming Store',
114
+ description: 'Gaming copy preset',
115
+ overrides: {
116
+ content: {
117
+ hero: {
118
+ title: 'Instant keys',
119
+ },
120
+ },
121
+ },
122
+ },
123
+ ],
124
+ builder: {
125
+ pages: [
126
+ {
127
+ id: 'home',
128
+ label: 'Home',
129
+ previewPath: '/',
130
+ blocks: ['hero', 'faq'],
131
+ },
132
+ {
133
+ id: 'contact-page',
134
+ label: 'Contact',
135
+ previewPath: '/contact',
136
+ blocks: [],
137
+ fields: [
138
+ { path: 'pages.contact.title', label: 'Title', defaultValue: 'Contact Us' },
139
+ ],
140
+ },
141
+ ],
142
+ blocks: {
143
+ hero: {
144
+ label: 'Hero',
145
+ maxInstances: 1,
146
+ fields: [
147
+ { path: 'hero.title', label: 'Title' },
148
+ {
149
+ path: 'hero.style',
150
+ type: 'select',
151
+ label: 'Hero Style',
152
+ options: [{ value: 'split', label: 'Split' }],
153
+ },
154
+ ],
155
+ },
156
+ faq: {
157
+ label: 'FAQ',
158
+ lists: [
159
+ { path: 'faq.items', label: 'FAQ Items', kind: 'faqItems' },
160
+ ],
161
+ },
162
+ },
163
+ },
164
+ });
165
+ expect(manifest.pages.home.allowedBlocks).toEqual(['hero', 'faq']);
166
+ expect(manifest.pages.home.defaultBlocks).toEqual([{ type: 'hero' }, { type: 'faq' }]);
167
+ expect(manifest.blocks.hero.settings['hero.title']).toMatchObject({ type: 'text', label: 'Title' });
168
+ expect(manifest.blocks.hero.settings['hero.style']).toMatchObject({ type: 'select' });
169
+ expect(manifest.blocks.faq.settings['faq.items']).toMatchObject({ type: 'list' });
170
+ expect(manifest.blocks['page-contact-page'].settings['pages.contact.title']).toMatchObject({ type: 'text' });
171
+ expect(manifest.pages['contact-page'].allowedBlocks).toEqual(['page-contact-page']);
172
+ expect(manifest.presets.gaming).toMatchObject({
173
+ label: 'Gaming Store',
174
+ description: 'Gaming copy preset',
175
+ content: {
176
+ 'hero.title': 'Instant keys',
177
+ },
178
+ });
179
+ });
180
+ test('applies manifest default blocks to empty builder settings', () => {
181
+ const settings = applyManifestDefaultLayout(createEmptyBuilderSettings(2), {
182
+ id: 'default',
183
+ name: 'Default',
184
+ version: '2.0.0',
185
+ pages: {
186
+ home: {
187
+ label: 'Home',
188
+ allowedBlocks: ['hero', 'faq'],
189
+ defaultBlocks: [{ type: 'hero' }, { type: 'faq' }],
190
+ },
191
+ },
192
+ blocks: {
193
+ hero: { label: 'Hero', settings: {}, variants: [], exposedStyleSlots: [], presets: [] },
194
+ faq: { label: 'FAQ', settings: {}, variants: [], exposedStyleSlots: [], presets: [] },
195
+ },
196
+ styleSlots: {},
197
+ presets: {},
198
+ });
199
+ expect(settings.theme.layout.home.blocks.map((block) => block.type)).toEqual(['hero', 'faq']);
200
+ expect(settings.theme.layout.home.blocks.map((block) => block.id)).toEqual(['home-hero-1', 'home-faq-2']);
201
+ });
202
+ test('applies default layout for converted page-level fields', () => {
203
+ const manifest = convertLegacyThemeManifest({
204
+ id: 'classic',
205
+ name: 'Classic',
206
+ version: '1.0.0',
207
+ builder: {
208
+ pages: [
209
+ {
210
+ id: 'faq-page',
211
+ label: 'FAQ',
212
+ previewPath: '/faq',
213
+ fields: [
214
+ { path: 'pages.faq.title', label: 'Title', defaultValue: 'Questions' },
215
+ ],
216
+ },
217
+ ],
218
+ blocks: {},
219
+ },
220
+ });
221
+ const settings = applyManifestDefaultLayout(createEmptyBuilderSettings(0), manifest);
222
+ expect(settings.theme.layout['faq-page'].blocks).toHaveLength(1);
223
+ expect(settings.theme.layout['faq-page'].blocks[0]).toMatchObject({
224
+ id: 'faq-page-page-faq-page-1',
225
+ type: 'page-faq-page',
226
+ visible: true,
227
+ });
228
+ });
229
+ test('validates builder settings against the active manifest', () => {
230
+ const settings = BuilderSettingsSchema.parse({
231
+ version: 2,
232
+ revision: 1,
233
+ theme: {
234
+ content: {},
235
+ layout: {
236
+ home: {
237
+ blocks: [
238
+ {
239
+ id: 'hero-1',
240
+ type: 'hero',
241
+ variant: 'missing',
242
+ visible: true,
243
+ settings: {
244
+ 'hero.title': 'Launch sale',
245
+ title: 'Launch sale',
246
+ 'hero.magic': 'not declared',
247
+ },
248
+ style_overrides: {
249
+ 'input.radius': { base: 10 },
250
+ },
251
+ },
252
+ { id: 'hero-2', type: 'hero', visible: true, settings: {} },
253
+ { id: 'hero-2', type: 'faq', visible: true, settings: {} },
254
+ { id: 'ghost-1', type: 'ghost', visible: true, settings: {} },
255
+ ],
256
+ },
257
+ checkout: {
258
+ blocks: [],
259
+ },
260
+ },
261
+ style_slots: {},
262
+ pages: [],
263
+ terms: {},
264
+ },
265
+ });
266
+ const issues = validateBuilderSettingsAgainstManifest(settings, {
267
+ id: 'default',
268
+ name: 'Default',
269
+ version: '2.0.0',
270
+ pages: {
271
+ home: {
272
+ label: 'Home',
273
+ allowedBlocks: ['hero'],
274
+ defaultBlocks: [{ type: 'hero' }],
275
+ },
276
+ },
277
+ blocks: {
278
+ hero: {
279
+ label: 'Hero',
280
+ settings: {
281
+ 'hero.title': { type: 'text', label: 'Title' },
282
+ },
283
+ variants: [{ id: 'split', label: 'Split' }],
284
+ exposedStyleSlots: ['button.radius'],
285
+ maxInstances: 1,
286
+ presets: [],
287
+ },
288
+ faq: {
289
+ label: 'FAQ',
290
+ settings: {},
291
+ variants: [],
292
+ exposedStyleSlots: [],
293
+ presets: [],
294
+ },
295
+ },
296
+ styleSlots: {},
297
+ presets: {},
298
+ });
299
+ expect(issues.map((issue) => issue.code)).toContain('unknown_page');
300
+ expect(issues.map((issue) => issue.code)).toContain('duplicate_block_id');
301
+ expect(issues.map((issue) => issue.code)).toContain('unknown_block_type');
302
+ expect(issues.map((issue) => issue.code)).toContain('block_not_allowed');
303
+ expect(issues.map((issue) => issue.code)).toContain('too_many_block_instances');
304
+ expect(issues.map((issue) => issue.code)).toContain('unknown_block_variant');
305
+ expect(issues.map((issue) => issue.code)).toContain('unknown_block_setting');
306
+ expect(issues.map((issue) => issue.code)).toContain('unexposed_style_slot');
307
+ });
308
+ test('accepts full and short block setting keys declared by the manifest', () => {
309
+ const settings = BuilderSettingsSchema.parse({
310
+ version: 2,
311
+ revision: 1,
312
+ theme: {
313
+ content: {},
314
+ layout: {
315
+ home: {
316
+ blocks: [
317
+ {
318
+ id: 'hero-1',
319
+ type: 'hero',
320
+ visible: true,
321
+ settings: {
322
+ 'hero.title': 'Launch sale',
323
+ title: 'Launch sale',
324
+ },
325
+ },
326
+ ],
327
+ },
328
+ },
329
+ style_slots: {},
330
+ pages: [],
331
+ terms: {},
332
+ },
333
+ });
334
+ const issues = validateBuilderSettingsAgainstManifest(settings, {
335
+ id: 'default',
336
+ name: 'Default',
337
+ version: '2.0.0',
338
+ pages: {
339
+ home: {
340
+ label: 'Home',
341
+ allowedBlocks: ['hero'],
342
+ defaultBlocks: [{ type: 'hero' }],
343
+ },
344
+ },
345
+ blocks: {
346
+ hero: {
347
+ label: 'Hero',
348
+ settings: {
349
+ 'hero.title': { type: 'text', label: 'Title' },
350
+ },
351
+ variants: [],
352
+ exposedStyleSlots: [],
353
+ presets: [],
354
+ },
355
+ },
356
+ styleSlots: {},
357
+ presets: {},
358
+ });
359
+ expect(issues).toEqual([]);
360
+ });
361
+ });