@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.
- package/dist/builder-contracts.test.js +71 -1
- package/dist/builder-settings.d.ts +9 -666
- package/dist/builder-settings.d.ts.map +1 -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/events.d.ts +35 -240
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +7 -0
- package/dist/fields.d.ts +169 -8
- package/dist/fields.d.ts.map +1 -1
- package/dist/fields.js +27 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/legacy-manifest.d.ts +11 -0
- package/dist/legacy-manifest.d.ts.map +1 -1
- package/dist/legacy-manifest.js +106 -16
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +50 -4
- 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 +36 -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 +26 -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/style-slots.d.ts +49 -151
- package/dist/style-slots.d.ts.map +1 -1
- package/dist/style-slots.js +75 -29
- package/dist/theme-manifest.d.ts +229 -454
- 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 +24 -0
- package/dist/validation.d.ts +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +18 -9
- package/package.json +43 -1
- package/src/builder-contracts.test.ts +398 -3
- package/src/canonical-settings.ts +156 -0
- package/src/events.ts +8 -0
- package/src/fields.ts +30 -0
- package/src/index.ts +7 -0
- package/src/legacy-manifest.ts +107 -16
- package/src/migrations.ts +65 -4
- package/src/persistence.ts +77 -0
- package/src/preview-boot.ts +47 -0
- package/src/preview-protocol.test.ts +132 -0
- package/src/preview-protocol.ts +122 -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 +63 -0
- package/src/storefront-initial-data-html.ts +112 -0
- package/src/style-slots.ts +96 -31
- package/src/theme-manifest.ts +118 -1
- package/src/theme-schemes.ts +33 -0
- 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';
|
package/src/legacy-manifest.ts
CHANGED
|
@@ -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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
270
|
+
remainingFields.map((field) => [field.path, convertLegacyField(field)]),
|
|
216
271
|
);
|
|
217
272
|
|
|
273
|
+
const allLists = [...inlineLists, ...lists];
|
|
218
274
|
const convertedLists = Object.fromEntries(
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
});
|