@deneb-ui/cli 2.0.34 → 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.
@@ -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
+ }