feat(steps): draw all arms of conditional navigations as separate edges
Adds multi-arm navigation support: when a destination is produced by a conditional, every arm is now drawn as its own edge. Introduces helpers (hrefArms, destinationsForHref) and updates framework resolvers and edge creation to emit multiple navigates edges (via alsoTargets) instead of a single one. Also introduces per-app rooted route tables to avoid cross-app crossings, and updates various resolvers (React Router, TanStack Router, Vue Router, SvelteKit, Vue, and SvelteKit’s linker) and the UI to reflect multiple possible destinations. Tests and docs updated to reflect the new behavior, ensuring the Screens tab shows all possible navigation paths from conditional destinations. This makes navigation visualization more accurate for forked destinations.
This commit is contained in:
@@ -30,6 +30,10 @@ import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer';
|
||||
import { goframeRouteEdges } from './goframe-synthesizer';
|
||||
import { expoRouterReturnEdges } from './expo-router-synthesizer';
|
||||
import { nextLinkEdges } from './next-router-synthesizer';
|
||||
import { reactRouterLinkEdges } from './react-router-synthesizer';
|
||||
import { tanstackLinkEdges } from './tanstack-router-synthesizer';
|
||||
import { vueRouterLinkEdges } from './vue-router-synthesizer';
|
||||
import { svelteKitLinkEdges, svelteKitPageComponentEdges } from './sveltekit-synthesizer';
|
||||
import { createYielder, type MaybeYield } from './cooperative-yield';
|
||||
import { crossTierEdges } from './tier-synthesizer';
|
||||
import { enclosingFn, makeLineAt } from './synth-utils';
|
||||
@@ -2050,11 +2054,16 @@ async function svelteKitLoadEdges(ctx: ResolutionContext, onYield: MaybeYield):
|
||||
const loaderFile = `${dir}${prefix}${ext}`;
|
||||
if (!allFiles.has(loaderFile)) continue;
|
||||
for (const hook of ctx.getNodesInFile(loaderFile)) {
|
||||
if (!HOOK_KINDS.has(hook.kind) || !HOOKS.has(hook.name)) continue;
|
||||
// `load` and `actions` by name, and every function the loader file
|
||||
// declares — a form action is an arrow inside `actions`, and it is a
|
||||
// node of its own (`default`, `logout`), where the redirect that ends
|
||||
// the submission is actually written.
|
||||
const named = HOOK_KINDS.has(hook.kind) && HOOKS.has(hook.name);
|
||||
if (!named && hook.kind !== 'function' && hook.kind !== 'method') continue;
|
||||
edges.push({
|
||||
source: page.id,
|
||||
target: hook.id,
|
||||
kind: 'references',
|
||||
kind: 'calls',
|
||||
line: page.startLine,
|
||||
provenance: 'heuristic',
|
||||
metadata: {
|
||||
@@ -3604,6 +3613,11 @@ export const SYNTH_PASSES: SynthPassDef[] = [
|
||||
{ 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: 'reactRouterLinkEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => reactRouterLinkEdges(c, y) },
|
||||
{ name: 'tanstackLinkEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => tanstackLinkEdges(c, y) },
|
||||
{ name: 'vueRouterLinkEdges', gate: (has) => has('vue', ...JS_FAMILY), run: (_q, c, y) => vueRouterLinkEdges(c, y) },
|
||||
{ name: 'svelteKitPageEdges', gate: (has) => has('svelte'), run: (_q, c, y) => svelteKitPageComponentEdges(c, y) },
|
||||
{ name: 'svelteKitLinkEdges', gate: (has) => has('svelte'), run: (_q, c, y) => svelteKitLinkEdges(c, y) },
|
||||
{ name: 'nixOptionEdges', gate: (has) => has('nix'), run: (q, _c, y) => nixOptionPathEdges(q, y) },
|
||||
];
|
||||
|
||||
|
||||
@@ -264,8 +264,17 @@ export interface HrefLiteral {
|
||||
path: string;
|
||||
/** The literal as written, holes rendered as `${…}` — for the edge metadata. */
|
||||
display: string;
|
||||
/** The other arm of a `cond ? a : b` argument, when the argument was one. */
|
||||
alternate?: HrefLiteral;
|
||||
/**
|
||||
* The OTHER destinations, when the argument was a conditional. A link
|
||||
* written `!isAdmin ? keyword ? '/search/…' : '/page/…' : '/admin/…'` names
|
||||
* three places a user can end up, and each is drawn.
|
||||
*/
|
||||
alternates?: HrefLiteral[];
|
||||
}
|
||||
|
||||
/** Every destination an href names — itself first, then its other arms. */
|
||||
export function hrefArms(href: HrefLiteral): HrefLiteral[] {
|
||||
return href.alternates?.length ? [href, ...href.alternates] : [href];
|
||||
}
|
||||
|
||||
/** Index of the first `ch` at bracket depth 0 and outside strings, or -1. */
|
||||
@@ -307,6 +316,34 @@ 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.
|
||||
*/
|
||||
/**
|
||||
* The `:` that closes the ternary opened at `q`, honouring nested ones.
|
||||
*
|
||||
* Taking the FIRST `:` splits `a ? b ? '/x' : '/y' : '/z'` between `b` and
|
||||
* `'/y'`, which reads as `'/y'` — a real path, from the wrong arm. A paginator
|
||||
* written that way (`!isAdmin ? keyword ? … : '/page/…' : '/admin/…'`) then
|
||||
* pointed an admin's page links at the storefront's pagination. With the arms
|
||||
* paired correctly the expression is a three-way fork, and a fork is nothing.
|
||||
*/
|
||||
function ternaryColon(s: string, q: number): number {
|
||||
let depth = 0;
|
||||
let i = q + 1;
|
||||
for (let steps = 0; steps < 64; steps++) {
|
||||
const nextQ = indexAtDepth0(s, '?', i);
|
||||
const nextColon = indexAtDepth0(s, ':', i);
|
||||
if (nextColon < 0) return -1;
|
||||
if (nextQ >= 0 && nextQ < nextColon) {
|
||||
depth++;
|
||||
i = nextQ + 1;
|
||||
continue;
|
||||
}
|
||||
if (depth === 0) return nextColon;
|
||||
depth--;
|
||||
i = nextColon + 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
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]+$/, '');
|
||||
@@ -317,12 +354,20 @@ export function parseHrefExpression(expr: string): HrefLiteral | null {
|
||||
if (args.length === 0) return null;
|
||||
const q = indexAtDepth0(args, '?', 0);
|
||||
if (q > 0) {
|
||||
const colon = indexAtDepth0(args, ':', q + 1);
|
||||
const colon = ternaryColon(args, q);
|
||||
if (colon > q) {
|
||||
const yes = parseHrefExpression(args.slice(q + 1, colon));
|
||||
const no = parseHrefExpression(args.slice(colon + 1));
|
||||
if (yes && no) return { ...yes, alternate: no };
|
||||
return null;
|
||||
// Every arm is a destination, flattened — an arm that is itself a
|
||||
// conditional contributes its own arms rather than being reduced to one.
|
||||
// `const redirect = location.search ? location.search.split('=')[1] : '/'`
|
||||
// then `history.push(redirect)` contributes just the `/`, which is where
|
||||
// that lands by default; reading neither arm lost the whole transition.
|
||||
const arms = [...(yes ? hrefArms(yes) : []), ...(no ? hrefArms(no) : [])];
|
||||
const head = arms[0];
|
||||
if (!head) return null;
|
||||
const rest = arms.slice(1);
|
||||
return rest.length ? { path: head.path, display: head.display, alternates: rest } : { path: head.path, display: head.display };
|
||||
}
|
||||
}
|
||||
if (args[0] === '{') {
|
||||
@@ -356,6 +401,25 @@ export function firstArgumentText(
|
||||
line: number,
|
||||
column: number,
|
||||
method: string
|
||||
): string | null {
|
||||
return nthArgumentText(lines, line, column, method, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The source text of a call's nth argument (0-based), or null when there is
|
||||
* no call there or it has too few arguments.
|
||||
*
|
||||
* Most navigation calls put the destination first; SvelteKit's
|
||||
* `redirect(303, '/login')` puts the status there, so the reader has to be
|
||||
* able to take the second. A `,` at depth 0 separates arguments — inside
|
||||
* parens, brackets, braces or a template it is part of one.
|
||||
*/
|
||||
export function nthArgumentText(
|
||||
lines: readonly string[],
|
||||
line: number,
|
||||
column: number,
|
||||
method: string,
|
||||
index: number
|
||||
): string | null {
|
||||
const first = line - 1;
|
||||
if (first < 0 || first >= lines.length) return null;
|
||||
@@ -366,8 +430,12 @@ export function firstArgumentText(
|
||||
while (open < text.length && /\s/.test(text[open]!)) open++;
|
||||
if (text[open] !== '(') return null;
|
||||
const close = matchParen(text, open);
|
||||
const args = text.slice(open + 1, close < 0 ? undefined : close);
|
||||
// Only the first argument: a `,` at depth 0 ends it (`push(href, opts)`).
|
||||
let args = text.slice(open + 1, close < 0 ? undefined : close);
|
||||
for (let i = 0; i < index; i++) {
|
||||
const comma = indexAtDepth0(args, ',', 0);
|
||||
if (comma < 0) return null;
|
||||
args = args.slice(comma + 1);
|
||||
}
|
||||
const comma = indexAtDepth0(args, ',', 0);
|
||||
return comma < 0 ? args : args.slice(0, comma);
|
||||
}
|
||||
@@ -439,6 +507,69 @@ function balanced(s: string): boolean {
|
||||
return depth <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* A route table split by the app each route belongs to.
|
||||
*
|
||||
* One table for a whole repository is wrong the moment the repository holds
|
||||
* more than one app: every app has a `/`, most have a `/login`, and a global
|
||||
* `exact` map keeps whichever was indexed first — so a `<Link to="/posts">`
|
||||
* in one app resolves to another app's `/posts`. Measured on the TanStack
|
||||
* Router monorepo (477 apps in one index): **82% of navigations pointed at a
|
||||
* route belonging to a different app.** Gating on the roots decides only
|
||||
* WHETHER to resolve; the table has to decide WHICH app's routes to match.
|
||||
*/
|
||||
export interface RootedRouteTable<T extends RouteTable = RouteTable> {
|
||||
/** Identity of the node array the table was built from — rebuild when it changes. */
|
||||
source: readonly Node[];
|
||||
/** App root (`apps/web/`, `''`) → the routes that app serves. */
|
||||
byRoot: Map<string, T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The routes of the app `filePath` belongs to, or null when it is under none.
|
||||
*
|
||||
* Longest root wins, so an app nested inside another resolves to the nested
|
||||
* one; a root of `''` is a single-app repo, and covers every file.
|
||||
*/
|
||||
export function routesForFile<T extends RouteTable>(
|
||||
table: RootedRouteTable<T>,
|
||||
filePath: string
|
||||
): T | null {
|
||||
let best: T | null = null;
|
||||
let bestLen = -1;
|
||||
for (const [root, routes] of table.byRoot) {
|
||||
if (root.length > bestLen && filePath.startsWith(root)) {
|
||||
best = routes;
|
||||
bestLen = root.length;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Register `path` → `node` in one app's table. The first route to claim an address keeps it. */
|
||||
export function addRouteTo(table: RouteTable, path: string, node: Node): void {
|
||||
if (!table.exact.has(path)) table.exact.set(path, node);
|
||||
if (path.includes(':')) table.dynamic.push({ node, segs: path.split('/').slice(1) });
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory the app owning `filePath` lives in — what a navigation call is
|
||||
* gated on, so a `push` in one package of a monorepo cannot name another
|
||||
* package's routes.
|
||||
*
|
||||
* The first conventional source directory ends it: proshop keeps its routes in
|
||||
* `frontend/src/App.js` and its screens in `frontend/src/screens/`, so the root
|
||||
* is `frontend/`; `src/routes/login/+page.svelte` and `pages/index.vue` are
|
||||
* both a repo-root app, whose root is `''` — every file, exactly as a Next app
|
||||
* at the repo root is. A file under no such directory owns only its own folder.
|
||||
*/
|
||||
export function appRootFor(filePath: string): string {
|
||||
const m = /^((?:[^/]+\/)*?)(?:src|pages|app|routes)\//.exec(filePath);
|
||||
if (m) return m[1]!;
|
||||
const slash = filePath.lastIndexOf('/');
|
||||
return slash < 0 ? '' : filePath.slice(0, slash + 1);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Route table
|
||||
// =============================================================================
|
||||
@@ -651,21 +782,29 @@ export const expoRouterResolver: FrameworkResolver = {
|
||||
}
|
||||
if (!href) return null;
|
||||
const table = routeTable(context);
|
||||
const segs = normalizeHrefPath(href.path, ref.filePath);
|
||||
if (segs === null) return null;
|
||||
const target = matchRoute(segs, table);
|
||||
if (!target) return null;
|
||||
if (href.alternate) {
|
||||
// `cond ? a : b` — one edge can carry one destination. Both arms
|
||||
// reaching the same screen (a query-string difference, typically) is a
|
||||
// confident bind; two different screens is a fork this ref can't record.
|
||||
const altSegs = normalizeHrefPath(href.alternate.path, ref.filePath);
|
||||
if (altSegs === null || matchRoute(altSegs, table)?.id !== target.id) return null;
|
||||
// `cond ? a : b` names a screen per arm, and the user reaches every one of
|
||||
// them; the extra arms ride along as `alsoTargets` and become edges of
|
||||
// their own. A relative href is resolved against the screen it sits in,
|
||||
// which is why this matches its own way rather than through `pagesForHref`.
|
||||
const targets: Node[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const arm of hrefArms(href)) {
|
||||
const segs = normalizeHrefPath(arm.path, ref.filePath);
|
||||
if (segs === null) continue;
|
||||
const hit = matchRoute(segs, table);
|
||||
if (!hit || seen.has(hit.id)) continue;
|
||||
seen.add(hit.id);
|
||||
targets.push(hit);
|
||||
}
|
||||
const target = targets[0];
|
||||
if (!target) return null;
|
||||
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: target.id,
|
||||
...(targets.length > 1
|
||||
? { alsoTargets: targets.slice(1).map((t) => ({ targetNodeId: t.id, metadata: { href: href.display, navMethod: method } })) }
|
||||
: {}),
|
||||
confidence: 0.95,
|
||||
resolvedBy: 'framework',
|
||||
edgeKind: 'navigates',
|
||||
|
||||
@@ -12,6 +12,10 @@ import { expressResolver } from './express';
|
||||
import { nestjsResolver } from './nestjs';
|
||||
import { reactResolver } from './react';
|
||||
import { nextjsResolver } from './nextjs';
|
||||
import { reactRouterResolver } from './react-router';
|
||||
import { tanstackRouterResolver } from './tanstack-router';
|
||||
import { vueRouterResolver } from './vue-router';
|
||||
import { svelteKitRouterResolver } from './sveltekit-router';
|
||||
import { svelteResolver } from './svelte';
|
||||
import { vueResolver } from './vue';
|
||||
import { astroResolver } from './astro';
|
||||
@@ -43,10 +47,18 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
|
||||
expressResolver,
|
||||
nestjsResolver,
|
||||
reactResolver,
|
||||
// React Router — `<Route path>` routes are `reactResolver`'s; `history.push('/x')` / `navigate('/x')` → navigates edges
|
||||
reactRouterResolver,
|
||||
// TanStack Router — `createFileRoute('/x')` / `createRoute({ path })` → route nodes; `navigate({ to })` → navigates edges
|
||||
tanstackRouterResolver,
|
||||
// Next.js — `app/**/page.tsx` + `pages/**` → route nodes; `route.ts` exports → endpoints; `router.push('/x')` / `redirect('/x')` → navigates edges
|
||||
nextjsResolver,
|
||||
svelteResolver,
|
||||
// SvelteKit — `src/routes/**/+page.svelte` routes are `svelteResolver`'s; `goto('/x')` / `redirect(303, '/x')` → navigates edges
|
||||
svelteKitRouterResolver,
|
||||
vueResolver,
|
||||
// Vue Router — `createRouter({ routes })` → route nodes; `router.push({ name })` / `router.push('/x')` → navigates edges
|
||||
vueRouterResolver,
|
||||
astroResolver,
|
||||
// Python
|
||||
djangoResolver,
|
||||
@@ -142,6 +154,10 @@ export { laravelResolver, FACADE_MAPPINGS } from './laravel';
|
||||
export { expressResolver } from './express';
|
||||
export { nestjsResolver } from './nestjs';
|
||||
export { reactResolver } from './react';
|
||||
export { reactRouterResolver } from './react-router';
|
||||
export { tanstackRouterResolver } from './tanstack-router';
|
||||
export { vueRouterResolver } from './vue-router';
|
||||
export { svelteKitRouterResolver } from './sveltekit-router';
|
||||
export { svelteResolver } from './svelte';
|
||||
export { vueResolver } from './vue';
|
||||
export { astroResolver } from './astro';
|
||||
|
||||
@@ -42,6 +42,7 @@ import { stripCommentsForRegex } from '../strip-comments';
|
||||
import { dependsOn } from './package-deps';
|
||||
import {
|
||||
HOLE,
|
||||
hrefArms,
|
||||
defaultExportName,
|
||||
firstArgumentText,
|
||||
matchRoute,
|
||||
@@ -164,17 +165,39 @@ function decode(s: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** A route a destination names, with the arm that named it — so each edge says the path it took. */
|
||||
export interface HrefDestination {
|
||||
node: Node;
|
||||
/** The arm of the expression this route came from; its `display` is the edge's href. */
|
||||
href: HrefLiteral;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every route a destination names — one per arm of a conditional, deduped.
|
||||
*
|
||||
* Each carries its OWN arm, because an edge that says
|
||||
* `/search/${…}/page/${…}` while pointing at `/admin/productlist/:pageNumber`
|
||||
* names a path it did not take.
|
||||
*/
|
||||
export function destinationsForHref(href: HrefLiteral, table: RouteTable): HrefDestination[] {
|
||||
const out: HrefDestination[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const arm of hrefArms(href)) {
|
||||
const segs = hrefSegments(arm);
|
||||
if (segs === null) continue;
|
||||
const target = matchRoute(segs, table);
|
||||
// An arm naming no route drops out; the arms that DO name one are still
|
||||
// places this navigation goes.
|
||||
if (!target || seen.has(target.id)) continue;
|
||||
seen.add(target.id);
|
||||
out.push({ node: target, href: arm });
|
||||
}
|
||||
return target;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The single route an href names, or null when it names none. The first arm wins a fork. */
|
||||
export function pageForHref(href: HrefLiteral, table: RouteTable): Node | null {
|
||||
return destinationsForHref(href, table)[0]?.node ?? null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -306,15 +329,21 @@ export const nextjsResolver: FrameworkResolver = {
|
||||
href = readHrefViaLocal(lines, ref.line, ref.column, callee, start);
|
||||
}
|
||||
if (!href) return null;
|
||||
const target = pageForHref(href, table);
|
||||
// Every arm of a conditional destination is somewhere this call goes; the
|
||||
// first is this reference's resolution and the rest ride as `alsoTargets`.
|
||||
const targets = destinationsForHref(href, table);
|
||||
const target = targets[0];
|
||||
if (!target) return null;
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: target.id,
|
||||
targetNodeId: target.node.id,
|
||||
...(targets.length > 1
|
||||
? { alsoTargets: targets.slice(1).map((t) => ({ targetNodeId: t.node.id, metadata: { href: t.href.display, navMethod: verb } })) }
|
||||
: {}),
|
||||
confidence: 0.95,
|
||||
resolvedBy: 'framework',
|
||||
edgeKind: 'navigates',
|
||||
metadata: { href: href.display, navMethod: verb },
|
||||
metadata: { href: target.href.display, navMethod: verb },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Walking an object literal in source, for the framework resolvers whose route
|
||||
* table IS an object literal.
|
||||
*
|
||||
* Vue's `routes: [{ name, path, component }]`, TanStack Router's
|
||||
* `createRoute({ path, getParentRoute, component })` and its
|
||||
* `createFileRoute('/x')({ component })` all state a route as a JavaScript
|
||||
* object, and reading one field out of a WINDOW around another is wrong in a
|
||||
* way that is silent: a Vue `name` is written above the `path` it belongs to,
|
||||
* so a window around each path hands an entry its predecessor's name — every
|
||||
* route in vue-realworld came out pointing one entry too far down.
|
||||
*
|
||||
* So each object is matched as a unit and only its own depth-1 fields are
|
||||
* read; nested objects, arrays, calls, strings and templates are stepped over
|
||||
* rather than searched. This is a scanner, not a parser: it knows brackets,
|
||||
* quotes and template interpolation, and nothing else about JavaScript.
|
||||
*/
|
||||
|
||||
/** The index of the bracket, brace or paren closing the one at `open`, or -1. */
|
||||
export function matchBracket(s: string, open: number): number {
|
||||
let depth = 0;
|
||||
for (let i = open; i < s.length; i++) {
|
||||
const ch = s[i]!;
|
||||
if (ch === '"' || ch === "'" || ch === '`') {
|
||||
const end = skipString(s, i);
|
||||
if (end < 0) return -1;
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
if (ch === '[' || ch === '{' || ch === '(') depth++;
|
||||
else if (ch === ']' || ch === '}' || ch === ')') {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** The index of the quote closing the string or template opened at `at`, or -1. */
|
||||
export function skipString(s: string, at: number): number {
|
||||
const quote = s[at]!;
|
||||
for (let i = at + 1; i < s.length; i++) {
|
||||
const ch = s[i]!;
|
||||
if (ch === '\\') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) return i;
|
||||
if (quote === '`' && ch === '$' && s[i + 1] === '{') {
|
||||
const end = matchBracket(s, i + 1);
|
||||
if (end < 0) return -1;
|
||||
i = end;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Each `{…}` written directly in `[from, to)`, as its own extent. */
|
||||
export function* topLevelObjects(s: string, from: number, to: number): Generator<{ start: number; end: number }> {
|
||||
for (let i = from; i < to; i++) {
|
||||
const ch = s[i]!;
|
||||
if (ch === '"' || ch === "'" || ch === '`') {
|
||||
const end = skipString(s, i);
|
||||
if (end < 0) return;
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
if (ch === '{') {
|
||||
const end = matchBracket(s, i);
|
||||
if (end < 0) return;
|
||||
yield { start: i, end };
|
||||
i = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** An object's own fields — key to its value text and where the key sits. Nested structures are stepped over. */
|
||||
export function readFields(s: string, start: number, end: number): Map<string, { text: string; at: number }> {
|
||||
const out = new Map<string, { text: string; at: number }>();
|
||||
let i = start + 1;
|
||||
while (i < end) {
|
||||
const ch = s[i]!;
|
||||
if (ch === '"' || ch === "'" || ch === '`') {
|
||||
const close = skipString(s, i);
|
||||
if (close < 0) return out;
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '{' || ch === '[' || ch === '(') {
|
||||
const close = matchBracket(s, i);
|
||||
if (close < 0) return out;
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
const key = /^([A-Za-z_$][\w$]*)\s*:/.exec(s.slice(i, i + 64));
|
||||
if (!key) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
let j = i + key[0].length;
|
||||
const valueAt = j;
|
||||
while (j < end) {
|
||||
const c = s[j]!;
|
||||
if (c === '"' || c === "'" || c === '`') {
|
||||
const close = skipString(s, j);
|
||||
if (close < 0) return out;
|
||||
j = close + 1;
|
||||
continue;
|
||||
}
|
||||
if (c === '{' || c === '[' || c === '(') {
|
||||
const close = matchBracket(s, j);
|
||||
if (close < 0) return out;
|
||||
j = close + 1;
|
||||
continue;
|
||||
}
|
||||
if (c === ',') break;
|
||||
j++;
|
||||
}
|
||||
if (!out.has(key[1]!)) out.set(key[1]!, { text: s.slice(valueAt, j), at: i });
|
||||
i = j + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -12,17 +12,30 @@ import type { ResolutionContext } from '../types';
|
||||
/** Nested manifests read per project, at most — a monorepo with hundreds of packages is sampled, not scanned. */
|
||||
const MAX_MANIFESTS = 24;
|
||||
|
||||
const cache = new WeakMap<ResolutionContext, Set<string>>();
|
||||
/**
|
||||
* Cached per context, keyed by how many files are indexed.
|
||||
*
|
||||
* The resolver is constructed — and every `detect()` runs once — BEFORE any
|
||||
* file exists, so that first pass sees no directories to probe and reads only
|
||||
* the root manifest. Caching that answer outright made the re-detect after
|
||||
* indexing (`CodeGraph.indexAll`) a cache hit on the empty set, and every
|
||||
* framework whose dependency lives one directory down stayed undetected: a
|
||||
* proshop-shaped repo indexed its React Router routes (extraction is not
|
||||
* gated on detection) and then resolved none of its navigation. Re-reading
|
||||
* when the file count changes costs one manifest sweep per index.
|
||||
*/
|
||||
const cache = new WeakMap<ResolutionContext, { files: number; names: Set<string> }>();
|
||||
|
||||
/** Every dependency name declared at the root or up to two directories down, de-duplicated. */
|
||||
export function declaredDependencies(context: ResolutionContext): Set<string> {
|
||||
const files = context.getAllFiles();
|
||||
const cached = cache.get(context);
|
||||
if (cached) return cached;
|
||||
if (cached && cached.files === files.length) return cached.names;
|
||||
const names = new Set<string>();
|
||||
// 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()) {
|
||||
for (const file of files) {
|
||||
const segs = file.split('/');
|
||||
if (segs.length > 1) dirs.add(segs[0] + '/');
|
||||
if (segs.length > 2) dirs.add(segs[0] + '/' + segs[1] + '/');
|
||||
@@ -45,7 +58,7 @@ export function declaredDependencies(context: ResolutionContext): Set<string> {
|
||||
// Not JSON — a template, a broken manifest; nothing to read.
|
||||
}
|
||||
}
|
||||
cache.set(context, names);
|
||||
cache.set(context, { files: files.length, names });
|
||||
return names;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* React Router — routes declared in markup, navigation written as a string.
|
||||
*
|
||||
* `frameworks/react.ts` already reads the route table out of the markup:
|
||||
* `<Route path="/payment" component={PaymentScreen}/>` (v5),
|
||||
* `<Route path="/payment" element={<PaymentScreen/>}/>` (v6) and
|
||||
* `createBrowserRouter([{ path, element }])` (v6.4+) each become a `route`
|
||||
* node named by its path, bound to the component that renders it. That is
|
||||
* half of what "how does this app flow" means. This file is the other half.
|
||||
*
|
||||
* **Navigation is a string.** `history.push('/placeorder')` (v5, and the
|
||||
* `useHistory` hook), `navigate('/placeorder')` (v6's `useNavigate`),
|
||||
* `router.navigate(…)` on a data router, `redirect('/login')` from a loader
|
||||
* or an action: the extractor records each as a call that resolves to
|
||||
* nothing, because the target is a path, not a symbol. `resolve()` claims
|
||||
* those refs, reads the argument off the source with 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 — the edge the Screens picture is drawn from and the step Steps
|
||||
* draws as another page. `<Link to>` and `<Navigate to>` are JSX attributes
|
||||
* rather than calls, so a synthesizer reads them instead
|
||||
* (`react-router-synthesizer.ts`).
|
||||
*
|
||||
* Precision rests on the string naming a real route: a computed path, a path
|
||||
* no route serves, and a conditional that forks are left unresolved rather
|
||||
* than guessed. `push` and `replace` are two of the most common method names
|
||||
* in JavaScript, so the receiver has to name a router — a bare `push` is an
|
||||
* array's, and is never claimed.
|
||||
*
|
||||
* Known limits, both deliberate: a nested route's path is relative to its
|
||||
* parent (`<Route path="team">` inside `<Route path="/dashboard">`), and the
|
||||
* markup scan does not compose that tree, so only an absolute path is a
|
||||
* destination an href can name; and a splat (`/admin/*`) matches anything, so
|
||||
* it is never the answer to a concrete href.
|
||||
*/
|
||||
|
||||
import type { Language, Node } from '../../types';
|
||||
import type { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
|
||||
import { dependsOn } from './package-deps';
|
||||
import {
|
||||
addRouteTo,
|
||||
appRootFor,
|
||||
firstArgumentText,
|
||||
parseHrefExpression,
|
||||
readHrefViaLocal,
|
||||
routesForFile,
|
||||
type RootedRouteTable,
|
||||
type RouteTable,
|
||||
} from './expo-router';
|
||||
// `pageForHref` is framework-agnostic — it takes any RouteTable and decides
|
||||
// which of its routes an href names (absolute URLs, holes, a conditional's
|
||||
// two arms). It lives in `nextjs.ts` because that is where it was first
|
||||
// needed; duplicating it here would be a second derivation of the same rule.
|
||||
import { destinationsForHref } from './nextjs';
|
||||
|
||||
const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'tsx', 'jsx'];
|
||||
|
||||
// =============================================================================
|
||||
// Route table — the routes `frameworks/react.ts` read out of the markup
|
||||
// =============================================================================
|
||||
|
||||
export type ReactRouterTable = RootedRouteTable;
|
||||
|
||||
/** The app a route file belongs to — the shared rule (`appRootFor`). */
|
||||
export const reactRouterRoot = appRootFor;
|
||||
|
||||
/**
|
||||
* True for a route node `frameworks/react.ts` emitted, and no other.
|
||||
*
|
||||
* Its id is a verbatim reconstruction of the node's own fields, which no
|
||||
* other framework's route id is: a server route carries its METHOD
|
||||
* (`route:file:12:POST:/login`), a file-based page carries no line.
|
||||
*/
|
||||
function isReactRouterRoute(node: Node): boolean {
|
||||
return (
|
||||
(node.language === 'tsx' || node.language === 'jsx') &&
|
||||
node.id === `route:${node.filePath}:${node.startLine}:${node.name}`
|
||||
);
|
||||
}
|
||||
|
||||
/** `:id?` — a parameter React Router serves the route with or without. */
|
||||
function isOptionalParam(seg: string): boolean {
|
||||
return seg.startsWith(':') && seg.endsWith('?');
|
||||
}
|
||||
|
||||
const tables = new WeakMap<ResolutionContext, ReactRouterTable>();
|
||||
|
||||
export function reactRouterTable(context: ResolutionContext): ReactRouterTable {
|
||||
const all = context.getNodesByKind('route');
|
||||
const cached = tables.get(context);
|
||||
if (cached && cached.source === all) return cached;
|
||||
const byRoot = new Map<string, RouteTable>();
|
||||
const shortened: { root: string; path: string; node: Node }[] = [];
|
||||
const tableAt = (root: string): RouteTable => {
|
||||
let t = byRoot.get(root);
|
||||
if (!t) byRoot.set(root, (t = { source: all, exact: new Map(), dynamic: [] }));
|
||||
return t;
|
||||
};
|
||||
for (const node of all) {
|
||||
if (!isReactRouterRoute(node)) continue;
|
||||
// A nested route's path is relative to its parent; without the tree it is
|
||||
// not a destination. A splat matches everything, so it answers nothing.
|
||||
if (!node.name.startsWith('/') || node.name.endsWith('*')) continue;
|
||||
const root = reactRouterRoot(node.filePath);
|
||||
const path = node.name.length > 1 && node.name.endsWith('/') ? node.name.slice(0, -1) : node.name;
|
||||
addRouteTo(tableAt(root), path, node);
|
||||
// React Router's optional parameter: `/cart/:id?` is the screen for
|
||||
// `/cart/5` AND for a bare `/cart`, which the navbar's cart icon links
|
||||
// to. The matcher pairs a route with an href of the same length, so the
|
||||
// shorter form is its own entry — collected now, registered after every
|
||||
// literal path, so a route someone actually wrote always wins.
|
||||
let segs = path.split('/').slice(1);
|
||||
while (segs.length > 1 && isOptionalParam(segs[segs.length - 1]!)) {
|
||||
segs = segs.slice(0, -1);
|
||||
shortened.push({ root, path: '/' + segs.join('/'), node });
|
||||
}
|
||||
}
|
||||
for (const s of shortened) {
|
||||
const t = byRoot.get(s.root);
|
||||
if (t && !t.exact.has(s.path)) addRouteTo(t, s.path, s.node);
|
||||
}
|
||||
const table: ReactRouterTable = { source: all, byRoot };
|
||||
tables.set(context, table);
|
||||
return table;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Navigation calls
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* `history.push` / `.replace` (v5, `useHistory`), `navigate(…)` (v6,
|
||||
* `useNavigate`), `router.navigate(…)` (a data router), `redirect(…)` (a
|
||||
* loader or an action).
|
||||
*
|
||||
* The receiver is required for `push` / `replace`: an unqualified `push` is
|
||||
* an array's, and claiming it would put every `paths.push('/tmp/x')` in the
|
||||
* repo one string-match away from a route.
|
||||
*/
|
||||
const NAV_CALL = /^(?:history|navigate|router)\.(?:push|replace|navigate)$|^(?:navigate|redirect)$/;
|
||||
|
||||
/** The verb a navigation call name stands for, or null. */
|
||||
export function reactRouterNavVerb(name: string): string | null {
|
||||
if (!NAV_CALL.test(name)) return null;
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot < 0 ? name : name.slice(dot + 1);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// The resolver
|
||||
// =============================================================================
|
||||
|
||||
export const reactRouterResolver: FrameworkResolver = {
|
||||
name: 'react-router',
|
||||
languages: [...ROUTE_LANGUAGES],
|
||||
|
||||
detect(context: ResolutionContext): boolean {
|
||||
return dependsOn(context, 'react-router', 'react-router-dom', 'react-router-native');
|
||||
},
|
||||
|
||||
claimsReference(name: string): boolean {
|
||||
return NAV_CALL.test(name);
|
||||
},
|
||||
|
||||
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||
if (ref.referenceKind !== 'calls') return null;
|
||||
const verb = reactRouterNavVerb(ref.referenceName);
|
||||
if (!verb) return null;
|
||||
if (!ROUTE_LANGUAGES.includes(ref.language)) return null;
|
||||
const routes = routesForFile(reactRouterTable(context), ref.filePath);
|
||||
if (!routes || routes.exact.size === 0) return null;
|
||||
const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? null;
|
||||
if (!lines) return null;
|
||||
|
||||
const arg = firstArgumentText(lines, ref.line, ref.column, verb);
|
||||
if (arg === null) return null;
|
||||
let href = 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, verb, start);
|
||||
}
|
||||
if (!href) return null;
|
||||
// Every arm of a conditional destination is somewhere this call goes; the
|
||||
// first is this reference's resolution and the rest ride as `alsoTargets`.
|
||||
const targets = destinationsForHref(href, routes);
|
||||
const target = targets[0];
|
||||
if (!target) return null;
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: target.node.id,
|
||||
...(targets.length > 1
|
||||
? { alsoTargets: targets.slice(1).map((t) => ({ targetNodeId: t.node.id, metadata: { href: t.href.display, navMethod: verb } })) }
|
||||
: {}),
|
||||
confidence: 0.95,
|
||||
resolvedBy: 'framework',
|
||||
edgeKind: 'navigates',
|
||||
metadata: { href: target.href.display, navMethod: verb },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -153,7 +153,12 @@ export const svelteResolver: FrameworkResolver = {
|
||||
const fileName = filePath.split(/[/\\]/).pop() || '';
|
||||
const routeMatch = getSvelteKitRouteInfo(fileName);
|
||||
|
||||
if (routeMatch) {
|
||||
// Only a `+page.svelte` is a URL. A `+layout.svelte` and a `+error.svelte`
|
||||
// sit at the same path as the page beside them, so emitting a route for
|
||||
// them put the same address in the index two and three times over — one
|
||||
// `/` for the page, one for the layout, one for the error page — which the
|
||||
// Screens picture then drew as three separate screens.
|
||||
if (routeMatch === 'page') {
|
||||
// Extract route path from directory structure
|
||||
// e.g., src/routes/blog/[slug]/+page.svelte -> /blog/:slug
|
||||
const routePath = filePathToSvelteKitRoute(filePath);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* SvelteKit — pages are directories, navigation is a string.
|
||||
*
|
||||
* `frameworks/svelte.ts` already reads the route table out of the file tree:
|
||||
* `src/routes/article/[slug]/+page.svelte` is `/article/:slug`,
|
||||
* `[[optional]]` is `:optional?` and `[...rest]` is `*rest`. This file is the
|
||||
* navigation half — without it a SvelteKit project's screens are drawn as
|
||||
* islands and the Screens tab stays hidden, because it is a picture of
|
||||
* `navigates` edges and there were none.
|
||||
*
|
||||
* Two calls carry a user from one page to another, and they do not agree on
|
||||
* where the path goes:
|
||||
*
|
||||
* goto('/login') // $app/navigation, in the browser
|
||||
* redirect(303, '/article/' + slug) // @sveltejs/kit, from a load or an action
|
||||
*
|
||||
* `redirect` takes the status FIRST, so the destination is its second
|
||||
* argument — the one difference from every other framework here. Both are
|
||||
* read with the Expo Router readers (a string, a template with holes, a
|
||||
* conditional whose arms agree, a local `const href = …`) and matched against
|
||||
* this framework's own routes. `<a href="/login">` is markup rather than a
|
||||
* call, so a synthesizer reads it (`sveltekit-link-synthesizer.ts`).
|
||||
*
|
||||
* Only `+page.svelte` is a screen. `+layout.svelte` and `+error.svelte` sit at
|
||||
* the same path and would be a second screen for one URL; `+server.ts` is an
|
||||
* endpoint, not a page. A computed destination, a path no page serves, and an
|
||||
* external URL are left unresolved rather than guessed.
|
||||
*/
|
||||
|
||||
import type { Language, Node } from '../../types';
|
||||
import type { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
|
||||
import { dependsOn } from './package-deps';
|
||||
import {
|
||||
addRouteTo,
|
||||
appRootFor,
|
||||
nthArgumentText,
|
||||
parseHrefExpression,
|
||||
readHrefViaLocal,
|
||||
routesForFile,
|
||||
type RootedRouteTable,
|
||||
type RouteTable,
|
||||
} from './expo-router';
|
||||
import { destinationsForHref } from './nextjs';
|
||||
|
||||
const NAV_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'svelte'];
|
||||
|
||||
// =============================================================================
|
||||
// Route table — the `+page.svelte` files `frameworks/svelte.ts` named
|
||||
// =============================================================================
|
||||
|
||||
export type SvelteKitTable = RootedRouteTable;
|
||||
|
||||
/** True for a page route node `frameworks/svelte.ts` emitted, and no other. */
|
||||
function isSvelteKitPage(node: Node): boolean {
|
||||
return (
|
||||
node.language === 'svelte' &&
|
||||
node.filePath.endsWith('/+page.svelte') &&
|
||||
node.id === `route:${node.filePath}:${node.name}:1`
|
||||
);
|
||||
}
|
||||
|
||||
/** `:id?` — a parameter SvelteKit serves the route with or without. */
|
||||
function isOptionalParam(seg: string): boolean {
|
||||
return seg.startsWith(':') && seg.endsWith('?');
|
||||
}
|
||||
|
||||
const tables = new WeakMap<ResolutionContext, SvelteKitTable>();
|
||||
|
||||
export function svelteKitTable(context: ResolutionContext): SvelteKitTable {
|
||||
const all = context.getNodesByKind('route');
|
||||
const cached = tables.get(context);
|
||||
if (cached && cached.source === all) return cached;
|
||||
const byRoot = new Map<string, RouteTable>();
|
||||
const shortened: { root: string; path: string; node: Node }[] = [];
|
||||
const tableAt = (root: string): RouteTable => {
|
||||
let t = byRoot.get(root);
|
||||
if (!t) byRoot.set(root, (t = { source: all, exact: new Map(), dynamic: [] }));
|
||||
return t;
|
||||
};
|
||||
for (const node of all) {
|
||||
if (!isSvelteKitPage(node)) continue;
|
||||
// `[...rest]` becomes `*rest`, which matches anything — never an answer.
|
||||
if (!node.name.startsWith('/') || node.name.includes('*')) continue;
|
||||
const root = appRootFor(node.filePath);
|
||||
addRouteTo(tableAt(root), node.name, node);
|
||||
// `[[optional]]` is `:x?`: the route serves the path with and without it.
|
||||
let segs = node.name.split('/').slice(1);
|
||||
while (segs.length > 1 && isOptionalParam(segs[segs.length - 1]!)) {
|
||||
segs = segs.slice(0, -1);
|
||||
shortened.push({ root, path: '/' + segs.join('/'), node });
|
||||
}
|
||||
}
|
||||
for (const s of shortened) {
|
||||
const t = byRoot.get(s.root);
|
||||
if (t && !t.exact.has(s.path)) addRouteTo(t, s.path, s.node);
|
||||
}
|
||||
const table: SvelteKitTable = { source: all, byRoot };
|
||||
tables.set(context, table);
|
||||
return table;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Navigation calls
|
||||
// =============================================================================
|
||||
|
||||
/** `goto('/x')` in the browser; `redirect(303, '/x')` from a load or an action. */
|
||||
const NAV_CALL = /^(goto|redirect)$/;
|
||||
|
||||
/** Which argument of a navigation call is the destination — `redirect` puts the status first. */
|
||||
export function svelteKitHrefArgument(name: string): 0 | 1 | null {
|
||||
if (name === 'goto') return 0;
|
||||
if (name === 'redirect') return 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// The resolver
|
||||
// =============================================================================
|
||||
|
||||
export const svelteKitRouterResolver: FrameworkResolver = {
|
||||
name: 'sveltekit-router',
|
||||
languages: [...NAV_LANGUAGES],
|
||||
|
||||
detect(context: ResolutionContext): boolean {
|
||||
return dependsOn(context, '@sveltejs/kit');
|
||||
},
|
||||
|
||||
claimsReference(name: string): boolean {
|
||||
return NAV_CALL.test(name);
|
||||
},
|
||||
|
||||
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||
// `import { redirect } from '@sveltejs/kit'` is not a navigation.
|
||||
if (ref.referenceKind !== 'calls') return null;
|
||||
const argIndex = svelteKitHrefArgument(ref.referenceName);
|
||||
if (argIndex === null) return null;
|
||||
if (!NAV_LANGUAGES.includes(ref.language)) return null;
|
||||
const routes = routesForFile(svelteKitTable(context), ref.filePath);
|
||||
if (!routes || routes.exact.size === 0) return null;
|
||||
const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? null;
|
||||
if (!lines) return null;
|
||||
|
||||
const arg = nthArgumentText(lines, ref.line, ref.column, ref.referenceName, argIndex);
|
||||
if (arg === null) return null;
|
||||
let href = parseHrefExpression(arg);
|
||||
if (!href && argIndex === 0) {
|
||||
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, ref.referenceName, start);
|
||||
}
|
||||
if (!href) return null;
|
||||
// Every arm of a conditional destination is somewhere this call goes; the
|
||||
// first is this reference's resolution and the rest ride as `alsoTargets`.
|
||||
const targets = destinationsForHref(href, routes);
|
||||
const target = targets[0];
|
||||
if (!target) return null;
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: target.node.id,
|
||||
...(targets.length > 1
|
||||
? { alsoTargets: targets.slice(1).map((t) => ({ targetNodeId: t.node.id, metadata: { href: t.href.display, navMethod: ref.referenceName } })) }
|
||||
: {}),
|
||||
confidence: 0.95,
|
||||
resolvedBy: 'framework',
|
||||
edgeKind: 'navigates',
|
||||
metadata: { href: target.href.display, navMethod: ref.referenceName },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* TanStack Router — the path is a literal, and so is the destination.
|
||||
*
|
||||
* Routes are declared two ways, and this reads both:
|
||||
*
|
||||
* // file-based (the plugin's default): the full path is the argument
|
||||
* export const Route = createFileRoute('/dashboard/invoices/$invoiceId')({
|
||||
* component: InvoiceComponent,
|
||||
* })
|
||||
*
|
||||
* // code-based: a path per route, composed through its parent
|
||||
* const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'posts' })
|
||||
* const postRoute = createRoute({ getParentRoute: () => postsRoute, path: '$postId' })
|
||||
*
|
||||
* Three things are TanStack's own, and each one decides whether the picture is
|
||||
* right:
|
||||
*
|
||||
* 1. **A parameter is `$id`, not `:id`.** Route names are normalised to the
|
||||
* `:id` every other framework here uses, so one matcher serves them all.
|
||||
* 2. **`to` is the route PATTERN, not a filled URL.** `<Link to="/posts/$postId"
|
||||
* params={{ postId }}>` names the route and passes the values beside it —
|
||||
* where React Router would write `/posts/5`. So a destination is normalised
|
||||
* the same way a route name is, and then matches it exactly.
|
||||
* 3. **A destination is an object.** `navigate({ to: '/' })`,
|
||||
* `throw redirect({ to: '/login' })` — the path is under a `to` key, and a
|
||||
* `navigate({ search: … })` with no `to` stays on the page it is on.
|
||||
*
|
||||
* Not every route file is a page. A segment written `_auth` is a pathless
|
||||
* layout — it does not appear in the URL, and the file that declares it renders
|
||||
* an outlet rather than a screen; a `(group)` segment is likewise invisible; a
|
||||
* `dashboard.route.tsx` is the layout for the `/dashboard` subtree while
|
||||
* `dashboard.index.tsx` — whose literal carries a trailing slash — is the page
|
||||
* AT `/dashboard`. Drawing both would put one address on the map twice.
|
||||
*
|
||||
* Left unresolved rather than guessed: a computed `to`, a path no route serves,
|
||||
* and a code-based route whose parent is declared in another file (the chain is
|
||||
* composed within a file, which is where a route tree is written).
|
||||
*/
|
||||
|
||||
import type { Language, Node } from '../../types';
|
||||
import type {
|
||||
FrameworkExtractionResult,
|
||||
FrameworkResolver,
|
||||
ResolutionContext,
|
||||
ResolvedRef,
|
||||
UnresolvedRef,
|
||||
} from '../types';
|
||||
import { stripCommentsForRegex } from '../strip-comments';
|
||||
import { dependsOn } from './package-deps';
|
||||
import { matchBracket, readFields } from './object-literal';
|
||||
import {
|
||||
addRouteTo,
|
||||
appRootFor,
|
||||
firstArgumentText,
|
||||
parseHrefExpression,
|
||||
readHrefViaLocal,
|
||||
readStringAt,
|
||||
routesForFile,
|
||||
toHref,
|
||||
type HrefLiteral,
|
||||
type RootedRouteTable,
|
||||
type RouteTable,
|
||||
} from './expo-router';
|
||||
import { destinationsForHref } from './nextjs';
|
||||
|
||||
const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'tsx', 'jsx'];
|
||||
|
||||
// =============================================================================
|
||||
// Paths
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A TanStack path in the form every other framework's routes take.
|
||||
*
|
||||
* `$invoiceId` is `:invoiceId` and a bare `$` is a splat; a `_auth` segment is
|
||||
* a pathless layout and a `(group)` segment is a route group, neither of which
|
||||
* appears in the URL; a trailing `_` un-nests without changing the segment.
|
||||
* Returns null for a path that names no address at all.
|
||||
*/
|
||||
export function tanstackPath(raw: string): string | null {
|
||||
if (!raw.startsWith('/')) return null;
|
||||
const segs: string[] = [];
|
||||
for (const seg of raw.split('/')) {
|
||||
if (seg.length === 0) continue;
|
||||
if (seg.startsWith('_')) continue; // pathless layout
|
||||
if (seg.startsWith('(') && seg.endsWith(')')) continue; // route group
|
||||
const bare = seg.endsWith('_') ? seg.slice(0, -1) : seg;
|
||||
if (bare === '$') {
|
||||
segs.push(':splat*');
|
||||
continue;
|
||||
}
|
||||
segs.push(bare.startsWith('$') ? ':' + bare.slice(1) : bare);
|
||||
}
|
||||
return '/' + segs.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the literal names a pathless layout rather than a page.
|
||||
*
|
||||
* `'/_auth'` is the layout file itself — it renders an outlet, at no address
|
||||
* of its own. `'/_auth/'` is the INDEX route inside that layout, and its
|
||||
* address is whatever the layout sits at: `_layout/index.tsx` is a project's
|
||||
* home page, and reading it as a layout dropped `/` from the map entirely.
|
||||
*/
|
||||
function isPathlessLayout(raw: string): boolean {
|
||||
if (raw.length > 1 && raw.endsWith('/')) return false; // an index route, not the layout
|
||||
const segs = raw.split('/').filter((s) => s.length > 0);
|
||||
const last = segs[segs.length - 1];
|
||||
return last !== undefined && last.startsWith('_');
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a file that wraps a subtree rather than rendering a page at its own
|
||||
* address.
|
||||
*
|
||||
* `<Outlet />` is where children render, so a route file that has one is the
|
||||
* layout AROUND an address and the index route beside it is the page AT it —
|
||||
* `_auth.invoices.tsx` and `_auth.invoices.index.tsx` both say `/invoices`,
|
||||
* and drawing both puts one address on the map twice. The name `route.tsx`
|
||||
* declares the same thing by convention, whether or not it draws an outlet.
|
||||
*
|
||||
* This is per-file on purpose: the alternative — a path that is a prefix of
|
||||
* another route's — is only knowable once every file has been read, and by
|
||||
* then the extra screen is already in the index.
|
||||
*/
|
||||
function isLayoutFile(filePath: string, content: string): boolean {
|
||||
const base = filePath.slice(filePath.lastIndexOf('/') + 1);
|
||||
if (/(?:^|\.)route\.(?:tsx|ts|jsx|js)$/.test(base)) return true;
|
||||
return /<Outlet\b/.test(content);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Reading the routes
|
||||
// =============================================================================
|
||||
|
||||
export interface TanstackRouteEntry {
|
||||
/** `/dashboard/invoices/:invoiceId` — normalised the way the table wants it. */
|
||||
path: string;
|
||||
/** The component the route renders, when it names one. */
|
||||
component: string | null;
|
||||
/** True for the index route AT an address, which outranks the layout that wraps it. */
|
||||
index: boolean;
|
||||
/** True when the route came from `createFileRoute` — one route per file, so the file's own shape describes it. */
|
||||
fileBased: boolean;
|
||||
line: number;
|
||||
}
|
||||
|
||||
/** The calls that declare a route — the cheap gate before parsing anything. */
|
||||
const ROUTE_FACTORY = /\bcreate(?:File|Lazy(?:File)?|Root)?Route\s*\(/;
|
||||
|
||||
/**
|
||||
* Every route a file declares, file-based and code-based alike.
|
||||
*
|
||||
* A code-based route's own `path` is a fragment (`posts`, `$postId`, `/`), so
|
||||
* the chain of `getParentRoute: () => parent` is followed to compose the full
|
||||
* address — within the file, which is where a route tree is written. A route
|
||||
* that is another route's parent is the layout for that subtree, and the
|
||||
* address belongs to the index route under it.
|
||||
*/
|
||||
export function parseTanstackRoutes(content: string): TanstackRouteEntry[] {
|
||||
if (!ROUTE_FACTORY.test(content)) return [];
|
||||
const safe = stripCommentsForRegex(content, 'typescript');
|
||||
const out: TanstackRouteEntry[] = [];
|
||||
const lineOf = (index: number): number => safe.slice(0, index).split('\n').length;
|
||||
|
||||
// ---- file-based: the path is the first argument, the options follow ----
|
||||
const fileRoutes = /\bcreate(?:Lazy)?FileRoute\s*\(/g;
|
||||
let f: RegExpExecArray | null;
|
||||
while ((f = fileRoutes.exec(safe)) !== null) {
|
||||
const open = f.index + f[0].length - 1;
|
||||
const close = matchBracket(safe, open);
|
||||
if (close < 0) continue;
|
||||
const raw = readStringAt(safe.slice(open + 1, close).trimStart(), 0);
|
||||
if (raw === null) continue;
|
||||
const path = tanstackPath(raw);
|
||||
if (path === null || isPathlessLayout(raw)) continue;
|
||||
out.push({
|
||||
path,
|
||||
component: componentIn(chainAfter(safe, close + 1)),
|
||||
// `createFileRoute('/dashboard/')` is the index page AT `/dashboard`;
|
||||
// `createFileRoute('/dashboard')` is the layout around it.
|
||||
index: raw.length > 1 && raw.endsWith('/'),
|
||||
fileBased: true,
|
||||
line: lineOf(f.index),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- code-based: a fragment per route, composed through its parent ----
|
||||
const decls = new Map<string, { path: string | null; parent: string | null; component: string | null; root: boolean; index: number }>();
|
||||
const named = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]*?)?=\s*create(Root)?Route\s*\(\s*\{/g;
|
||||
let d: RegExpExecArray | null;
|
||||
while ((d = named.exec(safe)) !== null) {
|
||||
const brace = safe.indexOf('{', d.index + d[0].length - 1);
|
||||
const end = matchBracket(safe, brace);
|
||||
if (end < 0) continue;
|
||||
const fields = readFields(safe, brace, end);
|
||||
const pathField = fields.get('path');
|
||||
const path = d[2] ? '/' : pathField ? readStringAt(pathField.text.trimStart(), 0) : null;
|
||||
const parentField = fields.get('getParentRoute');
|
||||
const parent = parentField ? (/=>\s*([A-Za-z_$][\w$]*)/.exec(parentField.text)?.[1] ?? null) : null;
|
||||
const componentField = fields.get('component');
|
||||
decls.set(d[1]!, {
|
||||
path,
|
||||
parent,
|
||||
component: componentField ? componentIn(componentField.text) : null,
|
||||
root: d[2] !== undefined,
|
||||
index: d.index,
|
||||
});
|
||||
}
|
||||
// A route with an index child is the LAYOUT around that address; the child
|
||||
// with `path: '/'` is what renders there. A parent with no index child still
|
||||
// is the page at its own address — its outlet is simply empty.
|
||||
const wrapsAnIndex = new Set(
|
||||
[...decls.values()].filter((r) => r.path === '/' && r.parent !== null).map((r) => r.parent!)
|
||||
);
|
||||
for (const [name, decl] of decls) {
|
||||
if (decl.path === null) continue; // a pathless layout contributes no address
|
||||
// `createRootRoute` is the outermost layout — every page renders inside it,
|
||||
// and the index route beside it is what renders at `/`. A `__root.tsx` that
|
||||
// counted as a page put a second `/` on every file-based project's map.
|
||||
if (decl.root) continue;
|
||||
if (wrapsAnIndex.has(name)) continue;
|
||||
const full = composePath(name, decls);
|
||||
if (full === null) continue;
|
||||
const path = tanstackPath(full);
|
||||
if (path === null) continue;
|
||||
out.push({ path, component: decl.component, index: decl.path === '/', fileBased: false, line: lineOf(decl.index) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The address a code-based route sits at, following `getParentRoute` up. */
|
||||
function composePath(
|
||||
name: string,
|
||||
decls: Map<string, { path: string | null; parent: string | null }>
|
||||
): string | null {
|
||||
const segs: string[] = [];
|
||||
let cur: string | null = name;
|
||||
for (let hops = 0; cur !== null && hops < 24; hops++) {
|
||||
const decl: { path: string | null; parent: string | null } | undefined = decls.get(cur);
|
||||
if (!decl) return null; // a parent declared in another file — not composed
|
||||
if (decl.path !== null) {
|
||||
const own = decl.path.split('/').filter((s) => s.length > 0);
|
||||
segs.unshift(...own);
|
||||
}
|
||||
cur = decl.parent;
|
||||
}
|
||||
return '/' + segs.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of the call chain starting at `at` — `({ … })`, and any `.update({ … })`
|
||||
* or `.lazy(…)` after it, which is where a route's component may be written.
|
||||
*/
|
||||
function chainAfter(s: string, at: number): string {
|
||||
let i = at;
|
||||
const start = i;
|
||||
for (let steps = 0; steps < 8; steps++) {
|
||||
while (i < s.length && /\s/.test(s[i]!)) i++;
|
||||
if (s[i] === '.') {
|
||||
i++;
|
||||
while (i < s.length && /[\w$]/.test(s[i]!)) i++;
|
||||
while (i < s.length && /\s/.test(s[i]!)) i++;
|
||||
}
|
||||
if (s[i] !== '(') break;
|
||||
const close = matchBracket(s, i);
|
||||
if (close < 0) break;
|
||||
i = close + 1;
|
||||
}
|
||||
return s.slice(start, i);
|
||||
}
|
||||
|
||||
/** The component a route names: an identifier, or the file a lazy import names. */
|
||||
function componentIn(text: string): string | null {
|
||||
const lazy = /\bimport\s*\(\s*['"`]([^'"`]+)['"`]/.exec(text);
|
||||
if (lazy) return (lazy[1]!.split('/').pop() ?? '').replace(/\.\w+$/, '') || null;
|
||||
return /(?:^|[^\w$])component\s*:\s*([A-Z][A-Za-z0-9_]*)/.exec(text)?.[1] ?? /^\s*([A-Z][A-Za-z0-9_]*)\s*$/.exec(text)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** The id a TanStack route carries — a verbatim reconstruction, so the table can recognise its own. */
|
||||
function routeId(filePath: string, line: number, path: string): string {
|
||||
return `route:${filePath}:${line}:${path}:tanstack`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Route table
|
||||
// =============================================================================
|
||||
|
||||
export type TanstackTable = RootedRouteTable;
|
||||
|
||||
/** True for a route node this resolver emitted, and no other. */
|
||||
function isTanstackRoute(node: Node): boolean {
|
||||
return node.id === routeId(node.filePath, node.startLine, node.name);
|
||||
}
|
||||
|
||||
const tables = new WeakMap<ResolutionContext, TanstackTable>();
|
||||
|
||||
export function tanstackTable(context: ResolutionContext): TanstackTable {
|
||||
const all = context.getNodesByKind('route');
|
||||
const cached = tables.get(context);
|
||||
if (cached && cached.source === all) return cached;
|
||||
const byRoot = new Map<string, RouteTable>();
|
||||
for (const node of all) {
|
||||
if (!isTanstackRoute(node)) continue;
|
||||
const root = appRootFor(node.filePath);
|
||||
let t = byRoot.get(root);
|
||||
if (!t) byRoot.set(root, (t = { source: all, exact: new Map(), dynamic: [] }));
|
||||
addRouteTo(t, node.name, node);
|
||||
}
|
||||
const table: TanstackTable = { source: all, byRoot };
|
||||
tables.set(context, table);
|
||||
return table;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Navigation calls
|
||||
// =============================================================================
|
||||
|
||||
/** `navigate({ to })` from `useNavigate`, `router.navigate({ to })`, and a thrown `redirect({ to })`. */
|
||||
const NAV_CALL = /^(?:navigate|redirect)$|^(?:router|Route)\.navigate$/;
|
||||
|
||||
/** The verb a navigation call name stands for, or null. */
|
||||
export function tanstackNavVerb(name: string): string | null {
|
||||
if (!NAV_CALL.test(name)) return null;
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot < 0 ? name : name.slice(dot + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The destination in a TanStack navigation: `{ to: '/posts/$postId' }`.
|
||||
*
|
||||
* `to` is the route pattern, so it is normalised exactly as a route name is
|
||||
* and then names that route. A `navigate({ search: … })` with no `to` is a
|
||||
* change of search parameters on the page the user is already on.
|
||||
*/
|
||||
export function tanstackDestination(expr: string): HrefLiteral | null {
|
||||
const args = expr.trim();
|
||||
const literal = args[0] === '{' ? toKeyOf(args) : readStringAt(args, 0);
|
||||
if (literal === null) return null;
|
||||
const path = tanstackPath(literal);
|
||||
return path === null ? parseHrefExpression(args) : toHref(path);
|
||||
}
|
||||
|
||||
/** The `to:` value of an object destination, or null when it has none or it is computed. */
|
||||
function toKeyOf(args: string): string | null {
|
||||
const end = matchBracket(args, 0);
|
||||
if (end < 0) return null;
|
||||
const field = readFields(args, 0, end).get('to');
|
||||
return field ? readStringAt(field.text.trimStart(), 0) : null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// The resolver
|
||||
// =============================================================================
|
||||
|
||||
export const tanstackRouterResolver: FrameworkResolver = {
|
||||
name: 'tanstack-router',
|
||||
languages: [...ROUTE_LANGUAGES],
|
||||
|
||||
detect(context: ResolutionContext): boolean {
|
||||
return dependsOn(
|
||||
context,
|
||||
'@tanstack/react-router',
|
||||
'@tanstack/solid-router',
|
||||
'@tanstack/router',
|
||||
'@tanstack/react-start',
|
||||
'@tanstack/start'
|
||||
);
|
||||
},
|
||||
|
||||
claimsReference(name: string): boolean {
|
||||
return NAV_CALL.test(name);
|
||||
},
|
||||
|
||||
extract(filePath: string, content: string): FrameworkExtractionResult {
|
||||
// A file-based route file describes ONE route, so the file's own shape says
|
||||
// whether that route is a page. A file holding a code-based route TREE
|
||||
// describes many, and its root component draws the outlet they render into
|
||||
// — judging that file by the same rule would drop every route in it.
|
||||
const layout = isLayoutFile(filePath, content);
|
||||
const entries = parseTanstackRoutes(content).filter((e) => !(e.fileBased && layout));
|
||||
if (entries.length === 0) return { nodes: [], references: [] };
|
||||
const language = languageForFile(filePath);
|
||||
const now = Date.now();
|
||||
const nodes: Node[] = [];
|
||||
const references: UnresolvedRef[] = [];
|
||||
// An index route is the page AT its address; a layout at the same address
|
||||
// wraps it. One address, one screen — the index wins it.
|
||||
const byPath = new Map<string, TanstackRouteEntry>();
|
||||
for (const entry of entries) {
|
||||
const held = byPath.get(entry.path);
|
||||
if (!held || (entry.index && !held.index)) byPath.set(entry.path, entry);
|
||||
}
|
||||
for (const entry of byPath.values()) {
|
||||
const node: Node = {
|
||||
id: routeId(filePath, entry.line, entry.path),
|
||||
kind: 'route',
|
||||
name: entry.path,
|
||||
qualifiedName: `${filePath}::route:${entry.path}`,
|
||||
filePath,
|
||||
startLine: entry.line,
|
||||
endLine: entry.line,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
language,
|
||||
updatedAt: now,
|
||||
};
|
||||
nodes.push(node);
|
||||
if (entry.component) {
|
||||
// `calls`, as every component-backed screen binds: a `references`
|
||||
// candidate list is filtered to the ref's own language family.
|
||||
references.push({
|
||||
fromNodeId: node.id,
|
||||
referenceName: entry.component,
|
||||
referenceKind: 'calls',
|
||||
line: entry.line,
|
||||
column: 0,
|
||||
filePath,
|
||||
language,
|
||||
candidates: [entry.component],
|
||||
});
|
||||
}
|
||||
}
|
||||
return { nodes, references };
|
||||
},
|
||||
|
||||
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||
if (ref.referenceKind !== 'calls') return null;
|
||||
const verb = tanstackNavVerb(ref.referenceName);
|
||||
if (!verb) return null;
|
||||
if (!ROUTE_LANGUAGES.includes(ref.language)) return null;
|
||||
const routes = routesForFile(tanstackTable(context), ref.filePath);
|
||||
if (!routes || routes.exact.size === 0) return null;
|
||||
const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? null;
|
||||
if (!lines) return null;
|
||||
|
||||
const arg = firstArgumentText(lines, ref.line, ref.column, verb);
|
||||
if (arg === null) return null;
|
||||
let href = tanstackDestination(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, verb, start);
|
||||
}
|
||||
if (!href) return null;
|
||||
// Every arm of a conditional destination is somewhere this call goes; the
|
||||
// first is this reference's resolution and the rest ride as `alsoTargets`.
|
||||
const targets = destinationsForHref(href, routes);
|
||||
const target = targets[0];
|
||||
if (!target) return null;
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: target.node.id,
|
||||
...(targets.length > 1
|
||||
? { alsoTargets: targets.slice(1).map((t) => ({ targetNodeId: t.node.id, metadata: { href: t.href.display, navMethod: verb } })) }
|
||||
: {}),
|
||||
confidence: 0.95,
|
||||
resolvedBy: 'framework',
|
||||
edgeKind: 'navigates',
|
||||
metadata: { href: target.href.display, navMethod: verb },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Vue Router — routes declared in a config object, navigation often by NAME.
|
||||
*
|
||||
* `frameworks/vue.ts` reads Nuxt's file convention (`pages/about.vue` is
|
||||
* `/about`), which is half the Vue world. The other half — every plain Vue 3
|
||||
* app — declares its routes in one object:
|
||||
*
|
||||
* const router = createRouter({
|
||||
* history: createWebHistory(),
|
||||
* routes: [
|
||||
* { name: 'login', path: '/login', component: () => import('@/views/Login') },
|
||||
* { name: 'profile', path: '/profile/:username', component: Profile },
|
||||
* ],
|
||||
* })
|
||||
*
|
||||
* `extract()` reads that array into one `route` node per entry, named by its
|
||||
* path the way every other framework's routes are, bound to the component it
|
||||
* names — an identifier, or the last segment of a lazy `() => import(…)`,
|
||||
* which is the `.vue` file's own name.
|
||||
*
|
||||
* **Navigation is usually a name, not a path.** This is what makes Vue
|
||||
* different from React Router and Next.js, where the destination is always a
|
||||
* URL:
|
||||
*
|
||||
* router.push({ name: 'login' }) // by name — the common idiom
|
||||
* router.push('/') // by path
|
||||
* router.push({ path: '/', query }) // by path, with extras
|
||||
* navigateTo('/dashboard') // Nuxt
|
||||
*
|
||||
* So `resolve()` reads the argument as a name FIRST and falls back to the
|
||||
* path readers every other framework shares. A route's name lives only in the
|
||||
* source — node metadata is not persisted — so the table re-reads the config
|
||||
* files its own route nodes came from, with the same parser `extract` used.
|
||||
* `<router-link to>` / `<RouterLink to>` / `<NuxtLink to>` are markup rather
|
||||
* than calls, so a synthesizer reads them (`vue-router-synthesizer.ts`).
|
||||
*
|
||||
* Left unresolved rather than guessed: a computed destination
|
||||
* (`router.push(postAuthRoute.value)`), a name or path nothing declares, and
|
||||
* a nested `children:` route, whose path is relative to its parent.
|
||||
*/
|
||||
|
||||
import type { Language, Node } from '../../types';
|
||||
import type {
|
||||
FrameworkExtractionResult,
|
||||
FrameworkResolver,
|
||||
ResolutionContext,
|
||||
ResolvedRef,
|
||||
UnresolvedRef,
|
||||
} from '../types';
|
||||
import { stripCommentsForRegex } from '../strip-comments';
|
||||
import { matchBracket, readFields, topLevelObjects } from './object-literal';
|
||||
import { dependsOn } from './package-deps';
|
||||
import {
|
||||
addRouteTo,
|
||||
appRootFor,
|
||||
firstArgumentText,
|
||||
parseHrefExpression,
|
||||
readHrefViaLocal,
|
||||
readStringAt,
|
||||
routesForFile,
|
||||
toHref,
|
||||
type HrefLiteral,
|
||||
type RootedRouteTable,
|
||||
type RouteTable,
|
||||
} from './expo-router';
|
||||
import { destinationsForHref } from './nextjs';
|
||||
|
||||
const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'vue'];
|
||||
|
||||
// =============================================================================
|
||||
// Reading the routes array
|
||||
// =============================================================================
|
||||
|
||||
export interface VueRouteEntry {
|
||||
/** `/profile/:username` — the path, in the form every other framework's routes use. */
|
||||
path: string;
|
||||
/** `profile` — what `router.push({ name })` names, when the entry has one. */
|
||||
name: string | null;
|
||||
/** The component the entry names, by identifier or by the tail of its lazy import. */
|
||||
component: string | null;
|
||||
line: number;
|
||||
}
|
||||
|
||||
/** A file that builds a router — the cheap gate before parsing anything. */
|
||||
const ROUTER_FACTORY = /\b(?:createRouter|createWebHistory|createWebHashHistory|createMemoryHistory)\s*\(|\bnew\s+VueRouter\s*\(/;
|
||||
|
||||
/** `routes: [` / `routes = [` — the array itself, for a file that only holds the table. */
|
||||
const ROUTES_ARRAY = /\broutes\s*[:=]\s*\[/;
|
||||
|
||||
/**
|
||||
* Every top-level entry of a `routes: [...]` array.
|
||||
*
|
||||
* The array is walked, not pattern-matched: a `name` is written ABOVE the
|
||||
* `path` it belongs to, so reading fields out of a window around each `path`
|
||||
* hands an entry its PREDECESSOR's name — vue-realworld's `login` came out as
|
||||
* `/register`, silently, for every route in the file. So each top-level `{…}`
|
||||
* is matched as a unit and only its own depth-1 fields are read; a nested
|
||||
* `children:` array, a `meta: {…}` and a lazy `component: () => import(…)`
|
||||
* are stepped over rather than searched.
|
||||
*
|
||||
* An entry whose path does not start with `/` is a child route, relative to a
|
||||
* parent this does not compose, and is not a destination on its own.
|
||||
*/
|
||||
export function parseVueRoutes(content: string): VueRouteEntry[] {
|
||||
if (!ROUTER_FACTORY.test(content) && !ROUTES_ARRAY.test(content)) return [];
|
||||
const safe = stripCommentsForRegex(content, 'typescript');
|
||||
const out: VueRouteEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
const arrays = /\broutes\s*[:=]\s*\[/g;
|
||||
let a: RegExpExecArray | null;
|
||||
while ((a = arrays.exec(safe)) !== null) {
|
||||
const open = a.index + a[0].length - 1;
|
||||
const close = matchBracket(safe, open);
|
||||
if (close < 0) continue;
|
||||
for (const obj of topLevelObjects(safe, open + 1, close)) {
|
||||
const fields = readFields(safe, obj.start, obj.end);
|
||||
const pathField = fields.get('path');
|
||||
if (!pathField) continue;
|
||||
const path = readStringAt(pathField.text.trimStart(), 0);
|
||||
if (path === null || !path.startsWith('/')) continue;
|
||||
const componentField = fields.get('component') ?? fields.get('components');
|
||||
if (!componentField) continue; // no component in the entry → not a route object
|
||||
const component = componentName(componentField.text);
|
||||
if (!component) continue;
|
||||
const nameField = fields.get('name');
|
||||
const name = nameField ? readStringAt(nameField.text.trimStart(), 0) : null;
|
||||
const line = safe.slice(0, pathField.at).split('\n').length;
|
||||
const key = `${path} ${name ?? ''}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({ path, name, component, line });
|
||||
}
|
||||
arrays.lastIndex = close;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The component an entry names: an identifier, or the file a lazy import names. */
|
||||
function componentName(value: string): string | null {
|
||||
const lazy = /\bimport\s*\(\s*['"`]([^'"`]+)['"`]/.exec(value);
|
||||
if (lazy) return (lazy[1]!.split('/').pop() ?? '').replace(/\.\w+$/, '') || null;
|
||||
const ident = /^\s*([A-Z][A-Za-z0-9_]*)\s*$/.exec(value);
|
||||
return ident?.[1] ?? null;
|
||||
}
|
||||
|
||||
function languageForFile(filePath: string): Language {
|
||||
if (filePath.endsWith('.vue')) return 'vue';
|
||||
if (/\.(?:ts|mts|cts)$/.test(filePath)) return 'typescript';
|
||||
return 'javascript';
|
||||
}
|
||||
|
||||
/** The id a config-declared route carries — a verbatim reconstruction, so the table can recognise its own. */
|
||||
function routeId(filePath: string, line: number, path: string): string {
|
||||
return `route:${filePath}:${line}:${path}:vue`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Route table — by path, and by name
|
||||
// =============================================================================
|
||||
|
||||
/** One app's routes, by path and — Vue's own idiom — by name. */
|
||||
export interface VueAppRoutes extends RouteTable {
|
||||
/** `login` → the route node, for `router.push({ name: 'login' })`. */
|
||||
byName: Map<string, Node>;
|
||||
}
|
||||
|
||||
export type VueRouteTable = RootedRouteTable<VueAppRoutes>;
|
||||
|
||||
/** True for a route node this resolver emitted, and no other. */
|
||||
function isVueConfigRoute(node: Node): boolean {
|
||||
return node.id === routeId(node.filePath, node.startLine, node.name);
|
||||
}
|
||||
|
||||
/** True for a Nuxt page route `frameworks/vue.ts` emitted. */
|
||||
function isNuxtPage(node: Node): boolean {
|
||||
return (
|
||||
node.language === 'vue' &&
|
||||
node.filePath.includes('/pages/') &&
|
||||
node.id === `route:${node.filePath}:${node.name}:1`
|
||||
);
|
||||
}
|
||||
|
||||
const tables = new WeakMap<ResolutionContext, VueRouteTable>();
|
||||
|
||||
export function vueRouteTable(context: ResolutionContext): VueRouteTable {
|
||||
const all = context.getNodesByKind('route');
|
||||
const cached = tables.get(context);
|
||||
if (cached && cached.source === all) return cached;
|
||||
const byRoot = new Map<string, VueAppRoutes>();
|
||||
const configFiles = new Map<string, { root: string; nodes: Node[] }>();
|
||||
const tableAt = (root: string): VueAppRoutes => {
|
||||
let t = byRoot.get(root);
|
||||
if (!t) byRoot.set(root, (t = { source: all, exact: new Map(), dynamic: [], byName: new Map() }));
|
||||
return t;
|
||||
};
|
||||
for (const node of all) {
|
||||
const config = isVueConfigRoute(node);
|
||||
if (!config && !isNuxtPage(node)) continue;
|
||||
if (!node.name.startsWith('/')) continue;
|
||||
const root = appRootFor(node.filePath);
|
||||
addRouteTo(tableAt(root), node.name, node);
|
||||
if (config) {
|
||||
const group = configFiles.get(node.filePath);
|
||||
if (group) group.nodes.push(node);
|
||||
else configFiles.set(node.filePath, { root, nodes: [node] });
|
||||
}
|
||||
}
|
||||
// A route's NAME is not persisted on the node, so the config files its own
|
||||
// route nodes came from are re-read with the same parser `extract` used.
|
||||
for (const [filePath, group] of configFiles) {
|
||||
const content = context.readFile(filePath);
|
||||
if (!content) continue;
|
||||
const byName = tableAt(group.root).byName;
|
||||
const byPath = new Map(group.nodes.map((n) => [n.name, n]));
|
||||
for (const entry of parseVueRoutes(content)) {
|
||||
if (!entry.name) continue;
|
||||
const node = byPath.get(entry.path);
|
||||
if (node && !byName.has(entry.name)) byName.set(entry.name, node);
|
||||
}
|
||||
}
|
||||
const table: VueRouteTable = { source: all, byRoot };
|
||||
tables.set(context, table);
|
||||
return table;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Navigation calls
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* `router.push` / `.replace` (the Composition API), `$router.push` /
|
||||
* `.replace` (the Options API and templates), and Nuxt's `navigateTo`.
|
||||
*
|
||||
* As everywhere else, `push` and `replace` need a receiver that names a
|
||||
* router: an unqualified `push` is an array's.
|
||||
*/
|
||||
const NAV_CALL = /^\$?router\.(?:push|replace)$|^navigateTo$/;
|
||||
|
||||
/** The verb a navigation call name stands for, or null. */
|
||||
export function vueNavVerb(name: string): string | null {
|
||||
if (!NAV_CALL.test(name)) return null;
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot < 0 ? name : name.slice(dot + 1);
|
||||
}
|
||||
|
||||
/** The route name in a `{ name: 'login' }` destination, or null for anything else. */
|
||||
export function routeNameInExpression(expr: string): string | null {
|
||||
const args = expr.trim();
|
||||
if (args[0] !== '{') return null;
|
||||
const key = /\bname\s*:\s*['"`]/.exec(args);
|
||||
if (!key) return null;
|
||||
return readStringAt(args, key.index + key[0].length - 1);
|
||||
}
|
||||
|
||||
/** `{ path: '/', query }` — Vue's object destination, whose key is `path`, not `pathname`. */
|
||||
export function parseVuePathObject(expr: string): HrefLiteral | null {
|
||||
const args = expr.trim();
|
||||
if (args[0] !== '{') return null;
|
||||
const key = /\bpath\s*:\s*['"`]/.exec(args);
|
||||
if (!key) return null;
|
||||
return toHref(readStringAt(args, key.index + key[0].length - 1));
|
||||
}
|
||||
|
||||
/** True for the `calls` ref this resolver's `extract` emitted from a route to its component. */
|
||||
function isVueRouteRef(ref: UnresolvedRef): boolean {
|
||||
return ref.fromNodeId.startsWith('route:') && ref.fromNodeId.endsWith(':vue');
|
||||
}
|
||||
|
||||
/**
|
||||
* The component a route names — a `.vue` file's own component node, or a
|
||||
* component declared in a plain script. Nearest app root first; an ambiguous
|
||||
* name resolves to nothing rather than to an arbitrary one of several.
|
||||
*/
|
||||
function vueComponentNamed(name: string, fromFile: string, context: ResolutionContext): Node | null {
|
||||
const candidates = context
|
||||
.getNodesByName(name)
|
||||
.filter((n) => n.kind === 'component' || (n.kind === 'function' && n.filePath.endsWith('.vue')));
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0]!;
|
||||
const root = appRootFor(fromFile);
|
||||
const near = candidates.filter((n) => n.filePath.startsWith(root));
|
||||
return near.length === 1 ? near[0]! : null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// The resolver
|
||||
// =============================================================================
|
||||
|
||||
export const vueRouterResolver: FrameworkResolver = {
|
||||
name: 'vue-router',
|
||||
languages: [...ROUTE_LANGUAGES],
|
||||
|
||||
detect(context: ResolutionContext): boolean {
|
||||
return dependsOn(context, 'vue-router', 'nuxt', 'nuxt3');
|
||||
},
|
||||
|
||||
claimsReference(name: string): boolean {
|
||||
return NAV_CALL.test(name);
|
||||
},
|
||||
|
||||
extract(filePath: string, content: string): FrameworkExtractionResult {
|
||||
const entries = parseVueRoutes(content);
|
||||
if (entries.length === 0) return { nodes: [], references: [] };
|
||||
const language = languageForFile(filePath);
|
||||
const now = Date.now();
|
||||
const nodes: Node[] = [];
|
||||
const references: UnresolvedRef[] = [];
|
||||
for (const entry of entries) {
|
||||
const node: Node = {
|
||||
id: routeId(filePath, entry.line, entry.path),
|
||||
kind: 'route',
|
||||
name: entry.path,
|
||||
qualifiedName: `${filePath}::route:${entry.path}`,
|
||||
filePath,
|
||||
startLine: entry.line,
|
||||
endLine: entry.line,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
language,
|
||||
updatedAt: now,
|
||||
};
|
||||
nodes.push(node);
|
||||
if (entry.component) {
|
||||
// `calls`, not `references`, for the same reason Next.js binds a page
|
||||
// that way: a `references` candidate list is filtered to the ref's own
|
||||
// language family, and a router config is `.js` while the component it
|
||||
// names is `.vue` — so the right component was dropped and a same-named
|
||||
// `.js` function in a store was picked instead. `route-roots.ts` reads
|
||||
// a `calls` edge to a component as the page a screen renders.
|
||||
references.push({
|
||||
fromNodeId: node.id,
|
||||
referenceName: entry.component,
|
||||
referenceKind: 'calls',
|
||||
line: entry.line,
|
||||
column: 0,
|
||||
filePath,
|
||||
language,
|
||||
candidates: [entry.component],
|
||||
});
|
||||
}
|
||||
}
|
||||
return { nodes, references };
|
||||
},
|
||||
|
||||
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||
if (ref.referenceKind !== 'calls') return null;
|
||||
|
||||
// A route naming the component it renders — this resolver's own reference,
|
||||
// bound here rather than by name alone: a Vue app usually has a `.vue`
|
||||
// `Login` view AND a `login` action in a store, and only one of them is
|
||||
// the screen.
|
||||
if (isVueRouteRef(ref)) {
|
||||
const component = vueComponentNamed(ref.referenceName, ref.filePath, context);
|
||||
return component
|
||||
? { original: ref, targetNodeId: component.id, confidence: 0.95, resolvedBy: 'framework' }
|
||||
: null;
|
||||
}
|
||||
|
||||
const verb = vueNavVerb(ref.referenceName);
|
||||
if (!verb) return null;
|
||||
if (!ROUTE_LANGUAGES.includes(ref.language)) return null;
|
||||
const routes = routesForFile(vueRouteTable(context), ref.filePath);
|
||||
if (!routes || routes.exact.size === 0) return null;
|
||||
const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? null;
|
||||
if (!lines) return null;
|
||||
|
||||
const arg = firstArgumentText(lines, ref.line, ref.column, verb);
|
||||
if (arg === null) return null;
|
||||
|
||||
// By name first — `{ name: 'login' }` is the idiom Vue apps are written in.
|
||||
const named = routeNameInExpression(arg);
|
||||
if (named !== null) {
|
||||
const target = routes.byName.get(named);
|
||||
return target
|
||||
? {
|
||||
original: ref,
|
||||
targetNodeId: target.id,
|
||||
confidence: 0.95,
|
||||
resolvedBy: 'framework',
|
||||
edgeKind: 'navigates',
|
||||
metadata: { href: named, navMethod: verb, by: 'name' },
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
// Otherwise a path, read exactly as every other framework reads one.
|
||||
let href = parseHrefExpression(arg) ?? parseVuePathObject(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, verb, start);
|
||||
}
|
||||
if (!href) return null;
|
||||
// Every arm of a conditional destination is somewhere this call goes; the
|
||||
// first is this reference's resolution and the rest ride as `alsoTargets`.
|
||||
const targets = destinationsForHref(href, routes);
|
||||
const target = targets[0];
|
||||
if (!target) return null;
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: target.node.id,
|
||||
...(targets.length > 1
|
||||
? { alsoTargets: targets.slice(1).map((t) => ({ targetNodeId: t.node.id, metadata: { href: t.href.display, navMethod: verb } })) }
|
||||
: {}),
|
||||
confidence: 0.95,
|
||||
resolvedBy: 'framework',
|
||||
edgeKind: 'navigates',
|
||||
metadata: { href: target.href.display, navMethod: verb },
|
||||
};
|
||||
},
|
||||
};
|
||||
+12
-5
@@ -1061,7 +1061,7 @@ export class ReferenceResolver {
|
||||
* Create edges from resolved references
|
||||
*/
|
||||
createEdges(resolved: ResolvedRef[]): Edge[] {
|
||||
return resolved.map((ref) => {
|
||||
return resolved.flatMap((ref) => {
|
||||
// `function_ref` (#756) is internal-only: it persists as a `references`
|
||||
// edge (the registration site depends on the callback), distinguishable
|
||||
// by metadata.resolvedBy === 'function-ref'. callers/impact already
|
||||
@@ -1097,14 +1097,21 @@ export class ReferenceResolver {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// One reference can name several targets — a navigation whose
|
||||
// destination is a conditional reaches every arm. Each becomes its own
|
||||
// edge, sharing this resolution's kind and confidence.
|
||||
const targets = [
|
||||
{ targetNodeId: ref.targetNodeId, metadata: ref.metadata },
|
||||
...(ref.alsoTargets ?? []),
|
||||
];
|
||||
return targets.map((t) => ({
|
||||
source: ref.original.fromNodeId,
|
||||
target: ref.targetNodeId,
|
||||
target: t.targetNodeId,
|
||||
kind,
|
||||
line: ref.original.line,
|
||||
column: ref.original.column,
|
||||
metadata: {
|
||||
...(ref.metadata ?? {}),
|
||||
...(t.metadata ?? {}),
|
||||
confidence: ref.confidence,
|
||||
resolvedBy: ref.resolvedBy,
|
||||
// The ORIGINAL reference text (and kind, when edge-kind promotion
|
||||
@@ -1125,7 +1132,7 @@ export class ReferenceResolver {
|
||||
// exactly the edges this feature added.
|
||||
...(ref.original.referenceKind === 'function_ref' ? { fnRef: true } : {}),
|
||||
},
|
||||
};
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ 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 { nextRouteTable, destinationsForHref } from './frameworks/nextjs';
|
||||
import { enclosingFn, makeLineAt } from './synth-utils';
|
||||
|
||||
const JSX_FILE = /\.(?:[cm]?[jt]sx?|mdx)$/;
|
||||
@@ -80,22 +80,24 @@ export async function nextLinkEdges(ctx: ResolutionContext, onYield: MaybeYield)
|
||||
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}` },
|
||||
});
|
||||
// A destination written as a choice names one route per arm, and the
|
||||
// user reaches every one of them — each is drawn.
|
||||
for (const { node: page, href: arm } of destinationsForHref(href, table)) {
|
||||
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: arm.display, navMethod: tag === 'a' ? 'a' : 'link', registeredAt: `${file}:${line}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* React Router — navigation written as markup.
|
||||
*
|
||||
* <Link to="/placeorder">Continue</Link>
|
||||
* <NavLink to="/profile">Profile</NavLink>
|
||||
* <Navigate to="/login" replace />
|
||||
* <LinkContainer to="/payment">…</LinkContainer> // react-router-bootstrap
|
||||
* <Link to={{ pathname: '/shipping' }}>…</Link> // v5's object form
|
||||
*
|
||||
* A JSX attribute is not a call, so the extractor records no reference for it
|
||||
* and the resolver in `frameworks/react-router.ts` — which binds
|
||||
* `history.push` and `navigate` — never sees it. This pass reads every `to`
|
||||
* attribute out of the source, attributes it to the component (the innermost
|
||||
* function) it is written in, matches it against the React Router route
|
||||
* table, and synthesizes one `navigates` edge from the component to the
|
||||
* route. That is the edge the Screens view walks back from, so a screen's
|
||||
* links are its transitions exactly as its pushes are.
|
||||
*
|
||||
* Edges are `provenance:'heuristic'`, `synthesizedBy:'react-router-link'`,
|
||||
* with the path as written and `registeredAt` = the JSX site. A computed
|
||||
* target (`to={next}`) is nothing; a path no route serves is nothing; a
|
||||
* relative `to` is nothing, because it is resolved against a nesting this
|
||||
* scan does not read. Nothing here runs on a project with no React Router
|
||||
* routes.
|
||||
*
|
||||
* This is `next-router-synthesizer.ts`'s twin — the same shape over the other
|
||||
* attribute (`to`, not `href`) and the other table.
|
||||
*/
|
||||
|
||||
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 { parseHrefExpression, routesForFile, toHref, type HrefLiteral } from './frameworks/expo-router';
|
||||
import { matchBracket } from './frameworks/object-literal';
|
||||
import { destinationsForHref } from './frameworks/nextjs';
|
||||
import { reactRouterTable } from './frameworks/react-router';
|
||||
import { enclosingFn, makeLineAt } from './synth-utils';
|
||||
|
||||
const JSX_FILE = /\.(?:[cm]?[jt]sx?|mdx)$/;
|
||||
|
||||
/** The tags that carry a route as a `to` attribute, the attribute anywhere in the tag. */
|
||||
const LINK_TAG = /<(Link|NavLink|Navigate|LinkContainer|IndexLinkContainer)\b([^>]*?)\bto\s*=\s*(?:"([^"]*)"|'([^']*)'|(?=\{))/g;
|
||||
|
||||
/** A tag this pass could possibly match — the cheap prefilter before stripping comments. */
|
||||
const HAS_LINK_TAG = /<(?:Link|NavLink|Navigate|LinkContainer|IndexLinkContainer)\b/;
|
||||
|
||||
/** Links a single component may carry before it is a navigation menu, not a decision. */
|
||||
const MAX_LINKS_PER_COMPONENT = 24;
|
||||
|
||||
export async function reactRouterLinkEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
|
||||
const table = reactRouterTable(ctx);
|
||||
if (table.byRoot.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;
|
||||
const routes = routesForFile(table, file);
|
||||
if (!routes || routes.exact.size === 0) continue;
|
||||
if ((++scanned & 63) === 0) await onYield();
|
||||
const source = ctx.readFile(file);
|
||||
if (!source || !HAS_LINK_TAG.test(source)) 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]!;
|
||||
const quoted: string | null = m[3] ?? m[4] ?? null;
|
||||
let href: HrefLiteral | null;
|
||||
if (quoted !== null) href = toHref(quoted);
|
||||
else {
|
||||
// `to={…}` holds an EXPRESSION, and it is read with the same reader
|
||||
// the `history.push(…)` path uses — a string, a template, a
|
||||
// `{ pathname }` object, or a conditional whose arms agree
|
||||
// (`to={redirect ? `/register?redirect=${redirect}` : '/register'}`,
|
||||
// which is how react-router apps write a link that carries state).
|
||||
// Peeking at the first character instead missed every one of those.
|
||||
const at = m.index + m[0].length;
|
||||
const close = matchBracket(safe, at);
|
||||
if (close < 0) continue;
|
||||
href = parseHrefExpression(safe.slice(at + 1, close));
|
||||
}
|
||||
// A relative `to` is resolved against the route this markup renders
|
||||
// under — a nesting this scan does not read, so it is not a destination.
|
||||
if (!href || !href.path.startsWith('/')) continue;
|
||||
const line = lineOf(m.index);
|
||||
const component = enclosingFn(nodes, line);
|
||||
if (!component) continue;
|
||||
// A destination written as a choice names one route per arm, and the
|
||||
// user reaches every one of them — each is drawn.
|
||||
for (const { node: route, href: arm } of destinationsForHref(href, routes)) {
|
||||
const key = `${component.id}>${route.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: route.id,
|
||||
kind: 'navigates',
|
||||
line,
|
||||
provenance: 'heuristic',
|
||||
metadata: {
|
||||
synthesizedBy: 'react-router-link',
|
||||
href: arm.display,
|
||||
navMethod: tag === 'Navigate' ? 'navigate' : 'link',
|
||||
registeredAt: `${file}:${line}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* SvelteKit — navigation written as markup.
|
||||
*
|
||||
* <a href="/login">Sign in</a>
|
||||
* <a href="/profile/@{user.username}">…</a>
|
||||
* <a href={`/article/${slug}`}>…</a>
|
||||
*
|
||||
* SvelteKit has no link component: an ordinary `<a href>` IS the navigation,
|
||||
* intercepted by the router. So a page's outgoing links are plain markup, the
|
||||
* extractor records no reference for them, and the resolver in
|
||||
* `frameworks/sveltekit-router.ts` — which binds `goto` and `redirect` —
|
||||
* never sees them. This pass reads every internal `<a href>` out of the
|
||||
* source, attributes it to the component (the innermost function) it is
|
||||
* written in, matches it against the SvelteKit route table, and synthesizes
|
||||
* one `navigates` edge from the component to the page.
|
||||
*
|
||||
* Edges are `provenance:'heuristic'`, `synthesizedBy:'sveltekit-link'`, with
|
||||
* the href as written and `registeredAt` = the markup site. An external href
|
||||
* is a link out of the site, not a transition; a computed one is nothing; a
|
||||
* path no page serves is nothing. Nothing here runs on a project with no
|
||||
* SvelteKit pages.
|
||||
*
|
||||
* This is `next-router-synthesizer.ts`'s twin over `<a href>` alone — Next
|
||||
* reads `<Link href>` too, and Svelte has no such component.
|
||||
*
|
||||
* A second pass here binds a route to the `+page.svelte` that serves it
|
||||
* (`svelteKitPageComponentEdges`): the route node and the component sit in the
|
||||
* same file, but nothing joined them, so a SvelteKit page had no body for the
|
||||
* Steps picture to walk and opened as a lone box.
|
||||
*
|
||||
* The other half of the join — a page and the `+page.server.js` beside it — is
|
||||
* `callback-synthesizer.ts`'s `svelteKitLoadEdges`, which already existed: a
|
||||
* SvelteKit page and its loader are two halves of one route joined by the file
|
||||
* system rather than by a call, and without that join a page's own auth guard
|
||||
* (`redirect(302, '/login')` in its loader) belongs to no screen at all.
|
||||
*/
|
||||
|
||||
import type { Edge, Node } from '../types';
|
||||
import type { ResolutionContext } from './types';
|
||||
import type { MaybeYield } from './cooperative-yield';
|
||||
import { isTestPath } from '../search/query-utils';
|
||||
import { HOLE, readStringAt, routesForFile, toHref } from './frameworks/expo-router';
|
||||
import { destinationsForHref } from './frameworks/nextjs';
|
||||
import { svelteKitTable } from './frameworks/sveltekit-router';
|
||||
import { enclosingFn, makeLineAt } from './synth-utils';
|
||||
|
||||
const MARKUP_FILE = /\.svelte$/;
|
||||
|
||||
/** `<a … href=…`, the attribute anywhere in the tag, quoted or bound. */
|
||||
const LINK_TAG = /<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 svelteKitLinkEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
|
||||
const table = svelteKitTable(ctx);
|
||||
if (table.byRoot.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 (!MARKUP_FILE.test(file) || isTestPath(file)) continue;
|
||||
const routes = routesForFile(table, file);
|
||||
if (!routes || routes.exact.size === 0) continue;
|
||||
if ((++scanned & 63) === 0) await onYield();
|
||||
const source = ctx.readFile(file);
|
||||
if (!source || !source.includes('href')) continue;
|
||||
const nodes = ctx.getNodesInFile(file);
|
||||
const lineOf = makeLineAt(source, 1);
|
||||
LINK_TAG.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = LINK_TAG.exec(source)) !== null) {
|
||||
let literal: string | null = m[2] ?? m[3] ?? null;
|
||||
if (literal === null) {
|
||||
// `href={…}`: a string or a template with holes.
|
||||
const at = m.index + m[0].length;
|
||||
const ch = source[at];
|
||||
if (ch === '"' || ch === "'" || ch === '`') literal = readStringAt(source, at);
|
||||
}
|
||||
if (literal === null) continue;
|
||||
// An external href is a link out of the site, not a transition. A
|
||||
// Svelte `{expr}` inside a quoted attribute is an interpolation, so it
|
||||
// becomes the same hole a template literal's `${…}` does — which is how
|
||||
// `/profile/@{user.username}` reaches the `/profile/@:user` page.
|
||||
if (!literal.startsWith('/')) continue;
|
||||
const href = toHref(literal.replace(/\{[^}]*\}/g, HOLE));
|
||||
if (!href) continue;
|
||||
const line = lineOf(m.index);
|
||||
const component = enclosingFn(nodes, line);
|
||||
if (!component) continue;
|
||||
// A destination written as a choice names one route per arm, and the
|
||||
// user reaches every one of them — each is drawn.
|
||||
for (const { node: page, href: arm } of destinationsForHref(href, routes)) {
|
||||
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: 'sveltekit-link',
|
||||
href: arm.display,
|
||||
navMethod: 'a',
|
||||
registeredAt: `${file}:${line}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// A route and the page that serves it
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* One `calls` edge from each `+page.svelte` route to the component in its own
|
||||
* file — the page that renders when a navigation lands there.
|
||||
*
|
||||
* Every other framework's resolver names this at extraction: a Next page route
|
||||
* points at the file's default export, a React Router route at the component
|
||||
* the markup named. SvelteKit's route is derived from the file's PATH, and its
|
||||
* component has no name of its own to reference (every page file's component
|
||||
* is called `+page`), so the two are joined here, where both are already in
|
||||
* hand and the match is the file itself rather than a name.
|
||||
*
|
||||
* With it, `route-roots.ts` reads the page as the route's root: the Steps
|
||||
* picture starts at the page instead of at an empty box, and the Screens walk
|
||||
* attributes a navigation to the screen whose component holds it rather than
|
||||
* falling back to the file it was written in.
|
||||
*/
|
||||
export async function svelteKitPageComponentEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
|
||||
const table = svelteKitTable(ctx);
|
||||
if (table.byRoot.size === 0) return [];
|
||||
const edges: Edge[] = [];
|
||||
let scanned = 0;
|
||||
const pages = new Set<Node>();
|
||||
for (const routes of table.byRoot.values()) for (const page of routes.exact.values()) pages.add(page);
|
||||
for (const page of pages) {
|
||||
if ((++scanned & 31) === 0) await onYield();
|
||||
const component = ctx.getNodesInFile(page.filePath).find((n) => n.kind === 'component');
|
||||
if (!component) continue;
|
||||
edges.push({
|
||||
source: page.id,
|
||||
target: component.id,
|
||||
kind: 'calls',
|
||||
line: component.startLine,
|
||||
provenance: 'heuristic',
|
||||
metadata: { synthesizedBy: 'sveltekit-page', registeredAt: page.filePath },
|
||||
});
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* TanStack Router — navigation written as markup.
|
||||
*
|
||||
* <Link to="/dashboard/invoices/$invoiceId" params={{ invoiceId: 3 }}>…</Link>
|
||||
* <Link to="/login">Sign in</Link>
|
||||
* <Navigate to="/dashboard" />
|
||||
*
|
||||
* A JSX attribute is not a call, so the extractor records no reference for it
|
||||
* and the resolver in `frameworks/tanstack-router.ts` — which binds
|
||||
* `navigate({ to })` and `redirect({ to })` — never sees it. This pass reads
|
||||
* every `to` out of the source, attributes it to the component (the innermost
|
||||
* function) it is written in, matches it against the TanStack route table, and
|
||||
* synthesizes one `navigates` edge from the component to the route.
|
||||
*
|
||||
* What makes this different from React Router's identical-looking `<Link to>`:
|
||||
* TanStack's `to` is the route PATTERN and the values ride beside it in
|
||||
* `params`, so `to="/posts/$postId"` names the route rather than an address —
|
||||
* and it is normalised the same way a route name is instead of being read as a
|
||||
* URL. A `<Link from=…>` with no `to` is a relative link within the route it
|
||||
* is already on, and names no destination of its own.
|
||||
*
|
||||
* Edges are `provenance:'heuristic'`, `synthesizedBy:'tanstack-link'`, with the
|
||||
* destination as written and `registeredAt` = the JSX site. A computed `to` is
|
||||
* nothing; a pattern no route serves is nothing. Nothing here runs on a project
|
||||
* with no TanStack routes.
|
||||
*/
|
||||
|
||||
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, routesForFile } from './frameworks/expo-router';
|
||||
import { destinationsForHref } from './frameworks/nextjs';
|
||||
import { tanstackDestination, tanstackTable } from './frameworks/tanstack-router';
|
||||
import { enclosingFn, makeLineAt } from './synth-utils';
|
||||
|
||||
const JSX_FILE = /\.(?:[cm]?[jt]sx?)$/;
|
||||
|
||||
/** `<Link … to=` / `<Navigate … to=`, the attribute anywhere in the tag. */
|
||||
const LINK_TAG = /<(Link|Navigate)\b([^>]*?)\bto\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*)/g;
|
||||
|
||||
/** A tag this pass could possibly match — the cheap prefilter. */
|
||||
const HAS_LINK_TAG = /<(?:Link|Navigate)\b/;
|
||||
|
||||
/** Links a single component may carry before it is a navigation menu, not a decision. */
|
||||
const MAX_LINKS_PER_COMPONENT = 24;
|
||||
|
||||
export async function tanstackLinkEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
|
||||
const table = tanstackTable(ctx);
|
||||
if (table.byRoot.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;
|
||||
const routes = routesForFile(table, file);
|
||||
if (!routes || routes.exact.size === 0) continue;
|
||||
if ((++scanned & 63) === 0) await onYield();
|
||||
const source = ctx.readFile(file);
|
||||
if (!source || !HAS_LINK_TAG.test(source)) 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) {
|
||||
let literal: string | null = m[3] ?? m[4] ?? null;
|
||||
if (literal === null) {
|
||||
// `to={…}`: a string or a template.
|
||||
const at = m.index + m[0].length;
|
||||
const ch = safe[at];
|
||||
if (ch === '"' || ch === "'" || ch === '`') literal = readStringAt(safe, at);
|
||||
}
|
||||
if (literal === null) continue;
|
||||
const href = tanstackDestination(JSON.stringify(literal));
|
||||
if (!href) continue;
|
||||
const line = lineOf(m.index);
|
||||
const component = enclosingFn(nodes, line);
|
||||
if (!component) continue;
|
||||
// A destination written as a choice names one route per arm, and the
|
||||
// user reaches every one of them — each is drawn.
|
||||
for (const { node: route, href: arm } of destinationsForHref(href, routes)) {
|
||||
const key = `${component.id}>${route.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: route.id,
|
||||
kind: 'navigates',
|
||||
line,
|
||||
provenance: 'heuristic',
|
||||
metadata: {
|
||||
synthesizedBy: 'tanstack-link',
|
||||
href: arm.display,
|
||||
navMethod: m[1] === 'Navigate' ? 'navigate' : 'link',
|
||||
registeredAt: `${file}:${line}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
@@ -52,6 +52,19 @@ export interface ResolvedRef {
|
||||
edgeKind?: EdgeKind;
|
||||
/** Extra metadata the strategy wants persisted on the edge (`href`, …). */
|
||||
metadata?: Record<string, unknown>;
|
||||
/**
|
||||
* The OTHER targets, when one reference names several.
|
||||
*
|
||||
* A navigation whose destination is a conditional reaches every arm —
|
||||
* `!isAdmin ? keyword ? '/search/…' : '/page/…' : '/admin/…'` is one call
|
||||
* and three screens — and drawing only the first would hide two places the
|
||||
* code goes. `createEdges` fans these out into an edge apiece, sharing this
|
||||
* resolution's kind and confidence; each carries its own metadata.
|
||||
*
|
||||
* The reference itself still resolves ONCE, so the resolution pipeline's
|
||||
* bookkeeping — cleanup by row id, counts, re-resolution — is unchanged.
|
||||
*/
|
||||
alsoTargets?: { targetNodeId: string; metadata?: Record<string, unknown> }[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Vue Router — navigation written as markup.
|
||||
*
|
||||
* <router-link to="/login">Sign in</router-link>
|
||||
* <RouterLink :to="{ name: 'profile', params: { username } }">…</RouterLink>
|
||||
* <NuxtLink to="/dashboard">…</NuxtLink> // Nuxt
|
||||
* <router-link :to="`/article/${slug}`">…</router-link>
|
||||
*
|
||||
* A template attribute is not a call, so the extractor records no reference
|
||||
* for it and the resolver in `frameworks/vue-router.ts` — which binds
|
||||
* `router.push` and `navigateTo` — never sees it. This pass reads every `to`
|
||||
* out of the source, attributes it to the component (the innermost function)
|
||||
* it is written in, matches it against the Vue route table by NAME or by
|
||||
* path, and synthesizes one `navigates` edge from the component to the route.
|
||||
*
|
||||
* The bound form (`:to`) is what carries an object or a template, and it is
|
||||
* the common one in a Vue template — so both spellings are read, and both a
|
||||
* `{ name: … }` and a `{ path: … }` destination resolve, exactly as they do
|
||||
* from a `router.push`.
|
||||
*
|
||||
* Edges are `provenance:'heuristic'`, `synthesizedBy:'vue-router-link'`, with
|
||||
* the destination as written and `registeredAt` = the template site. A
|
||||
* computed `:to="target"` is nothing; a name or path nothing declares is
|
||||
* nothing. Nothing here runs on a project with no Vue routes.
|
||||
*/
|
||||
|
||||
import type { Edge, Node } from '../types';
|
||||
import type { ResolutionContext } from './types';
|
||||
import type { MaybeYield } from './cooperative-yield';
|
||||
import { isTestPath } from '../search/query-utils';
|
||||
import { readStringAt, routesForFile, toHref } from './frameworks/expo-router';
|
||||
import { destinationsForHref } from './frameworks/nextjs';
|
||||
import { parseVuePathObject, routeNameInExpression, vueRouteTable } from './frameworks/vue-router';
|
||||
import { enclosingFn, makeLineAt } from './synth-utils';
|
||||
|
||||
const TEMPLATE_FILE = /\.(?:vue|[cm]?[jt]sx?)$/;
|
||||
|
||||
/** `<router-link … to=` / `<RouterLink … :to=` / `<NuxtLink … to=`, the attribute anywhere in the tag. */
|
||||
const LINK_TAG = /<(router-link|RouterLink|NuxtLink|nuxt-link)\b([^>]*?)\s:?to\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
||||
|
||||
/** A tag this pass could possibly match — the cheap prefilter. */
|
||||
const HAS_LINK_TAG = /<(?:router-link|RouterLink|NuxtLink|nuxt-link)\b/;
|
||||
|
||||
/** Links a single component may carry before it is a navigation menu, not a decision. */
|
||||
const MAX_LINKS_PER_COMPONENT = 24;
|
||||
|
||||
export async function vueRouterLinkEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
|
||||
const table = vueRouteTable(ctx);
|
||||
if (table.byRoot.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 (!TEMPLATE_FILE.test(file) || isTestPath(file)) continue;
|
||||
const routes = routesForFile(table, file);
|
||||
if (!routes || routes.exact.size === 0) continue;
|
||||
if ((++scanned & 63) === 0) await onYield();
|
||||
const source = ctx.readFile(file);
|
||||
if (!source || !HAS_LINK_TAG.test(source)) continue;
|
||||
const nodes = ctx.getNodesInFile(file);
|
||||
const lineOf = makeLineAt(source, 1);
|
||||
LINK_TAG.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = LINK_TAG.exec(source)) !== null) {
|
||||
// A bound `:to` holds an expression; a plain `to` holds a literal path.
|
||||
const value = (m[3] ?? m[4] ?? '').trim();
|
||||
if (value.length === 0) continue;
|
||||
const line = lineOf(m.index);
|
||||
const component = enclosingFn(nodes, line);
|
||||
if (!component) continue;
|
||||
const bound = m[0].includes(':to');
|
||||
const named = bound ? routeNameInExpression(value) : null;
|
||||
const byName = named === null ? undefined : routes.byName.get(named);
|
||||
// A `{ name }` destination names exactly one route; a path may be
|
||||
// written as a choice, and then every arm is drawn.
|
||||
let destinations: { node: Node; display: string }[];
|
||||
if (byName && named !== null) destinations = [{ node: byName, display: named }];
|
||||
else {
|
||||
const href = bound ? (parseVuePathObject(value) ?? toHref(readStringAt(value, 0))) : toHref(value);
|
||||
if (!href || !href.path.startsWith('/')) continue;
|
||||
destinations = destinationsForHref(href, routes).map((d) => ({ node: d.node, display: d.href.display }));
|
||||
}
|
||||
for (const { node: target, display } of destinations) {
|
||||
const key = `${component.id}>${target.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: target.id,
|
||||
kind: 'navigates',
|
||||
line,
|
||||
provenance: 'heuristic',
|
||||
metadata: {
|
||||
synthesizedBy: 'vue-router-link',
|
||||
href: display,
|
||||
navMethod: 'link',
|
||||
...(named !== null && routes.byName.has(named) ? { by: 'name' } : {}),
|
||||
registeredAt: `${file}:${line}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
@@ -157,12 +157,38 @@ const SHARED_CHROME_MIN = 3;
|
||||
// The endpoint
|
||||
// =============================================================================
|
||||
|
||||
/** True when the edge's destination is written at the line the edge points to. */
|
||||
function writtenHere(edge: Edge, holder: Node): boolean {
|
||||
const at = (edge.metadata as Record<string, unknown> | undefined)?.registeredAt;
|
||||
if (typeof at !== 'string') return edge.provenance !== 'heuristic';
|
||||
return at === `${holder.filePath}:${edge.line}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A route a user can be ON, as opposed to one a request goes to.
|
||||
*
|
||||
* Every server framework names its routes with the HTTP method that reaches
|
||||
* them — `GET /api/orders`, `POST /api/users/login`, `USE /api/products`,
|
||||
* `ANY /api/users`, `GET *` — while a screen is named by its path alone.
|
||||
* Nuxt is the one framework that names an endpoint like a page, so its
|
||||
* `server/api/` files are excluded by path instead.
|
||||
*
|
||||
* Without this the tab drew a store's thirty Express endpoints beside its
|
||||
* nineteen pages: boxes nothing can navigate to and nothing leaves, in a
|
||||
* picture that is only about navigation, pushing the pages that ARE
|
||||
* unreachable into a row hundreds of boxes wide. Every route still appears on
|
||||
* Entry points, which is the list of what a request or a user can arrive at.
|
||||
*/
|
||||
function isScreenRoute(route: Node): boolean {
|
||||
return route.name.startsWith('/') && !route.filePath.includes('/server/api/');
|
||||
}
|
||||
|
||||
export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<WireScreensPayload> {
|
||||
const started = Date.now();
|
||||
const stats = cg.getStats();
|
||||
const index = { lastIndexedAt: cg.getLastIndexedAt() ?? null, edges: stats.edgeCount, files: stats.fileCount };
|
||||
|
||||
const routes = cg.getNodesByKind('route');
|
||||
const routes = cg.getNodesByKind('route').filter(isScreenRoute);
|
||||
const routeIds = routes.map((r) => r.id);
|
||||
const navEdges = routeIds.length === 0 ? [] : cg.getIncomingEdgesTo(routeIds, ['navigates']);
|
||||
if (navEdges.length === 0) {
|
||||
@@ -183,14 +209,31 @@ export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<
|
||||
// A route standing in for its own inline handler binds to nothing here — a
|
||||
// walk back from a navigation cannot land on a registration site.
|
||||
const routeById = new Map(routes.map((r) => [r.id, r]));
|
||||
const routeByFile = new Map(routes.map((r) => [r.filePath, r.id]));
|
||||
// A file that declares exactly ONE route, for the fallback that says a
|
||||
// component belongs to the screen whose file defines it. A file holding
|
||||
// SEVERAL routes says nothing about which one a navigation belongs to —
|
||||
// an Express router file, or the `main.tsx` a code-based route tree is
|
||||
// written in, would otherwise hand every navigation in it to whichever
|
||||
// route happened to be declared last, and draw a root nav bar's links as
|
||||
// transitions out of an unrelated page.
|
||||
const routesPerFile = new Map<string, number>();
|
||||
for (const r of routes) routesPerFile.set(r.filePath, (routesPerFile.get(r.filePath) ?? 0) + 1);
|
||||
const routeByFile = new Map(routes.filter((r) => routesPerFile.get(r.filePath) === 1).map((r) => [r.filePath, r.id]));
|
||||
const roots = routeRoots(cg, routes);
|
||||
const componentOf = new Map<string, Node>();
|
||||
const screenOfComponent = new Map<string, string>();
|
||||
// Component → EVERY route it serves, not one of them. proshop renders
|
||||
// `HomeScreen` at `/`, `/search/:keyword`, `/page/:pageNumber` and
|
||||
// `/search/:keyword/page/:pageNumber`; keeping only the first route to claim
|
||||
// the component gave all four addresses' navigation to whichever `<Route>`
|
||||
// happened to be written first, and drew the home page as a screen you can
|
||||
// get to but never leave.
|
||||
const screenOfComponent = new Map<string, string[]>();
|
||||
for (const [routeId, root] of roots) {
|
||||
if (root.inline) continue;
|
||||
componentOf.set(routeId, root.node);
|
||||
if (!screenOfComponent.has(root.node.id)) screenOfComponent.set(root.node.id, routeId);
|
||||
const serves = screenOfComponent.get(root.node.id);
|
||||
if (serves) serves.push(routeId);
|
||||
else screenOfComponent.set(root.node.id, [routeId]);
|
||||
}
|
||||
const nodesById = cg.getNodesByIds([...componentOf.values()].map((n) => n.id).concat(navEdges.map((e) => e.source)));
|
||||
|
||||
@@ -226,8 +269,14 @@ export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<
|
||||
file: toPosix(holder.filePath),
|
||||
line: nav.line ?? holder.startLine,
|
||||
href: typeof meta.href === 'string' ? meta.href : target.name,
|
||||
method: nav.provenance === 'heuristic' ? 'return' : typeof meta.navMethod === 'string' ? meta.navMethod : 'push',
|
||||
when: nav.provenance === 'heuristic' ? '' : await whenAt(holder, nav),
|
||||
// How the destination got here. A synthesized edge whose `registeredAt`
|
||||
// is its OWN line had the destination written right there — a
|
||||
// `<Link to='/shipping'>` is markup, not a return value — so it keeps
|
||||
// its own verb. Only an edge whose destination came from somewhere else
|
||||
// (`expo-router-return`, where a helper returns the href and the push is
|
||||
// in another file) reads as `return`.
|
||||
method: writtenHere(nav, holder) && typeof meta.navMethod === 'string' ? meta.navMethod : nav.provenance === 'heuristic' ? 'return' : 'push',
|
||||
when: await whenAt(holder, nav),
|
||||
};
|
||||
|
||||
let starts = await attribute(cg, projectRoot, holder, screenOfComponent, routeByFile, nodesById);
|
||||
@@ -352,13 +401,14 @@ async function attribute(
|
||||
cg: CodeGraph,
|
||||
projectRoot: string,
|
||||
holder: Node,
|
||||
screenOfComponent: Map<string, string>,
|
||||
screenOfComponent: Map<string, string[]>,
|
||||
routeByFile: Map<string, string>,
|
||||
known: Map<string, Node>
|
||||
): Promise<Attribution[] | null> {
|
||||
// The holder IS a screen component: the transition starts on that screen.
|
||||
// The holder IS a screen component: the transition starts on that screen —
|
||||
// on each of them, when one component is rendered at several addresses.
|
||||
const own = screenOfComponent.get(holder.id);
|
||||
if (own) return [{ screenId: own, path: [{ node: holder, edge: null }] }];
|
||||
if (own) return own.map((screenId) => ({ screenId, path: [{ node: holder, edge: null }] }));
|
||||
|
||||
const parent = new Map<string, { prev: string | null; edge: Edge | null }>();
|
||||
parent.set(holder.id, { prev: null, edge: null });
|
||||
@@ -410,9 +460,10 @@ async function attribute(
|
||||
if (!caller || caller.kind === 'file' || caller.kind === 'route') continue;
|
||||
parent.set(e.source, { prev: e.target, edge: e });
|
||||
nodes.set(e.source, caller);
|
||||
const screen = screenOfComponent.get(caller.id);
|
||||
if (screen) {
|
||||
found.push({ screenId: screen, path: pathFrom(caller.id, parent, nodes) });
|
||||
const screens = screenOfComponent.get(caller.id);
|
||||
if (screens) {
|
||||
const path = pathFrom(caller.id, parent, nodes);
|
||||
for (const screenId of screens) found.push({ screenId, path });
|
||||
continue; // a screen is where the walk stops
|
||||
}
|
||||
nextIds.push(e.source);
|
||||
@@ -455,7 +506,11 @@ function collapseSharedChrome(starts: Attribution[], origins: Map<string, WireSc
|
||||
const out: Attribution[] = [];
|
||||
const collapsed = new Set<Attribution>();
|
||||
for (const [, group] of byFirstHop) {
|
||||
const screens = new Set(group.map((g) => g.screenId));
|
||||
// Counted by the screen COMPONENT the chain starts at, not by the address:
|
||||
// a top bar rendered by twelve different screens is chrome, while one
|
||||
// component serving four routes is one screen with four addresses, and
|
||||
// collapsing that would take the navigation away from all of them.
|
||||
const screens = new Set(group.map((g) => g.path[0]!.node.id));
|
||||
if (screens.size < SHARED_CHROME_MIN) continue;
|
||||
const head = group[0]!.path[1]!.node;
|
||||
const existing = origins.get(head.id);
|
||||
|
||||
Reference in New Issue
Block a user