@deneb-ui/cli 2.0.51 → 2.0.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/index.js CHANGED
@@ -187,6 +187,18 @@ function detectPages(projectDir) {
187
187
  continue;
188
188
  }
189
189
  if (!foundRoutes.has('/' + routeName)) {
190
+ const pageFile = ['page.tsx', 'page.jsx', 'page.js']
191
+ .map((name) => path.join(cDir, routeName, name))
192
+ .find((file) => fs.existsSync(file));
193
+ const pagesFile = ['tsx', 'jsx', 'js']
194
+ .map((ext) => path.join(cDir, `${routeName}.${ext}`))
195
+ .find((file) => fs.existsSync(file));
196
+ if (!pageFile && !pagesFile && path.basename(cDir) !== 'pages' && path.basename(cDir) !== 'src') {
197
+ continue;
198
+ }
199
+ if (!pageFile && path.basename(cDir) !== 'pages' && !cDir.endsWith(`${path.sep}pages`)) {
200
+ continue;
201
+ }
190
202
  foundRoutes.add('/' + routeName);
191
203
  const label = routeName
192
204
  .replace(/[-_]/g, ' ')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deneb-ui/cli",
3
- "version": "2.0.51",
3
+ "version": "2.0.52",
4
4
  "description": "Official DENEB CLI — scaffold, convert, validate, and package Fivora-ready storefront templates.",
5
5
  "bin": {
6
6
  "deneb": "bin/index.js",
@@ -49,7 +49,7 @@
49
49
  "license": "MIT",
50
50
  "dependencies": {
51
51
  "@babel/parser": "^7.28.0",
52
- "@deneb-ui/core": "^2.0.51",
52
+ "@deneb-ui/core": "^2.0.52",
53
53
  "@octokit/rest": "^22.0.1",
54
54
  "adm-zip": "^0.6.0",
55
55
  "dotenv": "^17.4.2",
@@ -158,8 +158,8 @@ test('AST transformer preserves className and uses nullish fallbacks', () => {
158
158
  assert.match(result.code, /data-preview-field-path=/);
159
159
  assert.match(result.code, /\?\?/);
160
160
  assert.match(result.code, /<span[\s\S]*data-preview-field-path="/);
161
- assert.match(result.code, /<span[^>]*hidden[^>]*data-preview-field-path="/);
162
- assert.match(result.code, /data-preview-static="action-link"/);
161
+ assert.match(result.code, /<a\b[^>]*data-preview-field-path=/);
162
+ assert.doesNotMatch(result.code, /<span[^>]*hidden[^>]*data-preview-field-path/);
163
163
  assert.doesNotMatch(result.code, /'use client'/);
164
164
  assert.match(result.code, /site-data\.json|@\/data\/site-data\.json/);
165
165
  assert.match(result.code, /data-preview-style-target=/);
@@ -409,12 +409,14 @@ test('static-array collections become list contracts without changing render log
409
409
  assert.equal(siteData.content.home.products[0].title, 'Minimalist Smart Watch');
410
410
  });
411
411
 
412
- test('collections holding component references are left alone', () => {
412
+ test('collections holding component references still bind primitive item fields', () => {
413
413
  const dir = copyOf(STOREFRONT_FIXTURE);
414
414
  silence(() => runDenebArc(dir, 'acme-store', { telemetry: 'off' }));
415
415
  const features = fs.readFileSync(path.join(dir, 'src', 'components', 'Features.tsx'), 'utf8');
416
- assert.ok(!features.includes('data-preview-list-path'), 'icon component refs are not merchant content');
416
+ assert.match(features, /data-preview-list-path=/);
417
417
  assert.match(features, /icon: Truck/);
418
+ assert.match(features, /data-preview-static="component-ref"/);
419
+ assert.match(features, /data-preview-field-path=\{`[^`]*\.title`\}/);
418
420
  });
419
421
 
420
422
  test('literal text inside a broad container is wrapped instead of marked illegally', () => {
@@ -435,8 +437,8 @@ test('shadcn Button asChild keeps the action on the link and the label in a span
435
437
  assert.match(hero, /<Button asChild>/);
436
438
  assert.match(hero, /href=\{siteData\?\.content\?\.home\?\.hero\?\.shopCollectionUrl \?\? "\/products"\}/);
437
439
  assert.match(hero, /<span[\s\S]*data-preview-field-path="home\.hero\.shopCollectionLabel"/);
438
- // The decorative icon stays static.
439
- assert.match(hero, /<ArrowRight className="ml-2 size-4" aria-hidden="true" \/>/);
440
+ // The decorative icon stays static (may also receive data-preview-static).
441
+ assert.match(hero, /<ArrowRight[^>]*className="ml-2 size-4"[^>]*aria-hidden="true"/);
440
442
  });
441
443
 
442
444
  test('a second init run converges on identical sources and manifest', () => {
@@ -821,11 +823,12 @@ export function ActionPanel() {
821
823
  assert.match(result.code, /<a\s+[^>]*href=\{siteData\?\.content\?\.home\?\.hero\?\.whatsappUrl \?\? "https:\/\/wa\.me\/1234567890"\}/);
822
824
  assert.match(result.code, /target="_blank"/);
823
825
  assert.match(result.code, /rel="noopener noreferrer"/);
824
- assert.match(result.code, /data-preview-static="action-link"/);
826
+ // URL field path is bound directly to outer <a> element (never hidden, never static conflict)
827
+ assert.match(result.code, /<a\s+[^>]*data-preview-field-path=\{?"home\.hero\.whatsappUrl"?\}/);
825
828
  // Text label wrapped in editable span
826
829
  assert.match(result.code, /<span\s+data-preview-field-path="home\.hero\.whatsappLabel"[^>]*>\{siteData\?\.content\?\.home\?\.hero\?\.whatsappLabel \?\? "Order on WhatsApp"\}<\/span>/);
827
- // Hidden URL span present for Fivora contract
828
- assert.match(result.code, /<span hidden aria-hidden="true" data-preview-field-path="home\.hero\.whatsappUrl">/);
830
+ // Never emit hidden preview markers
831
+ assert.doesNotMatch(result.code, /<span[^>]*hidden[^>]*data-preview-field-path/);
829
832
  // No parse errors
830
833
  assert.doesNotThrow(() => parseSource(result.code, 'ActionPanel.tsx'));
831
834
  });
@@ -1333,9 +1336,91 @@ export function ContactSection() {
1333
1336
  assert.ok(transformed.changed, 'file must be changed');
1334
1337
  assert.match(transformed.code, /data-preview-field-path/);
1335
1338
  assert.match(transformed.code, /formSubmitLabel/);
1336
- assert.match(transformed.code, /formWhatsappUrl/);
1337
1339
  assert.match(transformed.code, /type="submit"/);
1338
- assert.match(transformed.code, /hidden/);
1340
+ assert.doesNotMatch(transformed.code, /<span[^>]*hidden/);
1341
+ assert.doesNotMatch(transformed.code, /data-preview-static[\s\S]*data-preview-field-path/);
1342
+ });
1343
+
1344
+ test('auditMarkerPlacement detects and rejects hidden preview markers', () => {
1345
+ const hiddenCode = `
1346
+ <div>
1347
+ <span className="hidden" aria-hidden="true" data-preview-field-path="home.hero.buttonUrl">https://wa.me/123</span>
1348
+ <span>Click me</span>
1349
+ </div>
1350
+ `;
1351
+ const errors = contract.auditMarkerPlacement(hiddenCode, 'TestHidden.tsx');
1352
+ assert.ok(errors.some((e) => e.includes('data-preview field/list/item marker is hidden')));
1353
+ });
1354
+
1355
+ test('auditCoupledListMarkers detects and rejects coupling parallel list arrays by index', () => {
1356
+ const coupledCode = `
1357
+ <div data-preview-item-path="seasonal.items[0]">
1358
+ <span data-preview-field-path="seasonal.preOrderCta[0].buttonLabel">Pre-Order</span>
1359
+ </div>
1360
+ `;
1361
+ const errors = contract.auditCoupledListMarkers(coupledCode, 'TestCoupled.tsx');
1362
+ assert.ok(errors.some((e) => e.includes('belongs to a different list')));
1363
+ });
1364
+
1365
+ test('detectedPages without a page file are not invented in the manifest', () => {
1366
+ const dir = copyOf(FIXTURE);
1367
+ silence(() =>
1368
+ runDenebArc(dir, 'basic-store', {
1369
+ telemetry: 'off',
1370
+ detectedPages: [{ id: 'contact', label: 'Contact', route: '/contact', required: true }],
1371
+ })
1372
+ );
1373
+ const { manifest } = auditFivora(dir);
1374
+ assert.ok(!manifest.pages.some((page) => page.id === 'contact' || page.route === '/contact'));
1375
+ });
1376
+
1377
+ test('learning records contract failure instead of success', () => {
1378
+ const { honestOutcome } = require('../learning.cjs');
1379
+ assert.equal(
1380
+ honestOutcome({ syntaxPassed: true, contractPassed: true, fivoraContractPassed: false }, 'success'),
1381
+ 'failure'
1382
+ );
1383
+ assert.equal(
1384
+ honestOutcome({ syntaxPassed: true, contractPassed: true, fivoraContractPassed: true, uncoveredVisibleText: 0 }, 'success'),
1385
+ 'success'
1386
+ );
1387
+ });
1388
+
1389
+ test('residual pass marks leftover decorative copy static with a reason', () => {
1390
+ const { applyResidualPass } = require('../residual.cjs');
1391
+ const code = `export function Chrome() { return <button aria-hidden="true">Close</button>; }`;
1392
+ const result = applyResidualPass({
1393
+ code,
1394
+ file: 'src/components/Chrome.tsx',
1395
+ ownerScope: 'home',
1396
+ usedPaths: new Set(),
1397
+ });
1398
+ assert.equal(result.changed, true);
1399
+ assert.match(result.code, /data-preview-static="[^"]+"/);
1400
+ });
1401
+
1402
+ test('style-bind grid and card attributes are written onto collection nodes', () => {
1403
+ const dir = copyOf(STOREFRONT_FIXTURE);
1404
+ silence(() => runDenebArc(dir, 'acme-store', { telemetry: 'off' }));
1405
+ const grid = fs.readFileSync(path.join(dir, 'src', 'components', 'ProductGrid.tsx'), 'utf8');
1406
+ assert.match(grid, /data-preview-style-type="grid"/);
1407
+ assert.match(grid, /data-preview-style-type="card"/);
1408
+ assert.match(grid, /data-preview-style-target="home\.products\.grid"/);
1409
+ });
1410
+
1411
+ test('ContactActions stays mounted when all channels are empty', () => {
1412
+ const sourcePath = path.join(__dirname, '..', '..', '..', '..', '..', 'packages', 'deneb-ui', 'src', 'contact', 'ContactActions.tsx');
1413
+ const source = fs.readFileSync(sourcePath, 'utf8');
1414
+ assert.doesNotMatch(source, /if \(!hasPhone && !hasWhatsApp && !hasEmail\)/);
1415
+ assert.doesNotMatch(source, /return null/);
1416
+ });
1417
+
1418
+ test('empty-state source audit flags gated preview markers', () => {
1419
+ const errors = contract.auditEmptyStateSource(
1420
+ '{title && <span data-preview-field-path="home.hero.title">{title}</span>}',
1421
+ 'Hero.tsx'
1422
+ );
1423
+ assert.ok(errors.some((error) => error.includes('gated behind')));
1339
1424
  });
1340
1425
 
1341
1426
 
@@ -8,6 +8,9 @@
8
8
  * locally rather than at upload time.
9
9
  */
10
10
 
11
+ const recast = require('recast');
12
+ const { parseSource, getJsxName, hasJsxAttribute, collectJsxText } = require('./ast.cjs');
13
+
11
14
  const MARKER_ATTRIBUTE_TO_KIND = {
12
15
  'data-preview-field-path': 'field',
13
16
  'data-preview-list-path': 'list',
@@ -214,6 +217,17 @@ function auditMarkerPlacement(code, filePath) {
214
217
  const staticMatch = attrs.match(/\bdata-preview-static\s*=\s*(?:"([^"]*)"|'([^']*)')/);
215
218
  const hasStatic = /\bdata-preview-static\b/.test(attrs);
216
219
 
220
+ const isHidden =
221
+ /\bhidden(?:[\s=]|\/?>)/i.test(attrs) ||
222
+ /\baria-hidden\s*=\s*(?:"true"|'true'|\{\s*true\s*\})/i.test(attrs) ||
223
+ /\bstyle\s*=\s*\{\s*\{[\s\S]*?\b(?:display\s*:\s*['"]none['"]|visibility\s*:\s*['"]hidden['"])[\s\S]*?\}\s*\}/i.test(attrs) ||
224
+ /\bclassName\s*=\s*(?:"[^"]*\bhidden\b[^"]*"|'[^']*\bhidden\b[^']*'|\{\s*`[^`]*\bhidden\b[^`]*`\s*\})/i.test(attrs);
225
+
226
+ if ((hasField || hasList || hasItem) && isHidden) {
227
+ errors.push(
228
+ `${filePath}:${line} data-preview field/list/item marker is hidden. Strict visual-edit targets must remain visible and clickable in the exported page.`
229
+ );
230
+ }
217
231
  if (hasStatic && !(staticMatch?.[1] ?? staticMatch?.[2] ?? '').trim()) {
218
232
  errors.push(`${filePath}:${line} data-preview-static requires a reason.`);
219
233
  }
@@ -234,6 +248,33 @@ function auditMarkerPlacement(code, filePath) {
234
248
  }
235
249
  }
236
250
 
251
+ errors.push(...auditCoupledListMarkers(code, filePath));
252
+ return errors;
253
+ }
254
+
255
+ /**
256
+ * Detects coupling parallel arrays by index: rendering a field from list B inside an item belonging to list A.
257
+ */
258
+ function auditCoupledListMarkers(code, filePath) {
259
+ const errors = [];
260
+ const itemBlockPattern = /<\s*([a-zA-Z][a-zA-Z0-9.:-]*)((?:[^<>{}]|\{[^{}]*\})*?)\bdata-preview-item-path\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*`([^`]*)`\s*\})([\s\S]*?)<\/\1>/g;
261
+ for (const match of String(code).matchAll(itemBlockPattern)) {
262
+ const itemPath = match[3] ?? match[4] ?? match[5] ?? '';
263
+ const inner = match[6] || '';
264
+ if (!itemPath) continue;
265
+ const baseList = itemPath.replace(/\[[^\]]*\]$/, '');
266
+
267
+ for (const fieldMatch of inner.matchAll(/\bdata-preview-field-path\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*`([^`]*)`\s*\})/g)) {
268
+ const fieldPath = fieldMatch[1] ?? fieldMatch[2] ?? fieldMatch[3] ?? '';
269
+ if (!fieldPath || !/\[\d+\]/.test(fieldPath)) continue;
270
+ const fieldList = fieldPath.replace(/\[\d+\][\s\S]*$/, '');
271
+ if (fieldList && baseList && fieldList !== baseList) {
272
+ errors.push(
273
+ `${filePath}:${lineNumberAt(code, match.index ?? 0)} 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.`
274
+ );
275
+ }
276
+ }
277
+ }
237
278
  return errors;
238
279
  }
239
280
 
@@ -475,8 +516,71 @@ function auditPreviewRuntime(sources) {
475
516
  /**
476
517
  * Finds meaningful visible JSX text that is neither bound to a field nor marked
477
518
  * static. Strict mode treats these as errors in the exported HTML.
519
+ * Prefers an AST walk so TypeScript generics and nested tags are not misread.
478
520
  */
479
521
  function findUncoveredVisibleText(code, filePath) {
522
+ let ast;
523
+ try {
524
+ ast = parseSource(code, filePath);
525
+ } catch {
526
+ return findUncoveredVisibleTextRegex(code, filePath);
527
+ }
528
+
529
+ const findings = [];
530
+ recast.types.visit(ast, {
531
+ visitJSXElement(pathNode) {
532
+ const node = pathNode.node;
533
+ const tag = getJsxName(node);
534
+ const lower = String(tag || '').toLowerCase();
535
+ if (!tag || lower === 'option' || lower === 'script' || lower === 'style') {
536
+ this.traverse(pathNode);
537
+ return;
538
+ }
539
+ if (
540
+ hasJsxAttribute(node, 'data-preview-field-path') ||
541
+ hasJsxAttribute(node, 'data-preview-static')
542
+ ) {
543
+ this.traverse(pathNode);
544
+ return;
545
+ }
546
+ if (ancestorHasStaticMarker(pathNode)) {
547
+ this.traverse(pathNode);
548
+ return;
549
+ }
550
+ const text = collectJsxText(node);
551
+ if (isMeaningfulUncoveredText(text)) {
552
+ findings.push({
553
+ tag,
554
+ text,
555
+ filePath,
556
+ line: node.loc?.start?.line || 1,
557
+ });
558
+ }
559
+ this.traverse(pathNode);
560
+ },
561
+ });
562
+ return findings;
563
+ }
564
+
565
+ function ancestorHasStaticMarker(pathNode) {
566
+ let current = pathNode.parent;
567
+ while (current) {
568
+ const node = current.node || current.value;
569
+ if (node && node.type === 'JSXElement' && hasJsxAttribute(node, 'data-preview-static')) return true;
570
+ current = current.parentPath || current.parent;
571
+ }
572
+ return false;
573
+ }
574
+
575
+ function isMeaningfulUncoveredText(text) {
576
+ const value = String(text || '').replace(/\s+/g, ' ').trim();
577
+ if (!value || value.length <= 2 || !/\p{L}/u.test(value)) return false;
578
+ if (!/^[\p{L}\p{N}"'(¡¿#$€£]/u.test(value)) return false;
579
+ if (/^(?:true|false|null|undefined)$/i.test(value)) return false;
580
+ return true;
581
+ }
582
+
583
+ function findUncoveredVisibleTextRegex(code, filePath) {
480
584
  const findings = [];
481
585
  const source = String(code);
482
586
  const elementPattern = /<\s*([a-zA-Z][a-zA-Z0-9.:-]*)((?:[^<>{}]|\{[^{}]*\})*?)>([^<>{}]*)</g;
@@ -487,13 +591,9 @@ function findUncoveredVisibleText(code, filePath) {
487
591
  const text = (match[3] || '').replace(/\s+/g, ' ').trim();
488
592
  const offset = match.index ?? 0;
489
593
 
490
- // A `<` preceded by an identifier character is a TypeScript generic
491
- // (forwardRef<HTMLButtonElement, Props>), never a JSX element.
492
594
  if (/[\w$)\]]/.test(source[offset - 1] || '')) continue;
493
595
  if (tag.toLowerCase() === 'option') continue;
494
- if (!text || text.length <= 2 || !/\p{L}/u.test(text)) continue;
495
- if (!/^[\p{L}\p{N}"'(¡¿#$€£]/u.test(text)) continue;
496
- if (/^(?:true|false|null|undefined)$/i.test(text)) continue;
596
+ if (!isMeaningfulUncoveredText(text)) continue;
497
597
  if (/\bdata-preview-(?:field-path|static)\b/.test(attrs)) continue;
498
598
 
499
599
  findings.push({ tag, text, filePath, line: lineNumberAt(source, offset) });
@@ -502,6 +602,161 @@ function findUncoveredVisibleText(code, filePath) {
502
602
  return findings;
503
603
  }
504
604
 
605
+ function auditEmptyStateSource(code, filePath) {
606
+ const errors = [];
607
+ const source = String(code);
608
+ const andPattern =
609
+ /\{([^{}]{0,120}?)\s*&&\s*\(?\s*<\s*([a-zA-Z][\w.:-]*)([^>]*data-preview-(?:field-path|list-path|item-path)[^>]*)>/g;
610
+ for (const match of source.matchAll(andPattern)) {
611
+ errors.push(
612
+ `${filePath}:${lineNumberAt(source, match.index ?? 0)} editable marker is gated behind "${String(match[1]).trim()} &&". Keep the target mounted when its value is empty, false, or zero.`
613
+ );
614
+ }
615
+ const ternaryPattern =
616
+ /\{([^{}]{0,80}?)\s*\?\s*<\s*([a-zA-Z][\w.:-]*)([^>]*data-preview-(?:field-path|list-path|item-path)[^>]*)>[\s\S]{0,200}?\?\s*(?:null|false|undefined)/g;
617
+ for (const match of source.matchAll(ternaryPattern)) {
618
+ errors.push(
619
+ `${filePath}:${lineNumberAt(source, match.index ?? 0)} editable marker unmounts on a falsy ternary. Keep the target mounted when its value is empty.`
620
+ );
621
+ }
622
+ return errors;
623
+ }
624
+
625
+ function auditSelectOptions(editorSchema, pages = []) {
626
+ const errors = [];
627
+ const declaredPageIds = new Set((pages || []).map((page) => page?.id).filter(Boolean));
628
+
629
+ function checkSelect(path, options) {
630
+ if (!Array.isArray(options) || !options.some((option) => typeof option === 'string' && option.trim())) {
631
+ errors.push(
632
+ `editorSchema select field "${path}" must declare at least one non-empty option for strict visual editing.`
633
+ );
634
+ return;
635
+ }
636
+ if (!declaredPageIds.size) return;
637
+ if (!/(?:destination|action|targetpage|buttonaction|pagekey)/i.test(path)) return;
638
+ for (const option of options) {
639
+ const opt = typeof option === 'string' ? option.trim() : '';
640
+ if (opt && opt !== 'none' && opt !== 'external' && !declaredPageIds.has(opt)) {
641
+ errors.push(
642
+ `editorSchema select field "${path}" declares destination option "${opt}" which is not in fivora-template.json pages[].`
643
+ );
644
+ }
645
+ }
646
+ }
647
+
648
+ function walk(path, node) {
649
+ if (!node) return;
650
+ if (node.type === 'select') {
651
+ checkSelect(path, node.options);
652
+ return;
653
+ }
654
+ if (node.type === 'object') {
655
+ for (const field of node.fields || []) walk(appendPath(path, field.key), field);
656
+ return;
657
+ }
658
+ if (node.type !== 'list') return;
659
+ const itemPath = `${wildcardPath(path)}[*]`;
660
+ if (node.itemField?.type === 'select') checkSelect(itemPath, node.itemField.options);
661
+ for (const field of node.fields || []) walk(appendPath(itemPath, field.key), field);
662
+ }
663
+
664
+ for (const section of editorSchema?.sections || []) walk(section.path, section);
665
+ return errors;
666
+ }
667
+
668
+ function auditListBounds(editorSchema, content) {
669
+ const errors = [];
670
+
671
+ function validBound(value) {
672
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
673
+ }
674
+
675
+ function readValue(path) {
676
+ let value = content || {};
677
+ for (const part of String(path).split('.')) {
678
+ if (!part || !isPlainObject(value)) return undefined;
679
+ value = value[part];
680
+ }
681
+ return value;
682
+ }
683
+
684
+ function walkSchema(path, node) {
685
+ if (!node) return;
686
+ if (node.type === 'object') {
687
+ for (const field of node.fields || []) walkSchema(appendPath(path, field.key), field);
688
+ return;
689
+ }
690
+ if (node.type !== 'list') return;
691
+ const minItems = validBound(node.minItems);
692
+ const maxItems = validBound(node.maxItems);
693
+ if (node.minItems !== undefined && minItems === null) {
694
+ errors.push(`editorSchema list "${path}" minItems must be a non-negative safe integer.`);
695
+ }
696
+ if (node.maxItems !== undefined && maxItems === null) {
697
+ errors.push(`editorSchema list "${path}" maxItems must be a non-negative safe integer.`);
698
+ }
699
+ const effectiveMinimum = minItems ?? (node.required ? 1 : 0);
700
+ if (maxItems !== null && effectiveMinimum > maxItems) {
701
+ errors.push(
702
+ `editorSchema list "${path}" requires at least ${effectiveMinimum} item(s) but maxItems is ${maxItems}.`
703
+ );
704
+ }
705
+ const items = Array.isArray(readValue(path)) ? readValue(path) : [];
706
+ if (maxItems !== null && items.length > maxItems) {
707
+ errors.push(
708
+ `site-data.json content list "${path}" contains ${items.length} items, exceeding editorSchema maxItems ${maxItems}.`
709
+ );
710
+ }
711
+ const itemPath = `${wildcardPath(path)}[*]`;
712
+ for (const field of node.fields || []) walkSchema(appendPath(itemPath, field.key), field);
713
+ }
714
+
715
+ for (const section of editorSchema?.sections || []) walkSchema(section.path, section);
716
+ return errors;
717
+ }
718
+
719
+ function auditStaticMarkerAuthorship(code, filePath) {
720
+ if (!/\.[cm]?[jt]s$/i.test(filePath) || /\.(tsx|jsx)$/i.test(filePath)) return [];
721
+ const mutation =
722
+ /\b(?:setAttribute|setAttributeNS|toggleAttribute)\s*\(\s*['"`]data-preview-static['"`]/.exec(code) ||
723
+ /\b(?:writeFile|writeFileSync)\s*\([\s\S]{0,200}data-preview-static/.exec(code);
724
+ if (!mutation) return [];
725
+ return [
726
+ `${filePath}:${lineNumberAt(code, mutation.index || 0)} programmatically injects data-preview-static. Author each intentional static annotation directly on the smallest source element.`,
727
+ ];
728
+ }
729
+
730
+ function auditRouteOwnedMarkerCoverage({ editorSchema, pages, markers, controlOnlyPaths }) {
731
+ const errors = [];
732
+ const pagesById = new Map((pages || []).map((page) => [page.id, page]));
733
+ const sections = (editorSchema?.sections || [])
734
+ .map((section) => ({ section, canonicalPath: canonicalizeMarkerPath(section.path) }))
735
+ .filter((entry) => entry.canonicalPath);
736
+
737
+ for (const marker of markers || []) {
738
+ if (marker.kind === 'page' || marker.kind === 'item') continue;
739
+ const canonical = canonicalizeMarkerPath(marker.value);
740
+ if (!canonical) continue;
741
+ if (marker.kind === 'field' && isControlOnly(canonical, controlOnlyPaths)) continue;
742
+ const owner = sections
743
+ .filter((entry) => {
744
+ const sectionPath = wildcardPath(entry.canonicalPath);
745
+ const markerPath = wildcardPath(canonical);
746
+ return markerPath === sectionPath || markerPath.startsWith(`${sectionPath}.`) || markerPath.startsWith(`${sectionPath}[`);
747
+ })
748
+ .sort((left, right) => right.canonicalPath.length - left.canonicalPath.length)[0];
749
+ if (!owner?.section.pageKey) continue;
750
+ const page = pagesById.get(owner.section.pageKey);
751
+ if (!page) {
752
+ errors.push(
753
+ `editorSchema section "${owner.section.path}" assigns ${marker.kind} "${canonical}" to unknown manifest page "${owner.section.pageKey}".`
754
+ );
755
+ }
756
+ }
757
+ return errors;
758
+ }
759
+
505
760
  module.exports = {
506
761
  BROAD_CONTENT_CONTAINERS,
507
762
  PRIMITIVE_TYPES,
@@ -509,11 +764,17 @@ module.exports = {
509
764
  enumerateSchemaPaths,
510
765
  extractMarkers,
511
766
  auditMarkerPlacement,
767
+ auditCoupledListMarkers,
512
768
  auditActionLabelCollision,
513
769
  auditPathCoverage,
514
770
  auditSchemaUniqueness,
515
771
  auditPageCoverage,
516
772
  auditPreviewRuntime,
773
+ auditEmptyStateSource,
774
+ auditSelectOptions,
775
+ auditListBounds,
776
+ auditStaticMarkerAuthorship,
777
+ auditRouteOwnedMarkerCoverage,
517
778
  findUncoveredVisibleText,
518
779
  canonicalizeMarkerPath,
519
780
  wildcardPath,