@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.
- package/dist/builder-settings.d.ts +11 -666
- package/dist/builder-settings.d.ts.map +1 -1
- package/dist/builder-settings.js +2 -1
- package/dist/canonical-settings.d.ts +18 -0
- package/dist/canonical-settings.d.ts.map +1 -0
- package/dist/canonical-settings.js +106 -0
- package/dist/custom-pages.d.ts +15 -0
- package/dist/custom-pages.d.ts.map +1 -0
- package/dist/custom-pages.js +40 -0
- package/dist/dedicated-pages.d.ts +15 -0
- package/dist/dedicated-pages.d.ts.map +1 -0
- package/dist/dedicated-pages.js +142 -0
- package/dist/events.d.ts +35 -240
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +7 -0
- package/dist/fields.d.ts +229 -10
- package/dist/fields.d.ts.map +1 -1
- package/dist/fields.js +27 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/legacy-manifest.d.ts +18 -0
- package/dist/legacy-manifest.d.ts.map +1 -1
- package/dist/legacy-manifest.js +137 -22
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +55 -6
- package/dist/persistence.d.ts +7 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +58 -0
- package/dist/preview-boot.d.ts +68 -0
- package/dist/preview-boot.d.ts.map +1 -0
- package/dist/preview-boot.js +38 -0
- package/dist/preview-protocol.d.ts +227 -459
- package/dist/preview-protocol.d.ts.map +1 -1
- package/dist/preview-protocol.js +112 -0
- package/dist/preview-session-resolve.d.ts +115 -0
- package/dist/preview-session-resolve.d.ts.map +1 -0
- package/dist/preview-session-resolve.js +25 -0
- package/dist/preview-trusted-origins.d.ts +4 -0
- package/dist/preview-trusted-origins.d.ts.map +1 -0
- package/dist/preview-trusted-origins.js +28 -0
- package/dist/storefront-initial-data-html.d.ts +17 -0
- package/dist/storefront-initial-data-html.d.ts.map +1 -0
- package/dist/storefront-initial-data-html.js +83 -0
- package/dist/storefront-typography-fonts.d.ts +18 -0
- package/dist/storefront-typography-fonts.d.ts.map +1 -0
- package/dist/storefront-typography-fonts.js +89 -0
- package/dist/style-slots.d.ts +50 -152
- package/dist/style-slots.d.ts.map +1 -1
- package/dist/style-slots.js +80 -32
- package/dist/theme-manifest.d.ts +287 -456
- package/dist/theme-manifest.d.ts.map +1 -1
- package/dist/theme-manifest.js +92 -0
- package/dist/theme-schemes.d.ts +10 -0
- package/dist/theme-schemes.d.ts.map +1 -0
- package/dist/theme-schemes.js +25 -0
- package/dist/validation.d.ts +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +23 -12
- package/package.json +43 -1
- package/src/builder-contracts.test.ts +416 -3
- package/src/builder-settings.ts +4 -1
- package/src/canonical-settings.ts +156 -0
- package/src/custom-pages.test.ts +74 -0
- package/src/custom-pages.ts +70 -0
- package/src/dedicated-pages.test.ts +88 -0
- package/src/dedicated-pages.ts +173 -0
- package/src/events.ts +8 -0
- package/src/fields.ts +30 -0
- package/src/index.ts +10 -0
- package/src/legacy-manifest.ts +147 -23
- package/src/migrations.ts +70 -6
- package/src/persistence.ts +77 -0
- package/src/preview-boot.test.ts +72 -0
- package/src/preview-boot.ts +49 -0
- package/src/preview-protocol.test.ts +132 -0
- package/src/preview-protocol.ts +122 -0
- package/src/preview-session-resolve.test.ts +37 -0
- package/src/preview-session-resolve.ts +34 -0
- package/src/preview-trusted-origins.test.ts +24 -0
- package/src/preview-trusted-origins.ts +35 -0
- package/src/storefront-initial-data-html.test.ts +73 -0
- package/src/storefront-initial-data-html.ts +112 -0
- package/src/storefront-typography-fonts.test.ts +48 -0
- package/src/storefront-typography-fonts.ts +108 -0
- package/src/style-slots.ts +102 -34
- package/src/theme-manifest.ts +118 -1
- package/src/theme-schemes.ts +34 -0
- package/src/validation.ts +32 -13
- package/dist/builder-contracts.test.d.ts +0 -2
- package/dist/builder-contracts.test.d.ts.map +0 -1
- package/dist/builder-contracts.test.js +0 -361
package/src/legacy-manifest.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
266
|
+
remainingFields.map((field) => [field.path, convertLegacyField(field)]),
|
|
216
267
|
);
|
|
217
268
|
|
|
269
|
+
const allLists = [...inlineLists, ...lists];
|
|
218
270
|
const convertedLists = Object.fromEntries(
|
|
219
|
-
|
|
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
|
-
|
|
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:
|
|
75
|
-
layout: normalizeLegacyLayout(
|
|
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
|
|
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
|
|
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
|
+
}
|