@shoppexio/builder-contracts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builder-contracts.test.d.ts +2 -0
- package/dist/builder-contracts.test.d.ts.map +1 -0
- package/dist/builder-contracts.test.js +361 -0
- package/dist/builder-settings.d.ts +801 -0
- package/dist/builder-settings.d.ts.map +1 -0
- package/dist/builder-settings.js +65 -0
- package/dist/events.d.ts +512 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +104 -0
- package/dist/fields.d.ts +300 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +111 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/legacy-manifest.d.ts +172 -0
- package/dist/legacy-manifest.d.ts.map +1 -0
- package/dist/legacy-manifest.js +272 -0
- package/dist/migrations.d.ts +31 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +175 -0
- package/dist/preview-protocol.d.ts +687 -0
- package/dist/preview-protocol.d.ts.map +1 -0
- package/dist/preview-protocol.js +79 -0
- package/dist/style-slots.d.ts +209 -0
- package/dist/style-slots.d.ts.map +1 -0
- package/dist/style-slots.js +93 -0
- package/dist/theme-manifest.d.ts +845 -0
- package/dist/theme-manifest.d.ts.map +1 -0
- package/dist/theme-manifest.js +119 -0
- package/dist/validation.d.ts +16 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +131 -0
- package/package.json +95 -0
- package/src/builder-contracts.test.ts +405 -0
- package/src/builder-settings.ts +85 -0
- package/src/events.ts +121 -0
- package/src/fields.ts +134 -0
- package/src/index.ts +9 -0
- package/src/legacy-manifest.ts +321 -0
- package/src/migrations.ts +240 -0
- package/src/preview-protocol.ts +93 -0
- package/src/style-slots.ts +111 -0
- package/src/theme-manifest.ts +140 -0
- package/src/validation.ts +196 -0
package/src/fields.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import * as z from 'zod/v4';
|
|
2
|
+
import { ColorSchema } from './style-slots.ts';
|
|
3
|
+
|
|
4
|
+
const FieldBaseSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
label: z.string().min(1),
|
|
7
|
+
description: z.string().min(1).optional(),
|
|
8
|
+
defaultValue: z.unknown().optional(),
|
|
9
|
+
required: z.boolean().optional(),
|
|
10
|
+
})
|
|
11
|
+
.strict();
|
|
12
|
+
|
|
13
|
+
export const FieldOptionSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
label: z.string().min(1),
|
|
16
|
+
value: z.string().min(1),
|
|
17
|
+
})
|
|
18
|
+
.strict();
|
|
19
|
+
export type FieldOption = z.infer<typeof FieldOptionSchema>;
|
|
20
|
+
|
|
21
|
+
const TextFieldSchema = FieldBaseSchema.extend({
|
|
22
|
+
type: z.literal('text'),
|
|
23
|
+
placeholder: z.string().optional(),
|
|
24
|
+
minLength: z.number().int().nonnegative().optional(),
|
|
25
|
+
maxLength: z.number().int().positive().optional(),
|
|
26
|
+
}).strict();
|
|
27
|
+
|
|
28
|
+
const RichTextFieldSchema = FieldBaseSchema.extend({
|
|
29
|
+
type: z.literal('richtext'),
|
|
30
|
+
allowedMarks: z.array(z.enum(['bold', 'italic', 'link', 'code'])).optional(),
|
|
31
|
+
}).strict();
|
|
32
|
+
|
|
33
|
+
const ImageFieldSchema = FieldBaseSchema.extend({
|
|
34
|
+
type: z.literal('image'),
|
|
35
|
+
aspectRatio: z.string().min(1).optional(),
|
|
36
|
+
}).strict();
|
|
37
|
+
|
|
38
|
+
const LinkFieldSchema = FieldBaseSchema.extend({
|
|
39
|
+
type: z.literal('link'),
|
|
40
|
+
}).strict();
|
|
41
|
+
|
|
42
|
+
const BooleanFieldSchema = FieldBaseSchema.extend({
|
|
43
|
+
type: z.literal('boolean'),
|
|
44
|
+
defaultValue: z.boolean().optional(),
|
|
45
|
+
}).strict();
|
|
46
|
+
|
|
47
|
+
const NumberFieldSchema = FieldBaseSchema.extend({
|
|
48
|
+
type: z.literal('number'),
|
|
49
|
+
defaultValue: z.number().optional(),
|
|
50
|
+
min: z.number().optional(),
|
|
51
|
+
max: z.number().optional(),
|
|
52
|
+
step: z.number().positive().optional(),
|
|
53
|
+
}).strict();
|
|
54
|
+
|
|
55
|
+
const RangeFieldSchema = FieldBaseSchema.extend({
|
|
56
|
+
type: z.literal('range'),
|
|
57
|
+
defaultValue: z.number().optional(),
|
|
58
|
+
min: z.number(),
|
|
59
|
+
max: z.number(),
|
|
60
|
+
step: z.number().positive().optional(),
|
|
61
|
+
unit: z.string().min(1).optional(),
|
|
62
|
+
}).strict();
|
|
63
|
+
|
|
64
|
+
const SelectFieldSchema = FieldBaseSchema.extend({
|
|
65
|
+
type: z.literal('select'),
|
|
66
|
+
defaultValue: z.string().optional(),
|
|
67
|
+
options: z.array(FieldOptionSchema).min(1),
|
|
68
|
+
}).strict();
|
|
69
|
+
|
|
70
|
+
const ColorFieldSchema = FieldBaseSchema.extend({
|
|
71
|
+
type: z.literal('color'),
|
|
72
|
+
defaultValue: ColorSchema.optional(),
|
|
73
|
+
}).strict();
|
|
74
|
+
|
|
75
|
+
const ProductFieldSchema = FieldBaseSchema.extend({
|
|
76
|
+
type: z.literal('product'),
|
|
77
|
+
}).strict();
|
|
78
|
+
|
|
79
|
+
const ProductsFieldSchema = FieldBaseSchema.extend({
|
|
80
|
+
type: z.literal('products'),
|
|
81
|
+
minItems: z.number().int().nonnegative().optional(),
|
|
82
|
+
maxItems: z.number().int().positive().optional(),
|
|
83
|
+
}).strict();
|
|
84
|
+
|
|
85
|
+
export const ListItemFieldTypeSchema = z.enum([
|
|
86
|
+
'text',
|
|
87
|
+
'richtext',
|
|
88
|
+
'image',
|
|
89
|
+
'link',
|
|
90
|
+
'boolean',
|
|
91
|
+
'number',
|
|
92
|
+
'range',
|
|
93
|
+
'select',
|
|
94
|
+
'color',
|
|
95
|
+
'product',
|
|
96
|
+
]);
|
|
97
|
+
export type ListItemFieldType = z.infer<typeof ListItemFieldTypeSchema>;
|
|
98
|
+
|
|
99
|
+
export const ListItemFieldSchema = FieldBaseSchema.extend({
|
|
100
|
+
type: ListItemFieldTypeSchema,
|
|
101
|
+
options: z.array(FieldOptionSchema).optional(),
|
|
102
|
+
min: z.number().optional(),
|
|
103
|
+
max: z.number().optional(),
|
|
104
|
+
step: z.number().positive().optional(),
|
|
105
|
+
unit: z.string().min(1).optional(),
|
|
106
|
+
placeholder: z.string().optional(),
|
|
107
|
+
}).strict();
|
|
108
|
+
export type ListItemField = z.infer<typeof ListItemFieldSchema>;
|
|
109
|
+
|
|
110
|
+
const ListFieldSchema = FieldBaseSchema.extend({
|
|
111
|
+
type: z.literal('list'),
|
|
112
|
+
minItems: z.number().int().nonnegative().optional(),
|
|
113
|
+
maxItems: z.number().int().positive().optional(),
|
|
114
|
+
itemShape: z.record(z.string().min(1), ListItemFieldSchema),
|
|
115
|
+
}).strict();
|
|
116
|
+
|
|
117
|
+
export const BuilderFieldSchema = z.discriminatedUnion('type', [
|
|
118
|
+
TextFieldSchema,
|
|
119
|
+
RichTextFieldSchema,
|
|
120
|
+
ImageFieldSchema,
|
|
121
|
+
LinkFieldSchema,
|
|
122
|
+
BooleanFieldSchema,
|
|
123
|
+
NumberFieldSchema,
|
|
124
|
+
RangeFieldSchema,
|
|
125
|
+
SelectFieldSchema,
|
|
126
|
+
ColorFieldSchema,
|
|
127
|
+
ProductFieldSchema,
|
|
128
|
+
ProductsFieldSchema,
|
|
129
|
+
ListFieldSchema,
|
|
130
|
+
]);
|
|
131
|
+
export type BuilderField = z.infer<typeof BuilderFieldSchema>;
|
|
132
|
+
|
|
133
|
+
export const BlockSettingsSchema = z.record(z.string().min(1), BuilderFieldSchema);
|
|
134
|
+
export type BlockSettings = z.infer<typeof BlockSettingsSchema>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './builder-settings.ts';
|
|
2
|
+
export * from './events.ts';
|
|
3
|
+
export * from './fields.ts';
|
|
4
|
+
export * from './legacy-manifest.ts';
|
|
5
|
+
export * from './migrations.ts';
|
|
6
|
+
export * from './preview-protocol.ts';
|
|
7
|
+
export * from './style-slots.ts';
|
|
8
|
+
export * from './theme-manifest.ts';
|
|
9
|
+
export * from './validation.ts';
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import * as z from 'zod/v4';
|
|
2
|
+
import { BuilderFieldSchema, type BuilderField } from './fields.ts';
|
|
3
|
+
import { ThemeManifestSchema, type ThemeManifest } from './theme-manifest.ts';
|
|
4
|
+
|
|
5
|
+
const LegacyFieldOptionSchema = z
|
|
6
|
+
.object({
|
|
7
|
+
label: z.string().min(1),
|
|
8
|
+
value: z.string().min(1),
|
|
9
|
+
})
|
|
10
|
+
.passthrough();
|
|
11
|
+
|
|
12
|
+
export const LegacyBuilderFieldSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
path: z.string().min(1),
|
|
15
|
+
type: z.string().min(1).optional(),
|
|
16
|
+
label: z.string().min(1),
|
|
17
|
+
placeholder: z.string().optional(),
|
|
18
|
+
defaultValue: z.unknown().optional(),
|
|
19
|
+
description: z.string().optional(),
|
|
20
|
+
options: z.array(LegacyFieldOptionSchema).optional(),
|
|
21
|
+
min: z.number().optional(),
|
|
22
|
+
max: z.number().optional(),
|
|
23
|
+
step: z.number().positive().optional(),
|
|
24
|
+
})
|
|
25
|
+
.passthrough();
|
|
26
|
+
export type LegacyBuilderField = z.infer<typeof LegacyBuilderFieldSchema>;
|
|
27
|
+
|
|
28
|
+
export const LegacyBuilderListSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
path: z.string().min(1),
|
|
31
|
+
label: z.string().min(1),
|
|
32
|
+
kind: z.string().min(1),
|
|
33
|
+
description: z.string().optional(),
|
|
34
|
+
})
|
|
35
|
+
.passthrough();
|
|
36
|
+
export type LegacyBuilderList = z.infer<typeof LegacyBuilderListSchema>;
|
|
37
|
+
|
|
38
|
+
export const LegacyBuilderBlockSchema = z
|
|
39
|
+
.object({
|
|
40
|
+
id: z.string().min(1).optional(),
|
|
41
|
+
label: z.string().min(1),
|
|
42
|
+
maxInstances: z.number().int().positive().optional(),
|
|
43
|
+
mountable: z.boolean().optional(),
|
|
44
|
+
fields: z.array(LegacyBuilderFieldSchema).optional(),
|
|
45
|
+
lists: z.array(LegacyBuilderListSchema).optional(),
|
|
46
|
+
})
|
|
47
|
+
.passthrough();
|
|
48
|
+
export type LegacyBuilderBlock = z.infer<typeof LegacyBuilderBlockSchema>;
|
|
49
|
+
|
|
50
|
+
export const LegacyBuilderPageSchema = z
|
|
51
|
+
.object({
|
|
52
|
+
id: z.string().min(1),
|
|
53
|
+
label: z.string().min(1),
|
|
54
|
+
previewPath: z.string().min(1).optional(),
|
|
55
|
+
blocks: z.array(z.string().min(1)).default([]),
|
|
56
|
+
fields: z.array(LegacyBuilderFieldSchema).optional(),
|
|
57
|
+
lists: z.array(LegacyBuilderListSchema).optional(),
|
|
58
|
+
})
|
|
59
|
+
.passthrough();
|
|
60
|
+
export type LegacyBuilderPage = z.infer<typeof LegacyBuilderPageSchema>;
|
|
61
|
+
|
|
62
|
+
const LegacyThemePresetSchema = z
|
|
63
|
+
.object({
|
|
64
|
+
id: z.string().min(1),
|
|
65
|
+
name: z.string().min(1).optional(),
|
|
66
|
+
label: z.string().min(1).optional(),
|
|
67
|
+
description: z.string().optional(),
|
|
68
|
+
overrides: z
|
|
69
|
+
.object({
|
|
70
|
+
content: z.record(z.string().min(1), z.unknown()).optional(),
|
|
71
|
+
layout: z.record(z.string().min(1), z.unknown()).optional(),
|
|
72
|
+
style_slots: z.record(z.string().min(1), z.unknown()).optional(),
|
|
73
|
+
styleSlots: z.record(z.string().min(1), z.unknown()).optional(),
|
|
74
|
+
})
|
|
75
|
+
.passthrough()
|
|
76
|
+
.optional(),
|
|
77
|
+
})
|
|
78
|
+
.passthrough();
|
|
79
|
+
export type LegacyThemePreset = z.infer<typeof LegacyThemePresetSchema>;
|
|
80
|
+
|
|
81
|
+
export const LegacyThemeManifestSchema = z
|
|
82
|
+
.object({
|
|
83
|
+
id: z.string().min(1),
|
|
84
|
+
name: z.string().min(1),
|
|
85
|
+
version: z.string().min(1),
|
|
86
|
+
presets: z.array(LegacyThemePresetSchema).optional(),
|
|
87
|
+
builder: z
|
|
88
|
+
.object({
|
|
89
|
+
pages: z.array(LegacyBuilderPageSchema).default([]),
|
|
90
|
+
blocks: z.record(z.string().min(1), LegacyBuilderBlockSchema).default({}),
|
|
91
|
+
presets: z.record(z.string().min(1), z.unknown()).optional(),
|
|
92
|
+
})
|
|
93
|
+
.passthrough(),
|
|
94
|
+
})
|
|
95
|
+
.passthrough();
|
|
96
|
+
export type LegacyThemeManifest = z.infer<typeof LegacyThemeManifestSchema>;
|
|
97
|
+
|
|
98
|
+
export function convertLegacyThemeManifest(input: unknown): ThemeManifest {
|
|
99
|
+
const legacy = LegacyThemeManifestSchema.parse(input);
|
|
100
|
+
const blocks = Object.fromEntries(
|
|
101
|
+
Object.entries(legacy.builder.blocks).map(([blockType, block]) => [
|
|
102
|
+
blockType,
|
|
103
|
+
{
|
|
104
|
+
label: block.label,
|
|
105
|
+
maxInstances: block.maxInstances,
|
|
106
|
+
variants: [],
|
|
107
|
+
settings: convertLegacyFields(block.fields ?? [], block.lists ?? []),
|
|
108
|
+
exposedStyleSlots: [],
|
|
109
|
+
presets: [],
|
|
110
|
+
},
|
|
111
|
+
]),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
for (const page of legacy.builder.pages) {
|
|
115
|
+
const syntheticBlockType = createSyntheticPageBlockType(page.id);
|
|
116
|
+
if ((page.fields?.length ?? 0) === 0 && (page.lists?.length ?? 0) === 0) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
blocks[syntheticBlockType] = {
|
|
121
|
+
label: page.label,
|
|
122
|
+
maxInstances: 1,
|
|
123
|
+
variants: [],
|
|
124
|
+
settings: convertLegacyFields(page.fields ?? [], page.lists ?? []),
|
|
125
|
+
exposedStyleSlots: [],
|
|
126
|
+
presets: [],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const converted = {
|
|
131
|
+
id: normalizeLegacyThemeId(legacy.id),
|
|
132
|
+
name: legacy.name,
|
|
133
|
+
version: legacy.version,
|
|
134
|
+
pages: Object.fromEntries(
|
|
135
|
+
legacy.builder.pages.map((page) => {
|
|
136
|
+
const syntheticBlockType = createSyntheticPageBlockType(page.id);
|
|
137
|
+
const hasSyntheticBlock = blocks[syntheticBlockType] !== undefined;
|
|
138
|
+
const allowedBlocks = page.blocks.length > 0
|
|
139
|
+
? page.blocks
|
|
140
|
+
: (hasSyntheticBlock ? [syntheticBlockType] : []);
|
|
141
|
+
// Seed synthetic page-level blocks as defaults so pages that
|
|
142
|
+
// only surface page.fields/lists still open with an editable
|
|
143
|
+
// block instance instead of an empty editor.
|
|
144
|
+
const defaultBlocks = page.blocks.length > 0
|
|
145
|
+
? page.blocks.map((type) => ({ type }))
|
|
146
|
+
: (hasSyntheticBlock ? [{ type: syntheticBlockType }] : []);
|
|
147
|
+
return [
|
|
148
|
+
page.id,
|
|
149
|
+
{
|
|
150
|
+
label: page.label,
|
|
151
|
+
previewPath: page.previewPath,
|
|
152
|
+
allowedBlocks,
|
|
153
|
+
defaultBlocks,
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
}),
|
|
157
|
+
),
|
|
158
|
+
blocks,
|
|
159
|
+
styleSlots: {},
|
|
160
|
+
presets: convertLegacyThemePresets(legacy.presets ?? []),
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return ThemeManifestSchema.parse(converted);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeLegacyThemeId(rawId: string): string {
|
|
167
|
+
const lowered = rawId.toLowerCase().replace(/[^a-z0-9\-_.]+/g, '-').replace(/^[-_.]+/, '');
|
|
168
|
+
if (lowered.length > 0 && /^[a-z0-9]/.test(lowered)) {
|
|
169
|
+
return lowered;
|
|
170
|
+
}
|
|
171
|
+
return `legacy-${lowered || 'theme'}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
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
|
+
function convertLegacyThemePresets(presets: LegacyThemePreset[]): ThemeManifest['presets'] {
|
|
180
|
+
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
|
+
]),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function flattenContentRecord(content: Record<string, unknown>, prefix = ''): Record<string, unknown> {
|
|
195
|
+
const flattened: Record<string, unknown> = {};
|
|
196
|
+
|
|
197
|
+
for (const [key, value] of Object.entries(content)) {
|
|
198
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
199
|
+
if (isPlainObject(value)) {
|
|
200
|
+
Object.assign(flattened, flattenContentRecord(value, path));
|
|
201
|
+
} else {
|
|
202
|
+
flattened[path] = value;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return flattened;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
210
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function convertLegacyFields(fields: LegacyBuilderField[], lists: LegacyBuilderList[]): Record<string, BuilderField> {
|
|
214
|
+
const convertedFields = Object.fromEntries(
|
|
215
|
+
fields.map((field) => [field.path, convertLegacyField(field)]),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const convertedLists = Object.fromEntries(
|
|
219
|
+
lists.map((list) => [
|
|
220
|
+
list.path,
|
|
221
|
+
{
|
|
222
|
+
type: 'list',
|
|
223
|
+
label: list.label,
|
|
224
|
+
description: list.description,
|
|
225
|
+
itemShape: createListItemShape(list.kind),
|
|
226
|
+
} satisfies BuilderField,
|
|
227
|
+
]),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
...convertedFields,
|
|
232
|
+
...convertedLists,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function convertLegacyField(field: LegacyBuilderField): BuilderField {
|
|
237
|
+
const type = normalizeLegacyFieldType(field.type);
|
|
238
|
+
const base = {
|
|
239
|
+
label: field.label,
|
|
240
|
+
description: field.description,
|
|
241
|
+
defaultValue: field.defaultValue,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const candidate =
|
|
245
|
+
type === 'select'
|
|
246
|
+
? {
|
|
247
|
+
...base,
|
|
248
|
+
type,
|
|
249
|
+
options: (field.options ?? []).map((option) => ({
|
|
250
|
+
label: option.label,
|
|
251
|
+
value: option.value,
|
|
252
|
+
})),
|
|
253
|
+
}
|
|
254
|
+
: type === 'range'
|
|
255
|
+
? {
|
|
256
|
+
...base,
|
|
257
|
+
type,
|
|
258
|
+
min: field.min ?? 0,
|
|
259
|
+
max: field.max ?? 100,
|
|
260
|
+
step: field.step,
|
|
261
|
+
}
|
|
262
|
+
: type === 'text'
|
|
263
|
+
? {
|
|
264
|
+
...base,
|
|
265
|
+
type,
|
|
266
|
+
placeholder: field.placeholder,
|
|
267
|
+
}
|
|
268
|
+
: {
|
|
269
|
+
...base,
|
|
270
|
+
type,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
return BuilderFieldSchema.parse(candidate);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function normalizeLegacyFieldType(type: string | undefined): BuilderField['type'] {
|
|
277
|
+
if (
|
|
278
|
+
type === 'richtext' ||
|
|
279
|
+
type === 'image' ||
|
|
280
|
+
type === 'link' ||
|
|
281
|
+
type === 'boolean' ||
|
|
282
|
+
type === 'number' ||
|
|
283
|
+
type === 'range' ||
|
|
284
|
+
type === 'select' ||
|
|
285
|
+
type === 'color' ||
|
|
286
|
+
type === 'product' ||
|
|
287
|
+
type === 'products'
|
|
288
|
+
) {
|
|
289
|
+
return type;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return 'text';
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function createListItemShape(kind: string): Extract<BuilderField, { type: 'list' }>['itemShape'] {
|
|
296
|
+
if (kind === 'faqItems') {
|
|
297
|
+
return {
|
|
298
|
+
question: { type: 'text', label: 'Question' },
|
|
299
|
+
answer: { type: 'richtext', label: 'Answer' },
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (kind === 'galleryItems') {
|
|
304
|
+
return {
|
|
305
|
+
image: { type: 'image', label: 'Image' },
|
|
306
|
+
alt: { type: 'text', label: 'Alt Text' },
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (kind === 'reviewItems') {
|
|
311
|
+
return {
|
|
312
|
+
quote: { type: 'richtext', label: 'Quote' },
|
|
313
|
+
author: { type: 'text', label: 'Author' },
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
title: { type: 'text', label: 'Title' },
|
|
319
|
+
body: { type: 'richtext', label: 'Body' },
|
|
320
|
+
};
|
|
321
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BUILDER_SETTINGS_VERSION,
|
|
3
|
+
type BlockInstance,
|
|
4
|
+
type BuilderSettings,
|
|
5
|
+
BuilderSettingsSchema,
|
|
6
|
+
type PageLayout,
|
|
7
|
+
} from './builder-settings.ts';
|
|
8
|
+
import type { StyleSlots } from './style-slots.ts';
|
|
9
|
+
import type { ThemeManifest } from './theme-manifest.ts';
|
|
10
|
+
|
|
11
|
+
export type LegacyBuilderSettingsInput = {
|
|
12
|
+
theme?: {
|
|
13
|
+
content?: Record<string, unknown>;
|
|
14
|
+
layout?: Record<string, unknown>;
|
|
15
|
+
tokens_override?: Record<string, unknown>;
|
|
16
|
+
style_slots?: Record<string, unknown>;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type LegacyRedisOverride = {
|
|
21
|
+
target?: string;
|
|
22
|
+
property?: string;
|
|
23
|
+
value?: unknown;
|
|
24
|
+
className?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function createEmptyBuilderSettings(revision = 0): BuilderSettings {
|
|
28
|
+
return {
|
|
29
|
+
version: BUILDER_SETTINGS_VERSION,
|
|
30
|
+
revision,
|
|
31
|
+
theme: {
|
|
32
|
+
content: {},
|
|
33
|
+
layout: {},
|
|
34
|
+
style_slots: {},
|
|
35
|
+
pages: [],
|
|
36
|
+
terms: {},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createBlockInstance(input: {
|
|
42
|
+
id: string;
|
|
43
|
+
type: string;
|
|
44
|
+
variant?: string;
|
|
45
|
+
visible?: boolean;
|
|
46
|
+
settings?: Record<string, unknown>;
|
|
47
|
+
style_overrides?: StyleSlots;
|
|
48
|
+
}): BlockInstance {
|
|
49
|
+
return {
|
|
50
|
+
id: input.id,
|
|
51
|
+
type: input.type,
|
|
52
|
+
variant: input.variant,
|
|
53
|
+
visible: input.visible ?? true,
|
|
54
|
+
settings: input.settings ?? {},
|
|
55
|
+
style_overrides: input.style_overrides,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createPageLayoutFromBlocks(blocks: BlockInstance[]): PageLayout {
|
|
60
|
+
return { blocks };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function migrateLegacyBuilderSettings(input: LegacyBuilderSettingsInput, revision = 0): BuilderSettings {
|
|
64
|
+
const tokensOverride = input.theme?.tokens_override ?? {};
|
|
65
|
+
const styleSlots = {
|
|
66
|
+
...mapLegacyTokenOverridesToStyleSlots(tokensOverride),
|
|
67
|
+
...(input.theme?.style_slots ?? {}),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return BuilderSettingsSchema.parse({
|
|
71
|
+
version: BUILDER_SETTINGS_VERSION,
|
|
72
|
+
revision,
|
|
73
|
+
theme: {
|
|
74
|
+
content: input.theme?.content ?? {},
|
|
75
|
+
layout: normalizeLegacyLayout(input.theme?.layout ?? {}),
|
|
76
|
+
style_slots: styleSlots,
|
|
77
|
+
pages: [],
|
|
78
|
+
terms: {},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function applyManifestDefaultLayout(settings: BuilderSettings, manifest: ThemeManifest): BuilderSettings {
|
|
84
|
+
const next = cloneBuilderSettings(settings);
|
|
85
|
+
|
|
86
|
+
for (const [pageId, page] of Object.entries(manifest.pages)) {
|
|
87
|
+
// Only seed defaults when the page has never been initialized
|
|
88
|
+
// (no layout entry at all). An explicit empty layout means the
|
|
89
|
+
// merchant intentionally removed all sections and must be
|
|
90
|
+
// preserved across reloads.
|
|
91
|
+
const hasPageLayoutEntry = Object.prototype.hasOwnProperty.call(next.theme.layout, pageId);
|
|
92
|
+
if (hasPageLayoutEntry || page.defaultBlocks.length === 0) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
next.theme.layout[pageId] = {
|
|
97
|
+
blocks: page.defaultBlocks.map((block, index) => createBlockInstance({
|
|
98
|
+
id: createDefaultBlockId(pageId, block.type, index),
|
|
99
|
+
type: block.type,
|
|
100
|
+
variant: block.variant,
|
|
101
|
+
settings: block.settings ?? {},
|
|
102
|
+
})),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return BuilderSettingsSchema.parse(next);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function mapLegacyTokenOverridesToStyleSlots(tokens: Record<string, unknown>): StyleSlots {
|
|
110
|
+
const slots: StyleSlots = {};
|
|
111
|
+
|
|
112
|
+
assignResponsiveNumber(tokens, slots, 'buttons.borderRadius', 'button.radius');
|
|
113
|
+
assignResponsiveNumber(tokens, slots, 'inputs.borderRadius', 'input.radius');
|
|
114
|
+
assignResponsiveNumber(tokens, slots, 'inputs.height', 'input.height');
|
|
115
|
+
assignColor(tokens, slots, 'colors.primary', 'color.primary');
|
|
116
|
+
assignColor(tokens, slots, 'colors.accent', 'color.accent');
|
|
117
|
+
assignColor(tokens, slots, 'colors.background', 'color.background');
|
|
118
|
+
assignColor(tokens, slots, 'colors.foreground', 'color.foreground');
|
|
119
|
+
assignColor(tokens, slots, 'colors.muted', 'color.muted');
|
|
120
|
+
|
|
121
|
+
return slots;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeLegacyLayout(layout: Record<string, unknown>): Record<string, PageLayout> {
|
|
125
|
+
const normalized: Record<string, PageLayout> = {};
|
|
126
|
+
|
|
127
|
+
for (const [pageId, value] of Object.entries(layout)) {
|
|
128
|
+
if (isPageLayout(value)) {
|
|
129
|
+
normalized[pageId] = value;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (Array.isArray(value)) {
|
|
134
|
+
normalized[pageId] = {
|
|
135
|
+
blocks: value.filter(isBlockInstance),
|
|
136
|
+
};
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Pre-v2 editor shape: { sections: [...] } with entries that
|
|
141
|
+
// commonly only carry { id, visible } (no type field). Use id as
|
|
142
|
+
// the type fallback so merchant layout/visibility survives the
|
|
143
|
+
// migration instead of being dropped by the stricter
|
|
144
|
+
// isBlockInstance check.
|
|
145
|
+
if (value && typeof value === 'object') {
|
|
146
|
+
const record = value as Record<string, unknown>;
|
|
147
|
+
const sections = Array.isArray(record.sections) ? record.sections : null;
|
|
148
|
+
if (sections) {
|
|
149
|
+
normalized[pageId] = {
|
|
150
|
+
blocks: sections.flatMap(normalizeLegacySection),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return normalized;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeLegacySection(entry: unknown): BlockInstance[] {
|
|
160
|
+
if (!entry || typeof entry !== 'object') return [];
|
|
161
|
+
const record = entry as Record<string, unknown>;
|
|
162
|
+
const rawType = typeof record.type === 'string' ? record.type.trim() : '';
|
|
163
|
+
const rawId = typeof record.id === 'string' ? record.id.trim() : '';
|
|
164
|
+
const type = rawType || rawId;
|
|
165
|
+
if (!type) return [];
|
|
166
|
+
return [{
|
|
167
|
+
id: rawId || type,
|
|
168
|
+
type,
|
|
169
|
+
variant: typeof record.variant === 'string' ? record.variant : undefined,
|
|
170
|
+
visible: typeof record.visible === 'boolean' ? record.visible : true,
|
|
171
|
+
settings: isRecord(record.settings) ? record.settings : {},
|
|
172
|
+
}];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
176
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function assignResponsiveNumber(
|
|
180
|
+
tokens: Record<string, unknown>,
|
|
181
|
+
slots: StyleSlots,
|
|
182
|
+
tokenPath: string,
|
|
183
|
+
slotId: keyof StyleSlots,
|
|
184
|
+
): void {
|
|
185
|
+
const value = tokens[tokenPath];
|
|
186
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
187
|
+
slots[slotId] = { base: value } as never;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (isResponsiveNumber(value)) {
|
|
192
|
+
slots[slotId] = value as never;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function assignColor(tokens: Record<string, unknown>, slots: StyleSlots, tokenPath: string, slotId: keyof StyleSlots): void {
|
|
197
|
+
const value = tokens[tokenPath];
|
|
198
|
+
if (typeof value === 'string') {
|
|
199
|
+
slots[slotId] = value as never;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isResponsiveNumber(value: unknown): value is { base: number; sm?: number; md?: number; lg?: number; xl?: number } {
|
|
204
|
+
if (!value || typeof value !== 'object') {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const candidate = value as Record<string, unknown>;
|
|
209
|
+
return typeof candidate.base === 'number' && Number.isFinite(candidate.base);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isBlockInstance(value: unknown): value is BlockInstance {
|
|
213
|
+
if (!value || typeof value !== 'object') {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const candidate = value as Record<string, unknown>;
|
|
218
|
+
return typeof candidate.id === 'string' && typeof candidate.type === 'string';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isPageLayout(value: unknown): value is PageLayout {
|
|
222
|
+
if (!value || typeof value !== 'object') {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const candidate = value as Record<string, unknown>;
|
|
227
|
+
return Array.isArray(candidate.blocks) && candidate.blocks.every(isBlockInstance);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function cloneBuilderSettings(settings: BuilderSettings): BuilderSettings {
|
|
231
|
+
if (typeof structuredClone === 'function') {
|
|
232
|
+
return structuredClone(settings);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return JSON.parse(JSON.stringify(settings)) as BuilderSettings;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function createDefaultBlockId(pageId: string, blockType: string, index: number): string {
|
|
239
|
+
return `${pageId}-${blockType}-${index + 1}`;
|
|
240
|
+
}
|