@deneb-ui/cli 2.0.51 → 2.0.53
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 +12 -0
- package/package.json +2 -2
- package/src/arc/__tests__/arc.test.cjs +96 -11
- package/src/arc/fivora-contract.cjs +266 -5
- package/src/arc/index.cjs +127 -28
- package/src/arc/learning.cjs +19 -1
- package/src/arc/manifest.cjs +89 -11
- package/src/arc/planner.cjs +2 -1
- package/src/arc/printer.cjs +9 -0
- package/src/arc/residual.cjs +400 -0
- package/src/arc/scanner.cjs +46 -2
- package/src/arc/semantic.cjs +8 -4
- package/src/arc/transformer.cjs +321 -28
- package/src/arc/validator.cjs +43 -3
- package/src/arc/version.cjs +1 -1
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.
|
|
3
|
+
"version": "2.0.53",
|
|
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.
|
|
52
|
+
"@deneb-ui/core": "^2.0.53",
|
|
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, /<
|
|
162
|
-
assert.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
828
|
-
assert.
|
|
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.
|
|
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 (!
|
|
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,
|