@deneb-ui/cli 2.0.22 → 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 (47) hide show
  1. package/README.md +62 -114
  2. package/bin/index.js +37 -12
  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
@@ -0,0 +1,49 @@
1
+ import Image from 'next/image';
2
+
3
+ const products = [
4
+ {
5
+ id: 'prod-1',
6
+ title: 'Minimalist Smart Watch',
7
+ price: '$249.00',
8
+ image: '/watch.webp',
9
+ description: 'Titanium case with a sapphire display.',
10
+ },
11
+ {
12
+ id: 'prod-2',
13
+ title: 'Leather Travel Backpack',
14
+ price: '$189.00',
15
+ image: '/backpack.webp',
16
+ description: 'Full-grain waterproof Italian leather.',
17
+ },
18
+ {
19
+ id: 'prod-3',
20
+ title: 'Linen Weekend Shirt',
21
+ price: '$89.00',
22
+ image: '/shirt.webp',
23
+ description: 'Breathable European linen, garment washed.',
24
+ },
25
+ ];
26
+
27
+ export function ProductGrid() {
28
+ return (
29
+ <section className="px-6">
30
+ <h2 className="mb-10 text-center text-3xl font-semibold">Featured products</h2>
31
+ <div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
32
+ {products.map((product) => (
33
+ <article key={product.id} className="group flex flex-col gap-4">
34
+ <Image
35
+ src={product.image}
36
+ alt={product.title}
37
+ width={480}
38
+ height={480}
39
+ className="rounded-2xl object-cover transition group-hover:scale-[1.02]"
40
+ />
41
+ <h3 className="text-lg font-medium">{product.title}</h3>
42
+ <p className="text-sm text-slate-600">{product.description}</p>
43
+ <span className="text-base font-semibold">{product.price}</span>
44
+ </article>
45
+ ))}
46
+ </div>
47
+ </section>
48
+ );
49
+ }
@@ -0,0 +1,22 @@
1
+ import { Instagram, Facebook } from 'lucide-react';
2
+
3
+ export function SiteFooter() {
4
+ return (
5
+ <footer className="border-t border-slate-200 px-6 py-12">
6
+ <div className="mx-auto flex max-w-6xl flex-col gap-6 md:flex-row md:items-center md:justify-between">
7
+ <p className="text-sm text-slate-500">© 2026 Acme Supply Co. All rights reserved.</p>
8
+ <div className="flex items-center gap-4">
9
+ <a href="https://instagram.com/acme" aria-label="Instagram">
10
+ <Instagram className="size-5" aria-hidden="true" />
11
+ </a>
12
+ <a href="https://facebook.com/acme" aria-label="Facebook">
13
+ <Facebook className="size-5" aria-hidden="true" />
14
+ </a>
15
+ <a href="mailto:hello@acme.test" className="text-sm underline underline-offset-4">
16
+ hello@acme.test
17
+ </a>
18
+ </div>
19
+ </div>
20
+ </footer>
21
+ );
22
+ }
@@ -0,0 +1,21 @@
1
+ import Link from 'next/link';
2
+
3
+ export function SiteHeader() {
4
+ return (
5
+ <header className="sticky top-0 z-50 border-b border-slate-200 bg-white/80 backdrop-blur">
6
+ <nav className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
7
+ <Link href="/" className="text-lg font-semibold tracking-tight">
8
+ Acme
9
+ </Link>
10
+ <ul className="flex items-center gap-8 text-sm">
11
+ <li>
12
+ <Link href="/products">Products</Link>
13
+ </li>
14
+ <li>
15
+ <Link href="/about">About</Link>
16
+ </li>
17
+ </ul>
18
+ </nav>
19
+ </header>
20
+ );
21
+ }
@@ -0,0 +1,36 @@
1
+ import * as React from 'react';
2
+ import { Slot } from '@radix-ui/react-slot';
3
+ import { cva, type VariantProps } from 'class-variance-authority';
4
+
5
+ const buttonVariants = cva(
6
+ 'inline-flex items-center justify-center rounded-full text-sm font-medium transition-colors',
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default: 'bg-slate-900 text-white hover:bg-slate-800',
11
+ outline: 'border border-slate-300 bg-transparent hover:bg-slate-50',
12
+ },
13
+ size: {
14
+ default: 'h-11 px-6',
15
+ sm: 'h-9 px-4',
16
+ },
17
+ },
18
+ defaultVariants: { variant: 'default', size: 'default' },
19
+ }
20
+ );
21
+
22
+ export interface ButtonProps
23
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
24
+ VariantProps<typeof buttonVariants> {
25
+ asChild?: boolean;
26
+ }
27
+
28
+ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
29
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
30
+ const Comp = asChild ? Slot : 'button';
31
+ return (
32
+ <Comp className={buttonVariants({ variant, size, className })} ref={ref} {...props} />
33
+ );
34
+ }
35
+ );
36
+ Button.displayName = 'Button';
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "esnext"],
5
+ "jsx": "preserve",
6
+ "module": "esnext",
7
+ "moduleResolution": "bundler",
8
+ "strict": true,
9
+ "baseUrl": ".",
10
+ "paths": {
11
+ "@/*": ["./src/*"]
12
+ }
13
+ },
14
+ "include": ["src"]
15
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "pages-router-shop",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "dependencies": {
6
+ "next": "14.2.0",
7
+ "react": "18.3.1",
8
+ "react-dom": "18.3.1"
9
+ }
10
+ }
@@ -0,0 +1,5 @@
1
+ import '../styles/globals.css';
2
+
3
+ export default function App({ Component, pageProps }) {
4
+ return <Component {...pageProps} />;
5
+ }
@@ -0,0 +1,9 @@
1
+ export default function Contact() {
2
+ return (
3
+ <main className="wrapper">
4
+ <h1 className="title">Visit us</h1>
5
+ <p className="lede">Open Tuesday to Saturday, 9am until 5pm.</p>
6
+ <img src="/studio.jpg" alt="The pottery studio" className="photo" />
7
+ </main>
8
+ );
9
+ }
@@ -0,0 +1,11 @@
1
+ export default function Home() {
2
+ return (
3
+ <main className="wrapper">
4
+ <h1 className="title">Handmade ceramics from Kandy</h1>
5
+ <p className="lede">Every piece is thrown, glazed and fired in our studio.</p>
6
+ <a href="tel:+94771234567" className="cta">
7
+ Call the studio
8
+ </a>
9
+ </main>
10
+ );
11
+ }
@@ -0,0 +1,9 @@
1
+ .wrapper {
2
+ max-width: 60rem;
3
+ margin: 0 auto;
4
+ padding: 4rem 1.5rem;
5
+ }
6
+
7
+ .title {
8
+ font-size: 2.5rem;
9
+ }
@@ -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
+ });