@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,613 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const {
6
+ walkFiles,
7
+ readJsonSafe,
8
+ detectPackageManager,
9
+ findFirstExisting,
10
+ isJsxFile,
11
+ isSourceFile,
12
+ rel,
13
+ shortHash,
14
+ toPosix,
15
+ } = require('./fs-utils.cjs');
16
+ const { ARC_VERSION } = require('./version.cjs');
17
+
18
+ const SOURCE_EXT = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs'];
19
+
20
+ function readPackage(projectDir) {
21
+ return readJsonSafe(path.join(projectDir, 'package.json'), {}) || {};
22
+ }
23
+
24
+ function allDeps(pkg) {
25
+ return {
26
+ ...(pkg.dependencies || {}),
27
+ ...(pkg.devDependencies || {}),
28
+ ...(pkg.peerDependencies || {}),
29
+ };
30
+ }
31
+
32
+ function depVersion(deps, name) {
33
+ return deps[name] ? String(deps[name]).replace(/^[^\d]*/, '') : undefined;
34
+ }
35
+
36
+ function parseTsconfig(projectDir) {
37
+ const file = findFirstExisting([
38
+ path.join(projectDir, 'tsconfig.json'),
39
+ path.join(projectDir, 'jsconfig.json'),
40
+ ]);
41
+ if (!file) return { file: null, config: {}, aliases: {} };
42
+ const config = readJsonSafe(file, {}) || {};
43
+ const paths = config.compilerOptions?.paths || {};
44
+ const baseUrl = config.compilerOptions?.baseUrl || '.';
45
+ const aliases = {};
46
+ for (const [alias, targets] of Object.entries(paths)) {
47
+ const target = Array.isArray(targets) ? targets[0] : targets;
48
+ if (!target) continue;
49
+ aliases[alias] = path.resolve(projectDir, baseUrl, target);
50
+ }
51
+ return { file, config, aliases };
52
+ }
53
+
54
+ function detectFramework(pkg, deps, projectDir) {
55
+ if (deps.next) {
56
+ return {
57
+ framework: 'nextjs',
58
+ frameworkVersion: depVersion(deps, 'next'),
59
+ };
60
+ }
61
+ if (deps.vite || findFirstExisting([
62
+ path.join(projectDir, 'vite.config.ts'),
63
+ path.join(projectDir, 'vite.config.js'),
64
+ path.join(projectDir, 'vite.config.mjs'),
65
+ ])) {
66
+ return {
67
+ framework: 'vite-react',
68
+ frameworkVersion: depVersion(deps, 'vite'),
69
+ };
70
+ }
71
+ if (deps.react) {
72
+ return {
73
+ framework: 'react',
74
+ frameworkVersion: depVersion(deps, 'react'),
75
+ };
76
+ }
77
+ return { framework: 'unknown' };
78
+ }
79
+
80
+ function detectRouter(projectDir, framework) {
81
+ const appDir = findFirstExisting([
82
+ path.join(projectDir, 'src', 'app'),
83
+ path.join(projectDir, 'app'),
84
+ ]);
85
+ const pagesDir = findFirstExisting([
86
+ path.join(projectDir, 'src', 'pages'),
87
+ path.join(projectDir, 'pages'),
88
+ ]);
89
+
90
+ if (framework === 'nextjs' && appDir) {
91
+ return { router: 'next-app', appDir, pagesDir };
92
+ }
93
+ if (framework === 'nextjs' && pagesDir) {
94
+ return { router: 'next-pages', appDir: null, pagesDir };
95
+ }
96
+ if (findReactRouter(projectDir)) {
97
+ return { router: 'react-router', appDir, pagesDir };
98
+ }
99
+ return { router: pagesDir || appDir ? 'custom' : 'unknown', appDir, pagesDir };
100
+ }
101
+
102
+ function findReactRouter(projectDir) {
103
+ const pkg = readPackage(projectDir);
104
+ const deps = allDeps(pkg);
105
+ return Boolean(deps['react-router'] || deps['react-router-dom']);
106
+ }
107
+
108
+ function detectCssSystems(deps, projectDir) {
109
+ const systems = [];
110
+ const hasTailwindPkg = Boolean(deps.tailwindcss);
111
+ const hasTailwindConfig = Boolean(findFirstExisting([
112
+ path.join(projectDir, 'tailwind.config.js'),
113
+ path.join(projectDir, 'tailwind.config.ts'),
114
+ path.join(projectDir, 'tailwind.config.mjs'),
115
+ path.join(projectDir, 'tailwind.config.cjs'),
116
+ ]));
117
+ const hasTailwindV4 =
118
+ (hasTailwindPkg && /^4\b/.test(String(deps.tailwindcss || '').replace(/^[^\d]*/, ''))) ||
119
+ Boolean(deps['@tailwindcss/postcss']) ||
120
+ Boolean(deps['@tailwindcss/vite']);
121
+
122
+ if (hasTailwindPkg || hasTailwindConfig || hasTailwindV4) {
123
+ systems.push(hasTailwindV4 ? 'tailwind-v4' : 'tailwind-v3');
124
+ }
125
+ if (deps.sass || deps['sass-embedded']) systems.push('scss');
126
+ if (walkFiles(projectDir, { include: (_p, name) => name.endsWith('.module.css') }).length) {
127
+ systems.push('css-modules');
128
+ }
129
+ if (walkFiles(projectDir, { include: (_p, name) => name.endsWith('.css') && !name.endsWith('.module.css') }).length) {
130
+ systems.push('vanilla-css');
131
+ }
132
+ return [...new Set(systems)];
133
+ }
134
+
135
+ function detectLibraries(deps, projectDir) {
136
+ const componentLibraries = [];
137
+ const animationLibraries = [];
138
+ const iconLibraries = [];
139
+
140
+ const hasShadcn = Boolean(
141
+ fs.existsSync(path.join(projectDir, 'components.json')) ||
142
+ deps['@radix-ui/react-slot'] ||
143
+ deps['class-variance-authority']
144
+ );
145
+ if (hasShadcn) componentLibraries.push('shadcn');
146
+ if (Object.keys(deps).some((d) => d.startsWith('@radix-ui/'))) componentLibraries.push('radix');
147
+ if (deps['@heroui/react'] || deps['@nextui-org/react'] || Object.keys(deps).some((d) => d.startsWith('@heroui/') || d.startsWith('@nextui-org/'))) {
148
+ componentLibraries.push('heroui');
149
+ }
150
+ if (deps['react-bits'] || Object.keys(deps).some((d) => d.includes('react-bits') || d.startsWith('@react-bits/'))) {
151
+ componentLibraries.push('react-bits');
152
+ }
153
+ if (deps['@headlessui/react']) componentLibraries.push('headless-ui');
154
+ if (deps['styled-components'] || deps['@emotion/react'] || deps['@emotion/styled']) {
155
+ componentLibraries.push('css-in-js');
156
+ }
157
+
158
+ if (deps['framer-motion'] || deps.motion) animationLibraries.push('framer-motion');
159
+ if (deps['gsap']) animationLibraries.push('gsap');
160
+
161
+ if (deps['lucide-react']) iconLibraries.push('lucide');
162
+ if (deps['@heroicons/react']) iconLibraries.push('heroicons');
163
+ if (deps['react-icons']) iconLibraries.push('react-icons');
164
+ if (deps['@tabler/icons-react']) iconLibraries.push('tabler');
165
+
166
+ return { componentLibraries: [...new Set(componentLibraries)], animationLibraries, iconLibraries, hasShadcn };
167
+ }
168
+
169
+ function titleize(value) {
170
+ return String(value || '')
171
+ .replace(/[-_]/g, ' ')
172
+ .replace(/\b\w/g, (c) => c.toUpperCase());
173
+ }
174
+
175
+ function routeIdFromSegments(segments) {
176
+ const cleaned = segments.filter(Boolean).join('_') || 'home';
177
+ return cleaned.replace(/[^a-z0-9_-]/gi, '_').toLowerCase();
178
+ }
179
+
180
+ function scanAppRouterRoutes(appDir, projectDir) {
181
+ const routes = [];
182
+ if (!appDir) return routes;
183
+
184
+ function walk(dir, urlSegments, groupLayouts) {
185
+ let entries;
186
+ try {
187
+ entries = fs.readdirSync(dir, { withFileTypes: true });
188
+ } catch {
189
+ return;
190
+ }
191
+
192
+ const layoutFile = ['layout.tsx', 'layout.jsx', 'layout.js']
193
+ .map((name) => path.join(dir, name))
194
+ .find((file) => fs.existsSync(file));
195
+ const pageFile = ['page.tsx', 'page.jsx', 'page.js']
196
+ .map((name) => path.join(dir, name))
197
+ .find((file) => fs.existsSync(file));
198
+ const layouts = layoutFile ? [...groupLayouts, rel(projectDir, layoutFile)] : groupLayouts;
199
+
200
+ if (pageFile) {
201
+ const routePath = '/' + urlSegments.filter(Boolean).join('/');
202
+ const id = routePath === '/' ? 'home' : routeIdFromSegments(urlSegments.filter((s) => !s.startsWith('[')));
203
+ routes.push({
204
+ id,
205
+ label: routePath === '/' ? 'Home' : titleize(urlSegments.filter((s) => !s.startsWith('[')).join(' ') || id),
206
+ route: routePath === '/' ? '/' : routePath.replace(/\/+/g, '/'),
207
+ file: rel(projectDir, pageFile),
208
+ layouts,
209
+ required: id === 'home' || id === 'contact' || routePath === '/contact',
210
+ dynamic: urlSegments.some((s) => s.startsWith('[')),
211
+ router: 'next-app',
212
+ });
213
+ }
214
+
215
+ for (const entry of entries) {
216
+ if (!entry.isDirectory()) continue;
217
+ const name = entry.name;
218
+ if (name.startsWith('_') || name === 'api' || name === 'favicon.ico') continue;
219
+ const nextUrl = name.startsWith('(') && name.endsWith(')')
220
+ ? urlSegments
221
+ : [...urlSegments, name];
222
+ walk(path.join(dir, name), nextUrl, layouts);
223
+ }
224
+ }
225
+
226
+ walk(appDir, [], []);
227
+ return routes;
228
+ }
229
+
230
+ function scanPagesRouterRoutes(pagesDir, projectDir) {
231
+ const routes = [];
232
+ if (!pagesDir) return routes;
233
+
234
+ function walk(dir, urlSegments) {
235
+ let entries;
236
+ try {
237
+ entries = fs.readdirSync(dir, { withFileTypes: true });
238
+ } catch {
239
+ return;
240
+ }
241
+
242
+ for (const entry of entries) {
243
+ const full = path.join(dir, entry.name);
244
+ if (entry.isDirectory()) {
245
+ if (entry.name.startsWith('_') || entry.name === 'api') continue;
246
+ walk(full, [...urlSegments, entry.name]);
247
+ continue;
248
+ }
249
+ if (!/\.(tsx|jsx|js)$/.test(entry.name)) continue;
250
+ const base = entry.name.replace(/\.(tsx|jsx|js)$/, '');
251
+ if (base.startsWith('_')) continue;
252
+ if (base === 'api') continue;
253
+ const last = base === 'index' ? urlSegments : [...urlSegments, base];
254
+ const routePath = '/' + last.join('/');
255
+ const id = routePath === '/' ? 'home' : routeIdFromSegments(last.filter((s) => !s.startsWith('[')));
256
+ routes.push({
257
+ id,
258
+ label: routePath === '/' ? 'Home' : titleize(last.filter((s) => !s.startsWith('[')).join(' ') || id),
259
+ route: routePath === '/' ? '/' : routePath.replace(/\/+/g, '/'),
260
+ file: rel(projectDir, full),
261
+ layouts: [],
262
+ required: id === 'home' || id === 'contact',
263
+ dynamic: last.some((s) => s.startsWith('[')),
264
+ router: 'next-pages',
265
+ });
266
+ }
267
+ }
268
+
269
+ walk(pagesDir, []);
270
+ return routes;
271
+ }
272
+
273
+ /**
274
+ * Fivora's strict contract requires every manifest page to resolve to an
275
+ * exported HTML file. Inventing a route that has no page component on disk
276
+ * guarantees "route has no exported HTML file" at ingest time, so ARC only
277
+ * ever declares routes it actually found.
278
+ */
279
+ function keepExportableRoutes(routes) {
280
+ const exportable = routes.filter((route) => !route.dynamic);
281
+ return exportable.length ? exportable : routes;
282
+ }
283
+
284
+ function detectLanguage(sourceFiles) {
285
+ let ts = 0;
286
+ let js = 0;
287
+ for (const file of sourceFiles) {
288
+ if (/\.(tsx|ts)$/.test(file)) ts++;
289
+ else if (/\.(jsx|js|mjs|cjs)$/.test(file)) js++;
290
+ }
291
+ if (ts && js) return 'mixed';
292
+ if (ts) return 'typescript';
293
+ return 'javascript';
294
+ }
295
+
296
+ function fileHasUseClient(filePath) {
297
+ try {
298
+ const head = fs.readFileSync(filePath, 'utf8').slice(0, 800);
299
+ return /['"]use client['"]/.test(head);
300
+ } catch {
301
+ return false;
302
+ }
303
+ }
304
+
305
+ function detectGlobalCss(projectDir, appDir, pagesDir) {
306
+ const candidates = [
307
+ appDir && path.join(appDir, 'globals.css'),
308
+ appDir && path.join(appDir, 'global.css'),
309
+ pagesDir && path.join(path.dirname(pagesDir), 'styles', 'globals.css'),
310
+ path.join(projectDir, 'src', 'app', 'globals.css'),
311
+ path.join(projectDir, 'src', 'index.css'),
312
+ path.join(projectDir, 'src', 'styles', 'globals.css'),
313
+ path.join(projectDir, 'app', 'globals.css'),
314
+ ].filter(Boolean);
315
+ const hit = findFirstExisting(candidates);
316
+ return hit ? rel(projectDir, hit) : null;
317
+ }
318
+
319
+ function listAssets(projectDir) {
320
+ const publicDir = findFirstExisting([
321
+ path.join(projectDir, 'public'),
322
+ path.join(projectDir, 'src', 'public'),
323
+ ]);
324
+ if (!publicDir) return [];
325
+ return walkFiles(publicDir, {
326
+ include: (_p, name) => /\.(png|jpe?g|webp|gif|svg|avif|mp4|webm|ico)$/i.test(name),
327
+ }).map((file) => ({
328
+ file: rel(projectDir, file),
329
+ kind: 'public',
330
+ }));
331
+ }
332
+
333
+ function listContentSources(projectDir) {
334
+ const files = walkFiles(projectDir, {
335
+ include: (_p, name) => /\.(json|md|mdx)$/.test(name) && !name.includes('package'),
336
+ }).filter((file) => /\/(data|content|messages|locales)\//i.test(toPosix(file)));
337
+ return files.map((file) => ({ file: rel(projectDir, file), kind: 'static-file' }));
338
+ }
339
+
340
+ function classifyComponentFile(filePath, projectDir) {
341
+ const relative = rel(projectDir, filePath);
342
+ const base = path.basename(filePath).replace(/\.(tsx|jsx|ts|js)$/, '');
343
+ const posix = toPosix(relative).toLowerCase();
344
+ let role = 'component';
345
+ if (/(^|\/)layout\./.test(posix)) role = 'layout';
346
+ else if (/(^|\/)page\./.test(posix) || /(^|\/)pages\//.test(posix)) role = 'page';
347
+ else if (/nav|header|navbar/.test(base.toLowerCase()) || /nav|header/.test(posix)) role = 'navigation';
348
+ else if (/footer/.test(base.toLowerCase()) || /footer/.test(posix)) role = 'footer';
349
+ else if (/hero/.test(base.toLowerCase())) role = 'hero';
350
+ else if (/product/.test(base.toLowerCase())) role = 'product';
351
+ return {
352
+ file: relative,
353
+ name: base,
354
+ role,
355
+ client: fileHasUseClient(filePath),
356
+ };
357
+ }
358
+
359
+ function buildArchitectureFingerprint(profile) {
360
+ return shortHash(JSON.stringify({
361
+ framework: profile.framework,
362
+ router: profile.router,
363
+ css: profile.cssSystems,
364
+ libs: profile.componentLibraries,
365
+ icons: profile.iconLibraries,
366
+ animation: profile.animationLibraries,
367
+ language: profile.language,
368
+ routeCount: profile.routes.length,
369
+ hasSrc: profile.hasSrc,
370
+ }));
371
+ }
372
+
373
+ function scanProject(projectDir) {
374
+ const pkg = readPackage(projectDir);
375
+ const deps = allDeps(pkg);
376
+ const tsconfig = parseTsconfig(projectDir);
377
+ const { framework, frameworkVersion } = detectFramework(pkg, deps, projectDir);
378
+ const { router, appDir, pagesDir } = detectRouter(projectDir, framework);
379
+ const sourceFilesAbs = walkFiles(projectDir, { include: (_p, name) => isSourceFile(name) });
380
+ const jsxFilesAbs = sourceFilesAbs.filter((file) => isJsxFile(file));
381
+ let routes = router === 'next-pages'
382
+ ? scanPagesRouterRoutes(pagesDir, projectDir)
383
+ : scanAppRouterRoutes(appDir, projectDir);
384
+ if (!routes.length) {
385
+ routes = [{ id: 'home', label: 'Home', route: '/', required: true, inferred: true }];
386
+ }
387
+ routes = keepExportableRoutes(routes);
388
+
389
+ const libs = detectLibraries(deps, projectDir);
390
+ const components = jsxFilesAbs.map((file) => classifyComponentFile(file, projectDir));
391
+ const hasSrc = fs.existsSync(path.join(projectDir, 'src'));
392
+
393
+ const profile = {
394
+ engine: 'deneb-arc',
395
+ arcVersion: ARC_VERSION,
396
+ root: projectDir,
397
+ name: pkg.name || path.basename(projectDir),
398
+ framework,
399
+ frameworkVersion,
400
+ language: detectLanguage(sourceFilesAbs),
401
+ router,
402
+ packageManager: detectPackageManager(projectDir),
403
+ cssSystems: detectCssSystems(deps, projectDir),
404
+ componentLibraries: libs.componentLibraries,
405
+ animationLibraries: libs.animationLibraries,
406
+ iconLibraries: libs.iconLibraries,
407
+ aliases: Object.fromEntries(
408
+ Object.entries(tsconfig.aliases).map(([k, v]) => [k, rel(projectDir, v)])
409
+ ),
410
+ aliasMap: tsconfig.aliases,
411
+ tsconfigFile: tsconfig.file ? rel(projectDir, tsconfig.file) : null,
412
+ tsconfig: tsconfig.config,
413
+ hasSrc,
414
+ appDir: appDir ? rel(projectDir, appDir) : null,
415
+ pagesDir: pagesDir ? rel(projectDir, pagesDir) : null,
416
+ uiDir: relIfExists(projectDir, [
417
+ path.join(projectDir, 'src', 'components', 'ui'),
418
+ path.join(projectDir, 'components', 'ui'),
419
+ ]),
420
+ routes,
421
+ components,
422
+ sourceFiles: sourceFilesAbs.map((file) => rel(projectDir, file)),
423
+ jsxFiles: jsxFilesAbs.map((file) => rel(projectDir, file)),
424
+ contentSources: listContentSources(projectDir),
425
+ assets: listAssets(projectDir),
426
+ globalCssEntry: detectGlobalCss(projectDir, appDir, pagesDir),
427
+ tailwindVersion: detectCssSystems(deps, projectDir).includes('tailwind-v4') ? 4 : detectCssSystems(deps, projectDir).some((s) => s.startsWith('tailwind')) ? 3 : null,
428
+ shadcn: libs.hasShadcn || libs.componentLibraries.includes('shadcn'),
429
+ heroui: libs.componentLibraries.includes('heroui'),
430
+ reactBits: libs.componentLibraries.includes('react-bits'),
431
+ clientComponentFiles: components.filter((c) => c.client).map((c) => c.file),
432
+ serverComponentFiles: components.filter((c) => !c.client && (c.role === 'page' || c.role === 'layout')).map((c) => c.file),
433
+ packageName: pkg.name || path.basename(projectDir),
434
+ dependencies: deps,
435
+ pkg,
436
+ };
437
+ profile.architectureFingerprint = buildArchitectureFingerprint(profile);
438
+ return profile;
439
+ }
440
+
441
+ function relIfExists(projectDir, candidates) {
442
+ const hit = findFirstExisting(candidates);
443
+ return hit ? rel(projectDir, hit) : null;
444
+ }
445
+
446
+ function resolveImportSpecifier(profile, fromFileAbs, specifier) {
447
+ if (!specifier || specifier.startsWith('\0')) return null;
448
+ if (specifier.startsWith('.')) {
449
+ return resolveWithExtensions(path.resolve(path.dirname(fromFileAbs), specifier));
450
+ }
451
+
452
+ for (const [alias, target] of Object.entries(profile.aliasMap || {})) {
453
+ const aliasPrefix = alias.replace(/\*$/, '');
454
+ const targetPrefix = String(target).replace(/\*$/, '');
455
+ if (alias.includes('*')) {
456
+ if (specifier.startsWith(aliasPrefix)) {
457
+ const remainder = specifier.slice(aliasPrefix.length);
458
+ return resolveWithExtensions(path.join(targetPrefix, remainder));
459
+ }
460
+ } else if (specifier === alias || specifier.startsWith(alias + '/')) {
461
+ const remainder = specifier.slice(alias.length);
462
+ return resolveWithExtensions(path.join(targetPrefix, remainder));
463
+ }
464
+ }
465
+ return null;
466
+ }
467
+
468
+ function resolveWithExtensions(base) {
469
+ const candidates = [
470
+ base,
471
+ ...SOURCE_EXT.map((ext) => base + ext),
472
+ ...SOURCE_EXT.map((ext) => path.join(base, 'index' + ext)),
473
+ path.join(base, 'page.tsx'),
474
+ path.join(base, 'page.jsx'),
475
+ ];
476
+ return findFirstExisting(candidates);
477
+ }
478
+
479
+ function extractImportSpecifiers(code) {
480
+ const specs = [];
481
+ const re = /import\s+(?:[\s\S]*?)\s+from\s+['"]([^'"]+)['"]/g;
482
+ let match;
483
+ while ((match = re.exec(code))) specs.push(match[1]);
484
+ const re2 = /require\(\s*['"]([^'"]+)['"]\s*\)/g;
485
+ while ((match = re2.exec(code))) specs.push(match[1]);
486
+ const re3 = /import\(\s*['"]([^'"]+)['"]\s*\)/g;
487
+ while ((match = re3.exec(code))) specs.push(match[1]);
488
+ return specs;
489
+ }
490
+
491
+ function buildDependencyGraph(profile) {
492
+ const projectDir = profile.root;
493
+ const nodes = {};
494
+ const edges = [];
495
+
496
+ for (const relative of profile.jsxFiles) {
497
+ const abs = path.join(projectDir, relative);
498
+ let code = '';
499
+ try {
500
+ code = fs.readFileSync(abs, 'utf8');
501
+ } catch {
502
+ continue;
503
+ }
504
+ const specifiers = extractImportSpecifiers(code);
505
+ const imports = [];
506
+ for (const spec of specifiers) {
507
+ const resolved = resolveImportSpecifier(profile, abs, spec);
508
+ if (resolved) {
509
+ const relFile = rel(projectDir, resolved);
510
+ imports.push(relFile);
511
+ edges.push({ from: relative, to: relFile, specifier: spec });
512
+ } else {
513
+ imports.push(spec);
514
+ edges.push({ from: relative, to: spec, specifier: spec, external: true });
515
+ }
516
+ }
517
+ nodes[relative] = { file: relative, imports };
518
+ }
519
+
520
+ const usageCount = {};
521
+ for (const edge of edges) {
522
+ if (edge.external) continue;
523
+ usageCount[edge.to] = (usageCount[edge.to] || 0) + 1;
524
+ }
525
+
526
+ const routeFiles = new Set(profile.routes.map((r) => r.file).filter(Boolean));
527
+ const sharedFiles = Object.entries(usageCount)
528
+ .filter(([, count]) => count >= 2)
529
+ .map(([file]) => file);
530
+
531
+ const graph = {
532
+ nodes,
533
+ edges,
534
+ usageCount,
535
+ routeFiles: [...routeFiles],
536
+ sharedFiles,
537
+ };
538
+ graph.routeClosure = buildRouteClosure(profile, graph);
539
+ graph.routesByFile = invertRouteClosure(graph.routeClosure);
540
+ return graph;
541
+ }
542
+
543
+ /**
544
+ * Maps each route id to every source file that renders on it (page component
545
+ * plus its layouts plus the transitive import closure of both). ARC needs this
546
+ * to decide whether an editable field is page-owned or globally shared: Fivora
547
+ * rejects an editorSchema section whose pageKey names a route that never
548
+ * renders the section's markers.
549
+ */
550
+ function buildRouteClosure(profile, graph) {
551
+ const closure = {};
552
+
553
+ for (const route of profile.routes || []) {
554
+ const seen = new Set();
555
+ const queue = [route.file, ...(route.layouts || [])].filter(Boolean);
556
+ while (queue.length) {
557
+ const current = queue.shift();
558
+ if (!current || seen.has(current)) continue;
559
+ seen.add(current);
560
+ for (const next of graph.nodes[current]?.imports || []) {
561
+ if (!seen.has(next) && graph.nodes[next]) queue.push(next);
562
+ }
563
+ }
564
+ closure[route.id] = [...seen];
565
+ }
566
+
567
+ return closure;
568
+ }
569
+
570
+ function invertRouteClosure(routeClosure) {
571
+ const byFile = {};
572
+ for (const [routeId, files] of Object.entries(routeClosure || {})) {
573
+ for (const file of files) {
574
+ byFile[file] = byFile[file] || [];
575
+ if (!byFile[file].includes(routeId)) byFile[file].push(routeId);
576
+ }
577
+ }
578
+ return byFile;
579
+ }
580
+
581
+ function inferOwnerScope(profile, graph, relativeFile) {
582
+ const posix = toPosix(relativeFile).toLowerCase();
583
+ const component = (profile.components || []).find((c) => c.file === relativeFile);
584
+ if (component?.role === 'navigation' || component?.role === 'footer' || component?.role === 'layout') {
585
+ return 'common';
586
+ }
587
+ if (/layout\.(tsx|jsx|js)$/.test(posix)) return 'common';
588
+ if (/(header|navbar|nav|footer|announcement)/.test(posix)) return 'common';
589
+
590
+ // Reachability is authoritative: a file rendered by more than one route owns
591
+ // shared content, and a file rendered by exactly one route owns that route's
592
+ // content. Name-based guessing is only a fallback.
593
+ const owningRoutes = graph.routesByFile?.[relativeFile];
594
+ if (owningRoutes?.length > 1) return 'common';
595
+ if (owningRoutes?.length === 1) return owningRoutes[0];
596
+
597
+ if ((graph.sharedFiles || []).includes(relativeFile)) return 'common';
598
+
599
+ for (const route of profile.routes || []) {
600
+ if (route.file === relativeFile) return route.id || 'home';
601
+ if (route.file && posix.includes(`/${route.id}/`)) return route.id;
602
+ }
603
+ return (profile.routes || [])[0]?.id || 'home';
604
+ }
605
+
606
+ module.exports = {
607
+ scanProject,
608
+ buildDependencyGraph,
609
+ inferOwnerScope,
610
+ resolveImportSpecifier,
611
+ extractImportSpecifiers,
612
+ parseTsconfig,
613
+ };