@deneb-ui/cli 2.0.21 → 2.0.23

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.
Files changed (53) hide show
  1. package/README.md +62 -114
  2. package/bin/index.js +101 -207
  3. package/package.json +20 -5
  4. package/src/arc/__fixtures__/next-app-basic/package.json +10 -0
  5. package/src/arc/__fixtures__/next-app-basic/src/app/globals.css +3 -0
  6. package/src/arc/__fixtures__/next-app-basic/src/app/layout.tsx +9 -0
  7. package/src/arc/__fixtures__/next-app-basic/src/app/page.tsx +11 -0
  8. package/src/arc/__fixtures__/next-app-basic/src/components/Header.tsx +13 -0
  9. package/src/arc/__fixtures__/next-app-basic/src/components/Hero.tsx +12 -0
  10. package/src/arc/__fixtures__/next-app-basic/src/components/PromoBanner.tsx +10 -0
  11. package/src/arc/__fixtures__/next-app-basic/tsconfig.json +12 -0
  12. package/src/arc/__fixtures__/next-app-storefront/components.json +14 -0
  13. package/src/arc/__fixtures__/next-app-storefront/package.json +22 -0
  14. package/src/arc/__fixtures__/next-app-storefront/src/app/about/page.tsx +13 -0
  15. package/src/arc/__fixtures__/next-app-storefront/src/app/globals.css +5 -0
  16. package/src/arc/__fixtures__/next-app-storefront/src/app/layout.tsx +19 -0
  17. package/src/arc/__fixtures__/next-app-storefront/src/app/page.tsx +13 -0
  18. package/src/arc/__fixtures__/next-app-storefront/src/components/Features.tsx +31 -0
  19. package/src/arc/__fixtures__/next-app-storefront/src/components/Hero.tsx +45 -0
  20. package/src/arc/__fixtures__/next-app-storefront/src/components/ProductGrid.tsx +49 -0
  21. package/src/arc/__fixtures__/next-app-storefront/src/components/SiteFooter.tsx +22 -0
  22. package/src/arc/__fixtures__/next-app-storefront/src/components/SiteHeader.tsx +21 -0
  23. package/src/arc/__fixtures__/next-app-storefront/src/components/ui/button.tsx +36 -0
  24. package/src/arc/__fixtures__/next-app-storefront/tsconfig.json +15 -0
  25. package/src/arc/__fixtures__/next-pages-basic/package.json +10 -0
  26. package/src/arc/__fixtures__/next-pages-basic/pages/_app.jsx +5 -0
  27. package/src/arc/__fixtures__/next-pages-basic/pages/contact.jsx +9 -0
  28. package/src/arc/__fixtures__/next-pages-basic/pages/index.jsx +11 -0
  29. package/src/arc/__fixtures__/next-pages-basic/styles/globals.css +9 -0
  30. package/src/arc/__tests__/arc.test.cjs +458 -0
  31. package/src/arc/adapters.cjs +184 -0
  32. package/src/arc/ast.cjs +323 -0
  33. package/src/arc/field-paths.cjs +165 -0
  34. package/src/arc/fivora-contract.cjs +521 -0
  35. package/src/arc/fs-utils.cjs +170 -0
  36. package/src/arc/index.cjs +628 -0
  37. package/src/arc/learning.cjs +185 -0
  38. package/src/arc/manifest.cjs +421 -0
  39. package/src/arc/next-config.cjs +279 -0
  40. package/src/arc/planner.cjs +227 -0
  41. package/src/arc/printer.cjs +153 -0
  42. package/src/arc/recipes-v2.cjs +49 -0
  43. package/src/arc/scanner.cjs +613 -0
  44. package/src/arc/semantic.cjs +651 -0
  45. package/src/arc/transformer.cjs +646 -0
  46. package/src/arc/validator.cjs +173 -0
  47. package/src/arc/version.cjs +22 -0
  48. package/src/recipes/cosmetics-beauty-store.json +1097 -0
  49. package/src/recipes/electronics-gadgets-store.json +1080 -0
  50. package/src/recipes/fashion-apparel-store.json +1074 -0
  51. package/src/tools/deneb-doctor.cjs +646 -0
  52. package/src/tools/recipe-engine.cjs +30 -0
  53. package/src/tools/template-converter.cjs +19 -6
