@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,6 +1,8 @@
1
1
  import * as z from 'zod/v4';
2
+ import { createDedicatedPageBlockType } from './dedicated-pages.ts';
2
3
  import { BuilderFieldSchema, type BuilderField } from './fields.ts';
3
4
  import { ThemeManifestSchema, type ThemeManifest } from './theme-manifest.ts';
5
+ import { StyleSlotDefaultsSchema } from './style-slots.ts';
4
6
 
5
7
  const LegacyFieldOptionSchema = z
6
8
  .object({
@@ -83,6 +85,17 @@ export const LegacyThemeManifestSchema = z
83
85
  id: z.string().min(1),
84
86
  name: z.string().min(1),
85
87
  version: z.string().min(1),
88
+ author: z.string().min(1).optional(),
89
+ description: z.string().min(1).optional(),
90
+ preview: z.string().min(1).optional(),
91
+ features: z.array(z.string().min(1)).optional(),
92
+ techStack: z.array(z.string().min(1)).optional(),
93
+ templates: z.array(z.string().min(1)).optional(),
94
+ createdAt: z.string().min(1).optional(),
95
+ demoUrl: z.string().min(1).optional(),
96
+ audience: z.string().min(1).optional(),
97
+ tags: z.array(z.string().min(1)).optional(),
98
+ hotfixPaths: z.array(z.string().min(1)).optional(),
86
99
  presets: z.array(LegacyThemePresetSchema).optional(),
87
100
  builder: z
88
101
  .object({
@@ -96,6 +109,11 @@ export const LegacyThemeManifestSchema = z
96
109
  export type LegacyThemeManifest = z.infer<typeof LegacyThemeManifestSchema>;
97
110
 
98
111
  export function convertLegacyThemeManifest(input: unknown): ThemeManifest {
112
+ const canonical = ThemeManifestSchema.safeParse(input);
113
+ if (canonical.success) {
114
+ return canonical.data;
115
+ }
116
+
99
117
  const legacy = LegacyThemeManifestSchema.parse(input);
100
118
  const blocks = Object.fromEntries(
101
119
  Object.entries(legacy.builder.blocks).map(([blockType, block]) => [
@@ -112,7 +130,7 @@ export function convertLegacyThemeManifest(input: unknown): ThemeManifest {
112
130
  );
113
131
 
114
132
  for (const page of legacy.builder.pages) {
115
- const syntheticBlockType = createSyntheticPageBlockType(page.id);
133
+ const syntheticBlockType = createDedicatedPageBlockType(page.id);
116
134
  if ((page.fields?.length ?? 0) === 0 && (page.lists?.length ?? 0) === 0) {
117
135
  continue;
118
136
  }
@@ -131,9 +149,20 @@ export function convertLegacyThemeManifest(input: unknown): ThemeManifest {
131
149
  id: normalizeLegacyThemeId(legacy.id),
132
150
  name: legacy.name,
133
151
  version: legacy.version,
152
+ author: legacy.author,
153
+ description: legacy.description,
154
+ preview: legacy.preview,
155
+ features: legacy.features,
156
+ techStack: legacy.techStack,
157
+ templates: legacy.templates,
158
+ createdAt: legacy.createdAt,
159
+ demoUrl: legacy.demoUrl,
160
+ audience: legacy.audience,
161
+ tags: legacy.tags,
162
+ hotfixPaths: legacy.hotfixPaths,
134
163
  pages: Object.fromEntries(
135
164
  legacy.builder.pages.map((page) => {
136
- const syntheticBlockType = createSyntheticPageBlockType(page.id);
165
+ const syntheticBlockType = createDedicatedPageBlockType(page.id);
137
166
  const hasSyntheticBlock = blocks[syntheticBlockType] !== undefined;
138
167
  const allowedBlocks = page.blocks.length > 0
139
168
  ? page.blocks
@@ -158,6 +187,8 @@ export function convertLegacyThemeManifest(input: unknown): ThemeManifest {
158
187
  blocks,
159
188
  styleSlots: {},
160
189
  presets: convertLegacyThemePresets(legacy.presets ?? []),
190
+ linkGroups: Array.isArray(legacy.builder.linkGroups) ? legacy.builder.linkGroups : [],
191
+ defaultLinkItems: isPlainObject(legacy.builder.defaultLinkItems) ? legacy.builder.defaultLinkItems : {},
161
192
  };
162
193
 
163
194
  return ThemeManifestSchema.parse(converted);
@@ -171,23 +202,24 @@ function normalizeLegacyThemeId(rawId: string): string {
171
202
  return `legacy-${lowered || 'theme'}`;
172
203
  }
173
204
 
174
- function createSyntheticPageBlockType(pageId: string): string {
175
- const normalizedPageId = pageId.toLowerCase().replace(/[^a-z0-9\-_.]+/g, '-').replace(/^[-_.]+/, '');
176
- return `page-${normalizedPageId || 'custom'}`;
177
- }
178
-
179
205
  function convertLegacyThemePresets(presets: LegacyThemePreset[]): ThemeManifest['presets'] {
180
206
  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
- ]),
207
+ presets.map((preset) => {
208
+ const styleSlots = StyleSlotDefaultsSchema.parse(
209
+ preset.overrides?.style_slots ?? preset.overrides?.styleSlots ?? {},
210
+ );
211
+ return [
212
+ preset.id,
213
+ {
214
+ label: preset.name ?? preset.label ?? preset.id,
215
+ description: preset.description,
216
+ preview: typeof preset.preview === 'string' ? preset.preview : undefined,
217
+ content: flattenContentRecord(preset.overrides?.content ?? {}),
218
+ layout: preset.overrides?.layout ?? {},
219
+ style_slots: styleSlots,
220
+ },
221
+ ];
222
+ }),
191
223
  );
192
224
  }
193
225
 
@@ -211,20 +243,42 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
211
243
  }
212
244
 
213
245
  function convertLegacyFields(fields: LegacyBuilderField[], lists: LegacyBuilderList[]): Record<string, BuilderField> {
246
+ // Auto-promote inline `type: 'list'` fields to first-class list entries so
247
+ // the migrator can emit list-shaped data without separately maintaining a
248
+ // `lists: []` block-level entry. The promoted list inherits its kind from
249
+ // the field path (e.g. `header.left_links` → kind `navLinks`).
250
+ const inlineLists: LegacyBuilderList[] = [];
251
+ const remainingFields: LegacyBuilderField[] = [];
252
+ for (const field of fields) {
253
+ if (field.type === 'list') {
254
+ inlineLists.push({
255
+ path: field.path,
256
+ label: field.label,
257
+ kind: inferListKindFromPath(field.path),
258
+ description: field.description,
259
+ });
260
+ } else {
261
+ remainingFields.push(field);
262
+ }
263
+ }
264
+
214
265
  const convertedFields = Object.fromEntries(
215
- fields.map((field) => [field.path, convertLegacyField(field)]),
266
+ remainingFields.map((field) => [field.path, convertLegacyField(field)]),
216
267
  );
217
268
 
269
+ const allLists = [...inlineLists, ...lists];
218
270
  const convertedLists = Object.fromEntries(
219
- lists.map((list) => [
220
- list.path,
221
- {
271
+ allLists.map((list) => {
272
+ const inlineField = fields.find((f) => f.type === 'list' && f.path === list.path);
273
+ const candidate: BuilderField = {
222
274
  type: 'list',
223
275
  label: list.label,
224
276
  description: list.description,
225
277
  itemShape: createListItemShape(list.kind),
226
- } satisfies BuilderField,
227
- ]),
278
+ ...(inlineField?.defaultValue !== undefined ? { defaultValue: inlineField.defaultValue } : {}),
279
+ } as BuilderField;
280
+ return [list.path, candidate];
281
+ }),
228
282
  );
229
283
 
230
284
  return {
@@ -233,6 +287,17 @@ function convertLegacyFields(fields: LegacyBuilderField[], lists: LegacyBuilderL
233
287
  };
234
288
  }
235
289
 
290
+ function inferListKindFromPath(path: string): string {
291
+ const suffix = path.split('.').at(-1) ?? '';
292
+ if (/items$/i.test(suffix)) return 'faqItems';
293
+ if (/links$/i.test(suffix)) return 'navLinks';
294
+ if (/images$/i.test(suffix) || /gallery$/i.test(suffix)) return 'galleryItems';
295
+ if (/features$/i.test(suffix)) return 'featureItems';
296
+ if (/reviews$/i.test(suffix) || /testimonials$/i.test(suffix)) return 'reviewItems';
297
+ if (/announcements$/i.test(suffix)) return 'announcementItems';
298
+ return 'genericItems';
299
+ }
300
+
236
301
  function convertLegacyField(field: LegacyBuilderField): BuilderField {
237
302
  const type = normalizeLegacyFieldType(field.type);
238
303
  const base = {
@@ -314,8 +379,67 @@ function createListItemShape(kind: string): Extract<BuilderField, { type: 'list'
314
379
  };
315
380
  }
316
381
 
382
+ if (kind === 'navLinks') {
383
+ return {
384
+ text: { type: 'text', label: 'Text' },
385
+ link: { type: 'link', label: 'Link' },
386
+ };
387
+ }
388
+
389
+ if (kind === 'featureItems') {
390
+ return {
391
+ title: { type: 'text', label: 'Title' },
392
+ description: { type: 'richtext', label: 'Description' },
393
+ icon: { type: 'text', label: 'Icon' },
394
+ };
395
+ }
396
+
397
+ if (kind === 'announcementItems') {
398
+ return {
399
+ text: { type: 'text', label: 'Text' },
400
+ link: { type: 'link', label: 'Link' },
401
+ };
402
+ }
403
+
317
404
  return {
318
405
  title: { type: 'text', label: 'Title' },
319
406
  body: { type: 'richtext', label: 'Body' },
320
407
  };
321
408
  }
409
+
410
+ export type ThemePageBlockOrderPage = {
411
+ allowedBlocks?: string[];
412
+ defaultBlocks?: Array<{ type?: string }>;
413
+ };
414
+
415
+ function resolveManifestPagesForBlockOrder(manifest: unknown): Record<string, ThemePageBlockOrderPage> {
416
+ const canonical = ThemeManifestSchema.safeParse(manifest);
417
+ if (canonical.success) {
418
+ return canonical.data.pages;
419
+ }
420
+
421
+ try {
422
+ return convertLegacyThemeManifest(manifest).pages;
423
+ } catch {
424
+ if (typeof manifest === 'object' && manifest !== null && 'pages' in manifest) {
425
+ const pages = (manifest as { pages?: unknown }).pages;
426
+ if (typeof pages === 'object' && pages !== null && !Array.isArray(pages)) {
427
+ return pages as Record<string, ThemePageBlockOrderPage>;
428
+ }
429
+ }
430
+ return {};
431
+ }
432
+ }
433
+
434
+ export function getThemePageBlockOrderFromManifest(manifest: unknown, pageId: string): string[] {
435
+ const page = resolveManifestPagesForBlockOrder(manifest)[pageId];
436
+ if (!page) {
437
+ return [];
438
+ }
439
+
440
+ const defaultBlockTypes = (Array.isArray(page.defaultBlocks) ? page.defaultBlocks : [])
441
+ .map((block) => (typeof block === 'object' && block !== null ? block.type : undefined))
442
+ .filter((blockType): blockType is string => typeof blockType === 'string' && blockType.length > 0);
443
+
444
+ return defaultBlockTypes.length > 0 ? defaultBlockTypes : page.allowedBlocks ?? [];
445
+ }
package/src/migrations.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { migrateDedicatedPageContent, migrateDedicatedPageLayout } from './dedicated-pages.ts';
1
2
  import {
2
3
  BUILDER_SETTINGS_VERSION,
3
4
  type BlockInstance,
@@ -6,6 +7,7 @@ import {
6
7
  type PageLayout,
7
8
  } from './builder-settings.ts';
8
9
  import type { StyleSlots } from './style-slots.ts';
10
+ import { sanitizeBuilderSettingsState } from './persistence.ts';
9
11
  import type { ThemeManifest } from './theme-manifest.ts';
10
12
 
11
13
  export type LegacyBuilderSettingsInput = {
@@ -66,18 +68,20 @@ export function migrateLegacyBuilderSettings(input: LegacyBuilderSettingsInput,
66
68
  ...mapLegacyTokenOverridesToStyleSlots(tokensOverride),
67
69
  ...(input.theme?.style_slots ?? {}),
68
70
  };
71
+ const rawContent = input.theme?.content ?? {};
72
+ const rawLayout = input.theme?.layout ?? {};
69
73
 
70
- return BuilderSettingsSchema.parse({
74
+ return sanitizeBuilderSettingsState(BuilderSettingsSchema.parse({
71
75
  version: BUILDER_SETTINGS_VERSION,
72
76
  revision,
73
77
  theme: {
74
- content: input.theme?.content ?? {},
75
- layout: normalizeLegacyLayout(input.theme?.layout ?? {}),
78
+ content: migrateDedicatedPageContent(rawContent as Record<string, unknown>),
79
+ layout: migrateDedicatedPageLayout(normalizeLegacyLayout(rawLayout) as Record<string, unknown>),
76
80
  style_slots: styleSlots,
77
81
  pages: [],
78
82
  terms: {},
79
83
  },
80
- });
84
+ }));
81
85
  }
82
86
 
83
87
  export function applyManifestDefaultLayout(settings: BuilderSettings, manifest: ThemeManifest): BuilderSettings {
@@ -110,13 +114,39 @@ export function mapLegacyTokenOverridesToStyleSlots(tokens: Record<string, unkno
110
114
  const slots: StyleSlots = {};
111
115
 
112
116
  assignResponsiveNumber(tokens, slots, 'buttons.borderRadius', 'button.radius');
117
+ assignColor(tokens, slots, 'buttons.background', 'button.background');
118
+ assignColor(tokens, slots, 'buttons.foreground', 'button.foreground');
119
+ assignColor(tokens, slots, 'buttons.border', 'button.border');
120
+ assignFontWeight(tokens, slots, 'buttons.fontWeight', 'button.font.weight');
121
+
113
122
  assignResponsiveNumber(tokens, slots, 'inputs.borderRadius', 'input.radius');
114
123
  assignResponsiveNumber(tokens, slots, 'inputs.height', 'input.height');
124
+ assignColor(tokens, slots, 'inputs.border', 'input.border');
125
+ assignColor(tokens, slots, 'inputs.background', 'input.background');
126
+ assignColor(tokens, slots, 'inputs.foreground', 'input.foreground');
127
+
128
+ assignResponsiveNumber(tokens, slots, 'cards.borderRadius', 'card.radius');
129
+ assignColor(tokens, slots, 'cards.background', 'card.background');
130
+ assignColor(tokens, slots, 'cards.border', 'card.border');
131
+
132
+ assignResponsiveNumber(tokens, slots, 'sections.paddingY', 'section.padding.y');
133
+ assignResponsiveNumber(tokens, slots, 'sections.paddingX', 'section.padding.x');
134
+ assignResponsiveNumber(tokens, slots, 'container.width', 'container.width');
135
+
115
136
  assignColor(tokens, slots, 'colors.primary', 'color.primary');
116
137
  assignColor(tokens, slots, 'colors.accent', 'color.accent');
117
138
  assignColor(tokens, slots, 'colors.background', 'color.background');
118
139
  assignColor(tokens, slots, 'colors.foreground', 'color.foreground');
119
140
  assignColor(tokens, slots, 'colors.muted', 'color.muted');
141
+ assignColor(tokens, slots, 'links.color', 'link.color');
142
+
143
+ assignFontWeight(tokens, slots, 'typography.headingWeight', 'typography.heading.weight');
144
+ assignResponsiveNumber(tokens, slots, 'typography.fontSize', 'typography.body.size');
145
+ assignResponsiveNumber(tokens, slots, 'typography.bodySize', 'typography.body.size');
146
+ assignString(tokens, slots, 'typography.fontFamily', 'theme.typography.font.family');
147
+ assignString(tokens, slots, 'typography.headingFont', 'theme.typography.heading.font');
148
+
149
+ assignResponsiveNumber(tokens, slots, 'header.height', 'theme.header.height');
120
150
 
121
151
  return slots;
122
152
  }
@@ -182,7 +212,7 @@ function assignResponsiveNumber(
182
212
  tokenPath: string,
183
213
  slotId: keyof StyleSlots,
184
214
  ): void {
185
- const value = tokens[tokenPath];
215
+ const value = readLegacyTokenPath(tokens, tokenPath);
186
216
  if (typeof value === 'number' && Number.isFinite(value)) {
187
217
  slots[slotId] = { base: value } as never;
188
218
  return;
@@ -194,12 +224,46 @@ function assignResponsiveNumber(
194
224
  }
195
225
 
196
226
  function assignColor(tokens: Record<string, unknown>, slots: StyleSlots, tokenPath: string, slotId: keyof StyleSlots): void {
197
- const value = tokens[tokenPath];
227
+ const value = readLegacyTokenPath(tokens, tokenPath);
198
228
  if (typeof value === 'string') {
199
229
  slots[slotId] = value as never;
200
230
  }
201
231
  }
202
232
 
233
+ function assignString(tokens: Record<string, unknown>, slots: StyleSlots, tokenPath: string, slotId: keyof StyleSlots): void {
234
+ const value = readLegacyTokenPath(tokens, tokenPath);
235
+ if (typeof value === 'string' && value.trim().length > 0) {
236
+ slots[slotId] = value as never;
237
+ }
238
+ }
239
+
240
+ function assignFontWeight(
241
+ tokens: Record<string, unknown>,
242
+ slots: StyleSlots,
243
+ tokenPath: string,
244
+ slotId: keyof StyleSlots,
245
+ ): void {
246
+ const value = readLegacyTokenPath(tokens, tokenPath);
247
+ if (typeof value === 'number' && Number.isFinite(value)) {
248
+ slots[slotId] = value as never;
249
+ }
250
+ }
251
+
252
+ function readLegacyTokenPath(tokens: Record<string, unknown>, tokenPath: string): unknown {
253
+ if (Object.prototype.hasOwnProperty.call(tokens, tokenPath)) {
254
+ return tokens[tokenPath];
255
+ }
256
+
257
+ let current: unknown = tokens;
258
+ for (const segment of tokenPath.split('.')) {
259
+ if (!isRecord(current)) {
260
+ return undefined;
261
+ }
262
+ current = current[segment];
263
+ }
264
+ return current;
265
+ }
266
+
203
267
  function isResponsiveNumber(value: unknown): value is { base: number; sm?: number; md?: number; lg?: number; xl?: number } {
204
268
  if (!value || typeof value !== 'object') {
205
269
  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,72 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { mergePreviewBootIntoInitialData, PreviewBootPayloadSchema } from './preview-boot.ts';
3
+
4
+ describe('PreviewBootPayloadSchema', () => {
5
+ it('accepts storefront seeds with catalog fields from built theme artifacts', () => {
6
+ const parsed = PreviewBootPayloadSchema.parse({
7
+ shopId: 'shop_1',
8
+ shopSlug: 'florain',
9
+ builderSettings: {
10
+ version: 2,
11
+ revision: 1,
12
+ theme: {
13
+ content: {},
14
+ layout: {},
15
+ style_slots: {},
16
+ pages: [],
17
+ terms: {},
18
+ },
19
+ },
20
+ storefrontSeed: {
21
+ store: { name: 'Florain' },
22
+ products: [],
23
+ items: [{ id: 'item_1' }],
24
+ categories: [{ id: 'cat_1' }],
25
+ addons: [{ id: 'addon_1' }],
26
+ menus: [{ id: 'menu_1' }],
27
+ },
28
+ });
29
+
30
+ expect(parsed.shopSlug).toBe('florain');
31
+ expect(parsed.storefrontSeed.items).toEqual([{ id: 'item_1' }]);
32
+ expect(parsed.storefrontSeed.categories).toEqual([{ id: 'cat_1' }]);
33
+ expect(parsed.storefrontSeed.addons).toEqual([{ id: 'addon_1' }]);
34
+ expect(parsed.storefrontSeed.menus).toEqual([{ id: 'menu_1' }]);
35
+ });
36
+
37
+ it('merges preview boot slug into initial data while preserving catalog passthrough fields', () => {
38
+ const initialData = mergePreviewBootIntoInitialData({
39
+ shopId: 'shop_1',
40
+ shopSlug: 'florain',
41
+ builderSettings: {
42
+ version: 2,
43
+ revision: 1,
44
+ theme: {
45
+ content: {},
46
+ layout: {},
47
+ style_slots: {},
48
+ pages: [],
49
+ terms: {},
50
+ },
51
+ },
52
+ storefrontSeed: {
53
+ store: { name: 'Florain' },
54
+ products: [],
55
+ items: [{ id: 'item_1' }],
56
+ categories: [{ id: 'cat_1' }],
57
+ addons: [{ id: 'addon_1' }],
58
+ menus: [{ id: 'menu_1' }],
59
+ },
60
+ });
61
+
62
+ expect(initialData.store).toMatchObject({
63
+ id: 'shop_1',
64
+ slug: 'florain',
65
+ name: 'Florain',
66
+ });
67
+ expect(initialData.items).toEqual([{ id: 'item_1' }]);
68
+ expect(initialData.categories).toEqual([{ id: 'cat_1' }]);
69
+ expect(initialData.addons).toEqual([{ id: 'addon_1' }]);
70
+ expect(initialData.menus).toEqual([{ id: 'menu_1' }]);
71
+ });
72
+ });
@@ -0,0 +1,49 @@
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
+ // Artifact HTML seeds include catalog/menu fields beyond the documented minimum.
7
+ // Passthrough keeps them available to the theme runtime in preview boot.
8
+ export const StorefrontSeedSchema = z
9
+ .object({
10
+ store: z.record(z.string(), z.unknown()),
11
+ products: z.array(z.unknown()).optional(),
12
+ groups: z.array(z.unknown()).optional(),
13
+ pages: z.array(z.unknown()).optional(),
14
+ })
15
+ .passthrough();
16
+
17
+ export type StorefrontSeed = z.infer<typeof StorefrontSeedSchema>;
18
+
19
+ export const PreviewBootPayloadSchema = z
20
+ .object({
21
+ shopId: z.string().min(1),
22
+ shopSlug: z.string().min(1),
23
+ builderSettings: BuilderSettingsSchema,
24
+ storefrontSeed: StorefrontSeedSchema,
25
+ artifactRevision: z.string().optional(),
26
+ artifactStale: z.boolean().optional(),
27
+ })
28
+ .strict();
29
+
30
+ export type PreviewBootPayload = z.infer<typeof PreviewBootPayloadSchema>;
31
+
32
+ export function mergePreviewBootIntoInitialData(
33
+ payload: PreviewBootPayload,
34
+ ): Record<string, unknown> {
35
+ const existingStore = isRecord(payload.storefrontSeed.store) ? payload.storefrontSeed.store : {};
36
+ return {
37
+ ...payload.storefrontSeed,
38
+ store: {
39
+ ...existingStore,
40
+ id: payload.shopId,
41
+ slug: payload.shopSlug,
42
+ builder_settings: payload.builderSettings,
43
+ },
44
+ };
45
+ }
46
+
47
+ function isRecord(value: unknown): value is Record<string, unknown> {
48
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
49
+ }