@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.
@@ -0,0 +1,2277 @@
1
+ import type {
2
+ TemplateEditorField,
3
+ TemplateEditorSchema,
4
+ TemplateEditorSection,
5
+ } from './template-editor-schema';
6
+ import { findDuplicateTemplateEditorPaths } from './template-editor-schema';
7
+
8
+ export type TemplateVisualEditingConfig = {
9
+ contractVersion: 1;
10
+ mode: 'strict' | 'legacy';
11
+ controlOnlyPaths?: string[];
12
+ };
13
+
14
+ export type TemplateVisualEditingPage = {
15
+ id: string;
16
+ label: string;
17
+ route?: string;
18
+ };
19
+
20
+ export type TemplateVisualEditingArtifact = {
21
+ filePath: string;
22
+ kind: 'source' | 'html';
23
+ content: string;
24
+ };
25
+
26
+ export type TemplateVisualEditMarkerKind = 'field' | 'list' | 'item' | 'page';
27
+
28
+ export type TemplateVisualEditMarker = {
29
+ kind: TemplateVisualEditMarkerKind;
30
+ value: string;
31
+ filePath: string;
32
+ artifactKind: TemplateVisualEditingArtifact['kind'];
33
+ offset: number;
34
+ line: number;
35
+ };
36
+
37
+ export type TemplateVisualEditPathInventory = {
38
+ fieldPatterns: string[];
39
+ concreteFields: string[];
40
+ listPatterns: string[];
41
+ concreteLists: string[];
42
+ itemPatterns: string[];
43
+ concreteItems: string[];
44
+ };
45
+
46
+ export type TemplateVisualEditingValidationInput = {
47
+ manifestVersion: number;
48
+ visualEditing?: TemplateVisualEditingConfig | null;
49
+ pages?: TemplateVisualEditingPage[];
50
+ contentDefaults: Record<string, unknown> | null;
51
+ editorSchema: TemplateEditorSchema | null;
52
+ artifacts: TemplateVisualEditingArtifact[];
53
+ };
54
+
55
+ export type TemplateVisualEditingValidationResult = {
56
+ mode: 'strict' | 'legacy';
57
+ errors: string[];
58
+ warnings: string[];
59
+ inventory: TemplateVisualEditPathInventory;
60
+ markers: TemplateVisualEditMarker[];
61
+ };
62
+
63
+ export type TemplateVisualEditingEmptyStateValidationInput = {
64
+ manifestVersion: number;
65
+ visualEditing?: TemplateVisualEditingConfig | null;
66
+ pages?: TemplateVisualEditingPage[];
67
+ contentDefaults: Record<string, unknown> | null;
68
+ editorSchema: TemplateEditorSchema | null;
69
+ artifacts: TemplateVisualEditingArtifact[];
70
+ };
71
+
72
+ export type TemplateVisualEditingEmptyStateValidationResult = {
73
+ errors: string[];
74
+ warnings: string[];
75
+ requiredFieldPaths: string[];
76
+ requiredListPaths: string[];
77
+ requiredItemPaths: string[];
78
+ };
79
+
80
+ type MutablePathInventory = {
81
+ fieldPatterns: Set<string>;
82
+ concreteFields: Set<string>;
83
+ listPatterns: Set<string>;
84
+ concreteLists: Set<string>;
85
+ itemPatterns: Set<string>;
86
+ concreteItems: Set<string>;
87
+ };
88
+
89
+ const MARKER_ATTRIBUTE_TO_KIND = {
90
+ 'data-preview-field-path': 'field',
91
+ 'data-preview-list-path': 'list',
92
+ 'data-preview-item-path': 'item',
93
+ 'data-preview-page-key': 'page',
94
+ } as const satisfies Record<string, TemplateVisualEditMarkerKind>;
95
+
96
+ const MARKER_ATTRIBUTE_PATTERN =
97
+ /\b(data-preview-(?:field-path|list-path|item-path|page-key))\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*`([\s\S]*?)`\s*\}|\{\s*"([^"]*)"\s*\}|\{\s*'([^']*)'\s*\})/g;
98
+
99
+ const MARKER_ATTRIBUTE_OCCURRENCE_PATTERN =
100
+ /\b(data-preview-(?:field-path|list-path|item-path|page-key))\s*=/g;
101
+
102
+ const PATH_SEGMENT_PATTERN = String.raw`[^.[\]\s]+`;
103
+ const CANONICAL_PATH_PATTERN = new RegExp(
104
+ String.raw`^${PATH_SEGMENT_PATTERN}(?:\[(?:\d+|\*)\])*(?:\.${PATH_SEGMENT_PATTERN}(?:\[(?:\d+|\*)\])*)*$`,
105
+ );
106
+
107
+ const VOID_HTML_TAGS = new Set([
108
+ 'area',
109
+ 'base',
110
+ 'br',
111
+ 'col',
112
+ 'embed',
113
+ 'hr',
114
+ 'img',
115
+ 'input',
116
+ 'link',
117
+ 'meta',
118
+ 'param',
119
+ 'source',
120
+ 'track',
121
+ 'wbr',
122
+ ]);
123
+
124
+ const IGNORED_HTML_CONTAINERS = new Set([
125
+ 'head',
126
+ 'script',
127
+ 'style',
128
+ 'noscript',
129
+ 'svg',
130
+ 'template',
131
+ ]);
132
+
133
+ const BROAD_CONTENT_CONTAINERS = new Set([
134
+ 'html',
135
+ 'body',
136
+ 'main',
137
+ 'header',
138
+ 'footer',
139
+ 'nav',
140
+ 'section',
141
+ 'article',
142
+ 'aside',
143
+ 'form',
144
+ 'div',
145
+ 'ul',
146
+ 'ol',
147
+ 'table',
148
+ 'thead',
149
+ 'tbody',
150
+ 'tfoot',
151
+ 'tr',
152
+ ]);
153
+
154
+ export const PRIMARY_PREVIEW_DATA_MESSAGE = 'FIVORA_PREVIEW_SITE_DATA';
155
+ const PREVIOUS_PREVIEW_PREFIX = `${['MARKET', 'PLACE'].join('')}_PREVIEW_`;
156
+ export const LEGACY_PREVIEW_DATA_MESSAGE = `${PREVIOUS_PREVIEW_PREFIX}SITE_DATA`;
157
+ export const PREVIEW_DATA_MESSAGE = PRIMARY_PREVIEW_DATA_MESSAGE;
158
+
159
+ export const PRIMARY_PREVIEW_READY_MESSAGE = 'FIVORA_PREVIEW_READY';
160
+ export const LEGACY_PREVIEW_READY_MESSAGE = `${PREVIOUS_PREVIEW_PREFIX}READY`;
161
+ export const PREVIEW_READY_MESSAGE = PRIMARY_PREVIEW_READY_MESSAGE;
162
+
163
+ export function enumerateTemplateVisualEditPaths(
164
+ contentDefaults: Record<string, unknown> | null,
165
+ editorSchema: TemplateEditorSchema | null,
166
+ ): TemplateVisualEditPathInventory {
167
+ const inventory: MutablePathInventory = {
168
+ fieldPatterns: new Set<string>(),
169
+ concreteFields: new Set<string>(),
170
+ listPatterns: new Set<string>(),
171
+ concreteLists: new Set<string>(),
172
+ itemPatterns: new Set<string>(),
173
+ concreteItems: new Set<string>(),
174
+ };
175
+
176
+ if (contentDefaults) {
177
+ for (const [key, value] of Object.entries(contentDefaults)) {
178
+ walkContentValue(value, key, inventory);
179
+ }
180
+ }
181
+
182
+ for (const section of editorSchema?.sections ?? []) {
183
+ walkSchemaNode(section.path, section, inventory);
184
+ }
185
+
186
+ return {
187
+ fieldPatterns: [...inventory.fieldPatterns].sort(),
188
+ concreteFields: [...inventory.concreteFields].sort(),
189
+ listPatterns: [...inventory.listPatterns].sort(),
190
+ concreteLists: [...inventory.concreteLists].sort(),
191
+ itemPatterns: [...inventory.itemPatterns].sort(),
192
+ concreteItems: [...inventory.concreteItems].sort(),
193
+ };
194
+ }
195
+
196
+ export function buildTemplateVisualEditingEmptyContent(
197
+ contentDefaults: Record<string, unknown> | null,
198
+ editorSchema: TemplateEditorSchema | null,
199
+ ) {
200
+ const emptyContent = emptyContentValue(contentDefaults ?? {}) as Record<
201
+ string,
202
+ unknown
203
+ >;
204
+
205
+ for (const section of editorSchema?.sections ?? []) {
206
+ applyEmptySchemaNode(emptyContent, section.path, section);
207
+ }
208
+
209
+ return emptyContent;
210
+ }
211
+
212
+ export function buildTemplateVisualEditingProbeContent(
213
+ contentDefaults: Record<string, unknown> | null,
214
+ editorSchema: TemplateEditorSchema | null,
215
+ ) {
216
+ const probeContent = structuredClone(contentDefaults ?? {});
217
+
218
+ for (const section of editorSchema?.sections ?? []) {
219
+ applyProbeSchemaNode(probeContent, section.path, section);
220
+ }
221
+
222
+ return fillUnschematizedProbeValues(probeContent, '') as Record<
223
+ string,
224
+ unknown
225
+ >;
226
+ }
227
+
228
+ export function extractTemplateVisualEditMarkers(
229
+ artifacts: TemplateVisualEditingArtifact[],
230
+ ): {
231
+ markers: TemplateVisualEditMarker[];
232
+ unparseableAttributes: string[];
233
+ } {
234
+ const markers: TemplateVisualEditMarker[] = [];
235
+ const unparseableAttributes: string[] = [];
236
+
237
+ for (const artifact of artifacts) {
238
+ const parsedOffsets = new Set<number>();
239
+ MARKER_ATTRIBUTE_PATTERN.lastIndex = 0;
240
+
241
+ for (const match of artifact.content.matchAll(MARKER_ATTRIBUTE_PATTERN)) {
242
+ const attributeName = match[1] as keyof typeof MARKER_ATTRIBUTE_TO_KIND;
243
+ const rawValue =
244
+ match[2] ?? match[3] ?? match[4] ?? match[5] ?? match[6] ?? '';
245
+ const offset = match.index ?? 0;
246
+ parsedOffsets.add(offset);
247
+ markers.push({
248
+ kind: MARKER_ATTRIBUTE_TO_KIND[attributeName],
249
+ value: decodeHtmlAttribute(rawValue).trim(),
250
+ filePath: artifact.filePath,
251
+ artifactKind: artifact.kind,
252
+ offset,
253
+ line: lineNumberAt(artifact.content, offset),
254
+ });
255
+ }
256
+
257
+ MARKER_ATTRIBUTE_OCCURRENCE_PATTERN.lastIndex = 0;
258
+ for (const match of artifact.content.matchAll(
259
+ MARKER_ATTRIBUTE_OCCURRENCE_PATTERN,
260
+ )) {
261
+ const offset = match.index ?? 0;
262
+ if (!parsedOffsets.has(offset)) {
263
+ unparseableAttributes.push(
264
+ `${artifact.filePath}:${lineNumberAt(artifact.content, offset)} ${match[1]} must use a literal string or a JSX template literal.`,
265
+ );
266
+ }
267
+ }
268
+ }
269
+
270
+ return { markers, unparseableAttributes };
271
+ }
272
+
273
+ /**
274
+ * Records which content paths the exported HTML actually renders on each page.
275
+ * The fivora stores this build-derived metadata beside the manifest so a
276
+ * selected Home page can still ask for a service list it genuinely displays,
277
+ * without exposing every field from an unselected Services page.
278
+ */
279
+ export function buildRenderedContentPathsByPage(
280
+ pages: TemplateVisualEditingPage[],
281
+ markers: TemplateVisualEditMarker[],
282
+ ) {
283
+ const htmlMarkers = markers.filter(
284
+ (marker) => marker.artifactKind === 'html',
285
+ );
286
+
287
+ return Object.fromEntries(
288
+ pages.map((page) => {
289
+ const pageFiles = new Set(
290
+ htmlMarkers
291
+ .filter(
292
+ (marker) =>
293
+ marker.kind === 'page' && marker.value.trim() === page.id,
294
+ )
295
+ .map((marker) => marker.filePath),
296
+ );
297
+ const paths = uniqueSorted(
298
+ htmlMarkers
299
+ .filter(
300
+ (marker) =>
301
+ marker.kind === 'field' && pageFiles.has(marker.filePath),
302
+ )
303
+ .map((marker) => canonicalizeMarkerPath(marker.value))
304
+ .filter((path): path is string => Boolean(path)),
305
+ );
306
+ return [page.id, paths] as const;
307
+ }),
308
+ ) as Record<string, string[]>;
309
+ }
310
+
311
+ export function validateTemplateVisualEditingContract(
312
+ input: TemplateVisualEditingValidationInput,
313
+ ): TemplateVisualEditingValidationResult {
314
+ const mode =
315
+ input.manifestVersion >= 2 || input.visualEditing?.mode === 'strict'
316
+ ? 'strict'
317
+ : 'legacy';
318
+ const strictFindings: string[] = [];
319
+ const warnings: string[] = [];
320
+ const inventory = enumerateTemplateVisualEditPaths(
321
+ input.contentDefaults,
322
+ input.editorSchema,
323
+ );
324
+ const extraction = extractTemplateVisualEditMarkers(input.artifacts);
325
+ const markers = extraction.markers;
326
+
327
+ if (!input.contentDefaults) {
328
+ strictFindings.push(
329
+ 'site-data.json must contain a JSON object at "content".',
330
+ );
331
+ }
332
+ if (!input.editorSchema) {
333
+ strictFindings.push(
334
+ 'An editor schema could not be derived from site-data.json content.',
335
+ );
336
+ }
337
+ validateSchemaSectionUniqueness(input.editorSchema, strictFindings);
338
+ validateSchemaPathUniqueness(input.editorSchema, strictFindings);
339
+ validateStrictSelectOptions(input.editorSchema, strictFindings, input.pages);
340
+ validateStrictListBounds(
341
+ input.editorSchema,
342
+ input.contentDefaults,
343
+ strictFindings,
344
+ );
345
+ validateStaticMarkerSourceAuthorship(input.artifacts, strictFindings);
346
+ validatePreviewRuntimeCapability(input.artifacts, strictFindings);
347
+
348
+ warnings.push(
349
+ ...extraction.unparseableAttributes.map(
350
+ (finding) =>
351
+ `${finding} Exported exact markers may still satisfy strict coverage.`,
352
+ ),
353
+ );
354
+
355
+ const fieldMarkers = collectCanonicalPathMarkers(
356
+ markers,
357
+ 'field',
358
+ strictFindings,
359
+ );
360
+ const listMarkers = collectCanonicalPathMarkers(
361
+ markers,
362
+ 'list',
363
+ strictFindings,
364
+ );
365
+ const itemMarkers = collectCanonicalPathMarkers(
366
+ markers,
367
+ 'item',
368
+ strictFindings,
369
+ );
370
+ const htmlMarkers = markers.filter(
371
+ (marker) => marker.artifactKind === 'html',
372
+ );
373
+ const htmlFieldMarkers = collectCanonicalPathMarkers(
374
+ htmlMarkers,
375
+ 'field',
376
+ strictFindings,
377
+ );
378
+ const htmlListMarkers = collectCanonicalPathMarkers(
379
+ htmlMarkers,
380
+ 'list',
381
+ strictFindings,
382
+ );
383
+ const htmlItemMarkers = collectCanonicalPathMarkers(
384
+ htmlMarkers,
385
+ 'item',
386
+ strictFindings,
387
+ );
388
+ const sourceMarkers = markers.filter(
389
+ (marker) => marker.artifactKind === 'source',
390
+ );
391
+ const sourceFieldMarkers = collectCanonicalPathMarkers(
392
+ sourceMarkers,
393
+ 'field',
394
+ strictFindings,
395
+ );
396
+ const sourceListMarkers = collectCanonicalPathMarkers(
397
+ sourceMarkers,
398
+ 'list',
399
+ strictFindings,
400
+ );
401
+ const sourceItemMarkers = collectCanonicalPathMarkers(
402
+ sourceMarkers,
403
+ 'item',
404
+ strictFindings,
405
+ );
406
+ const controlOnlyPaths = normalizeControlOnlyPaths(
407
+ input.visualEditing?.controlOnlyPaths ?? [],
408
+ inventory,
409
+ strictFindings,
410
+ );
411
+
412
+ validateExpectedPathCoverage({
413
+ label: 'editable field',
414
+ expectedPatterns: inventory.fieldPatterns,
415
+ concretePaths: inventory.concreteFields,
416
+ htmlMarkerPaths: htmlFieldMarkers,
417
+ sourceMarkerPaths: sourceFieldMarkers,
418
+ controlOnlyPaths,
419
+ findings: strictFindings,
420
+ });
421
+ validateExpectedPathCoverage({
422
+ label: 'editable list',
423
+ expectedPatterns: inventory.listPatterns,
424
+ concretePaths: inventory.concreteLists,
425
+ htmlMarkerPaths: htmlListMarkers,
426
+ sourceMarkerPaths: sourceListMarkers,
427
+ controlOnlyPaths: [],
428
+ findings: strictFindings,
429
+ });
430
+ validateExpectedPathCoverage({
431
+ label: 'editable list item',
432
+ expectedPatterns: inventory.itemPatterns,
433
+ concretePaths: inventory.concreteItems,
434
+ htmlMarkerPaths: htmlItemMarkers,
435
+ sourceMarkerPaths: sourceItemMarkers,
436
+ controlOnlyPaths: [],
437
+ findings: strictFindings,
438
+ });
439
+
440
+ validateUnknownMarkers(
441
+ 'field',
442
+ fieldMarkers,
443
+ inventory.fieldPatterns,
444
+ inventory.concreteFields,
445
+ strictFindings,
446
+ );
447
+ validateUnknownMarkers(
448
+ 'list',
449
+ listMarkers,
450
+ inventory.listPatterns,
451
+ inventory.concreteLists,
452
+ strictFindings,
453
+ );
454
+ validateUnknownMarkers(
455
+ 'item',
456
+ itemMarkers,
457
+ inventory.itemPatterns,
458
+ inventory.concreteItems,
459
+ strictFindings,
460
+ );
461
+ validatePageCoverage(
462
+ input.manifestVersion,
463
+ input.pages ?? [],
464
+ markers,
465
+ input.artifacts,
466
+ strictFindings,
467
+ );
468
+ validateRouteOwnedMarkerCoverage({
469
+ manifestVersion: input.manifestVersion,
470
+ pages: input.pages ?? [],
471
+ schema: input.editorSchema,
472
+ markers,
473
+ fieldPaths: uniqueSorted([
474
+ ...inventory.fieldPatterns.filter((path) => !path.includes('[*]')),
475
+ ...inventory.concreteFields,
476
+ ]),
477
+ listPaths: uniqueSorted([
478
+ ...inventory.listPatterns.filter((path) => !path.includes('[*]')),
479
+ ...inventory.concreteLists,
480
+ ]),
481
+ itemPaths: inventory.concreteItems,
482
+ controlOnlyPaths,
483
+ findings: strictFindings,
484
+ });
485
+
486
+ const visibleHtmlFindings = auditUnmarkedVisibleHtml(
487
+ input.artifacts,
488
+ input.pages ?? [],
489
+ );
490
+ if (mode === 'strict') {
491
+ strictFindings.push(...visibleHtmlFindings);
492
+ } else {
493
+ warnings.push(...visibleHtmlFindings);
494
+ }
495
+
496
+ const uniqueStrictFindings = uniqueSorted(strictFindings);
497
+ const uniqueWarnings = uniqueSorted(warnings);
498
+
499
+ if (mode === 'legacy') {
500
+ return {
501
+ mode,
502
+ errors: [],
503
+ warnings: uniqueSorted([
504
+ ...uniqueStrictFindings.map((finding) => `[legacy] ${finding}`),
505
+ ...uniqueWarnings,
506
+ ]),
507
+ inventory,
508
+ markers,
509
+ };
510
+ }
511
+
512
+ return {
513
+ mode,
514
+ errors: uniqueStrictFindings,
515
+ warnings: uniqueWarnings,
516
+ inventory,
517
+ markers,
518
+ };
519
+ }
520
+
521
+ export function validateTemplateVisualEditingEmptyState(
522
+ input: TemplateVisualEditingEmptyStateValidationInput,
523
+ ): TemplateVisualEditingEmptyStateValidationResult {
524
+ const findings: string[] = [];
525
+ const warnings: string[] = [];
526
+ const inventory = enumerateTemplateVisualEditPaths(
527
+ input.contentDefaults,
528
+ input.editorSchema,
529
+ );
530
+ const emptyContent = buildTemplateVisualEditingEmptyContent(
531
+ input.contentDefaults,
532
+ input.editorSchema,
533
+ );
534
+ const emptyInventory = enumerateTemplateVisualEditPaths(
535
+ emptyContent,
536
+ input.editorSchema,
537
+ );
538
+ const requiredFieldPaths = uniqueSorted([
539
+ ...inventory.fieldPatterns.filter((path) => !path.includes('[*]')),
540
+ ...emptyInventory.concreteFields,
541
+ ]);
542
+ const requiredListPaths = uniqueSorted([
543
+ ...inventory.listPatterns.filter((path) => !path.includes('[*]')),
544
+ ...emptyInventory.concreteLists,
545
+ ]);
546
+ const requiredItemPaths = emptyInventory.concreteItems;
547
+ const htmlArtifacts = input.artifacts.filter(
548
+ (artifact) => artifact.kind === 'html',
549
+ );
550
+ const extraction = extractTemplateVisualEditMarkers(htmlArtifacts);
551
+ const fieldMarkers = collectCanonicalPathMarkers(
552
+ extraction.markers,
553
+ 'field',
554
+ findings,
555
+ );
556
+ const listMarkers = collectCanonicalPathMarkers(
557
+ extraction.markers,
558
+ 'list',
559
+ findings,
560
+ );
561
+ const itemMarkers = collectCanonicalPathMarkers(
562
+ extraction.markers,
563
+ 'item',
564
+ findings,
565
+ );
566
+ const controlOnlyPaths = normalizeControlOnlyPaths(
567
+ input.visualEditing?.controlOnlyPaths ?? [],
568
+ inventory,
569
+ findings,
570
+ );
571
+
572
+ reportUnexpectedEmptyStateMarkers({
573
+ markerPaths: fieldMarkers,
574
+ expectedPaths: emptyInventory.concreteFields,
575
+ knownPatterns: inventory.fieldPatterns,
576
+ attribute: 'field',
577
+ findings,
578
+ });
579
+ reportUnexpectedEmptyStateMarkers({
580
+ markerPaths: listMarkers,
581
+ expectedPaths: emptyInventory.concreteLists,
582
+ knownPatterns: inventory.listPatterns,
583
+ attribute: 'list',
584
+ findings,
585
+ });
586
+ reportUnexpectedEmptyStateMarkers({
587
+ markerPaths: itemMarkers,
588
+ expectedPaths: emptyInventory.concreteItems,
589
+ knownPatterns: inventory.itemPatterns,
590
+ attribute: 'item',
591
+ findings,
592
+ });
593
+
594
+ for (const path of requiredFieldPaths) {
595
+ if (isControlOnly(path, controlOnlyPaths) || fieldMarkers.includes(path)) {
596
+ continue;
597
+ }
598
+ findings.push(
599
+ `Empty-state export removed data-preview-field-path="${path}". Keep the editable target mounted when its value is empty, false, or zero.`,
600
+ );
601
+ }
602
+
603
+ for (const path of requiredListPaths) {
604
+ if (listMarkers.includes(path)) {
605
+ continue;
606
+ }
607
+ findings.push(
608
+ `Empty-state export removed data-preview-list-path="${path}". Keep the list container mounted when the list has no items.`,
609
+ );
610
+ }
611
+
612
+ for (const path of requiredItemPaths) {
613
+ if (itemMarkers.includes(path)) {
614
+ continue;
615
+ }
616
+ findings.push(
617
+ `Empty-state export removed data-preview-item-path="${path}" required by minItems/required list validation.`,
618
+ );
619
+ }
620
+
621
+ validatePageCoverage(
622
+ input.manifestVersion,
623
+ input.pages ?? [],
624
+ extraction.markers,
625
+ htmlArtifacts,
626
+ findings,
627
+ );
628
+ validateRouteOwnedMarkerCoverage({
629
+ manifestVersion: input.manifestVersion,
630
+ pages: input.pages ?? [],
631
+ schema: input.editorSchema,
632
+ markers: extraction.markers,
633
+ fieldPaths: requiredFieldPaths,
634
+ listPaths: requiredListPaths,
635
+ itemPaths: requiredItemPaths,
636
+ controlOnlyPaths,
637
+ findings,
638
+ });
639
+
640
+ warnings.push(...extraction.unparseableAttributes);
641
+
642
+ return {
643
+ errors: uniqueSorted(findings),
644
+ warnings: uniqueSorted(warnings),
645
+ requiredFieldPaths,
646
+ requiredListPaths,
647
+ requiredItemPaths,
648
+ };
649
+ }
650
+
651
+ function reportUnexpectedEmptyStateMarkers(params: {
652
+ markerPaths: string[];
653
+ expectedPaths: string[];
654
+ knownPatterns: string[];
655
+ attribute: 'field' | 'list' | 'item';
656
+ findings: string[];
657
+ }) {
658
+ const expected = new Set(params.expectedPaths);
659
+ const known = new Set(params.knownPatterns);
660
+
661
+ for (const path of params.markerPaths) {
662
+ if (
663
+ path.includes('[*]') ||
664
+ expected.has(path) ||
665
+ !known.has(wildcardPath(path))
666
+ ) {
667
+ continue;
668
+ }
669
+ params.findings.push(
670
+ `Empty-state export rendered out-of-range data-preview-${params.attribute}-path="${path}" for a list item that does not exist. Render list items from the actual site-data array instead of fixed indexes or placeholder cards.`,
671
+ );
672
+ }
673
+ }
674
+
675
+ function applyProbeSchemaNode(
676
+ root: Record<string, unknown>,
677
+ path: string,
678
+ node: TemplateEditorSection,
679
+ ) {
680
+ const parts = path
681
+ .split('.')
682
+ .map((part) => part.trim())
683
+ .filter(Boolean);
684
+ if (parts.length === 0 || parts.some((part) => part.includes('['))) {
685
+ return;
686
+ }
687
+
688
+ let cursor = root;
689
+ for (const part of parts.slice(0, -1)) {
690
+ const existing = cursor[part];
691
+ if (!isPlainObject(existing)) {
692
+ cursor[part] = {};
693
+ }
694
+ cursor = cursor[part] as Record<string, unknown>;
695
+ }
696
+
697
+ const key = parts.at(-1)!;
698
+ applyProbeNodeAtKey(cursor, key, node, path);
699
+ }
700
+
701
+ function applyProbeNodeAtKey(
702
+ container: Record<string, unknown>,
703
+ key: string,
704
+ node: TemplateEditorSection | TemplateEditorField,
705
+ path: string,
706
+ ) {
707
+ if (node.type === 'list') {
708
+ const items = Array.isArray(container[key])
709
+ ? [...(container[key] as unknown[])]
710
+ : [];
711
+ const maximumItems = validListBound(node.maxItems);
712
+ const minimumItems = Math.min(
713
+ maximumItems ?? Number.MAX_SAFE_INTEGER,
714
+ maximumItems === 0
715
+ ? 0
716
+ : Math.max(1, validListBound(node.minItems) ?? (node.required ? 1 : 0)),
717
+ );
718
+ while (items.length < minimumItems) {
719
+ items.push(undefined);
720
+ }
721
+
722
+ container[key] = items.map((item, index) => {
723
+ const itemPath = `${path}[${index}]`;
724
+ if (node.itemField) {
725
+ return probePrimitiveValue(
726
+ item,
727
+ node.itemField.type,
728
+ itemPath,
729
+ node.itemField.options,
730
+ );
731
+ }
732
+
733
+ const objectItem = isPlainObject(item) ? item : {};
734
+ for (const field of node.fields ?? []) {
735
+ applyProbeNodeAtKey(
736
+ objectItem,
737
+ field.key,
738
+ field,
739
+ appendPath(itemPath, field.key),
740
+ );
741
+ }
742
+ return objectItem;
743
+ });
744
+ return;
745
+ }
746
+
747
+ if (node.type === 'object') {
748
+ const objectValue = isPlainObject(container[key]) ? container[key] : {};
749
+ container[key] = objectValue;
750
+ for (const field of node.fields ?? []) {
751
+ applyProbeNodeAtKey(
752
+ objectValue,
753
+ field.key,
754
+ field,
755
+ appendPath(path, field.key),
756
+ );
757
+ }
758
+ return;
759
+ }
760
+
761
+ container[key] = probePrimitiveValue(
762
+ container[key],
763
+ node.type,
764
+ path,
765
+ node.options,
766
+ );
767
+ }
768
+
769
+ function probePrimitiveValue(
770
+ value: unknown,
771
+ type: TemplateEditorSection['type'],
772
+ path: string,
773
+ options?: string[],
774
+ ) {
775
+ if (type === 'number') {
776
+ return typeof value === 'number' && Number.isFinite(value) && value !== 0
777
+ ? value
778
+ : 1;
779
+ }
780
+ if (type === 'boolean') {
781
+ return value === true ? value : true;
782
+ }
783
+ if (
784
+ typeof value === 'string' &&
785
+ value.trim() &&
786
+ (type !== 'select' || !options?.length || options.includes(value))
787
+ ) {
788
+ return value;
789
+ }
790
+
791
+ if (type === 'select') {
792
+ return options?.[0] ?? probeText(path);
793
+ }
794
+ if (type === 'url') {
795
+ return `https://template-validation.example.invalid/${probeSlug(path)}`;
796
+ }
797
+ if (type === 'image') {
798
+ return `/template-validation-${probeSlug(path)}.svg`;
799
+ }
800
+ if (type === 'email') {
801
+ return 'validation@example.invalid';
802
+ }
803
+ if (type === 'tel') {
804
+ return '+12025550142';
805
+ }
806
+ return probeText(path);
807
+ }
808
+
809
+ function fillUnschematizedProbeValues(value: unknown, path: string): unknown {
810
+ if (Array.isArray(value)) {
811
+ return value.map((item, index) =>
812
+ fillUnschematizedProbeValues(item, `${path}[${index}]`),
813
+ );
814
+ }
815
+ if (isPlainObject(value)) {
816
+ return Object.fromEntries(
817
+ Object.entries(value).map(([key, child]) => {
818
+ const childPath = appendPath(path, key);
819
+ return [key, fillUnschematizedProbeValues(child, childPath)];
820
+ }),
821
+ );
822
+ }
823
+ if (typeof value === 'string') {
824
+ return value.trim() ? value : inferUnschematizedStringProbe(path);
825
+ }
826
+ if (typeof value === 'number') {
827
+ return Number.isFinite(value) && value !== 0 ? value : 1;
828
+ }
829
+ if (typeof value === 'boolean') {
830
+ return true;
831
+ }
832
+ if (value === null || value === undefined) {
833
+ return probeText(path);
834
+ }
835
+ return value;
836
+ }
837
+
838
+ function inferUnschematizedStringProbe(path: string) {
839
+ const normalized = path.toLowerCase();
840
+ if (normalized.includes('email')) {
841
+ return 'validation@example.invalid';
842
+ }
843
+ if (
844
+ normalized.includes('phone') ||
845
+ normalized.includes('mobile') ||
846
+ normalized.includes('whatsapp') ||
847
+ normalized.includes('contactnumber')
848
+ ) {
849
+ return '+12025550142';
850
+ }
851
+ if (
852
+ normalized.includes('image') ||
853
+ normalized.includes('logo') ||
854
+ normalized.includes('banner') ||
855
+ normalized.includes('thumbnail') ||
856
+ normalized.includes('photo')
857
+ ) {
858
+ return `/template-validation-${probeSlug(path)}.svg`;
859
+ }
860
+ if (normalized.includes('url') || normalized.includes('link')) {
861
+ return `https://template-validation.example.invalid/${probeSlug(path)}`;
862
+ }
863
+ return probeText(path);
864
+ }
865
+
866
+ function probeText(path: string) {
867
+ return `Validation ${path || 'content'}`;
868
+ }
869
+
870
+ function probeSlug(path: string) {
871
+ return (
872
+ path
873
+ .toLowerCase()
874
+ .replace(/[^a-z0-9]+/g, '-')
875
+ .replace(/^-+|-+$/g, '') || 'content'
876
+ );
877
+ }
878
+
879
+ function emptyContentValue(value: unknown): unknown {
880
+ if (Array.isArray(value)) {
881
+ return [];
882
+ }
883
+ if (isPlainObject(value)) {
884
+ return Object.fromEntries(
885
+ Object.entries(value).map(([key, child]) => [
886
+ key,
887
+ emptyContentValue(child),
888
+ ]),
889
+ );
890
+ }
891
+ if (typeof value === 'string') {
892
+ return '';
893
+ }
894
+ if (typeof value === 'number') {
895
+ return 0;
896
+ }
897
+ if (typeof value === 'boolean') {
898
+ return false;
899
+ }
900
+ return value;
901
+ }
902
+
903
+ function applyEmptySchemaNode(
904
+ root: Record<string, unknown>,
905
+ path: string,
906
+ node: TemplateEditorSection,
907
+ ) {
908
+ const parts = path
909
+ .split('.')
910
+ .map((part) => part.trim())
911
+ .filter(Boolean);
912
+ if (parts.length === 0 || parts.some((part) => part.includes('['))) {
913
+ return;
914
+ }
915
+
916
+ let cursor = root;
917
+ for (const part of parts.slice(0, -1)) {
918
+ const existing = cursor[part];
919
+ if (!isPlainObject(existing)) {
920
+ cursor[part] = {};
921
+ }
922
+ cursor = cursor[part] as Record<string, unknown>;
923
+ }
924
+
925
+ const key = parts.at(-1)!;
926
+ applyEmptyNodeAtKey(cursor, key, node);
927
+ }
928
+
929
+ function applyEmptyNodeAtKey(
930
+ container: Record<string, unknown>,
931
+ key: string,
932
+ node: TemplateEditorSection | TemplateEditorField,
933
+ ) {
934
+ if (node.type === 'list') {
935
+ const minimumItems = Math.min(
936
+ validListBound(node.maxItems) ?? Number.MAX_SAFE_INTEGER,
937
+ validListBound(node.minItems) ?? (node.required ? 1 : 0),
938
+ );
939
+ container[key] = Array.from({ length: minimumItems }, () =>
940
+ createEmptyListItem(node),
941
+ );
942
+ return;
943
+ }
944
+
945
+ if (node.type === 'object') {
946
+ const objectValue = isPlainObject(container[key]) ? container[key] : {};
947
+ container[key] = objectValue;
948
+ for (const field of node.fields ?? []) {
949
+ applyEmptyNodeAtKey(objectValue, field.key, field);
950
+ }
951
+ return;
952
+ }
953
+
954
+ container[key] =
955
+ node.type === 'number' ? 0 : node.type === 'boolean' ? false : '';
956
+ }
957
+
958
+ function createEmptyListItem(node: {
959
+ itemField?: TemplateEditorSection['itemField'];
960
+ fields?: TemplateEditorField[];
961
+ }) {
962
+ if (node.itemField) {
963
+ return node.itemField.type === 'number'
964
+ ? 0
965
+ : node.itemField.type === 'boolean'
966
+ ? false
967
+ : '';
968
+ }
969
+
970
+ const item: Record<string, unknown> = {};
971
+ for (const field of node.fields ?? []) {
972
+ applyEmptyNodeAtKey(item, field.key, field);
973
+ }
974
+ return item;
975
+ }
976
+
977
+ function walkContentValue(
978
+ value: unknown,
979
+ path: string,
980
+ inventory: MutablePathInventory,
981
+ ) {
982
+ if (Array.isArray(value)) {
983
+ const listPattern = wildcardPath(path);
984
+ inventory.listPatterns.add(listPattern);
985
+ inventory.concreteLists.add(path);
986
+ inventory.itemPatterns.add(`${listPattern}[*]`);
987
+
988
+ value.forEach((item, index) => {
989
+ const itemPath = `${path}[${index}]`;
990
+ inventory.concreteItems.add(itemPath);
991
+ if (Array.isArray(item) || isPlainObject(item)) {
992
+ walkContentValue(item, itemPath, inventory);
993
+ } else {
994
+ inventory.fieldPatterns.add(wildcardPath(itemPath));
995
+ inventory.concreteFields.add(itemPath);
996
+ }
997
+ });
998
+ return;
999
+ }
1000
+
1001
+ if (isPlainObject(value)) {
1002
+ for (const [key, childValue] of Object.entries(value)) {
1003
+ walkContentValue(childValue, appendPath(path, key), inventory);
1004
+ }
1005
+ return;
1006
+ }
1007
+
1008
+ inventory.fieldPatterns.add(wildcardPath(path));
1009
+ inventory.concreteFields.add(path);
1010
+ }
1011
+
1012
+ function walkSchemaNode(
1013
+ path: string,
1014
+ node: TemplateEditorSection | TemplateEditorField,
1015
+ inventory: MutablePathInventory,
1016
+ ) {
1017
+ if (node.type === 'object') {
1018
+ for (const field of node.fields ?? []) {
1019
+ walkSchemaNode(appendPath(path, field.key), field, inventory);
1020
+ }
1021
+ return;
1022
+ }
1023
+
1024
+ if (node.type === 'list') {
1025
+ const listPattern = wildcardPath(path);
1026
+ const itemPattern = `${listPattern}[*]`;
1027
+ inventory.listPatterns.add(listPattern);
1028
+ inventory.itemPatterns.add(itemPattern);
1029
+
1030
+ if (node.itemField) {
1031
+ inventory.fieldPatterns.add(itemPattern);
1032
+ }
1033
+
1034
+ for (const field of node.fields ?? []) {
1035
+ walkSchemaNode(appendPath(itemPattern, field.key), field, inventory);
1036
+ }
1037
+ return;
1038
+ }
1039
+
1040
+ inventory.fieldPatterns.add(wildcardPath(path));
1041
+ }
1042
+
1043
+ function collectCanonicalPathMarkers(
1044
+ markers: TemplateVisualEditMarker[],
1045
+ kind: Exclude<TemplateVisualEditMarkerKind, 'page'>,
1046
+ findings: string[],
1047
+ ) {
1048
+ const values: string[] = [];
1049
+
1050
+ for (const marker of markers.filter((candidate) => candidate.kind === kind)) {
1051
+ const canonical = canonicalizeMarkerPath(marker.value);
1052
+ if (!canonical) {
1053
+ findings.push(
1054
+ `${marker.filePath}:${lineNumberForMarker(marker)} has invalid data-preview-${kind}-path "${marker.value}".`,
1055
+ );
1056
+ continue;
1057
+ }
1058
+ values.push(canonical);
1059
+ }
1060
+
1061
+ return uniqueSorted(values);
1062
+ }
1063
+
1064
+ function normalizeControlOnlyPaths(
1065
+ paths: string[],
1066
+ inventory: TemplateVisualEditPathInventory,
1067
+ findings: string[],
1068
+ ) {
1069
+ const normalized: string[] = [];
1070
+
1071
+ for (const path of paths) {
1072
+ const canonical = canonicalizeMarkerPath(path);
1073
+ if (!canonical) {
1074
+ findings.push(`controlOnlyPaths contains invalid path "${path}".`);
1075
+ continue;
1076
+ }
1077
+
1078
+ const known = [
1079
+ ...inventory.fieldPatterns,
1080
+ ...inventory.concreteFields,
1081
+ ].some((expected) => pathsOverlap(canonical, expected));
1082
+ if (!known) {
1083
+ findings.push(
1084
+ `controlOnlyPaths contains unknown editable field path "${canonical}".`,
1085
+ );
1086
+ continue;
1087
+ }
1088
+ normalized.push(canonical);
1089
+ }
1090
+
1091
+ return uniqueSorted(normalized);
1092
+ }
1093
+
1094
+ function validateExpectedPathCoverage(params: {
1095
+ label: string;
1096
+ expectedPatterns: string[];
1097
+ concretePaths: string[];
1098
+ htmlMarkerPaths: string[];
1099
+ sourceMarkerPaths: string[];
1100
+ controlOnlyPaths: string[];
1101
+ findings: string[];
1102
+ }) {
1103
+ for (const expected of params.expectedPatterns) {
1104
+ if (isControlOnly(expected, params.controlOnlyPaths)) {
1105
+ continue;
1106
+ }
1107
+
1108
+ const matchingConcretePaths = params.concretePaths.filter(
1109
+ (concrete) => wildcardPath(concrete) === expected,
1110
+ );
1111
+
1112
+ if (!expected.includes('[*]')) {
1113
+ if (!params.htmlMarkerPaths.includes(expected)) {
1114
+ params.findings.push(
1115
+ `Exported HTML is missing exact data-preview-${markerAttributeFragment(params.label)}="${expected}" for ${params.label}.`,
1116
+ );
1117
+ }
1118
+ continue;
1119
+ }
1120
+
1121
+ if (
1122
+ !params.sourceMarkerPaths.some((marker) =>
1123
+ markerCoversPattern(marker, expected),
1124
+ )
1125
+ ) {
1126
+ params.findings.push(
1127
+ `Source is missing dynamic data-preview-${markerAttributeFragment(params.label)} mapping for ${params.label} "${expected}". Use an exact JSX template path such as [\${index}] so newly added and reordered items remain editable.`,
1128
+ );
1129
+ }
1130
+
1131
+ if (matchingConcretePaths.length > 0) {
1132
+ continue;
1133
+ }
1134
+ }
1135
+
1136
+ for (const concrete of params.concretePaths) {
1137
+ if (params.expectedPatterns.includes(concrete)) {
1138
+ continue;
1139
+ }
1140
+ if (
1141
+ isControlOnly(concrete, params.controlOnlyPaths) ||
1142
+ params.htmlMarkerPaths.includes(concrete)
1143
+ ) {
1144
+ continue;
1145
+ }
1146
+ params.findings.push(
1147
+ `Exported HTML is missing exact data-preview-${markerAttributeFragment(params.label)}="${concrete}" for concrete ${params.label}.`,
1148
+ );
1149
+ }
1150
+ }
1151
+
1152
+ function validateUnknownMarkers(
1153
+ kind: Exclude<TemplateVisualEditMarkerKind, 'page'>,
1154
+ markerPaths: string[],
1155
+ expectedPatterns: string[],
1156
+ concretePaths: string[],
1157
+ findings: string[],
1158
+ ) {
1159
+ for (const marker of markerPaths) {
1160
+ const known =
1161
+ expectedPatterns.some((expected) => pathsOverlap(marker, expected)) ||
1162
+ concretePaths.some((expected) => pathsOverlap(marker, expected));
1163
+ if (!known) {
1164
+ findings.push(
1165
+ `data-preview-${kind}-path references unknown path "${marker}".`,
1166
+ );
1167
+ }
1168
+ }
1169
+ }
1170
+
1171
+ function validatePageCoverage(
1172
+ manifestVersion: number,
1173
+ pages: TemplateVisualEditingPage[],
1174
+ markers: TemplateVisualEditMarker[],
1175
+ artifacts: TemplateVisualEditingArtifact[],
1176
+ findings: string[],
1177
+ ) {
1178
+ const htmlArtifacts = artifacts.filter(
1179
+ (artifact) => artifact.kind === 'html',
1180
+ );
1181
+ const pageMarkers = markers.filter(
1182
+ (marker) => marker.kind === 'page' && marker.artifactKind === 'html',
1183
+ );
1184
+ const pageIds = new Set(pages.map((page) => page.id));
1185
+ const seenPageIds = new Set<string>();
1186
+ const seenPageRoutes = new Map<string, string>();
1187
+
1188
+ if (pages.length === 0) {
1189
+ findings.push(
1190
+ 'Strict visual editing requires at least one manifest pages[] entry.',
1191
+ );
1192
+ }
1193
+
1194
+ for (const page of pages) {
1195
+ if (seenPageIds.has(page.id)) {
1196
+ findings.push(`Manifest pages[] contains duplicate id "${page.id}".`);
1197
+ }
1198
+ seenPageIds.add(page.id);
1199
+
1200
+ const route =
1201
+ normalizePageRoute(page.route) ??
1202
+ (manifestVersion < 2 ? legacyPageRoute(page.id) : null);
1203
+
1204
+ if (!route) {
1205
+ findings.push(
1206
+ `Manifest page "${page.id}" must declare a canonical route for strict visual editing.`,
1207
+ );
1208
+ continue;
1209
+ }
1210
+
1211
+ const existingRoutePage = seenPageRoutes.get(route);
1212
+ if (existingRoutePage) {
1213
+ findings.push(
1214
+ `Manifest pages "${existingRoutePage}" and "${page.id}" use duplicate route "${route}".`,
1215
+ );
1216
+ } else {
1217
+ seenPageRoutes.set(route, page.id);
1218
+ }
1219
+
1220
+ const routeArtifacts = htmlArtifacts.filter((artifact) =>
1221
+ artifactMatchesRoute(artifact.filePath, route),
1222
+ );
1223
+ if (routeArtifacts.length === 0) {
1224
+ findings.push(
1225
+ `Manifest page "${page.id}" route "${route}" has no exported HTML file.`,
1226
+ );
1227
+ continue;
1228
+ }
1229
+
1230
+ const routeFiles = new Set(
1231
+ routeArtifacts.map((artifact) => artifact.filePath),
1232
+ );
1233
+ const hasPageRoot = pageMarkers.some(
1234
+ (marker) => marker.value === page.id && routeFiles.has(marker.filePath),
1235
+ );
1236
+ if (!hasPageRoot) {
1237
+ findings.push(
1238
+ `Exported route "${route}" is missing data-preview-page-key="${page.id}".`,
1239
+ );
1240
+ }
1241
+ }
1242
+
1243
+ for (const marker of markers.filter(
1244
+ (candidate) => candidate.kind === 'page',
1245
+ )) {
1246
+ if (!pageIds.has(marker.value)) {
1247
+ findings.push(
1248
+ `${marker.filePath}:${lineNumberForMarker(marker)} data-preview-page-key references unknown manifest page "${marker.value}".`,
1249
+ );
1250
+ }
1251
+ }
1252
+ }
1253
+
1254
+ function validateRouteOwnedMarkerCoverage(params: {
1255
+ manifestVersion: number;
1256
+ pages: TemplateVisualEditingPage[];
1257
+ schema: TemplateEditorSchema | null;
1258
+ markers: TemplateVisualEditMarker[];
1259
+ fieldPaths: string[];
1260
+ listPaths: string[];
1261
+ itemPaths: string[];
1262
+ controlOnlyPaths: string[];
1263
+ findings: string[];
1264
+ }) {
1265
+ const sections = (params.schema?.sections ?? [])
1266
+ .map((section) => ({
1267
+ section,
1268
+ canonicalPath: canonicalizeMarkerPath(section.path),
1269
+ }))
1270
+ .filter(
1271
+ (
1272
+ entry,
1273
+ ): entry is {
1274
+ section: TemplateEditorSection;
1275
+ canonicalPath: string;
1276
+ } => Boolean(entry.canonicalPath),
1277
+ );
1278
+ const pagesById = new Map(params.pages.map((page) => [page.id, page]));
1279
+
1280
+ const validatePaths = (
1281
+ kind: Exclude<TemplateVisualEditMarkerKind, 'page'>,
1282
+ paths: string[],
1283
+ ) => {
1284
+ for (const path of paths) {
1285
+ if (kind === 'field' && isControlOnly(path, params.controlOnlyPaths)) {
1286
+ continue;
1287
+ }
1288
+
1289
+ const shopOwner = sections
1290
+ .filter((entry) => pathBelongsToSection(path, entry.canonicalPath))
1291
+ .sort(
1292
+ (left, right) =>
1293
+ right.canonicalPath.length - left.canonicalPath.length,
1294
+ )[0];
1295
+ if (!shopOwner?.section.pageKey) {
1296
+ continue;
1297
+ }
1298
+
1299
+ const page = pagesById.get(shopOwner.section.pageKey);
1300
+ if (!page) {
1301
+ params.findings.push(
1302
+ `editorSchema section "${shopOwner.section.path}" assigns ${markerCoverageLabel(kind)} "${path}" to unknown manifest page "${shopOwner.section.pageKey}".`,
1303
+ );
1304
+ continue;
1305
+ }
1306
+
1307
+ const route =
1308
+ normalizePageRoute(page.route) ??
1309
+ (params.manifestVersion < 2 ? legacyPageRoute(page.id) : null);
1310
+ if (!route) {
1311
+ continue;
1312
+ }
1313
+
1314
+ const hasExportedMarker = params.markers.some(
1315
+ (marker) =>
1316
+ marker.kind === kind &&
1317
+ marker.artifactKind === 'html' &&
1318
+ canonicalizeMarkerPath(marker.value) === path,
1319
+ );
1320
+ if (!hasExportedMarker) {
1321
+ continue;
1322
+ }
1323
+
1324
+ const renderedOnOwnedRoute = params.markers.some(
1325
+ (marker) =>
1326
+ marker.kind === kind &&
1327
+ marker.artifactKind === 'html' &&
1328
+ canonicalizeMarkerPath(marker.value) === path &&
1329
+ artifactMatchesRoute(marker.filePath, route),
1330
+ );
1331
+ if (renderedOnOwnedRoute) {
1332
+ continue;
1333
+ }
1334
+
1335
+ params.findings.push(
1336
+ `Page-owned ${markerCoverageLabel(kind)} "${path}" from editorSchema section "${shopOwner.section.path}" must render data-preview-${markerPathAttribute(kind)}="${path}" on manifest page "${page.id}" route "${route}". Shared sections without pageKey may render globally.`,
1337
+ );
1338
+ }
1339
+ };
1340
+
1341
+ validatePaths('field', uniqueSorted(params.fieldPaths));
1342
+ validatePaths('list', uniqueSorted(params.listPaths));
1343
+ validatePaths('item', uniqueSorted(params.itemPaths));
1344
+ }
1345
+
1346
+ function pathBelongsToSection(path: string, sectionPath: string) {
1347
+ const wildcardedPath = wildcardPath(path);
1348
+ const wildcardedSection = wildcardPath(sectionPath);
1349
+ return (
1350
+ wildcardedPath === wildcardedSection ||
1351
+ wildcardedPath.startsWith(`${wildcardedSection}.`) ||
1352
+ wildcardedPath.startsWith(`${wildcardedSection}[`)
1353
+ );
1354
+ }
1355
+
1356
+ function markerCoverageLabel(
1357
+ kind: Exclude<TemplateVisualEditMarkerKind, 'page'>,
1358
+ ) {
1359
+ if (kind === 'field') return 'editable field';
1360
+ if (kind === 'list') return 'editable list';
1361
+ return 'editable list item';
1362
+ }
1363
+
1364
+ function markerPathAttribute(
1365
+ kind: Exclude<TemplateVisualEditMarkerKind, 'page'>,
1366
+ ) {
1367
+ if (kind === 'field') return 'field-path';
1368
+ if (kind === 'list') return 'list-path';
1369
+ return 'item-path';
1370
+ }
1371
+
1372
+ function validateStrictSelectOptions(
1373
+ schema: TemplateEditorSchema | null,
1374
+ findings: string[],
1375
+ manifestPages: TemplateVisualEditingPage[] = [],
1376
+ ) {
1377
+ const declaredPageIds = new Set(
1378
+ manifestPages
1379
+ .map((p) => p.id?.trim())
1380
+ .filter((id): id is string => Boolean(id)),
1381
+ );
1382
+
1383
+ const checkSelectNode = (path: string, options: string[] | undefined) => {
1384
+ if (
1385
+ !options?.some(
1386
+ (option) => typeof option === 'string' && option.trim().length > 0,
1387
+ )
1388
+ ) {
1389
+ findings.push(
1390
+ `editorSchema select field "${path}" must declare at least one non-empty option for strict visual editing. Add options: ["First option", ...] or change the field type.`,
1391
+ );
1392
+ return;
1393
+ }
1394
+
1395
+ if (declaredPageIds.size > 0) {
1396
+ const isDestinationField =
1397
+ /(?:destination|action|targetpage|buttonaction|pagekey)/i.test(path);
1398
+ if (isDestinationField) {
1399
+ for (const option of options) {
1400
+ const opt = typeof option === 'string' ? option.trim() : '';
1401
+ if (
1402
+ opt &&
1403
+ opt !== 'none' &&
1404
+ opt !== 'external' &&
1405
+ !declaredPageIds.has(opt)
1406
+ ) {
1407
+ findings.push(
1408
+ `editorSchema select field "${path}" declares destination option "${opt}" which is not in fivora-template.json pages[]. Declared pages: ${[...declaredPageIds].join(', ')}.`,
1409
+ );
1410
+ }
1411
+ }
1412
+ }
1413
+ }
1414
+ };
1415
+
1416
+ const walkNode = (
1417
+ path: string,
1418
+ node: TemplateEditorSection | TemplateEditorField,
1419
+ ) => {
1420
+ if (node.type === 'select') {
1421
+ checkSelectNode(path, node.options);
1422
+ return;
1423
+ }
1424
+
1425
+ if (node.type === 'object') {
1426
+ for (const field of node.fields ?? []) {
1427
+ walkNode(appendPath(path, field.key), field);
1428
+ }
1429
+ return;
1430
+ }
1431
+
1432
+ if (node.type !== 'list') {
1433
+ return;
1434
+ }
1435
+
1436
+ const itemPath = `${wildcardPath(path)}[*]`;
1437
+ if (node.itemField?.type === 'select') {
1438
+ checkSelectNode(itemPath, node.itemField.options);
1439
+ }
1440
+ for (const field of node.fields ?? []) {
1441
+ walkNode(appendPath(itemPath, field.key), field);
1442
+ }
1443
+ };
1444
+
1445
+ for (const section of schema?.sections ?? []) {
1446
+ walkNode(section.path, section);
1447
+ }
1448
+ }
1449
+
1450
+ function validateStrictListBounds(
1451
+ schema: TemplateEditorSchema | null,
1452
+ contentDefaults: Record<string, unknown> | null,
1453
+ findings: string[],
1454
+ ) {
1455
+ const walkSchemaNode = (
1456
+ path: string,
1457
+ node: TemplateEditorSection | TemplateEditorField,
1458
+ ) => {
1459
+ if (node.type === 'object') {
1460
+ for (const field of node.fields ?? []) {
1461
+ walkSchemaNode(appendPath(path, field.key), field);
1462
+ }
1463
+ return;
1464
+ }
1465
+
1466
+ if (node.type !== 'list') {
1467
+ return;
1468
+ }
1469
+
1470
+ const minItems = validListBound(node.minItems);
1471
+ const maxItems = validListBound(node.maxItems);
1472
+ if (node.minItems !== undefined && minItems === null) {
1473
+ findings.push(
1474
+ `editorSchema list "${path}" minItems must be a non-negative safe integer.`,
1475
+ );
1476
+ }
1477
+ if (node.maxItems !== undefined && maxItems === null) {
1478
+ findings.push(
1479
+ `editorSchema list "${path}" maxItems must be a non-negative safe integer.`,
1480
+ );
1481
+ }
1482
+
1483
+ const effectiveMinimum = minItems ?? (node.required ? 1 : 0);
1484
+ if (maxItems !== null && effectiveMinimum > maxItems) {
1485
+ findings.push(
1486
+ `editorSchema list "${path}" requires at least ${effectiveMinimum} item${effectiveMinimum === 1 ? '' : 's'} but maxItems is ${maxItems}. Increase maxItems or lower minItems/required.`,
1487
+ );
1488
+ }
1489
+
1490
+ const itemPath = `${wildcardPath(path)}[*]`;
1491
+ for (const field of node.fields ?? []) {
1492
+ walkSchemaNode(appendPath(itemPath, field.key), field);
1493
+ }
1494
+ };
1495
+
1496
+ const walkContentNode = (
1497
+ path: string,
1498
+ node: TemplateEditorSection | TemplateEditorField,
1499
+ value: unknown,
1500
+ ) => {
1501
+ if (node.type === 'object') {
1502
+ const objectValue = isPlainObject(value) ? value : {};
1503
+ for (const field of node.fields ?? []) {
1504
+ walkContentNode(
1505
+ appendPath(path, field.key),
1506
+ field,
1507
+ objectValue[field.key],
1508
+ );
1509
+ }
1510
+ return;
1511
+ }
1512
+
1513
+ if (node.type !== 'list') {
1514
+ return;
1515
+ }
1516
+
1517
+ const items = Array.isArray(value) ? value : [];
1518
+ const maxItems = validListBound(node.maxItems);
1519
+ if (maxItems !== null && items.length > maxItems) {
1520
+ findings.push(
1521
+ `site-data.json content list "${path}" contains ${items.length} items, exceeding editorSchema maxItems ${maxItems}.`,
1522
+ );
1523
+ }
1524
+
1525
+ for (const [index, item] of items.entries()) {
1526
+ const objectItem = isPlainObject(item) ? item : {};
1527
+ for (const field of node.fields ?? []) {
1528
+ walkContentNode(
1529
+ appendPath(`${path}[${index}]`, field.key),
1530
+ field,
1531
+ objectItem[field.key],
1532
+ );
1533
+ }
1534
+ }
1535
+ };
1536
+
1537
+ for (const section of schema?.sections ?? []) {
1538
+ walkSchemaNode(section.path, section);
1539
+ walkContentNode(
1540
+ section.path,
1541
+ section,
1542
+ readContentValueByPath(contentDefaults ?? {}, section.path),
1543
+ );
1544
+ }
1545
+ }
1546
+
1547
+ function validListBound(value: number | undefined): number | null {
1548
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
1549
+ ? value
1550
+ : null;
1551
+ }
1552
+
1553
+ function readContentValueByPath(
1554
+ content: Record<string, unknown>,
1555
+ path: string,
1556
+ ) {
1557
+ let value: unknown = content;
1558
+ for (const part of path.split('.').map((candidate) => candidate.trim())) {
1559
+ if (!part || !isPlainObject(value)) {
1560
+ return undefined;
1561
+ }
1562
+ value = value[part];
1563
+ }
1564
+ return value;
1565
+ }
1566
+
1567
+ function validateStaticMarkerSourceAuthorship(
1568
+ artifacts: TemplateVisualEditingArtifact[],
1569
+ findings: string[],
1570
+ ) {
1571
+ const mutationPatterns = [
1572
+ /\b(?:setAttribute|setAttributeNS|toggleAttribute)\s*\(\s*['"`]data-preview-static['"`]/m,
1573
+ /\.(?:attr|prop)\s*\(\s*['"`]data-preview-static['"`]/m,
1574
+ /\bdataset\s*(?:\.\s*previewStatic|\[\s*['"`]previewStatic['"`]\s*\])\s*=/m,
1575
+ /\b(?:innerHTML|outerHTML)\s*\+?=[^;]{0,500}data-preview-static/m,
1576
+ /\.(?:replace|replaceAll)\s*\([\s\S]{0,500}?data-preview-static/m,
1577
+ ];
1578
+
1579
+ for (const artifact of artifacts.filter(
1580
+ (candidate) => candidate.kind === 'source',
1581
+ )) {
1582
+ const directMutation = mutationPatterns
1583
+ .map((pattern) => pattern.exec(artifact.content))
1584
+ .find((candidate): candidate is RegExpExecArray => Boolean(candidate));
1585
+ const staticMarkerOffset = artifact.content.search(/data-preview-static/);
1586
+ const writesFilesystemOutput =
1587
+ /\b(?:writeFile|writeFileSync|appendFile|appendFileSync)\s*\(/m.exec(
1588
+ artifact.content,
1589
+ );
1590
+ const isNonJsxScript = /\.[cm]?[jt]s$/i.test(artifact.filePath);
1591
+ const generatedFileMutation =
1592
+ staticMarkerOffset >= 0 && isNonJsxScript && writesFilesystemOutput;
1593
+ if (!directMutation && !generatedFileMutation) {
1594
+ continue;
1595
+ }
1596
+ const offset = directMutation?.index ?? staticMarkerOffset;
1597
+ findings.push(
1598
+ `${artifact.filePath}:${lineNumberAt(artifact.content, offset)} programmatically injects data-preview-static. Author each intentional static annotation directly on the smallest source element; blanket or post-build annotation bypasses strict editable-content validation.`,
1599
+ );
1600
+ }
1601
+ }
1602
+
1603
+ function validatePreviewRuntimeCapability(
1604
+ artifacts: TemplateVisualEditingArtifact[],
1605
+ findings: string[],
1606
+ ) {
1607
+ const sourceArtifacts = artifacts.filter(
1608
+ (artifact) => artifact.kind === 'source',
1609
+ );
1610
+ const source = sourceArtifacts.map((artifact) => artifact.content).join('\n');
1611
+
1612
+ const usesPackageProvider =
1613
+ /(?:import|export)\s+[\s\S]*?\b(?:SiteDataProvider|BaseSiteDataProvider|useSiteData|DenebProvider|DenebSiteDataProvider|DenebUiProvider)\b[\s\S]*?\bfrom\s+['"][^'"]*['"]/m.test(
1614
+ source,
1615
+ ) ||
1616
+ /(?:import|export)\s+[\s\S]*?\bfrom\s+['"](?:@fivora\/|deneb-ui|@deneb-ui\/)/m.test(
1617
+ source,
1618
+ ) ||
1619
+ /<(?:SiteDataProvider|BaseSiteDataProvider|DenebProvider|DenebSiteDataProvider|DenebUiProvider|[A-Za-z_$][\w$]*\.(?:SiteDataProvider|DenebProvider))\b/m.test(
1620
+ source,
1621
+ );
1622
+
1623
+ if (usesPackageProvider) {
1624
+ return;
1625
+ }
1626
+
1627
+ const hasDataProtocol =
1628
+ source.includes(PRIMARY_PREVIEW_DATA_MESSAGE) ||
1629
+ source.includes(LEGACY_PREVIEW_DATA_MESSAGE);
1630
+ const hasReadyProtocol =
1631
+ source.includes(PRIMARY_PREVIEW_READY_MESSAGE) ||
1632
+ source.includes(LEGACY_PREVIEW_READY_MESSAGE);
1633
+ const hasMessageListener =
1634
+ /(?:window|globalThis|self)\s*\.\s*addEventListener\s*\(\s*['"]message['"]/m.test(
1635
+ source,
1636
+ ) || /(?:window|globalThis|self)\s*\.\s*onmessage\s*=/m.test(source);
1637
+ const hasReactiveUpdate =
1638
+ /\bset[A-Z_$][\w$]*\s*\(/m.test(source) ||
1639
+ /\bdispatch\s*\(/m.test(source) ||
1640
+ /\b(?:store|siteDataStore)\s*\.\s*(?:setState|set|update)\s*\(/m.test(
1641
+ source,
1642
+ );
1643
+ const hasReactiveDataHandler = sourceArtifacts.some((artifact) => {
1644
+ const artifactSource = artifact.content;
1645
+ const listensForMessages =
1646
+ /(?:window|globalThis|self)\s*\.\s*addEventListener\s*\(\s*['"]message['"]/m.test(
1647
+ artifactSource,
1648
+ ) ||
1649
+ /(?:window|globalThis|self)\s*\.\s*onmessage\s*=/m.test(artifactSource);
1650
+ const updatesReactiveData =
1651
+ /\bset[A-Z_$][\w$]*\s*\(/m.test(artifactSource) ||
1652
+ /\bdispatch\s*\(/m.test(artifactSource) ||
1653
+ /\b(?:store|siteDataStore)\s*\.\s*(?:setState|set|update)\s*\(/m.test(
1654
+ artifactSource,
1655
+ );
1656
+ return (
1657
+ (artifactSource.includes(PRIMARY_PREVIEW_DATA_MESSAGE) ||
1658
+ artifactSource.includes(LEGACY_PREVIEW_DATA_MESSAGE)) &&
1659
+ listensForMessages &&
1660
+ updatesReactiveData
1661
+ );
1662
+ });
1663
+ const hasContextProvider =
1664
+ /\bcreateContext\s*[<(]/m.test(source) &&
1665
+ /(?:\.Provider\b|<[A-Za-z_$][\w$]*Provider\b)/m.test(source) &&
1666
+ /\buseContext\s*[<(]/m.test(source);
1667
+ const hasReactiveProvider =
1668
+ hasContextProvider ||
1669
+ /\buseSyncExternalStore\s*[<(]/m.test(source) ||
1670
+ (/\bcreate(?:Store|Signal)\s*[<(]/m.test(source) &&
1671
+ /\buse[A-Z_$][\w$]*(?:Store|Data)\s*[<(]/m.test(source));
1672
+ const emitsReady = sourceArtifacts.some(
1673
+ (artifact) =>
1674
+ (artifact.content.includes(PRIMARY_PREVIEW_READY_MESSAGE) ||
1675
+ artifact.content.includes(LEGACY_PREVIEW_READY_MESSAGE)) &&
1676
+ /\bpostMessage\s*\(/m.test(artifact.content),
1677
+ );
1678
+
1679
+ if (!hasDataProtocol) {
1680
+ findings.push(
1681
+ `Preview runtime source must implement the "${PRIMARY_PREVIEW_DATA_MESSAGE}" (or legacy "${LEGACY_PREVIEW_DATA_MESSAGE}") protocol so editor changes replace the rendered site data instead of remaining in a static JSON import.`,
1682
+ );
1683
+ }
1684
+ if (!hasReadyProtocol) {
1685
+ findings.push(
1686
+ `Preview runtime source must implement the "${PRIMARY_PREVIEW_READY_MESSAGE}" (or legacy "${LEGACY_PREVIEW_READY_MESSAGE}") protocol so the editor can wait for a live preview before sending data.`,
1687
+ );
1688
+ }
1689
+ if (!hasMessageListener || !hasReactiveUpdate || !hasReactiveDataHandler) {
1690
+ findings.push(
1691
+ `Preview runtime source must handle "${PRIMARY_PREVIEW_DATA_MESSAGE}" (or legacy "${LEGACY_PREVIEW_DATA_MESSAGE}") and apply its payload through a reactive state setter, reducer, or store update in the same live-data module.`,
1692
+ );
1693
+ }
1694
+ if (!hasReactiveProvider) {
1695
+ findings.push(
1696
+ 'Preview runtime source must expose live site data through a recognizable reactive provider/hook (for example createContext + Provider + useContext, useSyncExternalStore, or a reactive store hook). Static site-data.json imports alone cannot be certified.',
1697
+ );
1698
+ }
1699
+ if (!emitsReady) {
1700
+ findings.push(
1701
+ `Preview runtime source must call postMessage with "${PREVIEW_READY_MESSAGE}" after its live-data listener is ready.`,
1702
+ );
1703
+ }
1704
+ }
1705
+
1706
+ function auditUnmarkedVisibleHtml(
1707
+ artifacts: TemplateVisualEditingArtifact[],
1708
+ pages: TemplateVisualEditingPage[],
1709
+ ) {
1710
+ const warnings: string[] = [];
1711
+ for (const artifact of artifacts.filter(
1712
+ (candidate) =>
1713
+ candidate.kind === 'html' &&
1714
+ !isUnmanifestedFrameworkErrorDocument(candidate.filePath, pages),
1715
+ )) {
1716
+ warnings.push(...auditHtmlArtifact(artifact));
1717
+ }
1718
+ return warnings;
1719
+ }
1720
+
1721
+ function isUnmanifestedFrameworkErrorDocument(
1722
+ filePath: string,
1723
+ pages: TemplateVisualEditingPage[],
1724
+ ) {
1725
+ const normalizedFile = filePath.replace(/\\/g, '/').replace(/^\/+/, '');
1726
+ const frameworkErrorDocument =
1727
+ /^(?:404|500|_error|_not-found)(?:\/index)?\.html$/i.test(normalizedFile);
1728
+ if (!frameworkErrorDocument) {
1729
+ return false;
1730
+ }
1731
+
1732
+ return !pages.some((page) => {
1733
+ const route = normalizePageRoute(page.route);
1734
+ return route ? artifactMatchesRoute(normalizedFile, route) : false;
1735
+ });
1736
+ }
1737
+
1738
+ function auditHtmlArtifact(artifact: TemplateVisualEditingArtifact) {
1739
+ const findings: string[] = [];
1740
+ const stack: Array<{
1741
+ tag: string;
1742
+ ignored: boolean;
1743
+ hidden: boolean;
1744
+ fieldCovered: boolean;
1745
+ staticCovered: boolean;
1746
+ ownsFieldMarker: boolean;
1747
+ hasNestedFieldMarker: boolean;
1748
+ fieldPath: string | null;
1749
+ itemPath: string | null;
1750
+ shopAttributeValues: string[];
1751
+ visibleTextParts: string[];
1752
+ offset: number;
1753
+ }> = [];
1754
+ const tokens = artifact.content.matchAll(
1755
+ /<!--[\s\S]*?-->|<![^>]*>|<\/?[^>]+>|[^<]+/g,
1756
+ );
1757
+
1758
+ for (const tokenMatch of tokens) {
1759
+ const token = tokenMatch[0];
1760
+ const offset = tokenMatch.index ?? 0;
1761
+ const parent = stack.at(-1);
1762
+
1763
+ if (token.startsWith('<!--') || token.startsWith('<!')) {
1764
+ continue;
1765
+ }
1766
+
1767
+ if (token.startsWith('</')) {
1768
+ const closingTag = token
1769
+ .slice(2, -1)
1770
+ .trim()
1771
+ .split(/\s+/)[0]
1772
+ ?.toLowerCase();
1773
+ const matchingIndex = stack
1774
+ .map((entry) => entry.tag)
1775
+ .lastIndexOf(closingTag);
1776
+ if (matchingIndex >= 0) {
1777
+ findings.push(...auditFieldMarkerScope(artifact, stack[matchingIndex]));
1778
+ stack.splice(matchingIndex);
1779
+ }
1780
+ continue;
1781
+ }
1782
+
1783
+ if (token.startsWith('<')) {
1784
+ const tagMatch = token.match(/^<\s*([a-zA-Z0-9:-]+)/);
1785
+ if (!tagMatch) {
1786
+ continue;
1787
+ }
1788
+ const tag = tagMatch[1].toLowerCase();
1789
+ const hasFieldMarker = /\bdata-preview-field-path\s*=/.test(token);
1790
+ const hasListMarker = /\bdata-preview-list-path\s*=/.test(token);
1791
+ const hasItemMarker = /\bdata-preview-item-path\s*=/.test(token);
1792
+ const hasEditableMarker =
1793
+ hasFieldMarker || hasListMarker || hasItemMarker;
1794
+ if (hasFieldMarker) {
1795
+ for (const ancestor of stack) {
1796
+ if (ancestor.ownsFieldMarker) {
1797
+ ancestor.hasNestedFieldMarker = true;
1798
+ }
1799
+ }
1800
+ }
1801
+ const staticMatch = token.match(
1802
+ /\bdata-preview-static\s*=\s*(?:"([^"]*)"|'([^']*)')/,
1803
+ );
1804
+ const hasStaticMarker = Boolean(staticMatch);
1805
+ if (
1806
+ hasStaticMarker &&
1807
+ !(staticMatch?.[1] ?? staticMatch?.[2] ?? '').trim()
1808
+ ) {
1809
+ findings.push(
1810
+ `${artifact.filePath}:${lineNumberAt(artifact.content, offset)} data-preview-static requires a reason.`,
1811
+ );
1812
+ }
1813
+ if (hasEditableMarker && (hasStaticMarker || parent?.staticCovered)) {
1814
+ findings.push(
1815
+ `${artifact.filePath}:${lineNumberAt(artifact.content, offset)} data-preview field/list/item markers cannot be on or inside data-preview-static. The visual editor intentionally ignores targets beneath a static ancestor.`,
1816
+ );
1817
+ }
1818
+ if (hasFieldMarker && BROAD_CONTENT_CONTAINERS.has(tag)) {
1819
+ findings.push(
1820
+ `${artifact.filePath}:${lineNumberAt(artifact.content, offset)} data-preview-field-path cannot be placed on broad <${tag}> content containers. Put the marker on the exact visible text, media, link, or control that the field edits.`,
1821
+ );
1822
+ }
1823
+ if (hasStaticMarker && BROAD_CONTENT_CONTAINERS.has(tag)) {
1824
+ findings.push(
1825
+ `${artifact.filePath}:${lineNumberAt(artifact.content, offset)} data-preview-static cannot cover a broad <${tag}> content container. Mark only the smallest genuinely non-editable element.`,
1826
+ );
1827
+ }
1828
+
1829
+ const hidden = Boolean(parent?.hidden) || isHiddenHtmlElement(token, tag);
1830
+ if (hasEditableMarker && hidden) {
1831
+ findings.push(
1832
+ `${artifact.filePath}:${lineNumberAt(artifact.content, offset)} data-preview field/list/item marker is hidden. Strict visual-edit targets must remain visible and clickable in the exported page.`,
1833
+ );
1834
+ }
1835
+
1836
+ const fieldPath =
1837
+ token
1838
+ .match(/\bdata-preview-field-path\s*=\s*(?:"([^"]*)"|'([^']*)')/)
1839
+ ?.slice(1)
1840
+ .find(Boolean) ?? null;
1841
+ const ownItemPath =
1842
+ token
1843
+ .match(/\bdata-preview-item-path\s*=\s*(?:"([^"]*)"|'([^']*)')/)
1844
+ ?.slice(1)
1845
+ .find(Boolean) ?? null;
1846
+ if (
1847
+ ownItemPath &&
1848
+ parent?.itemPath &&
1849
+ !isFieldOwnedByRepeatedItem(ownItemPath, parent.itemPath)
1850
+ ) {
1851
+ findings.push(
1852
+ `${artifact.filePath}:${lineNumberAt(artifact.content, offset)} repeated item "${ownItemPath}" is nested inside unrelated item "${parent.itemPath}". One visual card must be one object-list item; do not couple parallel arrays by index.`,
1853
+ );
1854
+ }
1855
+ const itemPath = ownItemPath ?? parent?.itemPath ?? null;
1856
+ if (
1857
+ fieldPath &&
1858
+ itemPath &&
1859
+ /\[\d+\]/.test(fieldPath) &&
1860
+ !isFieldOwnedByRepeatedItem(fieldPath, itemPath)
1861
+ ) {
1862
+ findings.push(
1863
+ `${artifact.filePath}:${lineNumberAt(artifact.content, offset)} repeated field "${fieldPath}" is rendered inside item "${itemPath}" but belongs to a different list. Model one visual card as one object-list item instead of coupling parallel arrays by index.`,
1864
+ );
1865
+ }
1866
+
1867
+ const context = {
1868
+ tag,
1869
+ ignored: Boolean(parent?.ignored) || IGNORED_HTML_CONTAINERS.has(tag),
1870
+ hidden,
1871
+ fieldCovered: Boolean(parent?.fieldCovered) || hasFieldMarker,
1872
+ staticCovered: Boolean(parent?.staticCovered) || hasStaticMarker,
1873
+ ownsFieldMarker: hasFieldMarker,
1874
+ hasNestedFieldMarker: false,
1875
+ fieldPath,
1876
+ itemPath,
1877
+ shopAttributeValues: collectShopSensitiveAttributeValues(token, tag),
1878
+ visibleTextParts: [] as string[],
1879
+ offset,
1880
+ };
1881
+
1882
+ if (!context.ignored && !context.fieldCovered && !context.staticCovered) {
1883
+ const sensitiveFindings = auditSensitiveAttributes(
1884
+ artifact,
1885
+ token,
1886
+ tag,
1887
+ offset,
1888
+ );
1889
+ findings.push(...sensitiveFindings);
1890
+ if (
1891
+ tag === 'img' &&
1892
+ !context.hidden &&
1893
+ sensitiveFindings.length === 0
1894
+ ) {
1895
+ findings.push(
1896
+ `${artifact.filePath}:${lineNumberAt(artifact.content, offset)} rendered <img> is not covered by data-preview-field-path or data-preview-static. Bind business media to an exact image field, or mark an intentional fixed/decorative image static with a reason.`,
1897
+ );
1898
+ }
1899
+ }
1900
+
1901
+ if (!VOID_HTML_TAGS.has(tag) && !/\/\s*>$/.test(token)) {
1902
+ stack.push(context);
1903
+ }
1904
+ continue;
1905
+ }
1906
+
1907
+ const visibleText = normalizeVisibleText(token);
1908
+ if (isMeaningfulVisibleText(visibleText)) {
1909
+ for (const ancestor of stack) {
1910
+ if (ancestor.ownsFieldMarker) {
1911
+ ancestor.visibleTextParts.push(visibleText);
1912
+ }
1913
+ }
1914
+ }
1915
+ if (parent?.ignored || parent?.fieldCovered || parent?.staticCovered) {
1916
+ continue;
1917
+ }
1918
+
1919
+ if (isMeaningfulVisibleText(visibleText)) {
1920
+ findings.push(
1921
+ `${artifact.filePath}:${lineNumberAt(artifact.content, offset)} visible text "${truncate(visibleText, 72)}" is not covered by data-preview-field-path or data-preview-static.`,
1922
+ );
1923
+ }
1924
+ }
1925
+
1926
+ return findings;
1927
+ }
1928
+
1929
+ function isFieldOwnedByRepeatedItem(fieldPath: string, itemPath: string) {
1930
+ const normalizedField = decodeHtmlAttribute(fieldPath).trim();
1931
+ const normalizedItem = decodeHtmlAttribute(itemPath).trim();
1932
+ return (
1933
+ normalizedField === normalizedItem ||
1934
+ normalizedField.startsWith(`${normalizedItem}.`) ||
1935
+ normalizedField.startsWith(`${normalizedItem}[`)
1936
+ );
1937
+ }
1938
+
1939
+ function auditFieldMarkerScope(
1940
+ artifact: TemplateVisualEditingArtifact,
1941
+ entry: {
1942
+ tag: string;
1943
+ ownsFieldMarker: boolean;
1944
+ hasNestedFieldMarker: boolean;
1945
+ fieldPath: string | null;
1946
+ shopAttributeValues: string[];
1947
+ visibleTextParts: string[];
1948
+ offset: number;
1949
+ },
1950
+ ) {
1951
+ if (
1952
+ !entry.ownsFieldMarker ||
1953
+ entry.hasNestedFieldMarker ||
1954
+ entry.shopAttributeValues.length === 0
1955
+ ) {
1956
+ return [];
1957
+ }
1958
+
1959
+ const visibleText = normalizeVisibleText(entry.visibleTextParts.join(' '));
1960
+ if (
1961
+ !isMeaningfulVisibleText(visibleText) ||
1962
+ entry.shopAttributeValues.some((value) =>
1963
+ attributeValueMatchesVisibleText(value, visibleText),
1964
+ )
1965
+ ) {
1966
+ return [];
1967
+ }
1968
+
1969
+ return [
1970
+ `${artifact.filePath}:${lineNumberAt(artifact.content, entry.offset)} data-preview-field-path="${entry.fieldPath ?? ''}" cannot cover both <${entry.tag}> action/media attributes and different visible text "${truncate(visibleText, 72)}". Put the action/media marker on the element and the label marker on an exact nested text element.`,
1971
+ ];
1972
+ }
1973
+
1974
+ function collectShopSensitiveAttributeValues(token: string, tag: string) {
1975
+ return [
1976
+ ...token.matchAll(/\b(href|src|poster)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi),
1977
+ ]
1978
+ .map((attribute) => ({
1979
+ name: attribute[1].toLowerCase(),
1980
+ value: decodeHtmlAttribute(attribute[2] ?? attribute[3] ?? '').trim(),
1981
+ }))
1982
+ .filter(({ name, value }) => isShopSensitiveAttribute(tag, name, value))
1983
+ .map(({ value }) => value);
1984
+ }
1985
+
1986
+ function attributeValueMatchesVisibleText(value: string, visibleText: string) {
1987
+ const normalizeComparable = (candidate: string) =>
1988
+ decodeHtmlAttribute(candidate)
1989
+ .replace(/^(?:mailto:|tel:|sms:|https?:\/\/)/i, '')
1990
+ .replace(/^www\./i, '')
1991
+ .replace(/\/+$/g, '')
1992
+ .replace(/\s+/g, '')
1993
+ .toLowerCase();
1994
+
1995
+ return normalizeComparable(value) === normalizeComparable(visibleText);
1996
+ }
1997
+
1998
+ function auditSensitiveAttributes(
1999
+ artifact: TemplateVisualEditingArtifact,
2000
+ token: string,
2001
+ tag: string,
2002
+ offset: number,
2003
+ ) {
2004
+ const findings: string[] = [];
2005
+ const attributes = [
2006
+ ...token.matchAll(/\b(href|src|poster)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi),
2007
+ ];
2008
+
2009
+ for (const attribute of attributes) {
2010
+ const name = attribute[1].toLowerCase();
2011
+ const value = decodeHtmlAttribute(
2012
+ attribute[2] ?? attribute[3] ?? '',
2013
+ ).trim();
2014
+ if (!isShopSensitiveAttribute(tag, name, value)) {
2015
+ continue;
2016
+ }
2017
+ findings.push(
2018
+ `${artifact.filePath}:${lineNumberAt(artifact.content, offset)} ${tag}[${name}="${truncate(value, 72)}"] is not covered by data-preview-field-path or data-preview-static.`,
2019
+ );
2020
+ }
2021
+
2022
+ const visibleTextAttributes = [
2023
+ ...token.matchAll(
2024
+ /\b(placeholder|alt|title|aria-label|value)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi,
2025
+ ),
2026
+ ];
2027
+ const inputTypeMatch = token.match(/\btype\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
2028
+ const inputType = (
2029
+ inputTypeMatch?.[1] ??
2030
+ inputTypeMatch?.[2] ??
2031
+ ''
2032
+ ).toLowerCase();
2033
+
2034
+ for (const attribute of visibleTextAttributes) {
2035
+ const name = attribute[1].toLowerCase();
2036
+ const value = normalizeVisibleText(attribute[2] ?? attribute[3] ?? '');
2037
+ const relevant =
2038
+ name === 'aria-label' ||
2039
+ name === 'title' ||
2040
+ (name === 'alt' && tag === 'img') ||
2041
+ (name === 'placeholder' && ['input', 'textarea'].includes(tag)) ||
2042
+ (name === 'value' &&
2043
+ ['input', 'button'].includes(tag) &&
2044
+ !['hidden', 'checkbox', 'radio'].includes(inputType));
2045
+ if (!relevant || !isMeaningfulVisibleText(value)) {
2046
+ continue;
2047
+ }
2048
+ findings.push(
2049
+ `${artifact.filePath}:${lineNumberAt(artifact.content, offset)} ${tag}[${name}="${truncate(value, 72)}"] is not covered by data-preview-field-path or data-preview-static.`,
2050
+ );
2051
+ }
2052
+
2053
+ const style = token.match(/\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
2054
+ if (
2055
+ style &&
2056
+ /(?:background|background-image)\s*:[^;]*url\(/i.test(
2057
+ style[1] ?? style[2] ?? '',
2058
+ )
2059
+ ) {
2060
+ findings.push(
2061
+ `${artifact.filePath}:${lineNumberAt(artifact.content, offset)} background image is not covered by data-preview-field-path or data-preview-static.`,
2062
+ );
2063
+ }
2064
+
2065
+ return findings;
2066
+ }
2067
+
2068
+ function isHiddenHtmlElement(token: string, tag: string) {
2069
+ if (
2070
+ /\bhidden(?:\s|=|\/?>)/i.test(token) ||
2071
+ /\baria-hidden\s*=\s*(?:"true"|'true')/i.test(token)
2072
+ ) {
2073
+ return true;
2074
+ }
2075
+
2076
+ if (tag === 'input') {
2077
+ const inputType = token.match(/\btype\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
2078
+ if (
2079
+ (inputType?.[1] ?? inputType?.[2] ?? '').trim().toLowerCase() === 'hidden'
2080
+ ) {
2081
+ return true;
2082
+ }
2083
+ }
2084
+
2085
+ const style = token.match(/\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
2086
+ const styleValue = style?.[1] ?? style?.[2] ?? '';
2087
+ return (
2088
+ /\bdisplay\s*:\s*none\b/i.test(styleValue) ||
2089
+ /\bvisibility\s*:\s*hidden\b/i.test(styleValue)
2090
+ );
2091
+ }
2092
+
2093
+ function validateSchemaSectionUniqueness(
2094
+ schema: TemplateEditorSchema | null,
2095
+ findings: string[],
2096
+ ) {
2097
+ const seenIds = new Set<string>();
2098
+ const seenPaths = new Set<string>();
2099
+
2100
+ for (const section of schema?.sections ?? []) {
2101
+ if (seenIds.has(section.id)) {
2102
+ findings.push(
2103
+ `editorSchema.sections contains duplicate id "${section.id}".`,
2104
+ );
2105
+ }
2106
+ if (seenPaths.has(section.path)) {
2107
+ findings.push(
2108
+ `editorSchema.sections contains duplicate path "${section.path}".`,
2109
+ );
2110
+ }
2111
+ seenIds.add(section.id);
2112
+ seenPaths.add(section.path);
2113
+ }
2114
+ }
2115
+
2116
+ function validateSchemaPathUniqueness(
2117
+ schema: TemplateEditorSchema | null,
2118
+ findings: string[],
2119
+ ) {
2120
+ for (const duplicate of findDuplicateTemplateEditorPaths(schema)) {
2121
+ const kind =
2122
+ duplicate.firstKind === duplicate.duplicateKind
2123
+ ? duplicate.firstKind
2124
+ : 'field/list';
2125
+ findings.push(
2126
+ `editorSchema declares duplicate editable ${kind} path "${duplicate.path}" in sections "${duplicate.firstSectionId}" and "${duplicate.duplicateSectionId}". Each editable path must be owned by exactly one section.`,
2127
+ );
2128
+ }
2129
+ }
2130
+
2131
+ function isShopSensitiveAttribute(tag: string, name: string, value: string) {
2132
+ if (
2133
+ !value ||
2134
+ value.startsWith('data:') ||
2135
+ value.startsWith('blob:') ||
2136
+ value.includes('/_next/') ||
2137
+ value.includes('${')
2138
+ ) {
2139
+ return false;
2140
+ }
2141
+
2142
+ if (name === 'href') {
2143
+ return tag === 'a' && !/^(?:javascript:|data:|blob:)/i.test(value);
2144
+ }
2145
+
2146
+ return (
2147
+ (name === 'src' && ['img', 'video', 'source'].includes(tag)) ||
2148
+ (name === 'poster' && tag === 'video')
2149
+ );
2150
+ }
2151
+
2152
+ function normalizeVisibleText(value: string) {
2153
+ return decodeHtmlAttribute(value).replace(/\s+/g, ' ').trim();
2154
+ }
2155
+
2156
+ function isMeaningfulVisibleText(value: string) {
2157
+ if (value.length <= 2 || !/\p{L}/u.test(value)) {
2158
+ return false;
2159
+ }
2160
+ return !/^(?:true|false|null|undefined)$/i.test(value);
2161
+ }
2162
+
2163
+ function markerAttributeFragment(label: string) {
2164
+ if (label === 'editable field') return 'field-path';
2165
+ if (label === 'editable list') return 'list-path';
2166
+ return 'item-path';
2167
+ }
2168
+
2169
+ function markerCoversPattern(marker: string, expectedPattern: string) {
2170
+ if (!expectedPattern.includes('[*]')) {
2171
+ return marker === expectedPattern;
2172
+ }
2173
+ return marker.includes('[*]') && marker === expectedPattern;
2174
+ }
2175
+
2176
+ function pathsOverlap(left: string, right: string) {
2177
+ return wildcardPath(left) === wildcardPath(right);
2178
+ }
2179
+
2180
+ function isControlOnly(path: string, controlOnlyPaths: string[]) {
2181
+ return controlOnlyPaths.some((controlPath) =>
2182
+ controlPath.includes('[*]')
2183
+ ? wildcardPath(path) === controlPath
2184
+ : path === controlPath,
2185
+ );
2186
+ }
2187
+
2188
+ function canonicalizeMarkerPath(value: string) {
2189
+ const normalized = value
2190
+ .trim()
2191
+ .replace(/\[\s*\$\{[^}]+\}\s*\]/g, '[*]')
2192
+ .replace(/\[\s+/g, '[')
2193
+ .replace(/\s+\]/g, ']');
2194
+ return CANONICAL_PATH_PATTERN.test(normalized) ? normalized : null;
2195
+ }
2196
+
2197
+ function wildcardPath(path: string) {
2198
+ return path.replace(/\[\d+\]/g, '[*]');
2199
+ }
2200
+
2201
+ function appendPath(basePath: string, key: string) {
2202
+ return basePath ? `${basePath}.${key}` : key;
2203
+ }
2204
+
2205
+ function normalizePageRoute(route?: string) {
2206
+ if (!route) {
2207
+ return null;
2208
+ }
2209
+ const trimmed = route.trim();
2210
+ if (
2211
+ !trimmed.startsWith('/') ||
2212
+ trimmed.includes('?') ||
2213
+ trimmed.includes('#') ||
2214
+ trimmed.includes('\\') ||
2215
+ trimmed.includes('..')
2216
+ ) {
2217
+ return null;
2218
+ }
2219
+ if (trimmed === '/') {
2220
+ return trimmed;
2221
+ }
2222
+ return trimmed.replace(/\/+$/, '');
2223
+ }
2224
+
2225
+ function legacyPageRoute(pageId: string) {
2226
+ return pageId === 'home' ? '/' : `/${pageId}`;
2227
+ }
2228
+
2229
+ function artifactMatchesRoute(filePath: string, route: string) {
2230
+ const normalizedFile = filePath.replace(/\\/g, '/').replace(/^\/+/, '');
2231
+ if (route === '/') {
2232
+ return normalizedFile === 'index.html';
2233
+ }
2234
+ const routePath = route.replace(/^\/+/, '');
2235
+ return (
2236
+ normalizedFile === `${routePath}.html` ||
2237
+ normalizedFile === `${routePath}/index.html`
2238
+ );
2239
+ }
2240
+
2241
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
2242
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
2243
+ }
2244
+
2245
+ function decodeHtmlAttribute(value: string) {
2246
+ return value
2247
+ .replace(/&quot;/gi, '"')
2248
+ .replace(/&#39;|&#x27;/gi, "'")
2249
+ .replace(/&lt;/gi, '<')
2250
+ .replace(/&gt;/gi, '>')
2251
+ .replace(/&amp;/gi, '&')
2252
+ .replace(/&nbsp;/gi, ' ');
2253
+ }
2254
+
2255
+ function lineNumberForMarker(marker: TemplateVisualEditMarker) {
2256
+ return marker.line;
2257
+ }
2258
+
2259
+ function lineNumberAt(content: string, offset: number) {
2260
+ let line = 1;
2261
+ for (let index = 0; index < offset; index += 1) {
2262
+ if (content.charCodeAt(index) === 10) {
2263
+ line += 1;
2264
+ }
2265
+ }
2266
+ return line;
2267
+ }
2268
+
2269
+ function uniqueSorted(values: string[]) {
2270
+ return [...new Set(values)].sort();
2271
+ }
2272
+
2273
+ function truncate(value: string, maxLength: number) {
2274
+ return value.length <= maxLength
2275
+ ? value
2276
+ : `${value.slice(0, Math.max(0, maxLength - 1))}…`;
2277
+ }