@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,458 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const { scanProject, buildDependencyGraph } = require('../scanner.cjs');
10
+ const { analyzeFile } = require('../semantic.cjs');
11
+ const { planTransformations } = require('../planner.cjs');
12
+ const { applyFilePlan } = require('../transformer.cjs');
13
+ const { buildFieldPath, inferFieldName } = require('../field-paths.cjs');
14
+ const { runDenebArc } = require('../index.cjs');
15
+ const { parseSource } = require('../ast.cjs');
16
+ const contract = require('../fivora-contract.cjs');
17
+ const { ensureStaticExportConfig } = require('../next-config.cjs');
18
+
19
+ const FIXTURE = path.join(__dirname, '..', '__fixtures__', 'next-app-basic');
20
+
21
+ function silence(fn) {
22
+ const log = console.log;
23
+ const err = console.error;
24
+ console.log = () => {};
25
+ console.error = () => {};
26
+ try {
27
+ return fn();
28
+ } finally {
29
+ console.log = log;
30
+ console.error = err;
31
+ }
32
+ }
33
+
34
+ function copyFixture() {
35
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'deneb-arc-'));
36
+ fs.cpSync(FIXTURE, dir, { recursive: true });
37
+ return dir;
38
+ }
39
+
40
+ test('scanner detects Next.js App Router, TypeScript, and Tailwind', () => {
41
+ const profile = scanProject(FIXTURE);
42
+ assert.equal(profile.framework, 'nextjs');
43
+ assert.equal(profile.router, 'next-app');
44
+ assert.equal(profile.language, 'typescript');
45
+ assert.ok(profile.cssSystems.some((s) => s.startsWith('tailwind')));
46
+ assert.ok(profile.routes.some((r) => r.id === 'home' && r.route === '/'));
47
+ assert.ok(profile.jsxFiles.some((f) => f.includes('Hero.tsx')));
48
+ assert.ok(profile.architectureFingerprint);
49
+ });
50
+
51
+ test('field paths are semantic and stable', () => {
52
+ const used = new Set();
53
+ const a = buildFieldPath({ scope: 'home', section: 'hero', field: 'title', used });
54
+ const b = buildFieldPath({ scope: 'home', section: 'hero', field: 'title', used });
55
+ assert.equal(a, 'home.hero.title');
56
+ assert.equal(b, 'home.hero.title2');
57
+ assert.equal(inferFieldName('url', 'a', 'Chat Now', { action: 'whatsapp' }), 'whatsappUrl');
58
+ assert.equal(inferFieldName('label', 'a', 'Chat Now', { action: 'whatsapp', paired: true }), 'whatsappLabel');
59
+ });
60
+
61
+ test('semantic engine splits WhatsApp action/label contracts', () => {
62
+ const code = fs.readFileSync(path.join(FIXTURE, 'src', 'components', 'Hero.tsx'), 'utf8');
63
+ const analysis = analyzeFile({
64
+ code,
65
+ relativeFile: 'src/components/Hero.tsx',
66
+ profile: scanProject(FIXTURE),
67
+ graph: { sharedFiles: [] },
68
+ ownerScope: 'home',
69
+ componentMeta: { name: 'Hero', role: 'hero' },
70
+ });
71
+ const split = analysis.candidates.find((c) => c.operation === 'split-action-contract');
72
+ assert.ok(split, 'expected split-action-contract candidate');
73
+ assert.equal(split.extra.action, 'whatsapp');
74
+ assert.match(split.label, /Start a Conversation/);
75
+ const heading = analysis.candidates.find((c) => c.operation === 'extract-text' && c.tag === 'h1');
76
+ assert.ok(heading);
77
+ assert.equal(heading.value, 'Summer Collection');
78
+ });
79
+
80
+ test('AST transformer preserves className and uses nullish fallbacks', () => {
81
+ const relativeFile = 'src/components/Hero.tsx';
82
+ const code = fs.readFileSync(path.join(FIXTURE, relativeFile), 'utf8');
83
+ const profile = scanProject(FIXTURE);
84
+ const analysis = analyzeFile({
85
+ code,
86
+ relativeFile,
87
+ profile,
88
+ graph: { sharedFiles: [] },
89
+ ownerScope: 'home',
90
+ componentMeta: { name: 'Hero', role: 'hero' },
91
+ });
92
+ analysis.relativeFile = relativeFile;
93
+ analysis.code = code;
94
+ const plan = planTransformations({
95
+ profile,
96
+ analyses: [analysis],
97
+ recipe: { actionRules: { splitActionAndLabel: true } },
98
+ });
99
+ const result = applyFilePlan(plan.files[0], profile);
100
+ assert.equal(result.changed, true);
101
+ assert.match(result.code, /className="hero"/);
102
+ assert.match(result.code, /data-preview-field-path=/);
103
+ assert.match(result.code, /\?\?/);
104
+ assert.match(result.code, /<span data-preview-field-path="/);
105
+ assert.doesNotMatch(result.code, /'use client'/);
106
+ assert.match(result.code, /site-data\.json|@\/data\/site-data\.json/);
107
+ parseSource(result.code, relativeFile);
108
+ });
109
+
110
+ test('dry-run does not modify source files', () => {
111
+ const dir = copyFixture();
112
+ const before = fs.readFileSync(path.join(dir, 'src', 'components', 'Hero.tsx'), 'utf8');
113
+ const result = silence(() => runDenebArc(dir, 'arc-fixture', { dryRun: true }));
114
+ const after = fs.readFileSync(path.join(dir, 'src', 'components', 'Hero.tsx'), 'utf8');
115
+ assert.equal(before, after);
116
+ assert.equal(result.outcome, 'dry-run');
117
+ assert.equal(fs.existsSync(path.join(dir, 'src', 'data', 'site-data.json')), false);
118
+ });
119
+
120
+ test('ARC converts a Next.js fixture into Fivora contracts without redesigning', () => {
121
+ const dir = copyFixture();
122
+ const result = silence(() => runDenebArc(dir, 'arc-fixture', { telemetry: 'off' }));
123
+ assert.equal(result.outcome, 'success');
124
+
125
+ const hero = fs.readFileSync(path.join(dir, 'src', 'components', 'Hero.tsx'), 'utf8');
126
+ const layout = fs.readFileSync(path.join(dir, 'src', 'app', 'layout.tsx'), 'utf8');
127
+ const page = fs.readFileSync(path.join(dir, 'src', 'app', 'page.tsx'), 'utf8');
128
+ const promo = fs.readFileSync(path.join(dir, 'src', 'components', 'PromoBanner.tsx'), 'utf8');
129
+ const siteData = JSON.parse(fs.readFileSync(path.join(dir, 'src', 'data', 'site-data.json'), 'utf8'));
130
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'fivora-template.json'), 'utf8'));
131
+
132
+ assert.match(layout, /SiteDataProvider/);
133
+ assert.match(page, /data-preview-page-key="home"/);
134
+ assert.doesNotMatch(layout, /'use client'/);
135
+ assert.match(hero, /data-preview-field-path="/);
136
+ assert.match(hero, /whatsapp/i);
137
+ assert.match(hero, /className="hero"/);
138
+ assert.match(hero, /className="btn cta"/);
139
+ assert.doesNotMatch(hero, /'use client'/);
140
+ assert.doesNotMatch(hero, /trueUrl/);
141
+ assert.match(promo, /useSiteData/);
142
+ assert.doesNotMatch(promo, /use client';;/);
143
+ assert.doesNotMatch(JSON.stringify(siteData.content), /VANTA/);
144
+ assert.ok(siteData.content.home.hero.title);
145
+ assert.ok(manifest.editorSchema.sections.length >= 1);
146
+ assert.ok(manifest.editorSchema.sections.length < 12);
147
+ assert.equal(manifest.arcVersion, result.arcVersion);
148
+ assert.equal(result.designPreservation >= 90, true, `design preservation ${result.designPreservation}`);
149
+
150
+ const second = silence(() => runDenebArc(dir, 'arc-fixture', { telemetry: 'off' }));
151
+ assert.equal(second.outcome, 'success');
152
+ const hero2 = fs.readFileSync(path.join(dir, 'src', 'components', 'Hero.tsx'), 'utf8');
153
+ const spanCount1 = (hero.match(/<span data-preview-field-path=/g) || []).length;
154
+ const spanCount2 = (hero2.match(/<span data-preview-field-path=/g) || []).length;
155
+ assert.equal(spanCount2, spanCount1);
156
+ });
157
+
158
+ test('dependency graph marks shared header as common-capable', () => {
159
+ const profile = scanProject(FIXTURE);
160
+ const graph = buildDependencyGraph(profile);
161
+ assert.ok(graph.nodes['src/app/page.tsx']);
162
+ assert.ok(graph.edges.some((e) => String(e.to).includes('Hero')));
163
+ });
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Fivora strict-contract conformance
167
+ // ---------------------------------------------------------------------------
168
+
169
+ const STOREFRONT_FIXTURE = path.join(__dirname, '..', '__fixtures__', 'next-app-storefront');
170
+
171
+ function copyOf(fixture) {
172
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'deneb-arc-'));
173
+ fs.cpSync(fixture, dir, { recursive: true });
174
+ return dir;
175
+ }
176
+
177
+ function readSources(root) {
178
+ const out = [];
179
+ (function walk(dir) {
180
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
181
+ if (entry.name === 'node_modules' || entry.name.startsWith('.deneb')) continue;
182
+ const abs = path.join(dir, entry.name);
183
+ if (entry.isDirectory()) walk(abs);
184
+ else if (/\.(tsx|jsx|ts|js)$/.test(entry.name)) {
185
+ out.push({ rel: path.relative(root, abs).replace(/\\/g, '/'), code: fs.readFileSync(abs, 'utf8') });
186
+ }
187
+ }
188
+ })(root);
189
+ return out;
190
+ }
191
+
192
+ /** Applies the ported Fivora strict rules the same way the ingest pipeline does. */
193
+ function auditFivora(dir) {
194
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'fivora-template.json'), 'utf8'));
195
+ const siteData = JSON.parse(fs.readFileSync(path.join(dir, manifest.siteDataFile), 'utf8'));
196
+ const sources = readSources(dir);
197
+
198
+ const markers = [];
199
+ const pageKeysByFile = {};
200
+ const errors = [];
201
+
202
+ for (const source of sources) {
203
+ const extracted = contract.extractMarkers(source.code, source.rel);
204
+ markers.push(...extracted.markers);
205
+ const pages = extracted.markers.filter((m) => m.kind === 'page').map((m) => m.value);
206
+ if (pages.length) pageKeysByFile[source.rel] = pages;
207
+ errors.push(...contract.auditMarkerPlacement(source.code, source.rel));
208
+ errors.push(...contract.auditActionLabelCollision(source.code, source.rel));
209
+ for (const finding of contract.findUncoveredVisibleText(source.code, source.rel)) {
210
+ errors.push(`${finding.filePath}:${finding.line} uncovered visible text "${finding.text}"`);
211
+ }
212
+ }
213
+
214
+ // Resolve manifest pages to source files for both Next.js routers.
215
+ const routeFiles = {};
216
+ for (const page of manifest.pages || []) {
217
+ const segment = page.route === '/' ? '' : String(page.route).replace(/^\//, '');
218
+ const candidates = [];
219
+ for (const base of ['src/app', 'app']) {
220
+ for (const ext of ['tsx', 'jsx', 'js']) {
221
+ candidates.push(segment ? path.join(base, segment, `page.${ext}`) : path.join(base, `page.${ext}`));
222
+ }
223
+ }
224
+ for (const base of ['src/pages', 'pages']) {
225
+ for (const ext of ['tsx', 'jsx', 'js']) {
226
+ candidates.push(path.join(base, `${segment || 'index'}.${ext}`));
227
+ if (segment) candidates.push(path.join(base, segment, `index.${ext}`));
228
+ }
229
+ }
230
+ for (const candidate of candidates) {
231
+ if (fs.existsSync(path.join(dir, candidate))) {
232
+ routeFiles[page.id] = candidate.replace(/\\/g, '/');
233
+ break;
234
+ }
235
+ }
236
+ }
237
+
238
+ errors.push(
239
+ ...contract.auditPathCoverage({
240
+ content: siteData.content,
241
+ editorSchema: manifest.editorSchema,
242
+ markers,
243
+ controlOnlyPaths: manifest.visualEditing?.controlOnlyPaths || [],
244
+ }).errors
245
+ );
246
+ errors.push(...contract.auditSchemaUniqueness(manifest.editorSchema));
247
+ errors.push(...contract.auditPageCoverage({ pages: manifest.pages, routeFiles, pageMarkersByFile: pageKeysByFile }));
248
+ errors.push(...contract.auditPreviewRuntime(sources.map((s) => s.code)));
249
+
250
+ return { errors: [...new Set(errors)], manifest, siteData };
251
+ }
252
+
253
+ test('converted basic fixture satisfies the Fivora strict contract', () => {
254
+ const dir = copyOf(FIXTURE);
255
+ silence(() => runDenebArc(dir, 'basic-store', { telemetry: 'off' }));
256
+ const { errors } = auditFivora(dir);
257
+ assert.deepEqual(errors, []);
258
+ });
259
+
260
+ test('converted storefront fixture satisfies the Fivora strict contract', () => {
261
+ const dir = copyOf(STOREFRONT_FIXTURE);
262
+ silence(() => runDenebArc(dir, 'acme-store', { telemetry: 'off' }));
263
+ const { errors } = auditFivora(dir);
264
+ assert.deepEqual(errors, []);
265
+ });
266
+
267
+ test('every unbound site-data field is declared control-only', () => {
268
+ const dir = copyOf(STOREFRONT_FIXTURE);
269
+ silence(() => runDenebArc(dir, 'acme-store', { telemetry: 'off' }));
270
+ const { manifest, siteData } = auditFivora(dir);
271
+ const controlOnly = manifest.visualEditing.controlOnlyPaths;
272
+
273
+ // The merchant baseline ARC always writes is never rendered by an arbitrary
274
+ // project, so it must be control-only rather than a coverage failure.
275
+ assert.ok(controlOnly.includes('common.business.phone'));
276
+ assert.ok(controlOnly.includes('common.websiteTitle'));
277
+ assert.ok(!controlOnly.includes('home.hero.title'), 'bound fields stay visually editable');
278
+
279
+ const inventory = contract.enumerateContentPaths(siteData.content);
280
+ for (const declared of controlOnly) {
281
+ assert.ok(
282
+ inventory.concreteFields.has(declared) ||
283
+ [...inventory.fieldPatterns].some((p) => p === contract.wildcardPath(declared)),
284
+ `controlOnlyPaths entry "${declared}" must exist in site-data content`
285
+ );
286
+ }
287
+ });
288
+
289
+ test('ARC never declares a manifest page without a page file on disk', () => {
290
+ const dir = copyOf(STOREFRONT_FIXTURE);
291
+ silence(() => runDenebArc(dir, 'acme-store', { telemetry: 'off' }));
292
+ const { manifest } = auditFivora(dir);
293
+ const ids = manifest.pages.map((page) => page.id);
294
+ assert.deepEqual(ids.sort(), ['about', 'home']);
295
+ assert.ok(!ids.includes('contact'), 'a contact route with no page component must not be invented');
296
+ });
297
+
298
+ test('static-array collections become list contracts without changing render logic', () => {
299
+ const dir = copyOf(STOREFRONT_FIXTURE);
300
+ silence(() => runDenebArc(dir, 'acme-store', { telemetry: 'off' }));
301
+ const grid = fs.readFileSync(path.join(dir, 'src', 'components', 'ProductGrid.tsx'), 'utf8');
302
+
303
+ assert.match(grid, /data-preview-list-path="home\.products"/);
304
+ assert.match(grid, /data-preview-item-path=\{`home\.products\[\$\{index\}\]`\}/);
305
+ assert.match(grid, /data-preview-field-path=\{`home\.products\[\$\{index\}\]\.title`\}/);
306
+ // The array is site-data backed with the developer's literal as fallback.
307
+ assert.match(grid, /const products = siteData\?\.content\?\.home\?\.products \?\? \[/);
308
+ // Render logic is untouched: items are still read off the map variable.
309
+ assert.match(grid, /\{product\.title\}/);
310
+ assert.match(grid, /className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3"/);
311
+
312
+ const { manifest, siteData } = auditFivora(dir);
313
+ const home = manifest.editorSchema.sections.find((s) => s.id === 'home');
314
+ const products = home.fields.find((f) => f.key === 'products');
315
+ assert.equal(products.type, 'list');
316
+ assert.deepEqual(products.fields.map((f) => f.key).sort(), ['description', 'image', 'price', 'title']);
317
+ assert.equal(siteData.content.home.products.length, 3);
318
+ assert.equal(siteData.content.home.products[0].title, 'Minimalist Smart Watch');
319
+ });
320
+
321
+ test('collections holding component references are left alone', () => {
322
+ const dir = copyOf(STOREFRONT_FIXTURE);
323
+ silence(() => runDenebArc(dir, 'acme-store', { telemetry: 'off' }));
324
+ const features = fs.readFileSync(path.join(dir, 'src', 'components', 'Features.tsx'), 'utf8');
325
+ assert.ok(!features.includes('data-preview-list-path'), 'icon component refs are not merchant content');
326
+ assert.match(features, /icon: Truck/);
327
+ });
328
+
329
+ test('literal text inside a broad container is wrapped instead of marked illegally', () => {
330
+ const dir = copyOf(STOREFRONT_FIXTURE);
331
+ silence(() => runDenebArc(dir, 'acme-store', { telemetry: 'off' }));
332
+ const about = fs.readFileSync(path.join(dir, 'src', 'app', 'about', 'page.tsx'), 'utf8');
333
+
334
+ // Fivora rejects data-preview-field-path on <div>, so the text gets a span.
335
+ assert.match(about, /<div className="mt-10 text-sm text-slate-500">\s*<span data-preview-field-path=/);
336
+ assert.ok(!/<div[^>]*data-preview-field-path/.test(about));
337
+ });
338
+
339
+ test('shadcn Button asChild keeps the action on the link and the label in a span', () => {
340
+ const dir = copyOf(STOREFRONT_FIXTURE);
341
+ silence(() => runDenebArc(dir, 'acme-store', { telemetry: 'off' }));
342
+ const hero = fs.readFileSync(path.join(dir, 'src', 'components', 'Hero.tsx'), 'utf8');
343
+
344
+ assert.match(hero, /<Button asChild>/);
345
+ assert.match(hero, /href=\{siteData\?\.content\?\.home\?\.hero\?\.shopCollectionUrl \?\? "\/products"\}/);
346
+ assert.match(hero, /<span data-preview-field-path="home\.hero\.shopCollectionLabel">/);
347
+ // The decorative icon stays static.
348
+ assert.match(hero, /<ArrowRight className="ml-2 size-4" aria-hidden="true" \/>/);
349
+ });
350
+
351
+ test('a second init run converges on identical sources and manifest', () => {
352
+ const dir = copyOf(STOREFRONT_FIXTURE);
353
+ silence(() => runDenebArc(dir, 'acme-store', { telemetry: 'off' }));
354
+ const firstSources = readSources(dir).map((s) => s.code).join('\n---\n');
355
+ const firstManifest = fs.readFileSync(path.join(dir, 'fivora-template.json'), 'utf8');
356
+
357
+ silence(() => runDenebArc(dir, 'acme-store', { telemetry: 'off' }));
358
+ const secondSources = readSources(dir).map((s) => s.code).join('\n---\n');
359
+ const secondManifest = fs.readFileSync(path.join(dir, 'fivora-template.json'), 'utf8');
360
+
361
+ assert.equal(secondSources, firstSources, 'sources must not drift on re-run');
362
+ assert.equal(secondManifest, firstManifest, 'manifest must not lose schema on re-run');
363
+ assert.deepEqual(auditFivora(dir).errors, []);
364
+ });
365
+
366
+ const PAGES_FIXTURE = path.join(__dirname, '..', '__fixtures__', 'next-pages-basic');
367
+
368
+ test('Pages Router projects convert and satisfy the strict contract', () => {
369
+ const dir = copyOf(PAGES_FIXTURE);
370
+ silence(() => runDenebArc(dir, 'pottery-shop', { telemetry: 'off' }));
371
+
372
+ const profile = scanProject(dir);
373
+ assert.equal(profile.router, 'next-pages');
374
+
375
+ const index = fs.readFileSync(path.join(dir, 'pages', 'index.jsx'), 'utf8');
376
+ const contact = fs.readFileSync(path.join(dir, 'pages', 'contact.jsx'), 'utf8');
377
+ const app = fs.readFileSync(path.join(dir, 'pages', '_app.jsx'), 'utf8');
378
+
379
+ // Page keys must come from the route id, not an App Router filename pattern.
380
+ assert.match(index, /<main data-preview-page-key="home"/);
381
+ assert.match(contact, /<main data-preview-page-key="contact"/);
382
+ // The provider mounts in _app for this router.
383
+ assert.match(app, /<SiteDataProvider initialSiteData=\{initialSiteData\}>/);
384
+ // No path alias exists here, so the import must be relative.
385
+ assert.match(index, /from "\.\.\/data\/site-data\.json"/);
386
+ // tel: actions are split into url + label.
387
+ assert.match(index, /href=\{siteData\?\.content\?\.home\?\.contact\?\.phoneUrl \?\? "tel:\+94771234567"\}/);
388
+ assert.match(index, /<span data-preview-field-path="home\.contact\.phoneLabel">/);
389
+ // Original class names are untouched.
390
+ assert.match(index, /className="wrapper"/);
391
+ assert.match(contact, /className="photo"/);
392
+
393
+ const { errors } = auditFivora(dir);
394
+ assert.deepEqual(errors, []);
395
+ });
396
+
397
+ test('static export is configured without discarding an existing next config', () => {
398
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'deneb-arc-cfg-'));
399
+ fs.writeFileSync(
400
+ path.join(dir, 'next.config.ts'),
401
+ `import type { NextConfig } from 'next';
402
+ import createMDX from '@next/mdx';
403
+
404
+ // Keep MDX support enabled.
405
+ const nextConfig: NextConfig = {
406
+ reactStrictMode: true,
407
+ pageExtensions: ['ts', 'tsx', 'mdx'],
408
+ };
409
+
410
+ export default createMDX()(nextConfig);
411
+ `,
412
+ 'utf8'
413
+ );
414
+
415
+ const result = ensureStaticExportConfig(dir, { language: 'typescript' });
416
+ const code = fs.readFileSync(path.join(dir, 'next.config.ts'), 'utf8');
417
+
418
+ assert.equal(result.updated, true);
419
+ assert.match(code, /output: "export"/);
420
+ assert.match(code, /unoptimized: true/);
421
+ assert.match(code, /const basePath = process\.env\.NEXT_PUBLIC_SITE_BASE_PATH \|\| ""/);
422
+ // Existing plugin wiring, options and comments survive.
423
+ assert.match(code, /export default createMDX\(\)\(nextConfig\)/);
424
+ assert.match(code, /reactStrictMode: true/);
425
+ assert.match(code, /pageExtensions: \['ts', 'tsx', 'mdx'\]/);
426
+ assert.match(code, /\/\/ Keep MDX support enabled\./);
427
+
428
+ // Re-running must not duplicate any setting.
429
+ ensureStaticExportConfig(dir, { language: 'typescript' });
430
+ const again = fs.readFileSync(path.join(dir, 'next.config.ts'), 'utf8');
431
+ assert.equal((again.match(/output:/g) || []).length, 1);
432
+ assert.equal((again.match(/const basePath =/g) || []).length, 1);
433
+ });
434
+
435
+ test('a developer output setting is reported instead of overwritten', () => {
436
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'deneb-arc-cfg2-'));
437
+ fs.writeFileSync(
438
+ path.join(dir, 'next.config.js'),
439
+ `module.exports = { output: 'standalone' };\n`,
440
+ 'utf8'
441
+ );
442
+
443
+ const result = ensureStaticExportConfig(dir, { language: 'javascript' });
444
+ assert.match(result.warnings.join(' '), /output: 'standalone'/);
445
+ assert.match(fs.readFileSync(path.join(dir, 'next.config.js'), 'utf8'), /standalone/);
446
+ });
447
+
448
+ test('schema sections only claim a pageKey when reachability proves it', () => {
449
+ const dir = copyOf(STOREFRONT_FIXTURE);
450
+ silence(() => runDenebArc(dir, 'acme-store', { telemetry: 'off' }));
451
+ const { manifest } = auditFivora(dir);
452
+ const sections = manifest.editorSchema.sections;
453
+
454
+ const common = sections.find((s) => s.id === 'common');
455
+ assert.equal(common.pageKey, undefined, 'shared content renders on every route');
456
+ assert.equal(sections.find((s) => s.id === 'home').pageKey, 'home');
457
+ assert.equal(sections.find((s) => s.id === 'about').pageKey, 'about');
458
+ });
@@ -0,0 +1,184 @@
1
+ 'use strict';
2
+
3
+ const { getJsxName, getJsxAttributeLiteral, hasJsxAttribute, collectJsxText } = require('./ast.cjs');
4
+
5
+ function createAdapter(id, impl) {
6
+ return { id, ...impl };
7
+ }
8
+
9
+ function detectFromProfile(profile, id) {
10
+ return (profile.componentLibraries || []).includes(id) || profile[id] === true;
11
+ }
12
+
13
+ const ICON_NAME_RE = /^(Icon|[A-Z][A-Za-z0-9]*(Icon|Logo)|Lucide[A-Z]|HiOutline|HiSolid|Fa[A-Z]|Md[A-Z]|Io[A-Z])/;
14
+ const DECORATIVE_TAGS = new Set([
15
+ 'svg', 'path', 'circle', 'rect', 'g', 'line', 'polyline', 'polygon', 'ellipse',
16
+ 'defs', 'clipPath', 'linearGradient', 'radialGradient', 'stop', 'use', 'mask',
17
+ ]);
18
+ const SKIP_TAGS = new Set([
19
+ 'script', 'style', 'link', 'meta', 'head', 'html', 'Fragment', 'Suspense',
20
+ 'StrictMode', 'ErrorBoundary',
21
+ ]);
22
+ const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'Heading', 'Title', 'CardTitle', 'ModalHeader', 'DialogTitle']);
23
+ const TEXT_TAGS = new Set(['p', 'span', 'li', 'blockquote', 'figcaption', 'label', 'CardDescription', 'Description', 'Subtitle', 'Typography', 'Text', 'Badge', 'badge']);
24
+ const ACTION_TAGS = new Set(['a', 'Link', 'NavLink', 'Button', 'button', 'IconButton', 'NavbarBrand']);
25
+ const IMAGE_TAGS = new Set(['img', 'Image', 'Img', 'BackgroundImage']);
26
+
27
+ const shadcn = createAdapter('shadcn', {
28
+ detect: (project) => detectFromProfile(project, 'shadcn') || project.shadcn,
29
+ recognizeNode(node, ctx) {
30
+ const name = getJsxName(node);
31
+ if (name === 'Button') {
32
+ return { library: 'shadcn', kind: 'button', asChild: hasJsxAttribute(node, 'asChild') };
33
+ }
34
+ if (name === 'CardTitle' || name === 'CardDescription' || name === 'Badge') {
35
+ return { library: 'shadcn', kind: 'text', tag: name };
36
+ }
37
+ return null;
38
+ },
39
+ resolveAction(node) {
40
+ const name = getJsxName(node);
41
+ if (name === 'Button' && hasJsxAttribute(node, 'asChild')) {
42
+ const child = (node.children || []).find((c) => c && c.type === 'JSXElement');
43
+ if (child && ACTION_TAGS.has(getJsxName(child))) return child;
44
+ }
45
+ return null;
46
+ },
47
+ });
48
+
49
+ const heroui = createAdapter('heroui', {
50
+ detect: (project) => detectFromProfile(project, 'heroui') || project.heroui,
51
+ recognizeNode(node) {
52
+ const name = getJsxName(node);
53
+ if (['Button', 'Link', 'Navbar', 'NavbarBrand', 'NavbarItem', 'NavbarContent'].includes(name)) {
54
+ return { library: 'heroui', kind: name === 'Button' || name === 'Link' ? 'action' : 'structure', tag: name };
55
+ }
56
+ return null;
57
+ },
58
+ resolveAction(node) {
59
+ const name = getJsxName(node);
60
+ if (name === 'Button' && (hasJsxAttribute(node, 'href') || hasJsxAttribute(node, 'as'))) return node;
61
+ if (name === 'Link') return node;
62
+ return null;
63
+ },
64
+ });
65
+
66
+ const nextjs = createAdapter('nextjs', {
67
+ detect: (project) => project.framework === 'nextjs',
68
+ recognizeNode(node) {
69
+ const name = getJsxName(node);
70
+ if (name === 'Link') return { library: 'nextjs', kind: 'action', tag: 'Link' };
71
+ if (name === 'Image') return { library: 'nextjs', kind: 'image', tag: 'Image' };
72
+ return null;
73
+ },
74
+ resolveAction(node) {
75
+ return getJsxName(node) === 'Link' ? node : null;
76
+ },
77
+ });
78
+
79
+ const framer = createAdapter('framer-motion', {
80
+ detect: (project) => (project.animationLibraries || []).includes('framer-motion'),
81
+ recognizeNode(node) {
82
+ const name = getJsxName(node);
83
+ if (name.startsWith('motion.')) {
84
+ const tag = name.slice('motion.'.length);
85
+ return { library: 'framer-motion', kind: HEADING_TAGS.has(tag) || TEXT_TAGS.has(tag) ? 'text' : 'structure', tag };
86
+ }
87
+ return null;
88
+ },
89
+ });
90
+
91
+ const reactBits = createAdapter('react-bits', {
92
+ detect: (project) => project.reactBits,
93
+ recognizeNode(node) {
94
+ const name = getJsxName(node);
95
+ if (/SplitText|BlurText|GradientText|ShinyText|CountUp|RotatingText/.test(name)) {
96
+ return { library: 'react-bits', kind: 'text', tag: name };
97
+ }
98
+ return null;
99
+ },
100
+ });
101
+
102
+ const radix = createAdapter('radix', {
103
+ detect: (project) => (project.componentLibraries || []).includes('radix'),
104
+ recognizeNode(node) {
105
+ const name = getJsxName(node);
106
+ if (name === 'Slot' || name.endsWith('.Root') || name.endsWith('.Trigger')) {
107
+ return { library: 'radix', kind: 'structure', tag: name };
108
+ }
109
+ return null;
110
+ },
111
+ });
112
+
113
+ const ADAPTERS = [nextjs, shadcn, heroui, framer, reactBits, radix];
114
+
115
+ function activeAdapters(profile) {
116
+ return ADAPTERS.filter((adapter) => {
117
+ try {
118
+ return adapter.detect(profile);
119
+ } catch {
120
+ return false;
121
+ }
122
+ });
123
+ }
124
+
125
+ function recognizeWithAdapters(node, ctx, adapters) {
126
+ for (const adapter of adapters) {
127
+ if (!adapter.recognizeNode) continue;
128
+ const result = adapter.recognizeNode(node, ctx);
129
+ if (result) return { ...result, adapterId: adapter.id };
130
+ }
131
+ return null;
132
+ }
133
+
134
+ function resolveActionWithAdapters(node, adapters) {
135
+ for (const adapter of adapters) {
136
+ if (!adapter.resolveAction) continue;
137
+ const result = adapter.resolveAction(node);
138
+ if (result) return result;
139
+ }
140
+ return null;
141
+ }
142
+
143
+ function isIconComponent(name, importSource) {
144
+ if (!name) return false;
145
+ if (DECORATIVE_TAGS.has(name)) return true;
146
+ if (ICON_NAME_RE.test(name)) return true;
147
+ if (importSource && /(lucide-react|heroicons|react-icons|tabler\/icons)/i.test(importSource)) return true;
148
+ return false;
149
+ }
150
+
151
+ function classifyHref(href) {
152
+ const value = String(href || '');
153
+ if (/wa\.me|whatsapp/i.test(value)) return 'whatsapp';
154
+ if (/^tel:/i.test(value) || /phone|call/i.test(value)) return 'phone';
155
+ if (/^mailto:/i.test(value)) return 'email';
156
+ const social = value.match(/(instagram|facebook|tiktok|twitter|x\.com|youtube|linkedin|pinterest|threads)\./i);
157
+ if (social) {
158
+ const platform = social[1].toLowerCase().replace('x.com', 'twitter');
159
+ return platform === 'x.com' ? 'twitter' : platform;
160
+ }
161
+ return 'link';
162
+ }
163
+
164
+ function isLikelyCtaClass(className) {
165
+ return /\b(btn|button|cta|action|rounded|bg-|hero[-_]?cta)\b/i.test(className || '');
166
+ }
167
+
168
+ module.exports = {
169
+ ADAPTERS,
170
+ activeAdapters,
171
+ recognizeWithAdapters,
172
+ resolveActionWithAdapters,
173
+ isIconComponent,
174
+ classifyHref,
175
+ isLikelyCtaClass,
176
+ DECORATIVE_TAGS,
177
+ SKIP_TAGS,
178
+ HEADING_TAGS,
179
+ TEXT_TAGS,
180
+ ACTION_TAGS,
181
+ IMAGE_TAGS,
182
+ collectJsxText,
183
+ getJsxAttributeLiteral,
184
+ };