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