@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
package/src/fields.ts CHANGED
@@ -7,6 +7,11 @@ const FieldBaseSchema = z
7
7
  description: z.string().min(1).optional(),
8
8
  defaultValue: z.unknown().optional(),
9
9
  required: z.boolean().optional(),
10
+ // Groups settings within the Inspector. "content" (default) shows
11
+ // under the Block Settings collapsible; "style" shows under a
12
+ // separate Style collapsible. Themes pick up the value via the
13
+ // existing block.settings.path lookup — no runtime change.
14
+ group: z.enum(['content', 'style']).optional(),
10
15
  })
11
16
  .strict();
12
17
 
@@ -30,6 +35,30 @@ const RichTextFieldSchema = FieldBaseSchema.extend({
30
35
  allowedMarks: z.array(z.enum(['bold', 'italic', 'link', 'code'])).optional(),
31
36
  }).strict();
32
37
 
38
+ // Plain-text source code field. Renders a Monaco editor in the
39
+ // Inspector and persists the raw string back into block.settings.
40
+ // Used by the `custom-html` block (and any future block that needs
41
+ // a code-shaped setting). Themes interpret the string however they
42
+ // see fit — sandboxed iframe srcDoc for HTML, etc.
43
+ const CodeFieldTemplateSchema = z
44
+ .object({
45
+ label: z.string().min(1),
46
+ snippet: z.string().min(1),
47
+ description: z.string().min(1).optional(),
48
+ })
49
+ .strict();
50
+ export type CodeFieldTemplate = z.infer<typeof CodeFieldTemplateSchema>;
51
+
52
+ const CodeFieldSchema = FieldBaseSchema.extend({
53
+ type: z.literal('code'),
54
+ language: z.enum(['html', 'css', 'javascript', 'json']).optional(),
55
+ placeholder: z.string().optional(),
56
+ maxLength: z.number().int().positive().optional(),
57
+ // Optional quick-insert chips rendered above the editor. Clicking a
58
+ // template replaces the field value with the snippet.
59
+ templates: z.array(CodeFieldTemplateSchema).optional(),
60
+ }).strict();
61
+
33
62
  const ImageFieldSchema = FieldBaseSchema.extend({
34
63
  type: z.literal('image'),
35
64
  aspectRatio: z.string().min(1).optional(),
@@ -117,6 +146,7 @@ const ListFieldSchema = FieldBaseSchema.extend({
117
146
  export const BuilderFieldSchema = z.discriminatedUnion('type', [
118
147
  TextFieldSchema,
119
148
  RichTextFieldSchema,
149
+ CodeFieldSchema,
120
150
  ImageFieldSchema,
121
151
  LinkFieldSchema,
122
152
  BooleanFieldSchema,
package/src/index.ts CHANGED
@@ -1,9 +1,16 @@
1
1
  export * from './builder-settings.ts';
2
+ export * from './canonical-settings.ts';
2
3
  export * from './events.ts';
3
4
  export * from './fields.ts';
4
5
  export * from './legacy-manifest.ts';
5
6
  export * from './migrations.ts';
7
+ export * from './persistence.ts';
8
+ export * from './preview-boot.ts';
6
9
  export * from './preview-protocol.ts';
10
+ export * from './preview-session-resolve.ts';
11
+ export * from './preview-trusted-origins.ts';
12
+ export * from './storefront-initial-data-html.ts';
7
13
  export * from './style-slots.ts';
8
14
  export * from './theme-manifest.ts';
15
+ export * from './theme-schemes.ts';
9
16
  export * from './validation.ts';
@@ -1,6 +1,7 @@
1
1
  import * as z from 'zod/v4';
2
2
  import { BuilderFieldSchema, type BuilderField } from './fields.ts';
3
3
  import { ThemeManifestSchema, type ThemeManifest } from './theme-manifest.ts';
4
+ import { StyleSlotDefaultsSchema } from './style-slots.ts';
4
5
 
5
6
  const LegacyFieldOptionSchema = z
6
7
  .object({
@@ -83,6 +84,17 @@ export const LegacyThemeManifestSchema = z
83
84
  id: z.string().min(1),
84
85
  name: z.string().min(1),
85
86
  version: z.string().min(1),
87
+ author: z.string().min(1).optional(),
88
+ description: z.string().min(1).optional(),
89
+ preview: z.string().min(1).optional(),
90
+ features: z.array(z.string().min(1)).optional(),
91
+ techStack: z.array(z.string().min(1)).optional(),
92
+ templates: z.array(z.string().min(1)).optional(),
93
+ createdAt: z.string().min(1).optional(),
94
+ demoUrl: z.string().min(1).optional(),
95
+ audience: z.string().min(1).optional(),
96
+ tags: z.array(z.string().min(1)).optional(),
97
+ hotfixPaths: z.array(z.string().min(1)).optional(),
86
98
  presets: z.array(LegacyThemePresetSchema).optional(),
87
99
  builder: z
88
100
  .object({
@@ -96,6 +108,11 @@ export const LegacyThemeManifestSchema = z
96
108
  export type LegacyThemeManifest = z.infer<typeof LegacyThemeManifestSchema>;
97
109
 
98
110
  export function convertLegacyThemeManifest(input: unknown): ThemeManifest {
111
+ const canonical = ThemeManifestSchema.safeParse(input);
112
+ if (canonical.success) {
113
+ return canonical.data;
114
+ }
115
+
99
116
  const legacy = LegacyThemeManifestSchema.parse(input);
100
117
  const blocks = Object.fromEntries(
101
118
  Object.entries(legacy.builder.blocks).map(([blockType, block]) => [
@@ -131,6 +148,17 @@ export function convertLegacyThemeManifest(input: unknown): ThemeManifest {
131
148
  id: normalizeLegacyThemeId(legacy.id),
132
149
  name: legacy.name,
133
150
  version: legacy.version,
151
+ author: legacy.author,
152
+ description: legacy.description,
153
+ preview: legacy.preview,
154
+ features: legacy.features,
155
+ techStack: legacy.techStack,
156
+ templates: legacy.templates,
157
+ createdAt: legacy.createdAt,
158
+ demoUrl: legacy.demoUrl,
159
+ audience: legacy.audience,
160
+ tags: legacy.tags,
161
+ hotfixPaths: legacy.hotfixPaths,
134
162
  pages: Object.fromEntries(
135
163
  legacy.builder.pages.map((page) => {
136
164
  const syntheticBlockType = createSyntheticPageBlockType(page.id);
@@ -158,6 +186,8 @@ export function convertLegacyThemeManifest(input: unknown): ThemeManifest {
158
186
  blocks,
159
187
  styleSlots: {},
160
188
  presets: convertLegacyThemePresets(legacy.presets ?? []),
189
+ linkGroups: Array.isArray(legacy.builder.linkGroups) ? legacy.builder.linkGroups : [],
190
+ defaultLinkItems: isPlainObject(legacy.builder.defaultLinkItems) ? legacy.builder.defaultLinkItems : {},
161
191
  };
162
192
 
163
193
  return ThemeManifestSchema.parse(converted);
@@ -178,16 +208,22 @@ function createSyntheticPageBlockType(pageId: string): string {
178
208
 
179
209
  function convertLegacyThemePresets(presets: LegacyThemePreset[]): ThemeManifest['presets'] {
180
210
  return Object.fromEntries(
181
- presets.map((preset) => [
182
- preset.id,
183
- {
184
- label: preset.name ?? preset.label ?? preset.id,
185
- description: preset.description,
186
- content: flattenContentRecord(preset.overrides?.content ?? {}),
187
- layout: preset.overrides?.layout ?? {},
188
- style_slots: preset.overrides?.style_slots ?? preset.overrides?.styleSlots ?? {},
189
- },
190
- ]),
211
+ presets.map((preset) => {
212
+ const styleSlots = StyleSlotDefaultsSchema.parse(
213
+ preset.overrides?.style_slots ?? preset.overrides?.styleSlots ?? {},
214
+ );
215
+ return [
216
+ preset.id,
217
+ {
218
+ label: preset.name ?? preset.label ?? preset.id,
219
+ description: preset.description,
220
+ preview: typeof preset.preview === 'string' ? preset.preview : undefined,
221
+ content: flattenContentRecord(preset.overrides?.content ?? {}),
222
+ layout: preset.overrides?.layout ?? {},
223
+ style_slots: styleSlots,
224
+ },
225
+ ];
226
+ }),
191
227
  );
192
228
  }
193
229
 
@@ -211,20 +247,42 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
211
247
  }
212
248
 
213
249
  function convertLegacyFields(fields: LegacyBuilderField[], lists: LegacyBuilderList[]): Record<string, BuilderField> {
250
+ // Auto-promote inline `type: 'list'` fields to first-class list entries so
251
+ // the migrator can emit list-shaped data without separately maintaining a
252
+ // `lists: []` block-level entry. The promoted list inherits its kind from
253
+ // the field path (e.g. `header.left_links` → kind `navLinks`).
254
+ const inlineLists: LegacyBuilderList[] = [];
255
+ const remainingFields: LegacyBuilderField[] = [];
256
+ for (const field of fields) {
257
+ if (field.type === 'list') {
258
+ inlineLists.push({
259
+ path: field.path,
260
+ label: field.label,
261
+ kind: inferListKindFromPath(field.path),
262
+ description: field.description,
263
+ });
264
+ } else {
265
+ remainingFields.push(field);
266
+ }
267
+ }
268
+
214
269
  const convertedFields = Object.fromEntries(
215
- fields.map((field) => [field.path, convertLegacyField(field)]),
270
+ remainingFields.map((field) => [field.path, convertLegacyField(field)]),
216
271
  );
217
272
 
273
+ const allLists = [...inlineLists, ...lists];
218
274
  const convertedLists = Object.fromEntries(
219
- lists.map((list) => [
220
- list.path,
221
- {
275
+ allLists.map((list) => {
276
+ const inlineField = fields.find((f) => f.type === 'list' && f.path === list.path);
277
+ const candidate: BuilderField = {
222
278
  type: 'list',
223
279
  label: list.label,
224
280
  description: list.description,
225
281
  itemShape: createListItemShape(list.kind),
226
- } satisfies BuilderField,
227
- ]),
282
+ ...(inlineField?.defaultValue !== undefined ? { defaultValue: inlineField.defaultValue } : {}),
283
+ } as BuilderField;
284
+ return [list.path, candidate];
285
+ }),
228
286
  );
229
287
 
230
288
  return {
@@ -233,6 +291,17 @@ function convertLegacyFields(fields: LegacyBuilderField[], lists: LegacyBuilderL
233
291
  };
234
292
  }
235
293
 
294
+ function inferListKindFromPath(path: string): string {
295
+ const suffix = path.split('.').at(-1) ?? '';
296
+ if (/items$/i.test(suffix)) return 'faqItems';
297
+ if (/links$/i.test(suffix)) return 'navLinks';
298
+ if (/images$/i.test(suffix) || /gallery$/i.test(suffix)) return 'galleryItems';
299
+ if (/features$/i.test(suffix)) return 'featureItems';
300
+ if (/reviews$/i.test(suffix) || /testimonials$/i.test(suffix)) return 'reviewItems';
301
+ if (/announcements$/i.test(suffix)) return 'announcementItems';
302
+ return 'genericItems';
303
+ }
304
+
236
305
  function convertLegacyField(field: LegacyBuilderField): BuilderField {
237
306
  const type = normalizeLegacyFieldType(field.type);
238
307
  const base = {
@@ -314,6 +383,28 @@ function createListItemShape(kind: string): Extract<BuilderField, { type: 'list'
314
383
  };
315
384
  }
316
385
 
386
+ if (kind === 'navLinks') {
387
+ return {
388
+ text: { type: 'text', label: 'Text' },
389
+ link: { type: 'link', label: 'Link' },
390
+ };
391
+ }
392
+
393
+ if (kind === 'featureItems') {
394
+ return {
395
+ title: { type: 'text', label: 'Title' },
396
+ description: { type: 'richtext', label: 'Description' },
397
+ icon: { type: 'text', label: 'Icon' },
398
+ };
399
+ }
400
+
401
+ if (kind === 'announcementItems') {
402
+ return {
403
+ text: { type: 'text', label: 'Text' },
404
+ link: { type: 'link', label: 'Link' },
405
+ };
406
+ }
407
+
317
408
  return {
318
409
  title: { type: 'text', label: 'Title' },
319
410
  body: { type: 'richtext', label: 'Body' },
package/src/migrations.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  type PageLayout,
7
7
  } from './builder-settings.ts';
8
8
  import type { StyleSlots } from './style-slots.ts';
9
+ import { sanitizeBuilderSettingsState } from './persistence.ts';
9
10
  import type { ThemeManifest } from './theme-manifest.ts';
10
11
 
11
12
  export type LegacyBuilderSettingsInput = {
@@ -67,7 +68,7 @@ export function migrateLegacyBuilderSettings(input: LegacyBuilderSettingsInput,
67
68
  ...(input.theme?.style_slots ?? {}),
68
69
  };
69
70
 
70
- return BuilderSettingsSchema.parse({
71
+ return sanitizeBuilderSettingsState(BuilderSettingsSchema.parse({
71
72
  version: BUILDER_SETTINGS_VERSION,
72
73
  revision,
73
74
  theme: {
@@ -77,7 +78,7 @@ export function migrateLegacyBuilderSettings(input: LegacyBuilderSettingsInput,
77
78
  pages: [],
78
79
  terms: {},
79
80
  },
80
- });
81
+ }));
81
82
  }
82
83
 
83
84
  export function applyManifestDefaultLayout(settings: BuilderSettings, manifest: ThemeManifest): BuilderSettings {
@@ -110,13 +111,39 @@ export function mapLegacyTokenOverridesToStyleSlots(tokens: Record<string, unkno
110
111
  const slots: StyleSlots = {};
111
112
 
112
113
  assignResponsiveNumber(tokens, slots, 'buttons.borderRadius', 'button.radius');
114
+ assignColor(tokens, slots, 'buttons.background', 'button.background');
115
+ assignColor(tokens, slots, 'buttons.foreground', 'button.foreground');
116
+ assignColor(tokens, slots, 'buttons.border', 'button.border');
117
+ assignFontWeight(tokens, slots, 'buttons.fontWeight', 'button.font.weight');
118
+
113
119
  assignResponsiveNumber(tokens, slots, 'inputs.borderRadius', 'input.radius');
114
120
  assignResponsiveNumber(tokens, slots, 'inputs.height', 'input.height');
121
+ assignColor(tokens, slots, 'inputs.border', 'input.border');
122
+ assignColor(tokens, slots, 'inputs.background', 'input.background');
123
+ assignColor(tokens, slots, 'inputs.foreground', 'input.foreground');
124
+
125
+ assignResponsiveNumber(tokens, slots, 'cards.borderRadius', 'card.radius');
126
+ assignColor(tokens, slots, 'cards.background', 'card.background');
127
+ assignColor(tokens, slots, 'cards.border', 'card.border');
128
+
129
+ assignResponsiveNumber(tokens, slots, 'sections.paddingY', 'section.padding.y');
130
+ assignResponsiveNumber(tokens, slots, 'sections.paddingX', 'section.padding.x');
131
+ assignResponsiveNumber(tokens, slots, 'container.width', 'container.width');
132
+
115
133
  assignColor(tokens, slots, 'colors.primary', 'color.primary');
116
134
  assignColor(tokens, slots, 'colors.accent', 'color.accent');
117
135
  assignColor(tokens, slots, 'colors.background', 'color.background');
118
136
  assignColor(tokens, slots, 'colors.foreground', 'color.foreground');
119
137
  assignColor(tokens, slots, 'colors.muted', 'color.muted');
138
+ assignColor(tokens, slots, 'links.color', 'link.color');
139
+
140
+ assignFontWeight(tokens, slots, 'typography.headingWeight', 'typography.heading.weight');
141
+ assignResponsiveNumber(tokens, slots, 'typography.fontSize', 'typography.body.size');
142
+ assignResponsiveNumber(tokens, slots, 'typography.bodySize', 'typography.body.size');
143
+ assignString(tokens, slots, 'typography.fontFamily', 'theme.typography.font.family');
144
+ assignString(tokens, slots, 'typography.headingFont', 'theme.typography.heading.font');
145
+
146
+ assignResponsiveNumber(tokens, slots, 'header.height', 'theme.header.height');
120
147
 
121
148
  return slots;
122
149
  }
@@ -182,7 +209,7 @@ function assignResponsiveNumber(
182
209
  tokenPath: string,
183
210
  slotId: keyof StyleSlots,
184
211
  ): void {
185
- const value = tokens[tokenPath];
212
+ const value = readLegacyTokenPath(tokens, tokenPath);
186
213
  if (typeof value === 'number' && Number.isFinite(value)) {
187
214
  slots[slotId] = { base: value } as never;
188
215
  return;
@@ -194,12 +221,46 @@ function assignResponsiveNumber(
194
221
  }
195
222
 
196
223
  function assignColor(tokens: Record<string, unknown>, slots: StyleSlots, tokenPath: string, slotId: keyof StyleSlots): void {
197
- const value = tokens[tokenPath];
224
+ const value = readLegacyTokenPath(tokens, tokenPath);
198
225
  if (typeof value === 'string') {
199
226
  slots[slotId] = value as never;
200
227
  }
201
228
  }
202
229
 
230
+ function assignString(tokens: Record<string, unknown>, slots: StyleSlots, tokenPath: string, slotId: keyof StyleSlots): void {
231
+ const value = readLegacyTokenPath(tokens, tokenPath);
232
+ if (typeof value === 'string' && value.trim().length > 0) {
233
+ slots[slotId] = value as never;
234
+ }
235
+ }
236
+
237
+ function assignFontWeight(
238
+ tokens: Record<string, unknown>,
239
+ slots: StyleSlots,
240
+ tokenPath: string,
241
+ slotId: keyof StyleSlots,
242
+ ): void {
243
+ const value = readLegacyTokenPath(tokens, tokenPath);
244
+ if (typeof value === 'number' && Number.isFinite(value)) {
245
+ slots[slotId] = value as never;
246
+ }
247
+ }
248
+
249
+ function readLegacyTokenPath(tokens: Record<string, unknown>, tokenPath: string): unknown {
250
+ if (Object.prototype.hasOwnProperty.call(tokens, tokenPath)) {
251
+ return tokens[tokenPath];
252
+ }
253
+
254
+ let current: unknown = tokens;
255
+ for (const segment of tokenPath.split('.')) {
256
+ if (!isRecord(current)) {
257
+ return undefined;
258
+ }
259
+ current = current[segment];
260
+ }
261
+ return current;
262
+ }
263
+
203
264
  function isResponsiveNumber(value: unknown): value is { base: number; sm?: number; md?: number; lg?: number; xl?: number } {
204
265
  if (!value || typeof value !== 'object') {
205
266
  return false;
@@ -0,0 +1,77 @@
1
+ import {
2
+ type BuilderSettings,
3
+ BuilderSettingsSchema,
4
+ } from './builder-settings.ts';
5
+
6
+ type JsonRecord = Record<string, unknown>;
7
+ const RESERVED_THEME_CONTENT_KEYS = new Set(['layout']);
8
+
9
+ function isRecord(value: unknown): value is JsonRecord {
10
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
11
+ }
12
+
13
+ function pickBuilderSettingsFields(value: unknown): unknown {
14
+ if (!isRecord(value)) {
15
+ return null;
16
+ }
17
+
18
+ return {
19
+ version: value.version,
20
+ revision: value.revision,
21
+ theme: value.theme,
22
+ };
23
+ }
24
+
25
+ export function extractPersistedBuilderSettings(input: unknown): BuilderSettings | null {
26
+ const parsedDirect = BuilderSettingsSchema.safeParse(pickBuilderSettingsFields(input));
27
+ if (parsedDirect.success) {
28
+ return sanitizeBuilderSettingsState(parsedDirect.data);
29
+ }
30
+
31
+ if (!isRecord(input)) {
32
+ return null;
33
+ }
34
+
35
+ const parsedNested = BuilderSettingsSchema.safeParse(pickBuilderSettingsFields(input.builder_settings));
36
+ return parsedNested.success ? sanitizeBuilderSettingsState(parsedNested.data) : null;
37
+ }
38
+
39
+ export function sanitizeBuilderSettingsState(settings: BuilderSettings): BuilderSettings {
40
+ const content = sanitizeThemeContent(settings.theme.content);
41
+ return BuilderSettingsSchema.parse({
42
+ ...settings,
43
+ theme: {
44
+ ...settings.theme,
45
+ content,
46
+ },
47
+ });
48
+ }
49
+
50
+ function sanitizeThemeContent(content: JsonRecord): JsonRecord {
51
+ const next: JsonRecord = {};
52
+ for (const [key, value] of Object.entries(content)) {
53
+ if (RESERVED_THEME_CONTENT_KEYS.has(key)) {
54
+ continue;
55
+ }
56
+ next[key] = value;
57
+ }
58
+ return next;
59
+ }
60
+
61
+ export function mergeBuilderSettingsIntoThemeSettings(
62
+ currentSettings: unknown,
63
+ builderSettings: BuilderSettings,
64
+ ): JsonRecord {
65
+ const root = isRecord(currentSettings) ? { ...currentSettings } : {};
66
+ const currentBuilderSettings = isRecord(root.builder_settings) ? root.builder_settings : {};
67
+ const sanitizedBuilderSettings = sanitizeBuilderSettingsState(builderSettings);
68
+
69
+ root.builder_settings = {
70
+ ...currentBuilderSettings,
71
+ version: sanitizedBuilderSettings.version,
72
+ revision: sanitizedBuilderSettings.revision,
73
+ theme: sanitizedBuilderSettings.theme,
74
+ };
75
+
76
+ return root;
77
+ }
@@ -0,0 +1,47 @@
1
+ import * as z from 'zod/v4';
2
+ import { BuilderSettingsSchema } from './builder-settings.ts';
3
+
4
+ export const PREVIEW_BOOT_SIDECAR_PATH = '__shoppex/preview-boot.json';
5
+
6
+ export const StorefrontSeedSchema = z
7
+ .object({
8
+ store: z.record(z.string(), z.unknown()),
9
+ products: z.array(z.unknown()).optional(),
10
+ groups: z.array(z.unknown()).optional(),
11
+ pages: z.array(z.unknown()).optional(),
12
+ })
13
+ .strict();
14
+
15
+ export type StorefrontSeed = z.infer<typeof StorefrontSeedSchema>;
16
+
17
+ export const PreviewBootPayloadSchema = z
18
+ .object({
19
+ shopId: z.string().min(1),
20
+ shopSlug: z.string().min(1),
21
+ builderSettings: BuilderSettingsSchema,
22
+ storefrontSeed: StorefrontSeedSchema,
23
+ artifactRevision: z.string().optional(),
24
+ artifactStale: z.boolean().optional(),
25
+ })
26
+ .strict();
27
+
28
+ export type PreviewBootPayload = z.infer<typeof PreviewBootPayloadSchema>;
29
+
30
+ export function mergePreviewBootIntoInitialData(
31
+ payload: PreviewBootPayload,
32
+ ): Record<string, unknown> {
33
+ const existingStore = isRecord(payload.storefrontSeed.store) ? payload.storefrontSeed.store : {};
34
+ return {
35
+ ...payload.storefrontSeed,
36
+ store: {
37
+ ...existingStore,
38
+ id: payload.shopId,
39
+ slug: payload.shopSlug,
40
+ builder_settings: payload.builderSettings,
41
+ },
42
+ };
43
+ }
44
+
45
+ function isRecord(value: unknown): value is Record<string, unknown> {
46
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
47
+ }
@@ -0,0 +1,132 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ PreviewMessageSchema,
4
+ PreviewResponseSchema,
5
+ } from './preview-protocol.ts';
6
+
7
+ describe('PreviewMessageSchema', () => {
8
+ it('accepts SET_INTERACTION_MODE with edit/preview', () => {
9
+ for (const mode of ['edit', 'preview'] as const) {
10
+ const parsed = PreviewMessageSchema.parse({
11
+ type: 'SET_INTERACTION_MODE',
12
+ mode,
13
+ });
14
+ expect(parsed).toEqual({ type: 'SET_INTERACTION_MODE', mode });
15
+ }
16
+ });
17
+
18
+ it('rejects unknown interaction mode', () => {
19
+ const result = PreviewMessageSchema.safeParse({
20
+ type: 'SET_INTERACTION_MODE',
21
+ mode: 'design',
22
+ });
23
+ expect(result.success).toBe(false);
24
+ });
25
+ });
26
+
27
+ describe('PreviewResponseSchema', () => {
28
+ it('accepts BLOCK_RECT with rect', () => {
29
+ const parsed = PreviewResponseSchema.parse({
30
+ type: 'BLOCK_RECT',
31
+ revision: 4,
32
+ blockId: 'hero-1',
33
+ rect: { top: 10, left: 20, width: 300, height: 120 },
34
+ });
35
+ expect(parsed.type).toBe('BLOCK_RECT');
36
+ if (parsed.type === 'BLOCK_RECT') {
37
+ expect(parsed.rect).toEqual({ top: 10, left: 20, width: 300, height: 120 });
38
+ }
39
+ });
40
+
41
+ it('accepts BLOCK_RECT with null rect to clear toolbar', () => {
42
+ const parsed = PreviewResponseSchema.parse({
43
+ type: 'BLOCK_RECT',
44
+ blockId: 'hero-1',
45
+ rect: null,
46
+ });
47
+ expect(parsed.type).toBe('BLOCK_RECT');
48
+ if (parsed.type === 'BLOCK_RECT') {
49
+ expect(parsed.rect).toBeNull();
50
+ }
51
+ });
52
+
53
+ it('accepts INSERTER_HOVER with null index to clear affordance', () => {
54
+ const parsed = PreviewResponseSchema.parse({
55
+ type: 'INSERTER_HOVER',
56
+ index: null,
57
+ rect: null,
58
+ });
59
+ expect(parsed.type).toBe('INSERTER_HOVER');
60
+ });
61
+
62
+ it('accepts INSERTER_HOVER with index + rect', () => {
63
+ const parsed = PreviewResponseSchema.parse({
64
+ type: 'INSERTER_HOVER',
65
+ index: 2,
66
+ rect: { top: 100, left: 0, width: 800, height: 16 },
67
+ });
68
+ expect(parsed.type).toBe('INSERTER_HOVER');
69
+ if (parsed.type === 'INSERTER_HOVER') {
70
+ expect(parsed.index).toBe(2);
71
+ }
72
+ });
73
+
74
+ it('accepts INLINE_EDIT_COMMIT', () => {
75
+ const parsed = PreviewResponseSchema.parse({
76
+ type: 'INLINE_EDIT_COMMIT',
77
+ blockId: 'hero-1',
78
+ contentPath: 'hero.title',
79
+ value: 'Launch sale',
80
+ });
81
+ expect(parsed.type).toBe('INLINE_EDIT_COMMIT');
82
+ if (parsed.type === 'INLINE_EDIT_COMMIT') {
83
+ expect(parsed.value).toBe('Launch sale');
84
+ }
85
+ });
86
+
87
+ it('accepts BOOTSTRAP_OK', () => {
88
+ const parsed = PreviewResponseSchema.parse({
89
+ type: 'BOOTSTRAP_OK',
90
+ revision: 3,
91
+ shopSlug: 'demo-shop',
92
+ shopId: 'shop_1',
93
+ });
94
+ expect(parsed.type).toBe('BOOTSTRAP_OK');
95
+ });
96
+
97
+ it('accepts PREVIEW_ERROR with bootstrap phase', () => {
98
+ const parsed = PreviewResponseSchema.parse({
99
+ type: 'PREVIEW_ERROR',
100
+ message: 'Preview initial data is missing store slug',
101
+ source: 'bootstrap',
102
+ phase: 'bootstrap',
103
+ });
104
+ expect(parsed.type).toBe('PREVIEW_ERROR');
105
+ if (parsed.type === 'PREVIEW_ERROR') {
106
+ expect(parsed.phase).toBe('bootstrap');
107
+ }
108
+ });
109
+
110
+ it('accepts BOOTSTRAP_OK with artifact stale hint', () => {
111
+ const parsed = PreviewResponseSchema.parse({
112
+ type: 'BOOTSTRAP_OK',
113
+ revision: 3,
114
+ shopSlug: 'demo-shop',
115
+ shopId: 'shop_1',
116
+ artifactStale: true,
117
+ });
118
+ expect(parsed.type).toBe('BOOTSTRAP_OK');
119
+ if (parsed.type === 'BOOTSTRAP_OK') {
120
+ expect(parsed.artifactStale).toBe(true);
121
+ }
122
+ });
123
+
124
+ it('rejects INLINE_EDIT_COMMIT without contentPath', () => {
125
+ const result = PreviewResponseSchema.safeParse({
126
+ type: 'INLINE_EDIT_COMMIT',
127
+ blockId: 'hero-1',
128
+ value: 'foo',
129
+ });
130
+ expect(result.success).toBe(false);
131
+ });
132
+ });