@@ -0,0 +1,521 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Faithful port of the Fivora strict visual-editing contract rules that the
5
+ * platform ingest pipeline applies (backend/src/common/template-visual-edit-contract.ts).
6
+ *
7
+ * ARC self-validates against these rules so a converted project is rejected
8
+ * locally rather than at upload time.
9
+ */
10
+
11
+ const MARKER_ATTRIBUTE_TO_KIND = {
12
+ 'data-preview-field-path': 'field',
13
+ 'data-preview-list-path': 'list',
14
+ 'data-preview-item-path': 'item',
15
+ 'data-preview-page-key': 'page',
16
+ };
17
+
18
+ const MARKER_ATTRIBUTE_PATTERN =
19
+ /\b(data-preview-(?:field-path|list-path|item-path|page-key))\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*`([\s\S]*?)`\s*\}|\{\s*"([^"]*)"\s*\}|\{\s*'([^']*)'\s*\})/g;
20
+
21
+ const MARKER_ATTRIBUTE_OCCURRENCE_PATTERN =
22
+ /\b(data-preview-(?:field-path|list-path|item-path|page-key))\s*=/g;
23
+
24
+ const PATH_SEGMENT_PATTERN = String.raw`[^.[\]\s]+`;
25
+ const CANONICAL_PATH_PATTERN = new RegExp(
26
+ String.raw`^${PATH_SEGMENT_PATTERN}(?:\[(?:\d+|\*)\])*(?:\.${PATH_SEGMENT_PATTERN}(?:\[(?:\d+|\*)\])*)*$`
27
+ );
28
+
29
+ // Mirrors BROAD_CONTENT_CONTAINERS in the platform contract.
30
+ const BROAD_CONTENT_CONTAINERS = new Set([
31
+ 'html', 'body', 'main', 'header', 'footer', 'nav', 'section', 'article',
32
+ 'aside', 'form', 'div', 'ul', 'ol', 'table', 'thead', 'tbody', 'tfoot', 'tr',
33
+ ]);
34
+
35
+ const PRIMITIVE_TYPES = new Set([
36
+ 'text', 'textarea', 'email', 'tel', 'url', 'image', 'number', 'boolean', 'select',
37
+ ]);
38
+
39
+ function isPlainObject(value) {
40
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
41
+ }
42
+
43
+ function appendPath(basePath, key) {
44
+ return basePath ? `${basePath}.${key}` : key;
45
+ }
46
+
47
+ function wildcardPath(path) {
48
+ return String(path).replace(/\[\d+\]/g, '[*]');
49
+ }
50
+
51
+ function canonicalizeMarkerPath(value) {
52
+ const normalized = String(value ?? '')
53
+ .trim()
54
+ .replace(/\[\s*\$\{[^}]+\}\s*\]/g, '[*]')
55
+ .replace(/\[\s+/g, '[')
56
+ .replace(/\s+\]/g, ']');
57
+ return CANONICAL_PATH_PATTERN.test(normalized) ? normalized : null;
58
+ }
59
+
60
+ function pathsOverlap(left, right) {
61
+ return wildcardPath(left) === wildcardPath(right);
62
+ }
63
+
64
+ function isControlOnly(path, controlOnlyPaths) {
65
+ return (controlOnlyPaths || []).some((controlPath) =>
66
+ controlPath.includes('[*]') ? wildcardPath(path) === controlPath : path === controlPath
67
+ );
68
+ }
69
+
70
+ /** Port of walkContentValue: derives every editable path implied by site-data content. */
71
+ function enumerateContentPaths(content) {
72
+ const inventory = {
73
+ fieldPatterns: new Set(),
74
+ concreteFields: new Set(),
75
+ listPatterns: new Set(),
76
+ concreteLists: new Set(),
77
+ itemPatterns: new Set(),
78
+ concreteItems: new Set(),
79
+ };
80
+
81
+ function walk(value, path) {
82
+ if (Array.isArray(value)) {
83
+ const listPattern = wildcardPath(path);
84
+ inventory.listPatterns.add(listPattern);
85
+ inventory.concreteLists.add(path);
86
+ inventory.itemPatterns.add(`${listPattern}[*]`);
87
+ value.forEach((item, index) => {
88
+ const itemPath = `${path}[${index}]`;
89
+ inventory.concreteItems.add(itemPath);
90
+ if (Array.isArray(item) || isPlainObject(item)) {
91
+ walk(item, itemPath);
92
+ } else {
93
+ inventory.fieldPatterns.add(wildcardPath(itemPath));
94
+ inventory.concreteFields.add(itemPath);
95
+ }
96
+ });
97
+ return;
98
+ }
99
+ if (isPlainObject(value)) {
100
+ for (const [key, child] of Object.entries(value)) {
101
+ walk(child, appendPath(path, key));
102
+ }
103
+ return;
104
+ }
105
+ inventory.fieldPatterns.add(wildcardPath(path));
106
+ inventory.concreteFields.add(path);
107
+ }
108
+
109
+ if (isPlainObject(content)) {
110
+ for (const [key, value] of Object.entries(content)) {
111
+ walk(value, key);
112
+ }
113
+ }
114
+
115
+ return inventory;
116
+ }
117
+
118
+ /** Port of walkSchemaNode: derives editable paths implied by editorSchema. */
119
+ function enumerateSchemaPaths(editorSchema) {
120
+ const fieldPatterns = new Set();
121
+ const listPatterns = new Set();
122
+ const itemPatterns = new Set();
123
+
124
+ function walk(path, node) {
125
+ if (!node) return;
126
+ if (node.type === 'object') {
127
+ for (const field of node.fields || []) {
128
+ walk(appendPath(path, field.key), field);
129
+ }
130
+ return;
131
+ }
132
+ if (node.type === 'list') {
133
+ const listPattern = wildcardPath(path);
134
+ const itemPattern = `${listPattern}[*]`;
135
+ listPatterns.add(listPattern);
136
+ itemPatterns.add(itemPattern);
137
+ if (node.itemField) fieldPatterns.add(itemPattern);
138
+ for (const field of node.fields || []) {
139
+ walk(appendPath(itemPattern, field.key), field);
140
+ }
141
+ return;
142
+ }
143
+ fieldPatterns.add(wildcardPath(path));
144
+ }
145
+
146
+ for (const section of editorSchema?.sections || []) {
147
+ walk(section.path, section);
148
+ }
149
+
150
+ return { fieldPatterns, listPatterns, itemPatterns };
151
+ }
152
+
153
+ /** Port of extractTemplateVisualEditMarkers for a single artifact. */
154
+ function extractMarkers(code, filePath = '') {
155
+ const markers = [];
156
+ const unparseable = [];
157
+ const parsedOffsets = new Set();
158
+
159
+ MARKER_ATTRIBUTE_PATTERN.lastIndex = 0;
160
+ for (const match of String(code).matchAll(MARKER_ATTRIBUTE_PATTERN)) {
161
+ const attributeName = match[1];
162
+ const rawValue = match[2] ?? match[3] ?? match[4] ?? match[5] ?? match[6] ?? '';
163
+ const offset = match.index ?? 0;
164
+ parsedOffsets.add(offset);
165
+ markers.push({
166
+ kind: MARKER_ATTRIBUTE_TO_KIND[attributeName],
167
+ value: String(rawValue).trim(),
168
+ filePath,
169
+ offset,
170
+ line: lineNumberAt(code, offset),
171
+ });
172
+ }
173
+
174
+ MARKER_ATTRIBUTE_OCCURRENCE_PATTERN.lastIndex = 0;
175
+ for (const match of String(code).matchAll(MARKER_ATTRIBUTE_OCCURRENCE_PATTERN)) {
176
+ const offset = match.index ?? 0;
177
+ if (!parsedOffsets.has(offset)) {
178
+ unparseable.push(
179
+ `${filePath}:${lineNumberAt(code, offset)} ${match[1]} must use a literal string or a JSX template literal.`
180
+ );
181
+ }
182
+ }
183
+
184
+ return { markers, unparseable };
185
+ }
186
+
187
+ function lineNumberAt(content, offset) {
188
+ let line = 1;
189
+ for (let index = 0; index < offset && index < content.length; index += 1) {
190
+ if (content.charCodeAt(index) === 10) line += 1;
191
+ }
192
+ return line;
193
+ }
194
+
195
+ /**
196
+ * Scans JSX source for marker placement violations that the platform detects in
197
+ * exported HTML. Source scanning cannot see the final DOM, but a violation in
198
+ * source deterministically produces the same violation in the export.
199
+ */
200
+ function auditMarkerPlacement(code, filePath) {
201
+ const errors = [];
202
+ const tagPattern = /<\s*([a-zA-Z][a-zA-Z0-9.:-]*)((?:[^<>{}]|\{[^{}]*\})*?)(\/?)>/g;
203
+
204
+ for (const match of String(code).matchAll(tagPattern)) {
205
+ const tag = match[1];
206
+ const attrs = match[2] || '';
207
+ const offset = match.index ?? 0;
208
+ const line = lineNumberAt(code, offset);
209
+ const lowerTag = tag.toLowerCase();
210
+
211
+ const hasField = /\bdata-preview-field-path\s*=/.test(attrs);
212
+ const hasList = /\bdata-preview-list-path\s*=/.test(attrs);
213
+ const hasItem = /\bdata-preview-item-path\s*=/.test(attrs);
214
+ const staticMatch = attrs.match(/\bdata-preview-static\s*=\s*(?:"([^"]*)"|'([^']*)')/);
215
+ const hasStatic = /\bdata-preview-static\b/.test(attrs);
216
+
217
+ if (hasStatic && !(staticMatch?.[1] ?? staticMatch?.[2] ?? '').trim()) {
218
+ errors.push(`${filePath}:${line} data-preview-static requires a reason.`);
219
+ }
220
+ if ((hasField || hasList || hasItem) && hasStatic) {
221
+ errors.push(
222
+ `${filePath}:${line} data-preview field/list/item markers cannot share an element with data-preview-static.`
223
+ );
224
+ }
225
+ if (hasField && BROAD_CONTENT_CONTAINERS.has(lowerTag)) {
226
+ errors.push(
227
+ `${filePath}:${line} data-preview-field-path cannot be placed on broad <${lowerTag}> content containers.`
228
+ );
229
+ }
230
+ if (hasStatic && BROAD_CONTENT_CONTAINERS.has(lowerTag)) {
231
+ errors.push(
232
+ `${filePath}:${line} data-preview-static cannot cover a broad <${lowerTag}> content container.`
233
+ );
234
+ }
235
+ }
236
+
237
+ return errors;
238
+ }
239
+
240
+ /**
241
+ * Detects the action/label collision the platform rejects: one element owning
242
+ * both an href/src action contract and different visible text.
243
+ */
244
+ function auditActionLabelCollision(code, filePath) {
245
+ const errors = [];
246
+ const anchorPattern = /<a\b([^>]*?)>([\s\S]*?)<\/a>/gi;
247
+
248
+ for (const match of String(code).matchAll(anchorPattern)) {
249
+ const attrs = match[1] || '';
250
+ const inner = match[2] || '';
251
+ if (!/\bdata-preview-field-path\s*=/.test(attrs)) continue;
252
+ if (/\bdata-preview-field-path\s*=/.test(inner)) continue;
253
+
254
+ const visibleText = inner
255
+ .replace(/<[^>]*>/g, ' ')
256
+ .replace(/\{[^{}]*\}/g, ' ')
257
+ .replace(/\s+/g, ' ')
258
+ .trim();
259
+ if (visibleText.length > 2 && /\p{L}/u.test(visibleText)) {
260
+ errors.push(
261
+ `${filePath}:${lineNumberAt(code, match.index ?? 0)} data-preview-field-path covers both the <a> action and visible text "${visibleText.slice(0, 48)}". Move the label marker to a nested element.`
262
+ );
263
+ }
264
+ }
265
+
266
+ return errors;
267
+ }
268
+
269
+ /**
270
+ * Validates that every editable path implied by site-data/editorSchema is
271
+ * actually rendered somewhere in source (or declared control-only).
272
+ * This is the check that most commonly rejects auto-converted templates.
273
+ */
274
+ function auditPathCoverage({ content, editorSchema, markers, controlOnlyPaths }) {
275
+ const errors = [];
276
+ const contentInventory = enumerateContentPaths(content);
277
+ const schemaInventory = enumerateSchemaPaths(editorSchema);
278
+
279
+ const fieldMarkers = new Set();
280
+ const listMarkers = new Set();
281
+ const itemMarkers = new Set();
282
+ const pageMarkers = new Set();
283
+
284
+ for (const marker of markers) {
285
+ const canonical = canonicalizeMarkerPath(marker.value);
286
+ if (marker.kind === 'page') {
287
+ pageMarkers.add(String(marker.value).trim());
288
+ continue;
289
+ }
290
+ if (!canonical) {
291
+ errors.push(
292
+ `${marker.filePath}:${marker.line} has invalid data-preview-${marker.kind}-path "${marker.value}".`
293
+ );
294
+ continue;
295
+ }
296
+ if (marker.kind === 'field') fieldMarkers.add(canonical);
297
+ if (marker.kind === 'list') listMarkers.add(canonical);
298
+ if (marker.kind === 'item') itemMarkers.add(canonical);
299
+ }
300
+
301
+ const covers = (markerSet, path) =>
302
+ markerSet.has(path) || [...markerSet].some((marker) => pathsOverlap(marker, path));
303
+
304
+ for (const path of contentInventory.concreteFields) {
305
+ if (isControlOnly(path, controlOnlyPaths)) continue;
306
+ if (covers(fieldMarkers, path)) continue;
307
+ errors.push(
308
+ `site-data content field "${path}" has no data-preview-field-path in source. Bind it, remove it, or declare it in visualEditing.controlOnlyPaths.`
309
+ );
310
+ }
311
+
312
+ for (const path of contentInventory.concreteLists) {
313
+ if (isControlOnly(path, controlOnlyPaths)) continue;
314
+ if (covers(listMarkers, path)) continue;
315
+ errors.push(
316
+ `site-data content list "${path}" has no data-preview-list-path in source.`
317
+ );
318
+ }
319
+
320
+ for (const path of contentInventory.concreteItems) {
321
+ if (isControlOnly(path, controlOnlyPaths)) continue;
322
+ if (covers(itemMarkers, path)) continue;
323
+ errors.push(
324
+ `site-data content list item "${path}" has no data-preview-item-path in source.`
325
+ );
326
+ }
327
+
328
+ const knownFieldPaths = [
329
+ ...contentInventory.fieldPatterns,
330
+ ...contentInventory.concreteFields,
331
+ ...schemaInventory.fieldPatterns,
332
+ ];
333
+ for (const marker of fieldMarkers) {
334
+ if (!knownFieldPaths.some((known) => pathsOverlap(marker, known))) {
335
+ errors.push(`data-preview-field-path references unknown path "${marker}".`);
336
+ }
337
+ }
338
+
339
+ const knownListPaths = [
340
+ ...contentInventory.listPatterns,
341
+ ...contentInventory.concreteLists,
342
+ ...schemaInventory.listPatterns,
343
+ ];
344
+ for (const marker of listMarkers) {
345
+ if (!knownListPaths.some((known) => pathsOverlap(marker, known))) {
346
+ errors.push(`data-preview-list-path references unknown path "${marker}".`);
347
+ }
348
+ }
349
+
350
+ return { errors, contentInventory, schemaInventory, fieldMarkers, listMarkers, itemMarkers, pageMarkers };
351
+ }
352
+
353
+ /** Ports validateSchemaPathUniqueness / section uniqueness. */
354
+ function auditSchemaUniqueness(editorSchema) {
355
+ const errors = [];
356
+ const seenIds = new Set();
357
+ const seenSectionPaths = new Set();
358
+ const seenLeafPaths = new Map();
359
+
360
+ for (const section of editorSchema?.sections || []) {
361
+ if (seenIds.has(section.id)) {
362
+ errors.push(`editorSchema.sections contains duplicate id "${section.id}".`);
363
+ }
364
+ if (seenSectionPaths.has(section.path)) {
365
+ errors.push(`editorSchema.sections contains duplicate path "${section.path}".`);
366
+ }
367
+ seenIds.add(section.id);
368
+ seenSectionPaths.add(section.path);
369
+
370
+ walkLeaves(section.path, section, section.id);
371
+ }
372
+
373
+ function walkLeaves(path, node, sectionId) {
374
+ if (!node) return;
375
+ if (node.type === 'object') {
376
+ for (const field of node.fields || []) walkLeaves(appendPath(path, field.key), field, sectionId);
377
+ return;
378
+ }
379
+ if (node.type === 'list') {
380
+ const itemPath = `${wildcardPath(path)}[*]`;
381
+ registerLeaf(wildcardPath(path), sectionId, 'list');
382
+ for (const field of node.fields || []) walkLeaves(appendPath(itemPath, field.key), field, sectionId);
383
+ return;
384
+ }
385
+ if (!PRIMITIVE_TYPES.has(node.type)) {
386
+ errors.push(`editorSchema path "${path}" declares unsupported type "${node.type}".`);
387
+ return;
388
+ }
389
+ registerLeaf(wildcardPath(path), sectionId, 'field');
390
+ }
391
+
392
+ function registerLeaf(path, sectionId, kind) {
393
+ const previous = seenLeafPaths.get(path);
394
+ if (previous) {
395
+ errors.push(
396
+ `editorSchema declares duplicate editable ${kind} path "${path}" in sections "${previous.sectionId}" and "${sectionId}".`
397
+ );
398
+ return;
399
+ }
400
+ seenLeafPaths.set(path, { sectionId, kind });
401
+ }
402
+
403
+ return errors;
404
+ }
405
+
406
+ /** Ports validatePageCoverage against on-disk routes instead of exported HTML. */
407
+ function auditPageCoverage({ pages, routeFiles, pageMarkersByFile }) {
408
+ const errors = [];
409
+ const seenIds = new Set();
410
+ const seenRoutes = new Map();
411
+
412
+ if (!pages || pages.length === 0) {
413
+ errors.push('Strict visual editing requires at least one manifest pages[] entry.');
414
+ return errors;
415
+ }
416
+
417
+ for (const page of pages) {
418
+ if (seenIds.has(page.id)) {
419
+ errors.push(`Manifest pages[] contains duplicate id "${page.id}".`);
420
+ }
421
+ seenIds.add(page.id);
422
+
423
+ const route = normalizePageRoute(page.route);
424
+ if (!route) {
425
+ errors.push(`Manifest page "${page.id}" must declare a canonical route.`);
426
+ continue;
427
+ }
428
+ const duplicate = seenRoutes.get(route);
429
+ if (duplicate) {
430
+ errors.push(`Manifest pages "${duplicate}" and "${page.id}" use duplicate route "${route}".`);
431
+ } else {
432
+ seenRoutes.set(route, page.id);
433
+ }
434
+
435
+ const sourceFile = routeFiles[page.id];
436
+ if (!sourceFile) {
437
+ errors.push(
438
+ `Manifest page "${page.id}" route "${route}" has no page file on disk, so the export cannot contain it.`
439
+ );
440
+ continue;
441
+ }
442
+ const keys = pageMarkersByFile[sourceFile] || [];
443
+ if (!keys.includes(page.id)) {
444
+ errors.push(`Route "${route}" (${sourceFile}) is missing data-preview-page-key="${page.id}".`);
445
+ }
446
+ }
447
+
448
+ return errors;
449
+ }
450
+
451
+ function normalizePageRoute(route) {
452
+ if (!route) return null;
453
+ const trimmed = String(route).trim();
454
+ if (!trimmed.startsWith('/') || trimmed.includes('?') || trimmed.includes('#') || trimmed.includes('..')) {
455
+ return null;
456
+ }
457
+ return trimmed === '/' ? trimmed : trimmed.replace(/\/+$/, '');
458
+ }
459
+
460
+ /** Ports validatePreviewRuntimeCapability's package-provider fast path. */
461
+ function auditPreviewRuntime(sources) {
462
+ const joined = sources.join('\n');
463
+ const usesPackageProvider =
464
+ /(?:import|export)\s+[\s\S]*?\b(?:SiteDataProvider|BaseSiteDataProvider|useSiteData|DenebProvider|DenebSiteDataProvider|DenebUiProvider)\b[\s\S]*?\bfrom\s+['"][^'"]*['"]/m.test(joined) ||
465
+ /(?:import|export)\s+[\s\S]*?\bfrom\s+['"](?:@fivora\/|deneb-ui|@deneb-ui\/)/m.test(joined) ||
466
+ /<(?:SiteDataProvider|BaseSiteDataProvider|DenebProvider|DenebSiteDataProvider|DenebUiProvider)\b/m.test(joined);
467
+
468
+ return usesPackageProvider
469
+ ? []
470
+ : [
471
+ 'Preview runtime is not certifiable: no @deneb-ui/ui SiteDataProvider/useSiteData usage was found. Mount SiteDataProvider in the root layout.',
472
+ ];
473
+ }
474
+
475
+ /**
476
+ * Finds meaningful visible JSX text that is neither bound to a field nor marked
477
+ * static. Strict mode treats these as errors in the exported HTML.
478
+ */
479
+ function findUncoveredVisibleText(code, filePath) {
480
+ const findings = [];
481
+ const source = String(code);
482
+ const elementPattern = /<\s*([a-zA-Z][a-zA-Z0-9.:-]*)((?:[^<>{}]|\{[^{}]*\})*?)>([^<>{}]*)</g;
483
+
484
+ for (const match of source.matchAll(elementPattern)) {
485
+ const tag = match[1];
486
+ const attrs = match[2] || '';
487
+ const text = (match[3] || '').replace(/\s+/g, ' ').trim();
488
+ const offset = match.index ?? 0;
489
+
490
+ // A `<` preceded by an identifier character is a TypeScript generic
491
+ // (forwardRef<HTMLButtonElement, Props>), never a JSX element.
492
+ if (/[\w$)\]]/.test(source[offset - 1] || '')) continue;
493
+ if (!text || text.length <= 2 || !/\p{L}/u.test(text)) continue;
494
+ if (!/^[\p{L}\p{N}"'(¡¿#$€£]/u.test(text)) continue;
495
+ if (/^(?:true|false|null|undefined)$/i.test(text)) continue;
496
+ if (/\bdata-preview-(?:field-path|static)\b/.test(attrs)) continue;
497
+
498
+ findings.push({ tag, text, filePath, line: lineNumberAt(source, offset) });
499
+ }
500
+
501
+ return findings;
502
+ }
503
+
504
+ module.exports = {
505
+ BROAD_CONTENT_CONTAINERS,
506
+ PRIMITIVE_TYPES,
507
+ enumerateContentPaths,
508
+ enumerateSchemaPaths,
509
+ extractMarkers,
510
+ auditMarkerPlacement,
511
+ auditActionLabelCollision,
512
+ auditPathCoverage,
513
+ auditSchemaUniqueness,
514
+ auditPageCoverage,
515
+ auditPreviewRuntime,
516
+ findUncoveredVisibleText,
517
+ canonicalizeMarkerPath,
518
+ wildcardPath,
519
+ isControlOnly,
520
+ normalizePageRoute,
521
+ };
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const crypto = require('crypto');
7
+
8
+ const IGNORE_DIRS = new Set([
9
+ '.git',
10
+ '.next',
11
+ '.turbo',
12
+ '.cache',
13
+ '.npm',
14
+ '.pnpm-store',
15
+ '.vercel',
16
+ '.output',
17
+ '.deneb',
18
+ 'node_modules',
19
+ 'out',
20
+ 'dist',
21
+ 'build',
22
+ 'coverage',
23
+ '__macosx',
24
+ ]);
25
+
26
+ function isIgnoredDirName(name) {
27
+ const lower = String(name || '').toLowerCase();
28
+ if (IGNORE_DIRS.has(lower)) return true;
29
+ if (lower.startsWith('.deneb-backup')) return true;
30
+ return false;
31
+ }
32
+
33
+ function isSecretFile(filename) {
34
+ const lower = String(filename || '').toLowerCase();
35
+ return (
36
+ lower.startsWith('.env') ||
37
+ lower.endsWith('.pem') ||
38
+ lower.endsWith('.key') ||
39
+ lower === 'id_rsa' ||
40
+ lower === 'credentials.json' ||
41
+ lower === '.npmrc'
42
+ );
43
+ }
44
+
45
+ function isSourceFile(filename) {
46
+ return /\.(tsx|jsx|ts|js|mjs|cjs)$/.test(filename) && !filename.endsWith('.d.ts');
47
+ }
48
+
49
+ function isJsxFile(filename) {
50
+ return /\.(tsx|jsx)$/.test(filename);
51
+ }
52
+
53
+ function readJsonSafe(filePath, fallback = null) {
54
+ try {
55
+ if (!fs.existsSync(filePath)) return fallback;
56
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
57
+ } catch {
58
+ return fallback;
59
+ }
60
+ }
61
+
62
+ function writeJson(filePath, value) {
63
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
64
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
65
+ }
66
+
67
+ function sha1(value) {
68
+ return crypto.createHash('sha1').update(String(value)).digest('hex');
69
+ }
70
+
71
+ function shortHash(value, length = 12) {
72
+ return sha1(value).slice(0, length);
73
+ }
74
+
75
+ function walkFiles(dir, options = {}, acc = []) {
76
+ if (!dir || !fs.existsSync(dir)) return acc;
77
+ const include = options.include || (() => true);
78
+ const follow = options.followDirectories !== false;
79
+
80
+ let entries;
81
+ try {
82
+ entries = fs.readdirSync(dir, { withFileTypes: true });
83
+ } catch {
84
+ return acc;
85
+ }
86
+
87
+ for (const entry of entries) {
88
+ const fullPath = path.join(dir, entry.name);
89
+ if (entry.isDirectory()) {
90
+ if (!follow || isIgnoredDirName(entry.name)) continue;
91
+ walkFiles(fullPath, options, acc);
92
+ } else if (entry.isFile()) {
93
+ if (isSecretFile(entry.name)) continue;
94
+ if (include(fullPath, entry.name)) acc.push(fullPath);
95
+ }
96
+ }
97
+ return acc;
98
+ }
99
+
100
+ function detectPackageManager(projectDir) {
101
+ if (fs.existsSync(path.join(projectDir, 'bun.lock')) || fs.existsSync(path.join(projectDir, 'bun.lockb'))) {
102
+ return 'bun';
103
+ }
104
+ if (fs.existsSync(path.join(projectDir, 'pnpm-lock.yaml'))) return 'pnpm';
105
+ if (fs.existsSync(path.join(projectDir, 'yarn.lock'))) return 'yarn';
106
+ if (fs.existsSync(path.join(projectDir, 'package-lock.json'))) return 'npm';
107
+ return 'npm';
108
+ }
109
+
110
+ function findFirstExisting(paths) {
111
+ for (const candidate of paths) {
112
+ if (candidate && fs.existsSync(candidate)) return candidate;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ function rel(projectDir, filePath) {
118
+ return path.relative(projectDir, filePath).replace(/\\/g, '/');
119
+ }
120
+
121
+ function homeDenebDir() {
122
+ return path.join(os.homedir(), '.deneb');
123
+ }
124
+
125
+ function copyFilePreserve(src, dest) {
126
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
127
+ fs.copyFileSync(src, dest);
128
+ }
129
+
130
+ function deepMerge(target, source) {
131
+ if (!isPlainObject(source)) return target;
132
+ const output = isPlainObject(target) ? { ...target } : {};
133
+ for (const [key, value] of Object.entries(source)) {
134
+ if (isPlainObject(value) && isPlainObject(output[key])) {
135
+ output[key] = deepMerge(output[key], value);
136
+ } else if (output[key] === undefined) {
137
+ output[key] = value;
138
+ }
139
+ }
140
+ return output;
141
+ }
142
+
143
+ function isPlainObject(value) {
144
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
145
+ }
146
+
147
+ function toPosix(filePath) {
148
+ return String(filePath || '').replace(/\\/g, '/');
149
+ }
150
+
151
+ module.exports = {
152
+ IGNORE_DIRS,
153
+ isIgnoredDirName,
154
+ isSecretFile,
155
+ isSourceFile,
156
+ isJsxFile,
157
+ readJsonSafe,
158
+ writeJson,
159
+ sha1,
160
+ shortHash,
161
+ walkFiles,
162
+ detectPackageManager,
163
+ findFirstExisting,
164
+ rel,
165
+ homeDenebDir,
166
+ copyFilePreserve,
167
+ deepMerge,
168
+ isPlainObject,
169
+ toPosix,
170
+ };