feat(screens): Next.js as a Screens app — pages, route handlers, links and redirects
- frameworks/nextjs.ts (split out of react.ts): App Router app/**/page.tsx and Pages Router pages → routes named by path ((group) stripped, [slug] → :slug, [...all] → :all*), bound to the default export; app/**/route.ts exports → METHOD /api/… endpoints referencing their functions; pages/api → ANY; resolve() claims router.push/replace/prefetch, redirect/permanentRedirect and NextResponse.redirect(new URL(…)) into navigates edges via the Expo href readers, against a Next-only route table gated on the app's root
- next-router-synthesizer.ts: <Link href> and internal <a href> → dashed navigates edges from the component (next-link, registeredAt)
- expo-router.ts: href readers exported; matcher accepts :param / :all* segments
- steps.ts: a Next page's own work fires from page load; a Next page makes the project a web app; {status: 201} read off the call site (branch-guards CallSiteText.status) for response rows
- frameworks/package-deps.ts: nested package.json files probed on disk (getAllFiles lists only sources); Express/React/Expo/Nest detectors use it; routing manifest names constant handlers
- tests: nextjs.test.ts (file→route rules, extract, verbs, end to end with Screens and Steps); frameworks.test.ts Next cases moved to the Next resolver
- docs: CHANGELOG, spec §3.12 frameworks paragraph, CLAUDE.md, synthesis doc, plan P4 built, playbook rows for Next / MERN / Nest channels
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
This commit is contained in:
co-authored by
Claude Fable 5
parent
b1f40c57dd
commit
9c6bc23b21
@@ -29,6 +29,7 @@ import { stripCommentsForRegex } from './strip-comments';
|
||||
import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer';
|
||||
import { goframeRouteEdges } from './goframe-synthesizer';
|
||||
import { expoRouterReturnEdges } from './expo-router-synthesizer';
|
||||
import { nextLinkEdges } from './next-router-synthesizer';
|
||||
import { createYielder, type MaybeYield } from './cooperative-yield';
|
||||
import { crossTierEdges } from './tier-synthesizer';
|
||||
import { enclosingFn, makeLineAt } from './synth-utils';
|
||||
@@ -3601,6 +3602,8 @@ export const SYNTH_PASSES: SynthPassDef[] = [
|
||||
{ name: 'goframeEdges', gate: (has) => has('go'), run: (_q, c, y) => goframeRouteEdges(c, y) },
|
||||
// `router.push(await helper())` — the helper's return literals are the screens.
|
||||
{ name: 'expoRouterReturnEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => expoRouterReturnEdges(c, y) },
|
||||
// `<Link href="/x">` / an internal `<a href>` — markup, not a call; the component navigates.
|
||||
{ name: 'nextLinkEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => nextLinkEdges(c, y) },
|
||||
{ name: 'nixOptionEdges', gate: (has) => has('nix'), run: (q, _c, y) => nixOptionPathEdges(q, y) },
|
||||
];
|
||||
|
||||
|
||||
@@ -307,7 +307,7 @@ export function toHref(literal: string | null): HrefLiteral | null {
|
||||
* with a literal `pathname`, or a conditional whose two arms are each one of
|
||||
* those (`cond ? \`/x?id=${id}\` : '/x'`). Anything else is not static.
|
||||
*/
|
||||
function parseHrefExpression(expr: string): HrefLiteral | null {
|
||||
export function parseHrefExpression(expr: string): HrefLiteral | null {
|
||||
// `expr as any` / `expr satisfies Href` — a cast says nothing about the value.
|
||||
let args = expr.trim().replace(/\s+(?:as|satisfies)\s+[\w$.<>[\]|&\s]+$/, '');
|
||||
// `(a ? b : c)` — unwrap one layer of grouping parens.
|
||||
@@ -351,7 +351,7 @@ export function readHrefArgument(
|
||||
}
|
||||
|
||||
/** The source text of the navigation call's first argument, or null when there is no call there. */
|
||||
function firstArgumentText(
|
||||
export function firstArgumentText(
|
||||
lines: readonly string[],
|
||||
line: number,
|
||||
column: number,
|
||||
@@ -542,12 +542,22 @@ export function matchRoute(segs: string[], table: RouteTable): Node | null {
|
||||
return best && !tied ? best.node : null;
|
||||
}
|
||||
|
||||
/** A route segment that takes a value: Expo's `[id]`, or the `:id` every other framework's routes use. */
|
||||
function isParamSegment(seg: string): boolean {
|
||||
return (seg.startsWith('[') && seg.endsWith(']')) || (seg.startsWith(':') && !seg.endsWith('*'));
|
||||
}
|
||||
|
||||
/** A route segment that takes the rest of the path: `[...slug]`, or `:slug*`. */
|
||||
function isCatchAllSegment(seg: string): boolean {
|
||||
return (seg.startsWith('[...') && seg.endsWith(']')) || (seg.startsWith(':') && seg.endsWith('*'));
|
||||
}
|
||||
|
||||
function scoreMatch(href: string[], route: string[]): number | null {
|
||||
let score = 0;
|
||||
let i = 0;
|
||||
for (let r = 0; r < route.length; r++) {
|
||||
const seg = route[r]!;
|
||||
if (seg.startsWith('[...') && seg.endsWith(']')) {
|
||||
if (isCatchAllSegment(seg)) {
|
||||
// Catch-all: needs at least one segment and takes the rest.
|
||||
if (i >= href.length) return null;
|
||||
score += href.length - i;
|
||||
@@ -556,7 +566,7 @@ function scoreMatch(href: string[], route: string[]): number | null {
|
||||
}
|
||||
if (i >= href.length) return null;
|
||||
const h = href[i]!;
|
||||
if (seg.startsWith('[') && seg.endsWith(']')) score += 2;
|
||||
if (isParamSegment(seg)) score += 2;
|
||||
else if (h === seg) score += 3;
|
||||
else if (h === '*') score += 1;
|
||||
else return null;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { laravelResolver } from './laravel';
|
||||
import { expressResolver } from './express';
|
||||
import { nestjsResolver } from './nestjs';
|
||||
import { reactResolver } from './react';
|
||||
import { nextjsResolver } from './nextjs';
|
||||
import { svelteResolver } from './svelte';
|
||||
import { vueResolver } from './vue';
|
||||
import { astroResolver } from './astro';
|
||||
@@ -42,6 +43,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
|
||||
expressResolver,
|
||||
nestjsResolver,
|
||||
reactResolver,
|
||||
// Next.js — `app/**/page.tsx` + `pages/**` → route nodes; `route.ts` exports → endpoints; `router.push('/x')` / `redirect('/x')` → navigates edges
|
||||
nextjsResolver,
|
||||
svelteResolver,
|
||||
vueResolver,
|
||||
astroResolver,
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Next.js — file-based pages and route handlers, and string-keyed navigation.
|
||||
*
|
||||
* Two things static extraction cannot see on its own, and that together are
|
||||
* most of what "how does the site flow" means in a Next app:
|
||||
*
|
||||
* 1. **A page is a file.** `app/users/page.tsx` is `/users`, `app/(marketing)/
|
||||
* about/page.tsx` is `/about` (a `(group)` is invisible in the URL),
|
||||
* `app/blog/[slug]/page.tsx` is `/blog/:slug`, `app/docs/[...all]/page.tsx`
|
||||
* is `/docs/:all*`; the Pages Router's `pages/about.tsx` is `/about`.
|
||||
* `extract()` emits one `route` node per page, named by its path, with a
|
||||
* `calls` ref to the file's default export so the route reaches the
|
||||
* component that renders it — exactly as Expo Router's screens do.
|
||||
* `app/api/users/route.ts` exports `GET` / `POST` / … — one route node per
|
||||
* method, `POST /api/users`, with a `references` ref to that function, as
|
||||
* every server resolver names a handler; `pages/api/users.ts` is
|
||||
* `ANY /api/users` bound to its default export.
|
||||
*
|
||||
* 2. **Navigation is a string.** `router.push('/users')` (`next/navigation`,
|
||||
* `next/router`), `redirect('/login')` / `permanentRedirect` in a server
|
||||
* action or a page, `NextResponse.redirect(new URL('/login', req.url))` in
|
||||
* the middleware or a route handler: the extractor records each as a call
|
||||
* that resolves to nothing, because the target is a path. `resolve()`
|
||||
* claims those refs, reads the argument off the source (the Expo Router
|
||||
* readers — a string, a template with holes, a `{ pathname }` object, a
|
||||
* conditional whose arms agree, a local `const href = …`), matches it
|
||||
* against this framework's own route table, and returns a **`navigates`**
|
||||
* edge carrying the href. `<Link href="/x">` and an internal `<a href>` are
|
||||
* JSX attributes, not calls, so a synthesizer (`next-router-synthesizer.ts`)
|
||||
* reads them from the source instead.
|
||||
*
|
||||
* Precision rests on the string resolving to a real page: a computed href, a
|
||||
* path no page serves, a relative href, or a conditional that forks are left
|
||||
* unresolved rather than guessed. Parallel (`@slot`) and intercepting
|
||||
* (`(.)photo`) routes are not modelled; `layout` / `loading` / `error` /
|
||||
* `template` files are not routes.
|
||||
*/
|
||||
|
||||
import type { Language, Node } from '../../types';
|
||||
import type { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
|
||||
import { stripCommentsForRegex } from '../strip-comments';
|
||||
import { dependsOn } from './package-deps';
|
||||
import {
|
||||
HOLE,
|
||||
defaultExportName,
|
||||
firstArgumentText,
|
||||
matchRoute,
|
||||
parseHrefExpression,
|
||||
readHrefViaLocal,
|
||||
type HrefLiteral,
|
||||
type RouteTable,
|
||||
} from './expo-router';
|
||||
|
||||
const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'tsx', 'jsx'];
|
||||
const HTTP_EXPORTS = 'GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS';
|
||||
|
||||
// =============================================================================
|
||||
// Route files
|
||||
// =============================================================================
|
||||
|
||||
export interface NextRouteFile {
|
||||
/** A page component, an App Router `route.ts` handler file, or a Pages Router API file. */
|
||||
kind: 'page' | 'handler' | 'api';
|
||||
/** `/blog/:slug` — the path, in the form every other framework's routes use. */
|
||||
path: string;
|
||||
/** The directory the Next app lives in (`''`, `apps/web/`) — what its navigation calls are gated on. */
|
||||
root: string;
|
||||
}
|
||||
|
||||
/** `[slug]` → `:slug`, `[...all]` / `[[...all]]` → `:all*`; anything else as written. */
|
||||
function nextSegment(seg: string): string {
|
||||
const optional = /^\[\[\.\.\.([^\]]+)\]\]$/.exec(seg);
|
||||
if (optional) return `:${optional[1]}*`;
|
||||
const rest = /^\[\.\.\.([^\]]+)\]$/.exec(seg);
|
||||
if (rest) return `:${rest[1]}*`;
|
||||
const param = /^\[([^\]]+)\]$/.exec(seg);
|
||||
if (param) return `:${param[1]}`;
|
||||
return seg;
|
||||
}
|
||||
|
||||
/** What a file is to the router, or null for a file that is not a route. */
|
||||
export function nextRouteForFile(filePath: string): NextRouteFile | null {
|
||||
if (/(?:^|\/)(?:__tests__|__mocks__|node_modules)\//.test(filePath)) return null;
|
||||
const app = /^((?:[^/]+\/)*?)(?:src\/)?app\/(.+)$/.exec(filePath);
|
||||
if (app) {
|
||||
const m = /^(.*?)(?:^|\/)(page|route)\.(?:tsx|ts|jsx|js|mjs|cjs|mdx?)$/.exec(app[2]!);
|
||||
if (!m) return null;
|
||||
const segs = m[1]!.split('/').filter(Boolean);
|
||||
// Parallel and intercepting routes are a picture of their own; not modelled.
|
||||
if (segs.some((s) => s.startsWith('@') || /^\(\.{1,3}\)/.test(s))) return null;
|
||||
const kept = segs.filter((s) => !(s.startsWith('(') && s.endsWith(')'))).map(nextSegment);
|
||||
return { kind: m[2] === 'page' ? 'page' : 'handler', path: '/' + kept.join('/'), root: app[1]! };
|
||||
}
|
||||
const pages = /^((?:[^/]+\/)*?)(?:src\/)?pages\/(.+)$/.exec(filePath);
|
||||
if (pages) {
|
||||
const rel = pages[2]!;
|
||||
const ext = /\.(?:tsx|ts|jsx|js|mjs|cjs|mdx?)$/.exec(rel);
|
||||
if (!ext) return null;
|
||||
const bare = rel.slice(0, ext.index);
|
||||
const segs = bare.split('/');
|
||||
const base = segs[segs.length - 1]!;
|
||||
if (base.startsWith('_') || /\.(?:test|spec|stories|config|d)$/.test(bare)) return null;
|
||||
if (segs[segs.length - 1] === 'index') segs.pop();
|
||||
return { kind: segs[0] === 'api' ? 'api' : 'page', path: '/' + segs.map(nextSegment).join('/'), root: pages[1]! };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function languageForFile(filePath: string): Language {
|
||||
if (filePath.endsWith('.tsx')) return 'tsx';
|
||||
if (filePath.endsWith('.jsx')) return 'jsx';
|
||||
if (/\.(?:ts|mts|cts)$/.test(filePath)) return 'typescript';
|
||||
return 'javascript';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Route table — this framework's pages, matched the Expo Router way
|
||||
// =============================================================================
|
||||
|
||||
interface NextTable extends RouteTable {
|
||||
/** The directories Next apps live in — a navigation call is only read from under one. */
|
||||
roots: string[];
|
||||
}
|
||||
|
||||
const tables = new WeakMap<ResolutionContext, NextTable>();
|
||||
|
||||
export function nextRouteTable(context: ResolutionContext): NextTable {
|
||||
const all = context.getNodesByKind('route');
|
||||
const cached = tables.get(context);
|
||||
if (cached && cached.source === all) return cached;
|
||||
const exact = new Map<string, Node>();
|
||||
const dynamic: RouteTable['dynamic'] = [];
|
||||
const roots = new Set<string>();
|
||||
for (const node of all) {
|
||||
const file = nextRouteForFile(node.filePath);
|
||||
if (!file || file.kind !== 'page' || file.path !== node.name) continue;
|
||||
exact.set(node.name, node);
|
||||
if (node.name.includes(':')) dynamic.push({ node, segs: node.name.split('/').slice(1) });
|
||||
roots.add(file.root);
|
||||
}
|
||||
const table: NextTable = { source: all, exact, dynamic, roots: [...roots] };
|
||||
tables.set(context, table);
|
||||
return table;
|
||||
}
|
||||
|
||||
/** `/users/${…}?tab=x` → `['users', '*']`; an absolute URL keeps its path; a relative href is nothing. */
|
||||
function hrefSegments(href: HrefLiteral): string[] | null {
|
||||
let p = href.path;
|
||||
const absolute = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/]*(\/.*)?$/i.exec(p);
|
||||
if (absolute) p = absolute[1] ?? '/';
|
||||
if (!p.startsWith('/')) return null;
|
||||
return p
|
||||
.split('/')
|
||||
.slice(1)
|
||||
.filter((s) => s.length > 0)
|
||||
.map((s) => (s.includes(HOLE) ? '*' : decode(s)));
|
||||
}
|
||||
|
||||
function decode(s: string): string {
|
||||
try {
|
||||
return decodeURIComponent(s);
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
/** The page an href names in this table, or null when none or several do (a fork), exactly as Expo Router decides. */
|
||||
export function pageForHref(href: HrefLiteral, table: RouteTable): Node | null {
|
||||
const segs = hrefSegments(href);
|
||||
if (segs === null) return null;
|
||||
const target = matchRoute(segs, table);
|
||||
if (!target) return null;
|
||||
if (href.alternate) {
|
||||
const alt = hrefSegments(href.alternate);
|
||||
if (alt === null || matchRoute(alt, table)?.id !== target.id) return null;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Navigation calls
|
||||
// =============================================================================
|
||||
|
||||
/** `router.push` / `.replace` / `.prefetch`, `redirect` / `permanentRedirect`, `NextResponse.redirect`. */
|
||||
const NAV_CALL = /(?:^|\.)(push|replace|prefetch)$|^(redirect|permanentRedirect)$|^(?:NextResponse|Response)\.(redirect)$/;
|
||||
|
||||
/** The verb a navigation call name stands for, or null. */
|
||||
export function nextNavVerb(name: string): string | null {
|
||||
const m = NAV_CALL.exec(name);
|
||||
if (!m) return null;
|
||||
if (m[3]) return 'response.redirect';
|
||||
return m[1] ?? m[2]!;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// The resolver
|
||||
// =============================================================================
|
||||
|
||||
export const nextjsResolver: FrameworkResolver = {
|
||||
name: 'nextjs',
|
||||
languages: [...ROUTE_LANGUAGES],
|
||||
|
||||
detect(context: ResolutionContext): boolean {
|
||||
if (dependsOn(context, 'next')) return true;
|
||||
const files = context.getAllFiles();
|
||||
const hasConfig = files.some((f) => /(?:^|\/)next\.config\.[cm]?[jt]s$/.test(f));
|
||||
return hasConfig && files.some((f) => nextRouteForFile(f) !== null);
|
||||
},
|
||||
|
||||
claimsReference(name: string): boolean {
|
||||
return NAV_CALL.test(name);
|
||||
},
|
||||
|
||||
extract(filePath: string, content: string) {
|
||||
const file = nextRouteForFile(filePath);
|
||||
if (!file) return { nodes: [], references: [] };
|
||||
const language = languageForFile(filePath);
|
||||
const now = Date.now();
|
||||
const nodes: Node[] = [];
|
||||
const references: UnresolvedRef[] = [];
|
||||
const stripped = stripCommentsForRegex(content, 'typescript');
|
||||
const lineOf = (index: number): number => stripped.slice(0, index).split('\n').length;
|
||||
|
||||
if (file.kind === 'handler') {
|
||||
// `export async function GET(req) {…}` / `export const POST = …` — one route per method.
|
||||
const seen = new Set<string>();
|
||||
const decl = new RegExp(`\\bexport\\s+(?:async\\s+)?function\\s+(${HTTP_EXPORTS})\\b|\\bexport\\s+(?:const|let)\\s+(${HTTP_EXPORTS})\\s*=`, 'g');
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = decl.exec(stripped)) !== null) {
|
||||
const method = (m[1] ?? m[2])!;
|
||||
if (seen.has(method)) continue;
|
||||
seen.add(method);
|
||||
const line = lineOf(m.index);
|
||||
const node: Node = {
|
||||
id: `route:${filePath}:${line}:${method}:${file.path}`,
|
||||
kind: 'route',
|
||||
name: `${method} ${file.path}`,
|
||||
qualifiedName: `${filePath}::${method}:${file.path}`,
|
||||
filePath,
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
startColumn: 0,
|
||||
endColumn: m[0].length,
|
||||
language,
|
||||
isExported: true,
|
||||
updatedAt: now,
|
||||
};
|
||||
nodes.push(node);
|
||||
references.push({ fromNodeId: node.id, referenceName: method, referenceKind: 'references', line, column: 0, filePath, language, candidates: [method] });
|
||||
}
|
||||
return { nodes, references };
|
||||
}
|
||||
|
||||
// A page, or a Pages Router API file: the default export is what runs.
|
||||
const name = file.kind === 'api' ? `ANY ${file.path}` : file.path;
|
||||
const node: Node = {
|
||||
id: file.kind === 'api' ? `route:${filePath}:1:ANY:${file.path}` : `route:${filePath}:${file.path}`,
|
||||
kind: 'route',
|
||||
name,
|
||||
qualifiedName: file.kind === 'api' ? `${filePath}::ANY:${file.path}` : `${filePath}::route:${file.path}`,
|
||||
filePath,
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
language,
|
||||
isExported: true,
|
||||
updatedAt: now,
|
||||
};
|
||||
nodes.push(node);
|
||||
const exported = defaultExportName(stripped);
|
||||
if (exported) {
|
||||
references.push({
|
||||
fromNodeId: node.id,
|
||||
referenceName: exported.name,
|
||||
referenceKind: file.kind === 'api' ? 'references' : 'calls',
|
||||
line: lineOf(exported.index),
|
||||
column: 0,
|
||||
filePath,
|
||||
language,
|
||||
candidates: [exported.name],
|
||||
});
|
||||
}
|
||||
return { nodes, references };
|
||||
},
|
||||
|
||||
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||
if (ref.referenceKind !== 'calls') return null;
|
||||
const verb = nextNavVerb(ref.referenceName);
|
||||
if (!verb) return null;
|
||||
if (!ROUTE_LANGUAGES.includes(ref.language)) return null;
|
||||
const table = nextRouteTable(context);
|
||||
if (table.exact.size === 0 || !table.roots.some((root) => ref.filePath.startsWith(root))) return null;
|
||||
const callee = ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1);
|
||||
const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? null;
|
||||
if (!lines) return null;
|
||||
|
||||
let arg = firstArgumentText(lines, ref.line, ref.column, callee);
|
||||
if (arg === null) return null;
|
||||
// `NextResponse.redirect(new URL('/login', req.url))` — the path is the URL's first argument.
|
||||
if (/^\s*new\s+URL\s*\(/.test(arg)) arg = firstArgumentText([arg], 1, 0, 'URL');
|
||||
let href = arg === null ? null : parseHrefExpression(arg);
|
||||
if (!href) {
|
||||
const enclosing = context.getNodeById?.(ref.fromNodeId);
|
||||
const start = enclosing && enclosing.filePath === ref.filePath ? enclosing.startLine : Math.max(1, ref.line - 40);
|
||||
href = readHrefViaLocal(lines, ref.line, ref.column, callee, start);
|
||||
}
|
||||
if (!href) return null;
|
||||
const target = pageForHref(href, table);
|
||||
if (!target) return null;
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: target.id,
|
||||
confidence: 0.95,
|
||||
resolvedBy: 'framework',
|
||||
edgeKind: 'navigates',
|
||||
metadata: { href: href.display, navMethod: verb },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -19,10 +19,19 @@ export function declaredDependencies(context: ResolutionContext): Set<string> {
|
||||
const cached = cache.get(context);
|
||||
if (cached) return cached;
|
||||
const names = new Set<string>();
|
||||
const manifests = ['package.json'];
|
||||
// The index lists source files, never manifests: the candidate directories
|
||||
// are the first one or two segments of what IS indexed, probed on disk.
|
||||
const dirs = new Set<string>();
|
||||
for (const file of context.getAllFiles()) {
|
||||
const segs = file.split('/');
|
||||
if (segs.length > 1) dirs.add(segs[0] + '/');
|
||||
if (segs.length > 2) dirs.add(segs[0] + '/' + segs[1] + '/');
|
||||
if (dirs.size > MAX_MANIFESTS * 8) break;
|
||||
}
|
||||
const manifests = ['package.json'];
|
||||
for (const dir of dirs) {
|
||||
if (manifests.length > MAX_MANIFESTS) break;
|
||||
if (/^(?:[^/]+\/){1,2}package\.json$/.test(file) && !file.includes('node_modules/')) manifests.push(file);
|
||||
if (!dir.includes('node_modules') && context.fileExists(dir + 'package.json')) manifests.push(dir + 'package.json');
|
||||
}
|
||||
for (const manifest of manifests) {
|
||||
const content = context.readFile(manifest);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* React Framework Resolver
|
||||
*
|
||||
* Handles React and Next.js patterns.
|
||||
* Handles React patterns: React Router routes, components, hooks, contexts.
|
||||
* Next.js pages, route handlers and navigation are `nextjs.ts`'s.
|
||||
*/
|
||||
|
||||
import { Node } from '../../types';
|
||||
@@ -180,31 +181,7 @@ export const reactResolver: FrameworkResolver = {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract Next.js pages/routes (pages directory convention)
|
||||
if (filePath.includes('pages/') || filePath.includes('app/')) {
|
||||
// Default export in pages becomes a route
|
||||
if (content.includes('export default')) {
|
||||
const routePath = filePathToRoute(filePath);
|
||||
if (routePath) {
|
||||
const line = content.indexOf('export default');
|
||||
const lineNum = content.slice(0, line).split('\n').length;
|
||||
|
||||
nodes.push({
|
||||
id: `route:${filePath}:${routePath}:${lineNum}`,
|
||||
kind: 'route',
|
||||
name: routePath,
|
||||
qualifiedName: `${filePath}::route:${routePath}`,
|
||||
filePath,
|
||||
startLine: lineNum,
|
||||
endLine: lineNum,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
language: filePath.endsWith('.tsx') ? 'tsx' : filePath.endsWith('.ts') ? 'typescript' : 'javascript',
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Next.js pages and route handlers are `frameworks/nextjs.ts`'s.
|
||||
|
||||
return { nodes, references };
|
||||
},
|
||||
@@ -308,52 +285,3 @@ function resolveContext(name: string, context: ResolutionContext): string | null
|
||||
|
||||
return candidates[0]!.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert file path to Next.js route
|
||||
*/
|
||||
function filePathToRoute(filePath: string): string | null {
|
||||
// pages/index.tsx -> /
|
||||
// pages/about.tsx -> /about
|
||||
// pages/blog/[slug].tsx -> /blog/:slug
|
||||
// app/page.tsx -> /
|
||||
// app/about/page.tsx -> /about
|
||||
|
||||
// Only real page-component files are routes. Exclude non-page extensions
|
||||
// (.mjs/.json/.cjs), config files (next.config.ts, vite.config.ts…), and
|
||||
// Next.js special files (_app/_document). This also stops a `*.config.mjs`
|
||||
// with `export default` in a dir like `nextjs-pages/` from being a "route".
|
||||
const base = filePath.split('/').pop() ?? '';
|
||||
if (!/\.(tsx?|jsx?)$/.test(base)) return null;
|
||||
if (base.startsWith('_') || /\.config\.[a-z]+$/.test(base)) return null;
|
||||
|
||||
// Match pages/ and app/ as PATH SEGMENTS (not a substring — `nextjs-pages/`
|
||||
// must not count as a `pages/` router dir).
|
||||
if (/(?:^|\/)pages\//.test(filePath)) {
|
||||
let route = filePath
|
||||
.replace(/^.*pages\//, '/')
|
||||
.replace(/\/index\.(tsx?|jsx?)$/, '')
|
||||
.replace(/\.(tsx?|jsx?)$/, '')
|
||||
.replace(/\[([^\]]+)\]/g, ':$1');
|
||||
|
||||
if (route === '') route = '/';
|
||||
return route;
|
||||
}
|
||||
|
||||
if (/(?:^|\/)app\//.test(filePath)) {
|
||||
// App router - only page.tsx files are routes
|
||||
if (!filePath.includes('page.')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let route = filePath
|
||||
.replace(/^.*app\//, '/')
|
||||
.replace(/\/page\.(tsx?|jsx?)$/, '')
|
||||
.replace(/\[([^\]]+)\]/g, ':$1');
|
||||
|
||||
if (route === '') route = '/';
|
||||
return route;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Next.js — navigation written as markup.
|
||||
*
|
||||
* <Link href="/users">Users</Link>
|
||||
* <Link href={`/users/${user.id}`}>…</Link>
|
||||
* <Link href={{ pathname: '/users/[id]', query: { id } }}>…</Link>
|
||||
* <a href="/pricing">Pricing</a>
|
||||
*
|
||||
* A JSX attribute is not a call, so the extractor records no reference for
|
||||
* it and the resolver in `frameworks/nextjs.ts` — which binds `router.push`
|
||||
* and `redirect` — never sees it. This pass reads every `<Link href>` and
|
||||
* internal `<a href>` out of the source, attributes it to the component
|
||||
* (the innermost function) it is written in, matches the href against the
|
||||
* Next route table, and synthesizes one `navigates` edge from the component
|
||||
* to the page. That is the edge the Screens view walks back from, so a
|
||||
* page's links are its transitions exactly as a screen's taps are.
|
||||
*
|
||||
* Edges are `provenance:'heuristic'`, `synthesizedBy:'next-link'`, with the
|
||||
* href as written and `registeredAt` = the JSX site. A computed href
|
||||
* (`href={href}`) is nothing; a path no page serves is nothing. Nothing here
|
||||
* runs on a project with no Next pages.
|
||||
*/
|
||||
|
||||
import type { Edge } from '../types';
|
||||
import type { ResolutionContext } from './types';
|
||||
import type { MaybeYield } from './cooperative-yield';
|
||||
import { stripCommentsForRegex } from './strip-comments';
|
||||
import { isTestPath } from '../search/query-utils';
|
||||
import { readStringAt, toHref } from './frameworks/expo-router';
|
||||
import { nextRouteTable, pageForHref } from './frameworks/nextjs';
|
||||
import { enclosingFn, makeLineAt } from './synth-utils';
|
||||
|
||||
const JSX_FILE = /\.(?:[cm]?[jt]sx?|mdx)$/;
|
||||
|
||||
/** `<Link … href=…` / `<NextLink … href=…` / `<a … href=…`, the attribute anywhere in the tag. */
|
||||
const LINK_TAG = /<(Link|NextLink|a)\b([^>]*?)\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*)/g;
|
||||
|
||||
/** Links a single component may carry before it is a navigation menu, not a decision. */
|
||||
const MAX_LINKS_PER_COMPONENT = 24;
|
||||
|
||||
export async function nextLinkEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
|
||||
const table = nextRouteTable(ctx);
|
||||
if (table.exact.size === 0) return [];
|
||||
const edges: Edge[] = [];
|
||||
const seen = new Set<string>();
|
||||
const perComponent = new Map<string, number>();
|
||||
let scanned = 0;
|
||||
for (const file of ctx.getAllFiles()) {
|
||||
if (!JSX_FILE.test(file) || isTestPath(file)) continue;
|
||||
if (!table.roots.some((root) => file.startsWith(root))) continue;
|
||||
if ((++scanned & 63) === 0) await onYield();
|
||||
const source = ctx.readFile(file);
|
||||
if (!source || !source.includes('href')) continue;
|
||||
const safe = stripCommentsForRegex(source, 'typescript');
|
||||
const nodes = ctx.getNodesInFile(file);
|
||||
const lineOf = makeLineAt(safe, 1);
|
||||
LINK_TAG.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = LINK_TAG.exec(safe)) !== null) {
|
||||
const tag = m[1]!;
|
||||
let literal: string | null = m[3] ?? m[4] ?? null;
|
||||
if (literal === null) {
|
||||
// `href={…}`: a string, a template, or an object with a literal pathname.
|
||||
const at = m.index + m[0].length;
|
||||
const ch = safe[at];
|
||||
if (ch === '"' || ch === "'" || ch === '`') literal = readStringAt(safe, at);
|
||||
else if (ch === '{') {
|
||||
const key = /\bpathname\s*:\s*/y;
|
||||
key.lastIndex = at;
|
||||
const close = safe.indexOf('}', at);
|
||||
const head = key.exec(safe.slice(0, close < 0 ? undefined : close).slice(at));
|
||||
if (head) literal = readStringAt(safe, at + head.index + head[0].length);
|
||||
}
|
||||
}
|
||||
if (literal === null) continue;
|
||||
// An external `<a href>` is a link out of the site, not a transition.
|
||||
if (tag === 'a' && !literal.startsWith('/')) continue;
|
||||
const href = toHref(literal);
|
||||
if (!href) continue;
|
||||
const line = lineOf(m.index);
|
||||
const component = enclosingFn(nodes, line);
|
||||
if (!component) continue;
|
||||
const page = pageForHref(href, table);
|
||||
if (!page) continue;
|
||||
const key = `${component.id}>${page.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
const count = (perComponent.get(component.id) ?? 0) + 1;
|
||||
perComponent.set(component.id, count);
|
||||
if (count > MAX_LINKS_PER_COMPONENT) continue;
|
||||
seen.add(key);
|
||||
edges.push({
|
||||
source: component.id,
|
||||
target: page.id,
|
||||
kind: 'navigates',
|
||||
line,
|
||||
provenance: 'heuristic',
|
||||
metadata: { synthesizedBy: 'next-link', href: href.display, navMethod: tag === 'a' ? 'a' : 'link', registeredAt: `${file}:${line}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
Reference in New Issue
Block a user