@deneb-ui/cli 2.0.33 → 2.0.35
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/package.json +2 -2
- package/src/common/fivora-site-data.ts +339 -0
- package/src/common/media.constants.ts +92 -0
- package/src/common/template-editor-schema.ts +2280 -0
- package/src/common/template-package-archive.ts +176 -0
- package/src/common/template-package-manifest.ts +272 -0
- package/src/common/template-visual-edit-contract.ts +2277 -0
- package/src/common/visual-customization.ts +679 -0
- package/src/sites/universal-page-selection.ts +520 -0
- package/src/tools/deneb-template-validator.cjs +23 -17
|
@@ -0,0 +1,2280 @@
|
|
|
1
|
+
type PrimitiveType =
|
|
2
|
+
| 'text'
|
|
3
|
+
| 'textarea'
|
|
4
|
+
| 'email'
|
|
5
|
+
| 'tel'
|
|
6
|
+
| 'url'
|
|
7
|
+
| 'image'
|
|
8
|
+
| 'number'
|
|
9
|
+
| 'boolean'
|
|
10
|
+
| 'select';
|
|
11
|
+
|
|
12
|
+
type BaseNode = {
|
|
13
|
+
label: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
required?: boolean;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
maxLength?: number;
|
|
18
|
+
/** Recommended source-image dimensions shown by fivora upload UIs. */
|
|
19
|
+
recommendedWidth?: number;
|
|
20
|
+
recommendedHeight?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type TemplateEditorPrimitiveField = BaseNode & {
|
|
24
|
+
key: string;
|
|
25
|
+
type: PrimitiveType;
|
|
26
|
+
options?: string[];
|
|
27
|
+
sharedFieldKey?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type TemplateEditorObjectField = BaseNode & {
|
|
31
|
+
key: string;
|
|
32
|
+
type: 'object';
|
|
33
|
+
fields: TemplateEditorField[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type TemplateEditorListField = BaseNode & {
|
|
37
|
+
key: string;
|
|
38
|
+
type: 'list';
|
|
39
|
+
itemLabel?: string;
|
|
40
|
+
itemField?: Omit<TemplateEditorPrimitiveField, 'key'>;
|
|
41
|
+
fields?: TemplateEditorField[];
|
|
42
|
+
minItems?: number;
|
|
43
|
+
maxItems?: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type TemplateEditorField =
|
|
47
|
+
| TemplateEditorPrimitiveField
|
|
48
|
+
| TemplateEditorObjectField
|
|
49
|
+
| TemplateEditorListField;
|
|
50
|
+
|
|
51
|
+
export type TemplateEditorSection = BaseNode & {
|
|
52
|
+
id: string;
|
|
53
|
+
path: string;
|
|
54
|
+
pageKey?: string;
|
|
55
|
+
type: PrimitiveType | 'object' | 'list';
|
|
56
|
+
fields?: TemplateEditorField[];
|
|
57
|
+
itemLabel?: string;
|
|
58
|
+
itemField?: Omit<TemplateEditorPrimitiveField, 'key'>;
|
|
59
|
+
minItems?: number;
|
|
60
|
+
maxItems?: number;
|
|
61
|
+
options?: string[];
|
|
62
|
+
/** Only valid when this section itself is primitive. */
|
|
63
|
+
sharedFieldKey?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type TemplateEditorSchema = {
|
|
67
|
+
version: number;
|
|
68
|
+
sections: TemplateEditorSection[];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type TemplateRenderedContentPathsByPage = Record<string, string[]>;
|
|
72
|
+
|
|
73
|
+
export type TemplateEditorValidationIssue = {
|
|
74
|
+
path: string;
|
|
75
|
+
label: string;
|
|
76
|
+
message: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type DuplicateTemplateEditorPath = {
|
|
80
|
+
path: string;
|
|
81
|
+
firstKind: 'field' | 'list';
|
|
82
|
+
duplicateKind: 'field' | 'list';
|
|
83
|
+
firstSectionId: string;
|
|
84
|
+
duplicateSectionId: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type TemplateSharedFieldMember = {
|
|
88
|
+
path: string;
|
|
89
|
+
label: string;
|
|
90
|
+
type: PrimitiveType;
|
|
91
|
+
required: boolean;
|
|
92
|
+
options?: string[];
|
|
93
|
+
underList: boolean;
|
|
94
|
+
order: number;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export type TemplateSharedFieldGroup = {
|
|
98
|
+
sharedFieldKey: string;
|
|
99
|
+
canonicalPath: string;
|
|
100
|
+
paths: string[];
|
|
101
|
+
members: TemplateSharedFieldMember[];
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type TemplateSharedFieldContractValidation = {
|
|
105
|
+
errors: string[];
|
|
106
|
+
warnings: string[];
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export type SynchronizeTemplateSharedFieldResult = {
|
|
110
|
+
content: Record<string, unknown>;
|
|
111
|
+
contentSourceMap: Record<string, string>;
|
|
112
|
+
synchronizedPaths: string[];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
type TemplateEditorPrimitiveNode =
|
|
116
|
+
| TemplateEditorPrimitiveField
|
|
117
|
+
| Omit<TemplateEditorPrimitiveField, 'key'>
|
|
118
|
+
| (TemplateEditorSection & {
|
|
119
|
+
type: Exclude<TemplateEditorSection['type'], 'object' | 'list'>;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
type ManifestPage = {
|
|
123
|
+
id: string;
|
|
124
|
+
label: string;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const SECTION_METADATA: Record<string, { label: string; pageKey?: string }> = {
|
|
128
|
+
common: { label: 'Common Content' },
|
|
129
|
+
home: { label: 'Home Page', pageKey: 'home' },
|
|
130
|
+
about: { label: 'About Page', pageKey: 'about_us' },
|
|
131
|
+
services: { label: 'Services', pageKey: 'services' },
|
|
132
|
+
products: { label: 'Products', pageKey: 'products' },
|
|
133
|
+
gallery: { label: 'Gallery', pageKey: 'gallery' },
|
|
134
|
+
contact: { label: 'Contact Page', pageKey: 'contact' },
|
|
135
|
+
additionalPages: { label: 'Additional Pages' },
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const PRIMITIVE_TYPES = new Set<PrimitiveType>([
|
|
139
|
+
'text',
|
|
140
|
+
'textarea',
|
|
141
|
+
'email',
|
|
142
|
+
'tel',
|
|
143
|
+
'url',
|
|
144
|
+
'image',
|
|
145
|
+
'number',
|
|
146
|
+
'boolean',
|
|
147
|
+
'select',
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
const STANDARD_EDITOR_SCHEMA: TemplateEditorSchema = {
|
|
151
|
+
version: 1,
|
|
152
|
+
sections: [
|
|
153
|
+
{
|
|
154
|
+
id: 'common',
|
|
155
|
+
path: 'common',
|
|
156
|
+
label: 'Common Content',
|
|
157
|
+
type: 'object',
|
|
158
|
+
fields: [
|
|
159
|
+
{
|
|
160
|
+
key: 'logoUrl',
|
|
161
|
+
type: 'image',
|
|
162
|
+
label: 'Website logo',
|
|
163
|
+
description:
|
|
164
|
+
'Used by templates that read content.common.logoUrl or merchant.logoUrl.',
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export function normalizeTemplateEditorSchema(
|
|
172
|
+
input: unknown,
|
|
173
|
+
): TemplateEditorSchema | null {
|
|
174
|
+
if (!input || typeof input !== 'object') {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const candidate = input as Record<string, unknown>;
|
|
179
|
+
const sectionCandidates = Array.isArray(candidate.sections)
|
|
180
|
+
? candidate.sections
|
|
181
|
+
: [];
|
|
182
|
+
|
|
183
|
+
const sections = sectionCandidates
|
|
184
|
+
.map((section) => normalizeSection(section))
|
|
185
|
+
.filter((section): section is TemplateEditorSection => Boolean(section));
|
|
186
|
+
|
|
187
|
+
if (sections.length === 0) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
version:
|
|
193
|
+
typeof candidate.version === 'number' &&
|
|
194
|
+
Number.isFinite(candidate.version)
|
|
195
|
+
? candidate.version
|
|
196
|
+
: 1,
|
|
197
|
+
sections,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function deriveTemplateEditorSchema(
|
|
202
|
+
contentDefaults: Record<string, unknown>,
|
|
203
|
+
manifestPages: ManifestPage[] = [],
|
|
204
|
+
): TemplateEditorSchema {
|
|
205
|
+
const pageLabelMap = new Map(
|
|
206
|
+
manifestPages.map((page) => [page.id, page.label]),
|
|
207
|
+
);
|
|
208
|
+
const sections = Object.entries(contentDefaults)
|
|
209
|
+
.map(([key, value]) => deriveSection(key, value, pageLabelMap))
|
|
210
|
+
.filter((section): section is TemplateEditorSection => Boolean(section));
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
version: 1,
|
|
214
|
+
sections,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A nested section owns its complete subtree. Some older templates declared
|
|
220
|
+
* that subtree both inside an ancestor object and as a dedicated section (for
|
|
221
|
+
* example `gallery.projects`). Keeping both copies makes the shop-owner form and
|
|
222
|
+
* AI field inventory ask for the exact same path twice. Preserve the dedicated
|
|
223
|
+
* section and remove its duplicate node from the ancestor at runtime.
|
|
224
|
+
*/
|
|
225
|
+
export function canonicalizeTemplateEditorSchema(
|
|
226
|
+
schema: TemplateEditorSchema | null,
|
|
227
|
+
): TemplateEditorSchema | null {
|
|
228
|
+
if (!schema) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const sectionsByPath = new Map<string, TemplateEditorSection>();
|
|
233
|
+
for (const section of schema.sections) {
|
|
234
|
+
const existing = sectionsByPath.get(section.path);
|
|
235
|
+
sectionsByPath.set(
|
|
236
|
+
section.path,
|
|
237
|
+
existing ? mergeSections(existing, section) : section,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const sections = [...sectionsByPath.values()];
|
|
242
|
+
const sectionPaths = sections.map((section) => section.path);
|
|
243
|
+
const canonicalSections = sections
|
|
244
|
+
.map((section) => {
|
|
245
|
+
const inheritedNodes = sections
|
|
246
|
+
.filter((candidate) =>
|
|
247
|
+
isDescendantEditorPath(section.path, candidate.path),
|
|
248
|
+
)
|
|
249
|
+
.sort(
|
|
250
|
+
(left, right) =>
|
|
251
|
+
editorPathDepth(right.path) - editorPathDepth(left.path),
|
|
252
|
+
)
|
|
253
|
+
.map((ancestor) =>
|
|
254
|
+
findSchemaSectionAtPath(
|
|
255
|
+
{ ...schema, sections: [ancestor] },
|
|
256
|
+
section.path,
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
.filter((node): node is TemplateEditorSection => Boolean(node));
|
|
260
|
+
const enrichedSection = inheritedNodes.reduce(
|
|
261
|
+
(primary, fallback) => mergeSections(primary, fallback),
|
|
262
|
+
section,
|
|
263
|
+
);
|
|
264
|
+
const delegatedPaths = new Set(
|
|
265
|
+
sectionPaths.filter(
|
|
266
|
+
(candidate) =>
|
|
267
|
+
candidate !== section.path &&
|
|
268
|
+
isDescendantEditorPath(candidate, section.path),
|
|
269
|
+
),
|
|
270
|
+
);
|
|
271
|
+
return omitSectionPaths(enrichedSection, delegatedPaths);
|
|
272
|
+
})
|
|
273
|
+
.filter((section): section is TemplateEditorSection => Boolean(section));
|
|
274
|
+
|
|
275
|
+
return { ...schema, sections: canonicalSections };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Reports repeated primitive/list paths before runtime canonicalization. This
|
|
280
|
+
* is used by template certification so new packages receive a clear authoring
|
|
281
|
+
* error, while canonicalization keeps already-published templates usable.
|
|
282
|
+
*/
|
|
283
|
+
export function findDuplicateTemplateEditorPaths(
|
|
284
|
+
schema: TemplateEditorSchema | null,
|
|
285
|
+
): DuplicateTemplateEditorPath[] {
|
|
286
|
+
if (!schema) {
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const seen = new Map<string, { kind: 'field' | 'list'; sectionId: string }>();
|
|
291
|
+
const duplicates: DuplicateTemplateEditorPath[] = [];
|
|
292
|
+
const reported = new Set<string>();
|
|
293
|
+
|
|
294
|
+
const record = (path: string, kind: 'field' | 'list', sectionId: string) => {
|
|
295
|
+
const canonicalPath = normalizeEditorPathPattern(path);
|
|
296
|
+
const first = seen.get(canonicalPath);
|
|
297
|
+
if (!first) {
|
|
298
|
+
seen.set(canonicalPath, { kind, sectionId });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (reported.has(canonicalPath)) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
reported.add(canonicalPath);
|
|
305
|
+
duplicates.push({
|
|
306
|
+
path: canonicalPath,
|
|
307
|
+
firstKind: first.kind,
|
|
308
|
+
duplicateKind: kind,
|
|
309
|
+
firstSectionId: first.sectionId,
|
|
310
|
+
duplicateSectionId: sectionId,
|
|
311
|
+
});
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const visit = (
|
|
315
|
+
path: string,
|
|
316
|
+
node: TemplateEditorSection | TemplateEditorField,
|
|
317
|
+
sectionId: string,
|
|
318
|
+
) => {
|
|
319
|
+
if (node.type === 'object') {
|
|
320
|
+
for (const field of node.fields ?? []) {
|
|
321
|
+
visit(appendPath(path, field.key), field, sectionId);
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (node.type === 'list') {
|
|
326
|
+
const listPath = normalizeEditorPathPattern(path);
|
|
327
|
+
record(listPath, 'list', sectionId);
|
|
328
|
+
const itemPath = `${listPath}[*]`;
|
|
329
|
+
if (node.itemField) {
|
|
330
|
+
record(itemPath, 'field', sectionId);
|
|
331
|
+
}
|
|
332
|
+
for (const field of node.fields ?? []) {
|
|
333
|
+
visit(appendPath(itemPath, field.key), field, sectionId);
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
record(path, 'field', sectionId);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
for (const section of schema.sections) {
|
|
341
|
+
visit(section.path, section, section.id);
|
|
342
|
+
}
|
|
343
|
+
return duplicates;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const TEMPLATE_SHARED_FIELD_KEY_PATTERN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
|
|
347
|
+
const TEMPLATE_SHARED_FIELD_KEY_MAX_LENGTH = 80;
|
|
348
|
+
|
|
349
|
+
export function isValidTemplateSharedFieldKey(value: string) {
|
|
350
|
+
return (
|
|
351
|
+
value.length <= TEMPLATE_SHARED_FIELD_KEY_MAX_LENGTH &&
|
|
352
|
+
TEMPLATE_SHARED_FIELD_KEY_PATTERN.test(value)
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Compiles authored shared-value declarations in stable schema order. A group
|
|
358
|
+
* keeps multiple physical content paths in sync while templates remain free to
|
|
359
|
+
* render the same business fact in different sections.
|
|
360
|
+
*/
|
|
361
|
+
export function collectTemplateSharedFieldGroups(
|
|
362
|
+
schema: TemplateEditorSchema | null,
|
|
363
|
+
): TemplateSharedFieldGroup[] {
|
|
364
|
+
if (!schema) return [];
|
|
365
|
+
|
|
366
|
+
const groups = new Map<string, TemplateSharedFieldMember[]>();
|
|
367
|
+
let order = 0;
|
|
368
|
+
const addPrimitive = (
|
|
369
|
+
path: string,
|
|
370
|
+
node: TemplateEditorPrimitiveNode,
|
|
371
|
+
underList: boolean,
|
|
372
|
+
) => {
|
|
373
|
+
const sharedFieldKey = readTemplateSharedFieldKey(node);
|
|
374
|
+
if (!sharedFieldKey) return;
|
|
375
|
+
const members = groups.get(sharedFieldKey) ?? [];
|
|
376
|
+
members.push({
|
|
377
|
+
path: normalizeEditorPathPattern(path),
|
|
378
|
+
label: node.label,
|
|
379
|
+
type: node.type,
|
|
380
|
+
required: Boolean(node.required),
|
|
381
|
+
options: node.type === 'select' ? node.options : undefined,
|
|
382
|
+
underList,
|
|
383
|
+
order: order++,
|
|
384
|
+
});
|
|
385
|
+
groups.set(sharedFieldKey, members);
|
|
386
|
+
};
|
|
387
|
+
const visit = (
|
|
388
|
+
path: string,
|
|
389
|
+
node:
|
|
390
|
+
| TemplateEditorSection
|
|
391
|
+
| TemplateEditorField
|
|
392
|
+
| Omit<TemplateEditorPrimitiveField, 'key'>,
|
|
393
|
+
underList: boolean,
|
|
394
|
+
) => {
|
|
395
|
+
if (node.type === 'object') {
|
|
396
|
+
for (const field of node.fields ?? []) {
|
|
397
|
+
visit(appendPath(path, field.key), field, underList);
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (node.type === 'list') {
|
|
402
|
+
const itemPath = `${normalizeEditorPathPattern(path)}[*]`;
|
|
403
|
+
if (node.itemField) {
|
|
404
|
+
addPrimitive(itemPath, node.itemField, true);
|
|
405
|
+
}
|
|
406
|
+
for (const field of node.fields ?? []) {
|
|
407
|
+
visit(appendPath(itemPath, field.key), field, true);
|
|
408
|
+
}
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
addPrimitive(path, node as TemplateEditorPrimitiveNode, underList);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
for (const section of schema.sections) {
|
|
415
|
+
visit(section.path, section, false);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return [...groups.entries()].map(([sharedFieldKey, unsortedMembers]) => {
|
|
419
|
+
const members = [...unsortedMembers].sort((left, right) => {
|
|
420
|
+
if (left.required !== right.required) {
|
|
421
|
+
return left.required ? -1 : 1;
|
|
422
|
+
}
|
|
423
|
+
return left.order - right.order;
|
|
424
|
+
});
|
|
425
|
+
return {
|
|
426
|
+
sharedFieldKey,
|
|
427
|
+
canonicalPath: members[0].path,
|
|
428
|
+
paths: members.map((member) => member.path),
|
|
429
|
+
members,
|
|
430
|
+
};
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function validateTemplateSharedFieldContract(
|
|
435
|
+
schema: TemplateEditorSchema | null,
|
|
436
|
+
): TemplateSharedFieldContractValidation {
|
|
437
|
+
if (!schema) return { errors: [], warnings: [] };
|
|
438
|
+
|
|
439
|
+
const errors: string[] = [];
|
|
440
|
+
const warnings: string[] = [];
|
|
441
|
+
const visitDeclarationPlacement = (
|
|
442
|
+
path: string,
|
|
443
|
+
node: TemplateEditorSection | TemplateEditorField,
|
|
444
|
+
) => {
|
|
445
|
+
const sharedFieldKey = readTemplateSharedFieldKey(node);
|
|
446
|
+
if (sharedFieldKey && (node.type === 'object' || node.type === 'list')) {
|
|
447
|
+
errors.push(
|
|
448
|
+
`editorSchema ${node.type} "${path}" cannot declare sharedFieldKey "${sharedFieldKey}". Put sharedFieldKey only on primitive fields.`,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
if (node.type === 'object') {
|
|
452
|
+
for (const field of node.fields ?? []) {
|
|
453
|
+
visitDeclarationPlacement(appendPath(path, field.key), field);
|
|
454
|
+
}
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (node.type === 'list') {
|
|
458
|
+
const itemPath = `${normalizeEditorPathPattern(path)}[*]`;
|
|
459
|
+
if (node.itemField) {
|
|
460
|
+
const itemKey = readTemplateSharedFieldKey(node.itemField);
|
|
461
|
+
if (itemKey && !isValidTemplateSharedFieldKey(itemKey)) {
|
|
462
|
+
errors.push(invalidTemplateSharedFieldKeyMessage(itemKey, itemPath));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
for (const field of node.fields ?? []) {
|
|
466
|
+
visitDeclarationPlacement(appendPath(itemPath, field.key), field);
|
|
467
|
+
}
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (sharedFieldKey && !isValidTemplateSharedFieldKey(sharedFieldKey)) {
|
|
471
|
+
errors.push(invalidTemplateSharedFieldKeyMessage(sharedFieldKey, path));
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
for (const section of schema.sections) {
|
|
475
|
+
visitDeclarationPlacement(section.path, section);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for (const group of collectTemplateSharedFieldGroups(schema)) {
|
|
479
|
+
if (!isValidTemplateSharedFieldKey(group.sharedFieldKey)) continue;
|
|
480
|
+
const uniquePaths = new Set(group.paths);
|
|
481
|
+
if (uniquePaths.size !== group.paths.length) {
|
|
482
|
+
errors.push(
|
|
483
|
+
`sharedFieldKey "${group.sharedFieldKey}" declares the same physical path more than once. Keep each path in one editorSchema location.`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
if (
|
|
487
|
+
group.members.some(
|
|
488
|
+
(member) => member.underList || member.path.includes('[*]'),
|
|
489
|
+
)
|
|
490
|
+
) {
|
|
491
|
+
errors.push(
|
|
492
|
+
`sharedFieldKey "${group.sharedFieldKey}" cannot include wildcard or list-member paths. Shared fields must be single primitive values.`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
if (group.members.length < 2) {
|
|
496
|
+
warnings.push(
|
|
497
|
+
`sharedFieldKey "${group.sharedFieldKey}" is declared only once at "${group.canonicalPath}". Add another physical path or remove the key.`,
|
|
498
|
+
);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const families = new Set(
|
|
503
|
+
group.members.map((member) => sharedFieldValueFamily(member.type)),
|
|
504
|
+
);
|
|
505
|
+
if (families.size > 1) {
|
|
506
|
+
errors.push(
|
|
507
|
+
`sharedFieldKey "${group.sharedFieldKey}" mixes incompatible value families (${group.members.map((member) => `${member.path}:${member.type}`).join(', ')}).`,
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const selectMembers = group.members.filter(
|
|
512
|
+
(member) => member.type === 'select',
|
|
513
|
+
);
|
|
514
|
+
if (
|
|
515
|
+
selectMembers.length > 0 &&
|
|
516
|
+
selectMembers.length !== group.members.length
|
|
517
|
+
) {
|
|
518
|
+
errors.push(
|
|
519
|
+
`sharedFieldKey "${group.sharedFieldKey}" cannot mix select and free-text fields.`,
|
|
520
|
+
);
|
|
521
|
+
} else if (selectMembers.length > 1) {
|
|
522
|
+
const firstOptions = normalizeSharedSelectOptions(
|
|
523
|
+
selectMembers[0].options,
|
|
524
|
+
);
|
|
525
|
+
if (
|
|
526
|
+
selectMembers
|
|
527
|
+
.slice(1)
|
|
528
|
+
.some(
|
|
529
|
+
(member) =>
|
|
530
|
+
normalizeSharedSelectOptions(member.options) !== firstOptions,
|
|
531
|
+
)
|
|
532
|
+
) {
|
|
533
|
+
errors.push(
|
|
534
|
+
`sharedFieldKey "${group.sharedFieldKey}" has conflicting select options. Every member must allow the same values.`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
errors: [...new Set(errors)].sort(),
|
|
542
|
+
warnings: [...new Set(warnings)].sort(),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Mirrors a declared shared value to every physical path. Explicitly changed
|
|
548
|
+
* paths win (including an intentional empty value); otherwise the first
|
|
549
|
+
* meaningful value in required-first schema order becomes the shared value.
|
|
550
|
+
*/
|
|
551
|
+
export function synchronizeTemplateSharedFieldContent(input: {
|
|
552
|
+
content: Record<string, unknown>;
|
|
553
|
+
schema: TemplateEditorSchema | null;
|
|
554
|
+
contentSourceMap?: Record<string, string>;
|
|
555
|
+
preferredPaths?: ReadonlySet<string> | string[];
|
|
556
|
+
}): SynchronizeTemplateSharedFieldResult {
|
|
557
|
+
let content = structuredClone(input.content);
|
|
558
|
+
const contentSourceMap = { ...(input.contentSourceMap ?? {}) };
|
|
559
|
+
const preferredPaths =
|
|
560
|
+
input.preferredPaths instanceof Set
|
|
561
|
+
? input.preferredPaths
|
|
562
|
+
: new Set(input.preferredPaths ?? []);
|
|
563
|
+
const synchronizedPaths = new Set<string>();
|
|
564
|
+
|
|
565
|
+
for (const group of collectTemplateSharedFieldGroups(input.schema)) {
|
|
566
|
+
if (!isRuntimeTemplateSharedFieldGroup(group)) continue;
|
|
567
|
+
const preferredMembers = group.members.filter((member) =>
|
|
568
|
+
preferredPaths.has(member.path),
|
|
569
|
+
);
|
|
570
|
+
const orderedMembers = [
|
|
571
|
+
...preferredMembers,
|
|
572
|
+
...group.members.filter((member) => !preferredPaths.has(member.path)),
|
|
573
|
+
];
|
|
574
|
+
const preferredValue = preferredMembers
|
|
575
|
+
.map((member) => ({
|
|
576
|
+
member,
|
|
577
|
+
presentValue: readEditorContentPath(content, member.path),
|
|
578
|
+
}))
|
|
579
|
+
.find(({ presentValue }) => presentValue.present);
|
|
580
|
+
const candidates = orderedMembers.map((member) => ({
|
|
581
|
+
member,
|
|
582
|
+
presentValue: readEditorContentPath(content, member.path),
|
|
583
|
+
}));
|
|
584
|
+
const selected =
|
|
585
|
+
preferredValue ??
|
|
586
|
+
candidates.find(({ presentValue }) =>
|
|
587
|
+
isMeaningfulTemplateSharedFieldValue(presentValue.value),
|
|
588
|
+
) ??
|
|
589
|
+
candidates.find(({ presentValue }) => presentValue.present);
|
|
590
|
+
if (!selected) continue;
|
|
591
|
+
|
|
592
|
+
const selectedSource = contentSourceMap[selected.member.path];
|
|
593
|
+
for (const member of group.members) {
|
|
594
|
+
const current = readEditorContentPath(content, member.path);
|
|
595
|
+
if (
|
|
596
|
+
!current.present ||
|
|
597
|
+
!Object.is(current.value, selected.presentValue.value)
|
|
598
|
+
) {
|
|
599
|
+
content = setEditorContentPath(
|
|
600
|
+
content,
|
|
601
|
+
member.path,
|
|
602
|
+
selected.presentValue.value,
|
|
603
|
+
);
|
|
604
|
+
synchronizedPaths.add(member.path);
|
|
605
|
+
}
|
|
606
|
+
if (selectedSource) {
|
|
607
|
+
contentSourceMap[member.path] = selectedSource;
|
|
608
|
+
} else {
|
|
609
|
+
delete contentSourceMap[member.path];
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
content,
|
|
616
|
+
contentSourceMap,
|
|
617
|
+
synchronizedPaths: [...synchronizedPaths].sort(),
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function readTemplateSharedFieldKey(node: unknown) {
|
|
622
|
+
if (!node || typeof node !== 'object') return null;
|
|
623
|
+
const value = (node as { sharedFieldKey?: unknown }).sharedFieldKey;
|
|
624
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function invalidTemplateSharedFieldKeyMessage(key: string, path: string) {
|
|
628
|
+
return `editorSchema primitive "${path}" has invalid sharedFieldKey "${key}". Use 1-${TEMPLATE_SHARED_FIELD_KEY_MAX_LENGTH} lowercase letters/numbers separated by dots, underscores, or hyphens.`;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function sharedFieldValueFamily(type: PrimitiveType) {
|
|
632
|
+
if (type === 'image') return 'image';
|
|
633
|
+
if (type === 'number') return 'number';
|
|
634
|
+
if (type === 'boolean') return 'boolean';
|
|
635
|
+
return 'string';
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function normalizeSharedSelectOptions(options?: string[]) {
|
|
639
|
+
return [...new Set(options ?? [])].sort().join('\u0000');
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function isRuntimeTemplateSharedFieldGroup(group: TemplateSharedFieldGroup) {
|
|
643
|
+
if (
|
|
644
|
+
!isValidTemplateSharedFieldKey(group.sharedFieldKey) ||
|
|
645
|
+
group.members.length < 2 ||
|
|
646
|
+
new Set(group.paths).size !== group.paths.length ||
|
|
647
|
+
group.members.some(
|
|
648
|
+
(member) => member.underList || member.path.includes('[*]'),
|
|
649
|
+
)
|
|
650
|
+
) {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
const families = new Set(
|
|
654
|
+
group.members.map((member) => sharedFieldValueFamily(member.type)),
|
|
655
|
+
);
|
|
656
|
+
if (families.size !== 1) return false;
|
|
657
|
+
const selects = group.members.filter((member) => member.type === 'select');
|
|
658
|
+
if (selects.length > 0 && selects.length !== group.members.length) {
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
return (
|
|
662
|
+
selects.length < 2 ||
|
|
663
|
+
selects
|
|
664
|
+
.slice(1)
|
|
665
|
+
.every(
|
|
666
|
+
(member) =>
|
|
667
|
+
normalizeSharedSelectOptions(member.options) ===
|
|
668
|
+
normalizeSharedSelectOptions(selects[0].options),
|
|
669
|
+
)
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function isMeaningfulTemplateSharedFieldValue(value: unknown) {
|
|
674
|
+
if (typeof value === 'string') return Boolean(value.trim());
|
|
675
|
+
if (typeof value === 'number') return Number.isFinite(value);
|
|
676
|
+
return typeof value === 'boolean';
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function readEditorContentPath(content: Record<string, unknown>, path: string) {
|
|
680
|
+
let cursor: unknown = content;
|
|
681
|
+
for (const part of path.split('.')) {
|
|
682
|
+
if (!isPlainObject(cursor)) {
|
|
683
|
+
return { present: false, value: undefined };
|
|
684
|
+
}
|
|
685
|
+
if (!Object.prototype.hasOwnProperty.call(cursor, part)) {
|
|
686
|
+
return { present: false, value: undefined };
|
|
687
|
+
}
|
|
688
|
+
cursor = (cursor as Record<string, unknown>)[part];
|
|
689
|
+
}
|
|
690
|
+
return { present: true, value: cursor };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function setEditorContentPath(
|
|
694
|
+
content: Record<string, unknown>,
|
|
695
|
+
path: string,
|
|
696
|
+
value: unknown,
|
|
697
|
+
) {
|
|
698
|
+
const next = structuredClone(content);
|
|
699
|
+
const parts = path.split('.');
|
|
700
|
+
let cursor = next;
|
|
701
|
+
for (const part of parts.slice(0, -1)) {
|
|
702
|
+
if (!isPlainObject(cursor[part])) cursor[part] = {};
|
|
703
|
+
cursor = cursor[part] as Record<string, unknown>;
|
|
704
|
+
}
|
|
705
|
+
cursor[parts[parts.length - 1]] = value;
|
|
706
|
+
return next;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Keeps the developer-authored labels, validation rules, and field order while
|
|
711
|
+
* recovering any content paths that exist in site-data.json but were omitted
|
|
712
|
+
* from a partial editorSchema.
|
|
713
|
+
*/
|
|
714
|
+
export function mergeTemplateEditorSchemas(
|
|
715
|
+
schema: TemplateEditorSchema | null,
|
|
716
|
+
fallbackSchema: TemplateEditorSchema | null,
|
|
717
|
+
): TemplateEditorSchema | null {
|
|
718
|
+
if (!schema) {
|
|
719
|
+
return canonicalizeTemplateEditorSchema(fallbackSchema);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (!fallbackSchema) {
|
|
723
|
+
return canonicalizeTemplateEditorSchema(schema);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const primary = canonicalizeTemplateEditorSchema(schema) ?? schema;
|
|
727
|
+
const fallback =
|
|
728
|
+
canonicalizeTemplateEditorSchema(fallbackSchema) ?? fallbackSchema;
|
|
729
|
+
const primaryPaths = primary.sections.map((section) => section.path);
|
|
730
|
+
const mergedSections = primary.sections.map((section) => {
|
|
731
|
+
const fallbackNode = findSchemaSectionAtPath(fallback, section.path);
|
|
732
|
+
if (!fallbackNode) {
|
|
733
|
+
return section;
|
|
734
|
+
}
|
|
735
|
+
const delegatedPaths = new Set(
|
|
736
|
+
primaryPaths.filter(
|
|
737
|
+
(candidate) =>
|
|
738
|
+
candidate !== section.path &&
|
|
739
|
+
isDescendantEditorPath(candidate, section.path),
|
|
740
|
+
),
|
|
741
|
+
);
|
|
742
|
+
const prunedFallback = omitSectionPaths(fallbackNode, delegatedPaths);
|
|
743
|
+
return prunedFallback ? mergeSections(section, prunedFallback) : section;
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
const primaryRootPaths = new Set(primary.sections.map(({ path }) => path));
|
|
747
|
+
const remainingFallbackSections = fallback.sections
|
|
748
|
+
.filter((section) => !primaryRootPaths.has(section.path))
|
|
749
|
+
.map((section) =>
|
|
750
|
+
omitSectionPaths(
|
|
751
|
+
section,
|
|
752
|
+
new Set(
|
|
753
|
+
primaryPaths.filter((candidate) =>
|
|
754
|
+
isDescendantEditorPath(candidate, section.path),
|
|
755
|
+
),
|
|
756
|
+
),
|
|
757
|
+
),
|
|
758
|
+
)
|
|
759
|
+
.filter((section): section is TemplateEditorSection => Boolean(section));
|
|
760
|
+
|
|
761
|
+
return canonicalizeTemplateEditorSchema({
|
|
762
|
+
...fallbackSchema,
|
|
763
|
+
...schema,
|
|
764
|
+
sections: [...mergedSections, ...remainingFallbackSections],
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
export function addStandardTemplateEditorFields(
|
|
769
|
+
schema: TemplateEditorSchema | null,
|
|
770
|
+
) {
|
|
771
|
+
return schema
|
|
772
|
+
? mergeTemplateEditorSchemas(schema, STANDARD_EDITOR_SCHEMA)
|
|
773
|
+
: null;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Reconstructs the pre-canonicalization merge only for validating intake-plan
|
|
778
|
+
* hashes stored by older releases. Runtime callers must use
|
|
779
|
+
* mergeTemplateEditorSchemas so duplicate paths are repaired.
|
|
780
|
+
*/
|
|
781
|
+
export function mergeTemplateEditorSchemasForLegacyHash(
|
|
782
|
+
schema: TemplateEditorSchema | null,
|
|
783
|
+
fallbackSchema: TemplateEditorSchema | null,
|
|
784
|
+
): TemplateEditorSchema | null {
|
|
785
|
+
if (!schema) {
|
|
786
|
+
return fallbackSchema;
|
|
787
|
+
}
|
|
788
|
+
if (!fallbackSchema) {
|
|
789
|
+
return schema;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const fallbackByPath = new Map(
|
|
793
|
+
fallbackSchema.sections.map((section) => [section.path, section]),
|
|
794
|
+
);
|
|
795
|
+
const mergedSections = schema.sections.map((section) => {
|
|
796
|
+
const fallback = fallbackByPath.get(section.path);
|
|
797
|
+
fallbackByPath.delete(section.path);
|
|
798
|
+
return fallback ? mergeSections(section, fallback) : section;
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
return {
|
|
802
|
+
...fallbackSchema,
|
|
803
|
+
...schema,
|
|
804
|
+
sections: [...mergedSections, ...fallbackByPath.values()],
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** See mergeTemplateEditorSchemasForLegacyHash. */
|
|
809
|
+
export function addStandardTemplateEditorFieldsForLegacyHash(
|
|
810
|
+
schema: TemplateEditorSchema | null,
|
|
811
|
+
) {
|
|
812
|
+
return schema
|
|
813
|
+
? mergeTemplateEditorSchemasForLegacyHash(schema, STANDARD_EDITOR_SCHEMA)
|
|
814
|
+
: null;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
export function filterEditorSchemaBySelectedPages(
|
|
818
|
+
schema: TemplateEditorSchema | null,
|
|
819
|
+
selectedPages: string[],
|
|
820
|
+
renderedContentPathsByPage: TemplateRenderedContentPathsByPage = {},
|
|
821
|
+
): TemplateEditorSchema | null {
|
|
822
|
+
if (!schema) {
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (selectedPages.length === 0) {
|
|
827
|
+
return schema;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const hasRenderedMetadata = selectedPages.every((page) =>
|
|
831
|
+
Object.prototype.hasOwnProperty.call(renderedContentPathsByPage, page),
|
|
832
|
+
);
|
|
833
|
+
if (!hasRenderedMetadata) {
|
|
834
|
+
return {
|
|
835
|
+
...schema,
|
|
836
|
+
sections: schema.sections.filter(
|
|
837
|
+
(section) =>
|
|
838
|
+
!section.pageKey || selectedPages.includes(section.pageKey),
|
|
839
|
+
),
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const renderedPaths = new Set(
|
|
844
|
+
selectedPages
|
|
845
|
+
.flatMap((page) => renderedContentPathsByPage[page] ?? [])
|
|
846
|
+
.map((path) => path.replace(/\[\d+\]/g, '[*]')),
|
|
847
|
+
);
|
|
848
|
+
const sections = schema.sections
|
|
849
|
+
.map((section): TemplateEditorSection | null =>
|
|
850
|
+
filterSectionByRenderedPaths(section, renderedPaths),
|
|
851
|
+
)
|
|
852
|
+
.filter((section): section is TemplateEditorSection => Boolean(section));
|
|
853
|
+
|
|
854
|
+
return { ...schema, sections };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function filterSectionByRenderedPaths(
|
|
858
|
+
section: TemplateEditorSection,
|
|
859
|
+
renderedPaths: ReadonlySet<string>,
|
|
860
|
+
): TemplateEditorSection | null {
|
|
861
|
+
const filterField = (
|
|
862
|
+
field: TemplateEditorField,
|
|
863
|
+
path: string,
|
|
864
|
+
): TemplateEditorField | null => {
|
|
865
|
+
if (field.type === 'object') {
|
|
866
|
+
const fields = field.fields
|
|
867
|
+
.map((child) => filterField(child, appendPath(path, child.key)))
|
|
868
|
+
.filter((child): child is TemplateEditorField => Boolean(child));
|
|
869
|
+
return fields.length > 0 ? { ...field, fields } : null;
|
|
870
|
+
}
|
|
871
|
+
if (field.type === 'list') {
|
|
872
|
+
const itemPath = `${path}[*]`;
|
|
873
|
+
const fields = field.fields
|
|
874
|
+
?.map((child) => filterField(child, appendPath(itemPath, child.key)))
|
|
875
|
+
.filter((child): child is TemplateEditorField => Boolean(child));
|
|
876
|
+
const itemField =
|
|
877
|
+
field.itemField && renderedPaths.has(itemPath)
|
|
878
|
+
? field.itemField
|
|
879
|
+
: undefined;
|
|
880
|
+
return fields?.length || itemField
|
|
881
|
+
? {
|
|
882
|
+
...field,
|
|
883
|
+
fields: fields?.length ? fields : undefined,
|
|
884
|
+
itemField,
|
|
885
|
+
}
|
|
886
|
+
: null;
|
|
887
|
+
}
|
|
888
|
+
return renderedPaths.has(path) ? field : null;
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
if (section.type === 'object') {
|
|
892
|
+
const fields = (section.fields ?? [])
|
|
893
|
+
.map((field) => filterField(field, appendPath(section.path, field.key)))
|
|
894
|
+
.filter((field): field is TemplateEditorField => Boolean(field));
|
|
895
|
+
return fields.length > 0 ? { ...section, fields } : null;
|
|
896
|
+
}
|
|
897
|
+
if (section.type === 'list') {
|
|
898
|
+
const itemPath = `${section.path}[*]`;
|
|
899
|
+
const fields = section.fields
|
|
900
|
+
?.map((field) => filterField(field, appendPath(itemPath, field.key)))
|
|
901
|
+
.filter((field): field is TemplateEditorField => Boolean(field));
|
|
902
|
+
const itemField =
|
|
903
|
+
section.itemField && renderedPaths.has(itemPath)
|
|
904
|
+
? section.itemField
|
|
905
|
+
: undefined;
|
|
906
|
+
return fields?.length || itemField
|
|
907
|
+
? {
|
|
908
|
+
...section,
|
|
909
|
+
fields: fields?.length ? fields : undefined,
|
|
910
|
+
itemField,
|
|
911
|
+
}
|
|
912
|
+
: null;
|
|
913
|
+
}
|
|
914
|
+
return renderedPaths.has(section.path) ? section : null;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Templates often demand a full demo-sized list (seven services, six projects).
|
|
919
|
+
* A real business only has to supply one item to publish, so the agent-facing
|
|
920
|
+
* schema caps how many items are demanded while keeping the list itself.
|
|
921
|
+
*/
|
|
922
|
+
export function clampEditorSchemaListMinimums(
|
|
923
|
+
schema: TemplateEditorSchema | null,
|
|
924
|
+
maxMinimumItems: number,
|
|
925
|
+
): TemplateEditorSchema | null {
|
|
926
|
+
if (!schema) {
|
|
927
|
+
return null;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const clampField = (field: TemplateEditorField): TemplateEditorField => {
|
|
931
|
+
if (field.type === 'object') {
|
|
932
|
+
return { ...field, fields: field.fields.map(clampField) };
|
|
933
|
+
}
|
|
934
|
+
if (field.type === 'list') {
|
|
935
|
+
return {
|
|
936
|
+
...field,
|
|
937
|
+
minItems: clampMinimum(field.minItems, maxMinimumItems, field.maxItems),
|
|
938
|
+
fields: field.fields?.map(clampField),
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
return field;
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
return {
|
|
945
|
+
...schema,
|
|
946
|
+
sections: schema.sections.map((section) => ({
|
|
947
|
+
...section,
|
|
948
|
+
...(section.type === 'list'
|
|
949
|
+
? {
|
|
950
|
+
minItems: clampMinimum(
|
|
951
|
+
section.minItems,
|
|
952
|
+
maxMinimumItems,
|
|
953
|
+
section.maxItems,
|
|
954
|
+
),
|
|
955
|
+
}
|
|
956
|
+
: {}),
|
|
957
|
+
fields: section.fields?.map(clampField),
|
|
958
|
+
})),
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function clampMinimum(
|
|
963
|
+
minItems: number | undefined,
|
|
964
|
+
maximum: number,
|
|
965
|
+
maxItems?: number,
|
|
966
|
+
) {
|
|
967
|
+
if (typeof minItems !== 'number') {
|
|
968
|
+
return minItems;
|
|
969
|
+
}
|
|
970
|
+
const upperBound =
|
|
971
|
+
typeof maxItems === 'number' ? Math.min(maximum, maxItems) : maximum;
|
|
972
|
+
return Math.max(0, Math.min(Math.floor(minItems), upperBound));
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
export function omitEditorSchemaPaths(
|
|
976
|
+
schema: TemplateEditorSchema | null,
|
|
977
|
+
hiddenPaths: string[],
|
|
978
|
+
): TemplateEditorSchema | null {
|
|
979
|
+
if (!schema) {
|
|
980
|
+
return null;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (hiddenPaths.length === 0) {
|
|
984
|
+
return schema;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const hidden = new Set(
|
|
988
|
+
hiddenPaths.map((path) => path.trim()).filter(Boolean),
|
|
989
|
+
);
|
|
990
|
+
const sections = schema.sections
|
|
991
|
+
.map((section) => omitSectionPaths(section, hidden))
|
|
992
|
+
.filter((section): section is TemplateEditorSection => Boolean(section));
|
|
993
|
+
|
|
994
|
+
if (sections.length === 0) {
|
|
995
|
+
return null;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
return {
|
|
999
|
+
...schema,
|
|
1000
|
+
sections,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
export function validateContentAgainstEditorSchema(
|
|
1005
|
+
content: Record<string, unknown>,
|
|
1006
|
+
schema: TemplateEditorSchema | null,
|
|
1007
|
+
): TemplateEditorValidationIssue[] {
|
|
1008
|
+
if (!schema) {
|
|
1009
|
+
return [];
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
return schema.sections.flatMap((section) =>
|
|
1013
|
+
validateSection(section, content),
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
export function validateSectionAgainstEditorSchema(
|
|
1018
|
+
content: Record<string, unknown>,
|
|
1019
|
+
section: TemplateEditorSection,
|
|
1020
|
+
): TemplateEditorValidationIssue[] {
|
|
1021
|
+
return validateSection(section, content);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
export function sectionHasRequiredFields(section: TemplateEditorSection) {
|
|
1025
|
+
return hasRequiredNodes(section);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function hasMeaningfulEditorValue(value: unknown): boolean {
|
|
1029
|
+
if (typeof value === 'string') {
|
|
1030
|
+
return value.trim().length > 0;
|
|
1031
|
+
}
|
|
1032
|
+
if (typeof value === 'number') {
|
|
1033
|
+
return Number.isFinite(value);
|
|
1034
|
+
}
|
|
1035
|
+
if (typeof value === 'boolean') {
|
|
1036
|
+
return true;
|
|
1037
|
+
}
|
|
1038
|
+
if (Array.isArray(value)) {
|
|
1039
|
+
return value.some((entry) => hasMeaningfulEditorValue(entry));
|
|
1040
|
+
}
|
|
1041
|
+
if (isPlainObject(value)) {
|
|
1042
|
+
return Object.values(value).some((entry) =>
|
|
1043
|
+
hasMeaningfulEditorValue(entry),
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
return false;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function isEmptyEditorListItem(
|
|
1050
|
+
item: unknown,
|
|
1051
|
+
node: Extract<TemplateEditorField, { type: 'list' }>,
|
|
1052
|
+
): boolean {
|
|
1053
|
+
if (node.fields?.length) {
|
|
1054
|
+
const record = isPlainObject(item) ? item : {};
|
|
1055
|
+
const requiredFields = node.fields.filter((field) => field.required);
|
|
1056
|
+
if (requiredFields.length > 0) {
|
|
1057
|
+
return requiredFields.every(
|
|
1058
|
+
(field) => !hasMeaningfulEditorValue(record[field.key]),
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
const contentFields = node.fields.filter(
|
|
1062
|
+
(field) =>
|
|
1063
|
+
!/^(?:id|identifier|uuid|uid|pageKey|slug|route|sortOrder|displayOrder|icon|iconName|iconClass|color|variant|theme)$/i.test(
|
|
1064
|
+
field.key,
|
|
1065
|
+
),
|
|
1066
|
+
);
|
|
1067
|
+
return !(contentFields.length > 0 ? contentFields : node.fields).some(
|
|
1068
|
+
(field) => hasMeaningfulEditorValue(record[field.key]),
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
if (node.itemField) {
|
|
1073
|
+
return !hasMeaningfulEditorValue(item);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
return !hasMeaningfulEditorValue(item);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function pruneEditorListNodeValue(
|
|
1080
|
+
value: unknown,
|
|
1081
|
+
node: TemplateEditorSection | TemplateEditorField,
|
|
1082
|
+
): unknown {
|
|
1083
|
+
if (node.type === 'object') {
|
|
1084
|
+
const record = isPlainObject(value) ? { ...value } : {};
|
|
1085
|
+
for (const field of node.fields ?? []) {
|
|
1086
|
+
record[field.key] = pruneEditorListNodeValue(record[field.key], field);
|
|
1087
|
+
}
|
|
1088
|
+
return record;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
if (node.type !== 'list') {
|
|
1092
|
+
return value;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const listNode = node as Extract<TemplateEditorField, { type: 'list' }>;
|
|
1096
|
+
const items = Array.isArray(value) ? value : [];
|
|
1097
|
+
const keptItems = items.filter(
|
|
1098
|
+
(item) => !isEmptyEditorListItem(item, listNode),
|
|
1099
|
+
);
|
|
1100
|
+
if (!listNode.fields?.length) {
|
|
1101
|
+
return keptItems;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
return keptItems.map((item) => {
|
|
1105
|
+
const record = isPlainObject(item) ? { ...item } : {};
|
|
1106
|
+
for (const field of listNode.fields ?? []) {
|
|
1107
|
+
record[field.key] = pruneEditorListNodeValue(record[field.key], field);
|
|
1108
|
+
}
|
|
1109
|
+
return record;
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function setEditorSectionValueByPath(
|
|
1114
|
+
content: Record<string, unknown>,
|
|
1115
|
+
path: string,
|
|
1116
|
+
value: unknown,
|
|
1117
|
+
) {
|
|
1118
|
+
const parts = path
|
|
1119
|
+
.split('.')
|
|
1120
|
+
.map((part) => part.trim())
|
|
1121
|
+
.filter(Boolean);
|
|
1122
|
+
if (parts.length === 0) {
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
let cursor = content;
|
|
1127
|
+
for (const part of parts.slice(0, -1)) {
|
|
1128
|
+
if (!isPlainObject(cursor[part])) {
|
|
1129
|
+
cursor[part] = {};
|
|
1130
|
+
}
|
|
1131
|
+
cursor = cursor[part] as Record<string, unknown>;
|
|
1132
|
+
}
|
|
1133
|
+
cursor[parts.at(-1)!] = value;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** Removes blank list rows so preview/build only render shop-owner-entered items. */
|
|
1137
|
+
export function pruneEmptyEditorListItems(
|
|
1138
|
+
content: Record<string, unknown>,
|
|
1139
|
+
schema: TemplateEditorSchema | null | undefined,
|
|
1140
|
+
): Record<string, unknown> {
|
|
1141
|
+
if (!schema) {
|
|
1142
|
+
return content;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const result = JSON.parse(JSON.stringify(content)) as Record<string, unknown>;
|
|
1146
|
+
for (const section of schema.sections) {
|
|
1147
|
+
setEditorSectionValueByPath(
|
|
1148
|
+
result,
|
|
1149
|
+
section.path,
|
|
1150
|
+
pruneEditorListNodeValue(getValueByPath(result, section.path), section),
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
return result;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function normalizeSection(input: unknown): TemplateEditorSection | null {
|
|
1157
|
+
if (!input || typeof input !== 'object') {
|
|
1158
|
+
return null;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const candidate = input as Record<string, unknown>;
|
|
1162
|
+
const id = normalizeString(candidate.id);
|
|
1163
|
+
const path = normalizeString(candidate.path);
|
|
1164
|
+
const label = normalizeString(candidate.label);
|
|
1165
|
+
const type = normalizeNodeType(candidate.type);
|
|
1166
|
+
|
|
1167
|
+
if (!id || !path || !label || !type) {
|
|
1168
|
+
return null;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const common = {
|
|
1172
|
+
id,
|
|
1173
|
+
path,
|
|
1174
|
+
label,
|
|
1175
|
+
description: normalizeString(candidate.description) || undefined,
|
|
1176
|
+
required:
|
|
1177
|
+
typeof candidate.required === 'boolean' ? candidate.required : undefined,
|
|
1178
|
+
placeholder: normalizeString(candidate.placeholder) || undefined,
|
|
1179
|
+
maxLength: normalizeMaxLength(candidate.maxLength),
|
|
1180
|
+
recommendedWidth: normalizeImageDimension(candidate.recommendedWidth),
|
|
1181
|
+
recommendedHeight: normalizeImageDimension(candidate.recommendedHeight),
|
|
1182
|
+
pageKey: normalizeString(candidate.pageKey) || undefined,
|
|
1183
|
+
...normalizeTemplateSharedFieldKeyProperty(candidate.sharedFieldKey),
|
|
1184
|
+
};
|
|
1185
|
+
|
|
1186
|
+
if (type === 'object') {
|
|
1187
|
+
const fields = normalizeFieldList(candidate.fields);
|
|
1188
|
+
return {
|
|
1189
|
+
...common,
|
|
1190
|
+
type,
|
|
1191
|
+
fields,
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (type === 'list') {
|
|
1196
|
+
const fields = normalizeFieldList(candidate.fields);
|
|
1197
|
+
const itemField = normalizeItemField(candidate.itemField);
|
|
1198
|
+
if (fields.length === 0 && !itemField) {
|
|
1199
|
+
return null;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
return {
|
|
1203
|
+
...common,
|
|
1204
|
+
type,
|
|
1205
|
+
fields: fields.length > 0 ? fields : undefined,
|
|
1206
|
+
itemField: itemField ?? undefined,
|
|
1207
|
+
itemLabel: normalizeString(candidate.itemLabel) || undefined,
|
|
1208
|
+
minItems: normalizeListBound(candidate.minItems),
|
|
1209
|
+
maxItems: normalizeListBound(candidate.maxItems),
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
return {
|
|
1214
|
+
...common,
|
|
1215
|
+
type,
|
|
1216
|
+
options:
|
|
1217
|
+
type === 'select' ? normalizeOptions(candidate.options) : undefined,
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function omitSectionPaths(
|
|
1222
|
+
section: TemplateEditorSection,
|
|
1223
|
+
hiddenPaths: Set<string>,
|
|
1224
|
+
): TemplateEditorSection | null {
|
|
1225
|
+
if (hiddenPaths.has(section.path)) {
|
|
1226
|
+
return null;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
if (section.type === 'object') {
|
|
1230
|
+
const fields = (section.fields ?? [])
|
|
1231
|
+
.map((field) => omitFieldPaths(field, section.path, hiddenPaths))
|
|
1232
|
+
.filter((field): field is TemplateEditorField => Boolean(field));
|
|
1233
|
+
|
|
1234
|
+
if (fields.length === 0) {
|
|
1235
|
+
return null;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
return {
|
|
1239
|
+
...section,
|
|
1240
|
+
fields,
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
if (section.type === 'list') {
|
|
1245
|
+
const fields = (section.fields ?? [])
|
|
1246
|
+
.map((field) => omitFieldPaths(field, section.path, hiddenPaths))
|
|
1247
|
+
.filter((field): field is TemplateEditorField => Boolean(field));
|
|
1248
|
+
|
|
1249
|
+
if (
|
|
1250
|
+
(section.fields?.length ?? 0) > 0 &&
|
|
1251
|
+
fields.length === 0 &&
|
|
1252
|
+
!section.itemField
|
|
1253
|
+
) {
|
|
1254
|
+
return null;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
return {
|
|
1258
|
+
...section,
|
|
1259
|
+
fields: fields.length > 0 ? fields : undefined,
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
return section;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function omitFieldPaths(
|
|
1267
|
+
field: TemplateEditorField,
|
|
1268
|
+
parentPath: string,
|
|
1269
|
+
hiddenPaths: Set<string>,
|
|
1270
|
+
): TemplateEditorField | null {
|
|
1271
|
+
const fieldPath = appendPath(parentPath, field.key);
|
|
1272
|
+
if (hiddenPaths.has(fieldPath)) {
|
|
1273
|
+
return null;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
if (field.type === 'object') {
|
|
1277
|
+
const fields = field.fields
|
|
1278
|
+
.map((child) => omitFieldPaths(child, fieldPath, hiddenPaths))
|
|
1279
|
+
.filter((child): child is TemplateEditorField => Boolean(child));
|
|
1280
|
+
|
|
1281
|
+
if (fields.length === 0) {
|
|
1282
|
+
return null;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
return {
|
|
1286
|
+
...field,
|
|
1287
|
+
fields,
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
if (field.type === 'list') {
|
|
1292
|
+
const fields = (field.fields ?? [])
|
|
1293
|
+
.map((child) => omitFieldPaths(child, fieldPath, hiddenPaths))
|
|
1294
|
+
.filter((child): child is TemplateEditorField => Boolean(child));
|
|
1295
|
+
|
|
1296
|
+
if (
|
|
1297
|
+
(field.fields?.length ?? 0) > 0 &&
|
|
1298
|
+
fields.length === 0 &&
|
|
1299
|
+
!field.itemField
|
|
1300
|
+
) {
|
|
1301
|
+
return null;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
return {
|
|
1305
|
+
...field,
|
|
1306
|
+
fields: fields.length > 0 ? fields : undefined,
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
return field;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function normalizeFieldList(input: unknown) {
|
|
1314
|
+
if (!Array.isArray(input)) {
|
|
1315
|
+
return [];
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
return input
|
|
1319
|
+
.map((field) => normalizeField(field))
|
|
1320
|
+
.filter((field): field is TemplateEditorField => Boolean(field));
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function normalizeField(input: unknown): TemplateEditorField | null {
|
|
1324
|
+
if (!input || typeof input !== 'object') {
|
|
1325
|
+
return null;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const candidate = input as Record<string, unknown>;
|
|
1329
|
+
const key = normalizeString(candidate.key);
|
|
1330
|
+
const label = normalizeString(candidate.label);
|
|
1331
|
+
const type = normalizeNodeType(candidate.type);
|
|
1332
|
+
|
|
1333
|
+
if (!key || !label || !type) {
|
|
1334
|
+
return null;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
const common = {
|
|
1338
|
+
key,
|
|
1339
|
+
label,
|
|
1340
|
+
description: normalizeString(candidate.description) || undefined,
|
|
1341
|
+
required:
|
|
1342
|
+
typeof candidate.required === 'boolean' ? candidate.required : undefined,
|
|
1343
|
+
placeholder: normalizeString(candidate.placeholder) || undefined,
|
|
1344
|
+
maxLength: normalizeMaxLength(candidate.maxLength),
|
|
1345
|
+
recommendedWidth: normalizeImageDimension(candidate.recommendedWidth),
|
|
1346
|
+
recommendedHeight: normalizeImageDimension(candidate.recommendedHeight),
|
|
1347
|
+
...normalizeTemplateSharedFieldKeyProperty(candidate.sharedFieldKey),
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
if (type === 'object') {
|
|
1351
|
+
return {
|
|
1352
|
+
...common,
|
|
1353
|
+
type,
|
|
1354
|
+
fields: normalizeFieldList(candidate.fields),
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
if (type === 'list') {
|
|
1359
|
+
const fields = normalizeFieldList(candidate.fields);
|
|
1360
|
+
const itemField = normalizeItemField(candidate.itemField);
|
|
1361
|
+
if (fields.length === 0 && !itemField) {
|
|
1362
|
+
return null;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
return {
|
|
1366
|
+
...common,
|
|
1367
|
+
type,
|
|
1368
|
+
fields: fields.length > 0 ? fields : undefined,
|
|
1369
|
+
itemField: itemField ?? undefined,
|
|
1370
|
+
itemLabel: normalizeString(candidate.itemLabel) || undefined,
|
|
1371
|
+
minItems: normalizeListBound(candidate.minItems),
|
|
1372
|
+
maxItems: normalizeListBound(candidate.maxItems),
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
return {
|
|
1377
|
+
...common,
|
|
1378
|
+
type,
|
|
1379
|
+
options:
|
|
1380
|
+
type === 'select' ? normalizeOptions(candidate.options) : undefined,
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function normalizeItemField(
|
|
1385
|
+
input: unknown,
|
|
1386
|
+
): Omit<TemplateEditorPrimitiveField, 'key'> | null {
|
|
1387
|
+
if (!input || typeof input !== 'object') {
|
|
1388
|
+
return null;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
const candidate = input as Record<string, unknown>;
|
|
1392
|
+
const label = normalizeString(candidate.label);
|
|
1393
|
+
const type = normalizePrimitiveType(candidate.type);
|
|
1394
|
+
|
|
1395
|
+
if (!label || !type) {
|
|
1396
|
+
return null;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
return {
|
|
1400
|
+
label,
|
|
1401
|
+
type,
|
|
1402
|
+
description: normalizeString(candidate.description) || undefined,
|
|
1403
|
+
required:
|
|
1404
|
+
typeof candidate.required === 'boolean' ? candidate.required : undefined,
|
|
1405
|
+
placeholder: normalizeString(candidate.placeholder) || undefined,
|
|
1406
|
+
maxLength: normalizeMaxLength(candidate.maxLength),
|
|
1407
|
+
recommendedWidth: normalizeImageDimension(candidate.recommendedWidth),
|
|
1408
|
+
recommendedHeight: normalizeImageDimension(candidate.recommendedHeight),
|
|
1409
|
+
...normalizeTemplateSharedFieldKeyProperty(candidate.sharedFieldKey),
|
|
1410
|
+
options:
|
|
1411
|
+
type === 'select' ? normalizeOptions(candidate.options) : undefined,
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function normalizeTemplateSharedFieldKeyProperty(input: unknown) {
|
|
1416
|
+
return typeof input === 'string' && input.trim()
|
|
1417
|
+
? { sharedFieldKey: input.trim() }
|
|
1418
|
+
: {};
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function normalizeOptions(input: unknown) {
|
|
1422
|
+
if (!Array.isArray(input)) {
|
|
1423
|
+
return undefined;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
const options = input
|
|
1427
|
+
.map((value) => normalizeString(value))
|
|
1428
|
+
.filter((value): value is string => Boolean(value));
|
|
1429
|
+
|
|
1430
|
+
return options.length > 0 ? options : undefined;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function normalizeMaxLength(input: unknown) {
|
|
1434
|
+
return typeof input === 'number' &&
|
|
1435
|
+
Number.isInteger(input) &&
|
|
1436
|
+
input > 0 &&
|
|
1437
|
+
input <= 10000
|
|
1438
|
+
? input
|
|
1439
|
+
: undefined;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function normalizeImageDimension(input: unknown) {
|
|
1443
|
+
return typeof input === 'number' && Number.isSafeInteger(input) && input > 0
|
|
1444
|
+
? input
|
|
1445
|
+
: undefined;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function normalizeListBound(input: unknown) {
|
|
1449
|
+
return typeof input === 'number' && Number.isSafeInteger(input) && input >= 0
|
|
1450
|
+
? input
|
|
1451
|
+
: undefined;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function normalizeNodeType(
|
|
1455
|
+
input: unknown,
|
|
1456
|
+
): TemplateEditorSection['type'] | TemplateEditorField['type'] | null {
|
|
1457
|
+
if (input === 'object') {
|
|
1458
|
+
return input;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
if (input === 'list' || input === 'array') {
|
|
1462
|
+
return 'list';
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
return normalizePrimitiveType(input);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function normalizePrimitiveType(input: unknown): PrimitiveType | null {
|
|
1469
|
+
return typeof input === 'string' &&
|
|
1470
|
+
PRIMITIVE_TYPES.has(input as PrimitiveType)
|
|
1471
|
+
? (input as PrimitiveType)
|
|
1472
|
+
: null;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function deriveSection(
|
|
1476
|
+
key: string,
|
|
1477
|
+
value: unknown,
|
|
1478
|
+
pageLabelMap: Map<string, string>,
|
|
1479
|
+
): TemplateEditorSection | null {
|
|
1480
|
+
const metadata = SECTION_METADATA[key];
|
|
1481
|
+
const exactPageKey = pageLabelMap.has(key) ? key : undefined;
|
|
1482
|
+
const legacyAboutPageKey =
|
|
1483
|
+
!exactPageKey && key === 'about' && pageLabelMap.has('about_us')
|
|
1484
|
+
? 'about_us'
|
|
1485
|
+
: undefined;
|
|
1486
|
+
const pageKey = exactPageKey || legacyAboutPageKey || metadata?.pageKey;
|
|
1487
|
+
const label =
|
|
1488
|
+
(pageKey ? pageLabelMap.get(pageKey) : undefined) ||
|
|
1489
|
+
metadata?.label ||
|
|
1490
|
+
humanizeKey(key);
|
|
1491
|
+
|
|
1492
|
+
if (Array.isArray(value)) {
|
|
1493
|
+
return deriveListSection(key, label, pageKey, value);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
if (isPlainObject(value)) {
|
|
1497
|
+
return {
|
|
1498
|
+
id: key,
|
|
1499
|
+
path: key,
|
|
1500
|
+
label,
|
|
1501
|
+
pageKey,
|
|
1502
|
+
type: 'object',
|
|
1503
|
+
fields: Object.entries(value).map(([childKey, childValue]) =>
|
|
1504
|
+
deriveField(childKey, childValue),
|
|
1505
|
+
),
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
return {
|
|
1510
|
+
id: key,
|
|
1511
|
+
path: key,
|
|
1512
|
+
label,
|
|
1513
|
+
pageKey,
|
|
1514
|
+
...derivePrimitivePresentation(key, value),
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function deriveField(key: string, value: unknown): TemplateEditorField {
|
|
1519
|
+
const label = humanizeKey(key);
|
|
1520
|
+
|
|
1521
|
+
if (Array.isArray(value)) {
|
|
1522
|
+
return deriveListField(key, label, value);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
if (isPlainObject(value)) {
|
|
1526
|
+
return {
|
|
1527
|
+
key,
|
|
1528
|
+
label,
|
|
1529
|
+
type: 'object',
|
|
1530
|
+
fields: Object.entries(value).map(([childKey, childValue]) =>
|
|
1531
|
+
deriveField(childKey, childValue),
|
|
1532
|
+
),
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
return {
|
|
1537
|
+
key,
|
|
1538
|
+
label,
|
|
1539
|
+
...derivePrimitivePresentation(key, value),
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
function deriveListSection(
|
|
1544
|
+
key: string,
|
|
1545
|
+
label: string,
|
|
1546
|
+
pageKey: string | undefined,
|
|
1547
|
+
value: unknown[],
|
|
1548
|
+
): TemplateEditorSection {
|
|
1549
|
+
const sample = mergeObjectSamples(value);
|
|
1550
|
+
const itemLabel = singularize(label);
|
|
1551
|
+
|
|
1552
|
+
if (isPlainObject(sample)) {
|
|
1553
|
+
return {
|
|
1554
|
+
id: key,
|
|
1555
|
+
path: key,
|
|
1556
|
+
label,
|
|
1557
|
+
pageKey,
|
|
1558
|
+
type: 'list',
|
|
1559
|
+
itemLabel,
|
|
1560
|
+
fields: Object.entries(sample).map(([childKey, childValue]) =>
|
|
1561
|
+
deriveField(childKey, childValue),
|
|
1562
|
+
),
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
return {
|
|
1567
|
+
id: key,
|
|
1568
|
+
path: key,
|
|
1569
|
+
label,
|
|
1570
|
+
pageKey,
|
|
1571
|
+
type: 'list',
|
|
1572
|
+
itemLabel,
|
|
1573
|
+
itemField: {
|
|
1574
|
+
label: itemLabel,
|
|
1575
|
+
...derivePrimitivePresentation(key, sample),
|
|
1576
|
+
},
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
function deriveListField(
|
|
1581
|
+
key: string,
|
|
1582
|
+
label: string,
|
|
1583
|
+
value: unknown[],
|
|
1584
|
+
): TemplateEditorField {
|
|
1585
|
+
const sample = mergeObjectSamples(value);
|
|
1586
|
+
const itemLabel = singularize(label);
|
|
1587
|
+
|
|
1588
|
+
if (isPlainObject(sample)) {
|
|
1589
|
+
return {
|
|
1590
|
+
key,
|
|
1591
|
+
label,
|
|
1592
|
+
type: 'list',
|
|
1593
|
+
itemLabel,
|
|
1594
|
+
fields: Object.entries(sample).map(([childKey, childValue]) =>
|
|
1595
|
+
deriveField(childKey, childValue),
|
|
1596
|
+
),
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
return {
|
|
1601
|
+
key,
|
|
1602
|
+
label,
|
|
1603
|
+
type: 'list',
|
|
1604
|
+
itemLabel,
|
|
1605
|
+
itemField: {
|
|
1606
|
+
label: itemLabel,
|
|
1607
|
+
...derivePrimitivePresentation(key, sample),
|
|
1608
|
+
},
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function mergeSections(
|
|
1613
|
+
primary: TemplateEditorSection,
|
|
1614
|
+
fallback: TemplateEditorSection,
|
|
1615
|
+
): TemplateEditorSection {
|
|
1616
|
+
const common = {
|
|
1617
|
+
...fallback,
|
|
1618
|
+
...primary,
|
|
1619
|
+
pageKey: primary.pageKey ?? fallback.pageKey,
|
|
1620
|
+
description: primary.description ?? fallback.description,
|
|
1621
|
+
placeholder: primary.placeholder ?? fallback.placeholder,
|
|
1622
|
+
};
|
|
1623
|
+
|
|
1624
|
+
if (primary.type === 'object' && fallback.type === 'object') {
|
|
1625
|
+
return {
|
|
1626
|
+
...common,
|
|
1627
|
+
type: 'object',
|
|
1628
|
+
fields: mergeFieldLists(primary.fields ?? [], fallback.fields ?? []),
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
if (primary.type === 'list' && fallback.type === 'list') {
|
|
1633
|
+
const fields = mergeFieldLists(primary.fields ?? [], fallback.fields ?? []);
|
|
1634
|
+
return {
|
|
1635
|
+
...common,
|
|
1636
|
+
type: 'list',
|
|
1637
|
+
fields: fields.length > 0 ? fields : undefined,
|
|
1638
|
+
itemField: mergePrimitiveItemFields(
|
|
1639
|
+
primary.itemField,
|
|
1640
|
+
fallback.itemField,
|
|
1641
|
+
),
|
|
1642
|
+
itemLabel: primary.itemLabel ?? fallback.itemLabel,
|
|
1643
|
+
minItems: primary.minItems ?? fallback.minItems,
|
|
1644
|
+
maxItems: primary.maxItems ?? fallback.maxItems,
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
return {
|
|
1649
|
+
...primary,
|
|
1650
|
+
recommendedWidth: primary.recommendedWidth ?? fallback.recommendedWidth,
|
|
1651
|
+
recommendedHeight: primary.recommendedHeight ?? fallback.recommendedHeight,
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function mergeFieldLists(
|
|
1656
|
+
primaryFields: TemplateEditorField[],
|
|
1657
|
+
fallbackFields: TemplateEditorField[],
|
|
1658
|
+
) {
|
|
1659
|
+
const fallbackByKey = new Map(
|
|
1660
|
+
fallbackFields.map((field) => [field.key, field]),
|
|
1661
|
+
);
|
|
1662
|
+
const mergedFields = primaryFields.map((field) => {
|
|
1663
|
+
const fallback = fallbackByKey.get(field.key);
|
|
1664
|
+
fallbackByKey.delete(field.key);
|
|
1665
|
+
return fallback ? mergeFields(field, fallback) : field;
|
|
1666
|
+
});
|
|
1667
|
+
|
|
1668
|
+
return [...mergedFields, ...fallbackByKey.values()];
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function mergeFields(
|
|
1672
|
+
primary: TemplateEditorField,
|
|
1673
|
+
fallback: TemplateEditorField,
|
|
1674
|
+
): TemplateEditorField {
|
|
1675
|
+
const common = {
|
|
1676
|
+
...fallback,
|
|
1677
|
+
...primary,
|
|
1678
|
+
description: primary.description ?? fallback.description,
|
|
1679
|
+
placeholder: primary.placeholder ?? fallback.placeholder,
|
|
1680
|
+
};
|
|
1681
|
+
|
|
1682
|
+
if (primary.type === 'object' && fallback.type === 'object') {
|
|
1683
|
+
return {
|
|
1684
|
+
...common,
|
|
1685
|
+
type: 'object',
|
|
1686
|
+
fields: mergeFieldLists(primary.fields, fallback.fields),
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
if (primary.type === 'list' && fallback.type === 'list') {
|
|
1691
|
+
const fields = mergeFieldLists(primary.fields ?? [], fallback.fields ?? []);
|
|
1692
|
+
return {
|
|
1693
|
+
...common,
|
|
1694
|
+
type: 'list',
|
|
1695
|
+
fields: fields.length > 0 ? fields : undefined,
|
|
1696
|
+
itemField: mergePrimitiveItemFields(
|
|
1697
|
+
primary.itemField,
|
|
1698
|
+
fallback.itemField,
|
|
1699
|
+
),
|
|
1700
|
+
itemLabel: primary.itemLabel ?? fallback.itemLabel,
|
|
1701
|
+
minItems: primary.minItems ?? fallback.minItems,
|
|
1702
|
+
maxItems: primary.maxItems ?? fallback.maxItems,
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
return {
|
|
1707
|
+
...primary,
|
|
1708
|
+
recommendedWidth: primary.recommendedWidth ?? fallback.recommendedWidth,
|
|
1709
|
+
recommendedHeight: primary.recommendedHeight ?? fallback.recommendedHeight,
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function mergePrimitiveItemFields(
|
|
1714
|
+
primary: Omit<TemplateEditorPrimitiveField, 'key'> | undefined,
|
|
1715
|
+
fallback: Omit<TemplateEditorPrimitiveField, 'key'> | undefined,
|
|
1716
|
+
) {
|
|
1717
|
+
if (!primary) return fallback;
|
|
1718
|
+
if (!fallback || primary.type !== fallback.type) return primary;
|
|
1719
|
+
return {
|
|
1720
|
+
...fallback,
|
|
1721
|
+
...primary,
|
|
1722
|
+
recommendedWidth: primary.recommendedWidth ?? fallback.recommendedWidth,
|
|
1723
|
+
recommendedHeight: primary.recommendedHeight ?? fallback.recommendedHeight,
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
function findSchemaSectionAtPath(
|
|
1728
|
+
schema: TemplateEditorSchema,
|
|
1729
|
+
targetPath: string,
|
|
1730
|
+
): TemplateEditorSection | null {
|
|
1731
|
+
const asSection = (
|
|
1732
|
+
field: TemplateEditorField,
|
|
1733
|
+
path: string,
|
|
1734
|
+
shopOwner: TemplateEditorSection,
|
|
1735
|
+
): TemplateEditorSection => {
|
|
1736
|
+
const { key, ...node } = field;
|
|
1737
|
+
void key;
|
|
1738
|
+
return {
|
|
1739
|
+
...node,
|
|
1740
|
+
id: shopOwner.id,
|
|
1741
|
+
path,
|
|
1742
|
+
pageKey: shopOwner.pageKey,
|
|
1743
|
+
};
|
|
1744
|
+
};
|
|
1745
|
+
|
|
1746
|
+
const visitField = (
|
|
1747
|
+
field: TemplateEditorField,
|
|
1748
|
+
path: string,
|
|
1749
|
+
shopOwner: TemplateEditorSection,
|
|
1750
|
+
): TemplateEditorSection | null => {
|
|
1751
|
+
if (
|
|
1752
|
+
normalizeEditorPathPattern(path) ===
|
|
1753
|
+
normalizeEditorPathPattern(targetPath)
|
|
1754
|
+
) {
|
|
1755
|
+
return asSection(field, path, shopOwner);
|
|
1756
|
+
}
|
|
1757
|
+
if (field.type === 'object') {
|
|
1758
|
+
for (const child of field.fields) {
|
|
1759
|
+
const found = visitField(child, appendPath(path, child.key), shopOwner);
|
|
1760
|
+
if (found) return found;
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
if (field.type === 'list') {
|
|
1764
|
+
const itemPath = `${normalizeEditorPathPattern(path)}[*]`;
|
|
1765
|
+
for (const child of field.fields ?? []) {
|
|
1766
|
+
const found = visitField(child, appendPath(itemPath, child.key), shopOwner);
|
|
1767
|
+
if (found) return found;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
return null;
|
|
1771
|
+
};
|
|
1772
|
+
|
|
1773
|
+
for (const section of schema.sections) {
|
|
1774
|
+
if (
|
|
1775
|
+
normalizeEditorPathPattern(section.path) ===
|
|
1776
|
+
normalizeEditorPathPattern(targetPath)
|
|
1777
|
+
) {
|
|
1778
|
+
return section;
|
|
1779
|
+
}
|
|
1780
|
+
if (section.type === 'object') {
|
|
1781
|
+
for (const field of section.fields ?? []) {
|
|
1782
|
+
const found = visitField(
|
|
1783
|
+
field,
|
|
1784
|
+
appendPath(section.path, field.key),
|
|
1785
|
+
section,
|
|
1786
|
+
);
|
|
1787
|
+
if (found) return found;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
if (section.type === 'list') {
|
|
1791
|
+
const itemPath = `${normalizeEditorPathPattern(section.path)}[*]`;
|
|
1792
|
+
for (const field of section.fields ?? []) {
|
|
1793
|
+
const found = visitField(
|
|
1794
|
+
field,
|
|
1795
|
+
appendPath(itemPath, field.key),
|
|
1796
|
+
section,
|
|
1797
|
+
);
|
|
1798
|
+
if (found) return found;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
return null;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
function isDescendantEditorPath(candidate: string, parent: string) {
|
|
1806
|
+
const normalizedCandidate = normalizeEditorPathPattern(candidate);
|
|
1807
|
+
const normalizedParent = normalizeEditorPathPattern(parent);
|
|
1808
|
+
return (
|
|
1809
|
+
normalizedCandidate.startsWith(`${normalizedParent}.`) ||
|
|
1810
|
+
normalizedCandidate.startsWith(`${normalizedParent}[`)
|
|
1811
|
+
);
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
function normalizeEditorPathPattern(path: string) {
|
|
1815
|
+
return path.replace(/\[\d+\]/g, '[*]').trim();
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
function editorPathDepth(path: string) {
|
|
1819
|
+
return normalizeEditorPathPattern(path).split(/\.|\[/).filter(Boolean).length;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function mergeObjectSamples(values: unknown[]) {
|
|
1823
|
+
const objectSamples = values.filter(isPlainObject);
|
|
1824
|
+
if (objectSamples.length === 0) {
|
|
1825
|
+
return values.find((item) => item !== null && item !== undefined);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
return objectSamples.reduce<Record<string, unknown>>(
|
|
1829
|
+
(merged, sample) => mergeSampleObjects(merged, sample),
|
|
1830
|
+
{},
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
function mergeSampleObjects(
|
|
1835
|
+
base: Record<string, unknown>,
|
|
1836
|
+
override: Record<string, unknown>,
|
|
1837
|
+
) {
|
|
1838
|
+
const merged = { ...base };
|
|
1839
|
+
|
|
1840
|
+
for (const [key, value] of Object.entries(override)) {
|
|
1841
|
+
const existing = merged[key];
|
|
1842
|
+
if (isPlainObject(existing) && isPlainObject(value)) {
|
|
1843
|
+
merged[key] = mergeSampleObjects(existing, value);
|
|
1844
|
+
continue;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
if (Array.isArray(existing) && Array.isArray(value)) {
|
|
1848
|
+
merged[key] = [...existing, ...value];
|
|
1849
|
+
continue;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
if (
|
|
1853
|
+
existing === undefined ||
|
|
1854
|
+
existing === null ||
|
|
1855
|
+
(Array.isArray(existing) && existing.length === 0)
|
|
1856
|
+
) {
|
|
1857
|
+
merged[key] = value;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
return merged;
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
function derivePrimitivePresentation(key: string, value: unknown) {
|
|
1865
|
+
const type = inferPrimitiveType(key, value);
|
|
1866
|
+
if (type !== 'image' || typeof value !== 'string' || !value.trim()) {
|
|
1867
|
+
return { type };
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// Many template image services encode the intended source crop directly in
|
|
1871
|
+
// their URL (`?w=600&h=400`). Reuse that template-authored information when
|
|
1872
|
+
// explicit editorSchema dimensions are absent, so every generated image
|
|
1873
|
+
// field can give useful upload guidance without a per-template path table.
|
|
1874
|
+
try {
|
|
1875
|
+
const url = new URL(value, 'https://fivora-template.invalid');
|
|
1876
|
+
const width = normalizeImageDimension(
|
|
1877
|
+
Number(url.searchParams.get('w') ?? url.searchParams.get('width')),
|
|
1878
|
+
);
|
|
1879
|
+
const height = normalizeImageDimension(
|
|
1880
|
+
Number(url.searchParams.get('h') ?? url.searchParams.get('height')),
|
|
1881
|
+
);
|
|
1882
|
+
return {
|
|
1883
|
+
type,
|
|
1884
|
+
...(width ? { recommendedWidth: width } : {}),
|
|
1885
|
+
...(height ? { recommendedHeight: height } : {}),
|
|
1886
|
+
};
|
|
1887
|
+
} catch {
|
|
1888
|
+
return { type };
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
function inferPrimitiveType(key: string, value: unknown): PrimitiveType {
|
|
1893
|
+
if (typeof value === 'boolean') {
|
|
1894
|
+
return 'boolean';
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
if (typeof value === 'number') {
|
|
1898
|
+
return 'number';
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
const normalizedKey = key.toLowerCase();
|
|
1902
|
+
|
|
1903
|
+
if (normalizedKey.includes('email')) {
|
|
1904
|
+
return 'email';
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
if (
|
|
1908
|
+
normalizedKey.includes('phone') ||
|
|
1909
|
+
normalizedKey.includes('mobile') ||
|
|
1910
|
+
normalizedKey.includes('whatsapp') ||
|
|
1911
|
+
normalizedKey.includes('contactnumber')
|
|
1912
|
+
) {
|
|
1913
|
+
return 'tel';
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
if (
|
|
1917
|
+
normalizedKey.includes('image') ||
|
|
1918
|
+
normalizedKey.includes('logo') ||
|
|
1919
|
+
normalizedKey.includes('banner') ||
|
|
1920
|
+
normalizedKey.includes('thumbnail') ||
|
|
1921
|
+
normalizedKey.includes('photo')
|
|
1922
|
+
) {
|
|
1923
|
+
return 'image';
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
if (
|
|
1927
|
+
normalizedKey.includes('url') ||
|
|
1928
|
+
normalizedKey.includes('link') ||
|
|
1929
|
+
(typeof value === 'string' && /^https?:\/\//i.test(value.trim()))
|
|
1930
|
+
) {
|
|
1931
|
+
return 'url';
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
if (
|
|
1935
|
+
normalizedKey.includes('description') ||
|
|
1936
|
+
normalizedKey.includes('about') ||
|
|
1937
|
+
normalizedKey.includes('mission') ||
|
|
1938
|
+
normalizedKey.includes('vision') ||
|
|
1939
|
+
normalizedKey.includes('history') ||
|
|
1940
|
+
normalizedKey.includes('hours') ||
|
|
1941
|
+
normalizedKey.includes('address') ||
|
|
1942
|
+
normalizedKey.includes('body') ||
|
|
1943
|
+
(typeof value === 'string' && value.length > 120)
|
|
1944
|
+
) {
|
|
1945
|
+
return 'textarea';
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
return 'text';
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
function validateSection(
|
|
1952
|
+
section: TemplateEditorSection,
|
|
1953
|
+
content: Record<string, unknown>,
|
|
1954
|
+
): TemplateEditorValidationIssue[] {
|
|
1955
|
+
const value = getValueByPath(content, section.path);
|
|
1956
|
+
return validateNode(value, section, section.path, section.label);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
function validateNode(
|
|
1960
|
+
value: unknown,
|
|
1961
|
+
node:
|
|
1962
|
+
| TemplateEditorSection
|
|
1963
|
+
| TemplateEditorField
|
|
1964
|
+
| Omit<TemplateEditorPrimitiveField, 'key'>,
|
|
1965
|
+
path: string,
|
|
1966
|
+
label: string,
|
|
1967
|
+
): TemplateEditorValidationIssue[] {
|
|
1968
|
+
if (node.type === 'object') {
|
|
1969
|
+
const objectValue = isPlainObject(value)
|
|
1970
|
+
? (value as Record<string, unknown>)
|
|
1971
|
+
: {};
|
|
1972
|
+
const fields =
|
|
1973
|
+
'fields' in node && Array.isArray(node.fields) ? node.fields : [];
|
|
1974
|
+
return fields.flatMap((field) =>
|
|
1975
|
+
validateNode(
|
|
1976
|
+
objectValue[field.key],
|
|
1977
|
+
field,
|
|
1978
|
+
appendPath(path, field.key),
|
|
1979
|
+
`${label} - ${field.label}`,
|
|
1980
|
+
),
|
|
1981
|
+
);
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
if (node.type === 'list') {
|
|
1985
|
+
const items = Array.isArray(value) ? value : [];
|
|
1986
|
+
const minItems =
|
|
1987
|
+
'minItems' in node && typeof node.minItems === 'number'
|
|
1988
|
+
? node.minItems
|
|
1989
|
+
: node.required
|
|
1990
|
+
? 1
|
|
1991
|
+
: 0;
|
|
1992
|
+
const maxItems =
|
|
1993
|
+
'maxItems' in node && typeof node.maxItems === 'number'
|
|
1994
|
+
? node.maxItems
|
|
1995
|
+
: undefined;
|
|
1996
|
+
const boundaryIssues: TemplateEditorValidationIssue[] = [];
|
|
1997
|
+
|
|
1998
|
+
if (items.length < minItems) {
|
|
1999
|
+
boundaryIssues.push({
|
|
2000
|
+
path,
|
|
2001
|
+
label,
|
|
2002
|
+
message: `${label} requires at least ${minItems} item${minItems === 1 ? '' : 's'}.`,
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
if (typeof maxItems === 'number' && items.length > maxItems) {
|
|
2007
|
+
boundaryIssues.push({
|
|
2008
|
+
path,
|
|
2009
|
+
label,
|
|
2010
|
+
message: `${label} allows at most ${maxItems} item${maxItems === 1 ? '' : 's'}.`,
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
if (boundaryIssues.length > 0) {
|
|
2015
|
+
return boundaryIssues;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
if (
|
|
2019
|
+
'fields' in node &&
|
|
2020
|
+
Array.isArray(node.fields) &&
|
|
2021
|
+
node.fields.length > 0
|
|
2022
|
+
) {
|
|
2023
|
+
return items.flatMap((item, index) => {
|
|
2024
|
+
const objectItem = isPlainObject(item)
|
|
2025
|
+
? (item as Record<string, unknown>)
|
|
2026
|
+
: {};
|
|
2027
|
+
return node.fields!.flatMap((field) =>
|
|
2028
|
+
validateNode(
|
|
2029
|
+
objectItem[field.key],
|
|
2030
|
+
field,
|
|
2031
|
+
appendIndexedPath(path, index, field.key),
|
|
2032
|
+
`${label} ${index + 1} - ${field.label}`,
|
|
2033
|
+
),
|
|
2034
|
+
);
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
if ('itemField' in node && node.itemField) {
|
|
2039
|
+
return items.flatMap((item, index) =>
|
|
2040
|
+
validatePrimitiveValue(
|
|
2041
|
+
item,
|
|
2042
|
+
node.itemField!,
|
|
2043
|
+
appendIndexedPath(path, index),
|
|
2044
|
+
`${label} ${index + 1}`,
|
|
2045
|
+
),
|
|
2046
|
+
);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
return [];
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
return validatePrimitiveValue(
|
|
2053
|
+
value,
|
|
2054
|
+
node as TemplateEditorPrimitiveNode,
|
|
2055
|
+
path,
|
|
2056
|
+
label,
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
function validatePrimitiveValue(
|
|
2061
|
+
value: unknown,
|
|
2062
|
+
node:
|
|
2063
|
+
| TemplateEditorPrimitiveField
|
|
2064
|
+
| Omit<TemplateEditorPrimitiveField, 'key'>
|
|
2065
|
+
| TemplateEditorPrimitiveNode,
|
|
2066
|
+
path: string,
|
|
2067
|
+
label: string,
|
|
2068
|
+
): TemplateEditorValidationIssue[] {
|
|
2069
|
+
if (!node.required) {
|
|
2070
|
+
return [];
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
if (node.type === 'boolean') {
|
|
2074
|
+
if (typeof value === 'boolean') {
|
|
2075
|
+
return [];
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
return [
|
|
2079
|
+
{
|
|
2080
|
+
path,
|
|
2081
|
+
label,
|
|
2082
|
+
message: `${label} is required.`,
|
|
2083
|
+
},
|
|
2084
|
+
];
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
if (node.type === 'number') {
|
|
2088
|
+
if (typeof value === 'number') {
|
|
2089
|
+
return [];
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
if (
|
|
2093
|
+
typeof value === 'string' &&
|
|
2094
|
+
value.trim() &&
|
|
2095
|
+
!Number.isNaN(Number(value))
|
|
2096
|
+
) {
|
|
2097
|
+
return [];
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
return [
|
|
2101
|
+
{
|
|
2102
|
+
path,
|
|
2103
|
+
label,
|
|
2104
|
+
message: `${label} is required.`,
|
|
2105
|
+
},
|
|
2106
|
+
];
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
if (typeof value === 'string' && value.trim()) {
|
|
2110
|
+
return [];
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
return [
|
|
2114
|
+
{
|
|
2115
|
+
path,
|
|
2116
|
+
label,
|
|
2117
|
+
message: `${label} is required.`,
|
|
2118
|
+
},
|
|
2119
|
+
];
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
function hasRequiredNodes(node: TemplateEditorSection | TemplateEditorField) {
|
|
2123
|
+
if ('required' in node && node.required) {
|
|
2124
|
+
return true;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
if (node.type === 'object') {
|
|
2128
|
+
return (node.fields ?? []).some((field) => hasRequiredNodes(field));
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
if (node.type === 'list') {
|
|
2132
|
+
if ((node.minItems ?? 0) > 0) {
|
|
2133
|
+
return true;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
if (node.itemField?.required) {
|
|
2137
|
+
return true;
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
return (node.fields ?? []).some((field) => hasRequiredNodes(field));
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
return false;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
function getValueByPath(content: Record<string, unknown>, path: string) {
|
|
2147
|
+
const parts = path
|
|
2148
|
+
.split('.')
|
|
2149
|
+
.map((part) => part.trim())
|
|
2150
|
+
.filter(Boolean);
|
|
2151
|
+
|
|
2152
|
+
let current: unknown = content;
|
|
2153
|
+
for (const part of parts) {
|
|
2154
|
+
if (!isPlainObject(current)) {
|
|
2155
|
+
return undefined;
|
|
2156
|
+
}
|
|
2157
|
+
current = (current as Record<string, unknown>)[part];
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
return current;
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
function normalizeSchemaPathPattern(path: string) {
|
|
2164
|
+
return path.replace(/\[\d+\]/g, '[*]');
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
function collectSchemaFieldPathPatterns(
|
|
2168
|
+
node: TemplateEditorSection | TemplateEditorField,
|
|
2169
|
+
path: string,
|
|
2170
|
+
patterns: Set<string>,
|
|
2171
|
+
) {
|
|
2172
|
+
if (node.type === 'object') {
|
|
2173
|
+
for (const child of node.fields ?? []) {
|
|
2174
|
+
collectSchemaFieldPathPatterns(
|
|
2175
|
+
child,
|
|
2176
|
+
appendPath(path, child.key),
|
|
2177
|
+
patterns,
|
|
2178
|
+
);
|
|
2179
|
+
}
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
if (node.type === 'list') {
|
|
2184
|
+
patterns.add(normalizeSchemaPathPattern(path));
|
|
2185
|
+
const itemPath = `${path}[*]`;
|
|
2186
|
+
if (node.fields?.length) {
|
|
2187
|
+
for (const child of node.fields) {
|
|
2188
|
+
collectSchemaFieldPathPatterns(
|
|
2189
|
+
child,
|
|
2190
|
+
appendPath(itemPath, child.key),
|
|
2191
|
+
patterns,
|
|
2192
|
+
);
|
|
2193
|
+
}
|
|
2194
|
+
} else if (node.itemField) {
|
|
2195
|
+
patterns.add(normalizeSchemaPathPattern(itemPath));
|
|
2196
|
+
}
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
patterns.add(normalizeSchemaPathPattern(path));
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/**
|
|
2204
|
+
* Derives page-to-field-path placement metadata from the editor schema. Strict
|
|
2205
|
+
* packages store the authoritative index from exported HTML markers; legacy
|
|
2206
|
+
* and uncertified templates fall back to this schema walk so every uploaded
|
|
2207
|
+
* template can resolve field locations consistently.
|
|
2208
|
+
*/
|
|
2209
|
+
export function deriveRenderedContentPathsByPage(
|
|
2210
|
+
schema: TemplateEditorSchema | null,
|
|
2211
|
+
pageKeys: string[],
|
|
2212
|
+
): TemplateRenderedContentPathsByPage {
|
|
2213
|
+
if (!schema || pageKeys.length === 0) {
|
|
2214
|
+
return {};
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
const pageKeySet = new Set(pageKeys);
|
|
2218
|
+
const result: Record<string, Set<string>> = {};
|
|
2219
|
+
|
|
2220
|
+
for (const section of schema.sections) {
|
|
2221
|
+
const pageKey =
|
|
2222
|
+
section.pageKey?.trim() ||
|
|
2223
|
+
section.id?.trim() ||
|
|
2224
|
+
section.path.split('.')[0]?.trim() ||
|
|
2225
|
+
'';
|
|
2226
|
+
if (!pageKey || !pageKeySet.has(pageKey)) {
|
|
2227
|
+
continue;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
const patterns = new Set<string>();
|
|
2231
|
+
collectSchemaFieldPathPatterns(section, section.path, patterns);
|
|
2232
|
+
if (!result[pageKey]) {
|
|
2233
|
+
result[pageKey] = new Set();
|
|
2234
|
+
}
|
|
2235
|
+
patterns.forEach((pattern) => result[pageKey].add(pattern));
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
return Object.fromEntries(
|
|
2239
|
+
Object.entries(result).map(([pageKey, patterns]) => [
|
|
2240
|
+
pageKey,
|
|
2241
|
+
[...patterns].sort(),
|
|
2242
|
+
]),
|
|
2243
|
+
);
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
function appendPath(basePath: string, key: string) {
|
|
2247
|
+
return basePath ? `${basePath}.${key}` : key;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
function appendIndexedPath(basePath: string, index: number, key?: string) {
|
|
2251
|
+
const indexed = `${basePath}[${index}]`;
|
|
2252
|
+
return key ? `${indexed}.${key}` : indexed;
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
function normalizeString(value: unknown) {
|
|
2256
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
2260
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
function humanizeKey(key: string) {
|
|
2264
|
+
return key
|
|
2265
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
2266
|
+
.replace(/[_-]+/g, ' ')
|
|
2267
|
+
.replace(/\b\w/g, (character) => character.toUpperCase());
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
function singularize(label: string) {
|
|
2271
|
+
if (/ies$/i.test(label)) {
|
|
2272
|
+
return label.replace(/ies$/i, 'y');
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
if (/s$/i.test(label) && !/ss$/i.test(label)) {
|
|
2276
|
+
return label.replace(/s$/i, '');
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
return label;
|
|
2280
|
+
}
|