mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
fix(react-router): nested, lazy and constant routes; per-app tsconfig aliases (#2165)
- Route tables compose nested paths: a child's `path` is relative to the
path-bearing routes around it (`children` of a data router, nested
`<Route>` elements), absolute ones reset. `lazy: () => import('./x')`
(object and JSX) renders the module's default export (`Component` export
as fallback) via a claimed `lazy-import:` ref. `path: paths.app.root.path`
keeps its parts on the node's signature and is named in postExtract from
the constant's object literal (found through the file's import). The
route keeps its extracted id; `isReactRouterRoute` accepts it. An element
wrapped in parentheses or a guard (`<ProtectedRoute>`, `<Suspense>`,
`*Provider`, `*Guard`) renders the first element inside.
- Import aliases: the tsconfig / jsconfig nearest the importing file that
declares `paths` is tried before the root's, so each monorepo app resolves
its own `@/*` / `~/*`.
A/B (edges removed / added): bulletproof-react -43/+367 (0 → 9 named,
linked routes; cross-app imports fixed), react-native-reusables -103/+193,
kit -184/+218, trpc -45/+71 (examples' `~/…` now their own), create-t3-turbo
-1/+13; excalidraw, halo, obytes, realworld byte-identical. #1348's nested
fixtures now expect composed paths (`/dashboard/settings`, `/data/prefs`).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Imports through a tsconfig or jsconfig path alias now use the config nearest the importing file, so each app in a monorepo resolves its own `@/…` or `~/…` alias. On bulletproof-react and trpc's examples, those imports used to fail or land in a different app.
|
||||
- React Router data-router routes are named by their full path: nested `children` paths are joined to their parent's, `path: paths.app.root.path` constants are read, and `lazy: () => import('./routes/x')` routes link to the module's component. A guard wrapper like `<ProtectedRoute>` no longer stands in for the page. On bulletproof-react, every route now has its real path and page.
|
||||
- In JavaScript, TypeScript and Vue, a call through a name destructured from a composable or custom hook, like `const { getDefaultActivityRoute } = useDefaultActivity()` or `const { t } = useI18n()`, now reaches the function the hook returns. These calls used to resolve to nothing. mealie, elk, halo and excalidraw each gained dozens to over a hundred such links.
|
||||
- In JavaScript and TypeScript, a module's top-level function, variable or type that the module doesn't export is no longer linked from other files. Every form of export still counts: the `export` keyword, `export { … }` lists, `export default`, and a default-exported object. On typeorm, test fixtures' local `ColumnMetadata` used to capture `import { ColumnMetadata } from "typeorm"`; on sveltekit, an unexported `resolve` collected other files' `resolve(…)` calls; on zod, a local `z` collected the `zod` package imports.
|
||||
- Flow-typed JavaScript files (those with `@flow` in their header comment) are now parsed like TypeScript, so type annotations like `render(): React.Node` no longer cut classes short. This mostly affects React Native code: on React Native's own libraries the graph gained over 8,000 links, and component classes keep their methods. Files without the pragma are unchanged. Re-index after upgrading.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* A data router's nested, lazy, constant-named routes (bulletproof-react):
|
||||
* - a child's `path` is relative to its parent's — `discussions` under `/app`
|
||||
* is `/app/discussions`;
|
||||
* - `lazy: () => import('./routes/x')` renders the module's default export;
|
||||
* - `path: paths.app.root.path` reads the constant object where it is
|
||||
* declared — here through a monorepo app's own tsconfig `@/*` alias, which
|
||||
* now resolves from the tsconfig nearest the importing file;
|
||||
* - `element: (<ProtectedRoute><AppRoot /></ProtectedRoute>)` renders AppRoot.
|
||||
*/
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
let root = '';
|
||||
let cg: CodeGraph;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-nested-'));
|
||||
const app = (rel: string) => `apps/web/${rel}`;
|
||||
const files: Record<string, string> = {
|
||||
'package.json': JSON.stringify({ name: 'mono', private: true }),
|
||||
[app('package.json')]: JSON.stringify({ name: 'web', dependencies: { react: '^18', 'react-router': '^7' } }),
|
||||
[app('tsconfig.json')]: JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['./src/*'] } } }),
|
||||
[app('src/config/paths.ts')]: `export const paths = {
|
||||
home: { path: '/', getHref: () => '/' },
|
||||
app: {
|
||||
root: { path: '/app', getHref: () => '/app' },
|
||||
discussions: { path: 'discussions', getHref: () => '/app/discussions' },
|
||||
},
|
||||
} as const;
|
||||
`,
|
||||
[app('src/app/router.tsx')]: `import { createBrowserRouter } from 'react-router';
|
||||
import { paths } from '@/config/paths';
|
||||
import { ProtectedRoute } from '@/lib/auth';
|
||||
import AppRoot from './routes/app/root';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: paths.home.path, lazy: () => import('./routes/landing') },
|
||||
{
|
||||
path: paths.app.root.path,
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<AppRoot />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{ path: paths.app.discussions.path, lazy: () => import('./routes/app/discussions') },
|
||||
{ path: 'settings', element: <Settings /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
`,
|
||||
[app('src/lib/auth.tsx')]: `export function ProtectedRoute({ children }: { children: unknown }) { return children; }
|
||||
`,
|
||||
[app('src/app/routes/landing.tsx')]: `const LandingRoute = () => null;
|
||||
export default LandingRoute;
|
||||
`,
|
||||
[app('src/app/routes/app/root.tsx')]: `export default function AppRoot() { return null; }
|
||||
`,
|
||||
[app('src/app/routes/app/discussions.tsx')]: `export default function DiscussionsRoute() { return null; }
|
||||
`,
|
||||
};
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, rel), content);
|
||||
}
|
||||
cg = await CodeGraph.init(root, { index: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cg?.close();
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const renders = (routeName: string): string[] => {
|
||||
const route = cg.getNodesByKind('route').find((r) => r.name === routeName);
|
||||
if (!route) return [];
|
||||
return cg.getOutgoingEdges(route.id).filter((e) => e.kind === 'references').map((e) => cg.getNode(e.target)!.name);
|
||||
};
|
||||
|
||||
describe('nested, lazy and constant React Router routes', () => {
|
||||
it('compose their paths and render their modules', () => {
|
||||
const names = cg.getNodesByKind('route').map((r) => r.name).sort();
|
||||
expect(names).toEqual(['/', '/app', '/app/discussions', '/app/settings']);
|
||||
expect(renders('/')).toEqual(['LandingRoute']);
|
||||
expect(renders('/app')).toEqual(['AppRoot']);
|
||||
expect(renders('/app/discussions')).toEqual(['DiscussionsRoute']);
|
||||
});
|
||||
|
||||
it('resolves an app’s own tsconfig alias', () => {
|
||||
const router = cg.getNodesInFile('apps/web/src/app/router.tsx').map((n) => n.id);
|
||||
const imported = cg.getOutgoingEdgesFrom(router).filter((e) => e.kind === 'imports').map((e) => cg.getNode(e.target)!.filePath);
|
||||
expect(imported).toContain('apps/web/src/config/paths.ts');
|
||||
expect(imported).toContain('apps/web/src/lib/auth.tsx');
|
||||
});
|
||||
});
|
||||
@@ -548,8 +548,9 @@ describe('react-router: route declaration boundaries (#1348)', () => {
|
||||
}
|
||||
`, extension);
|
||||
expect(result).toEqual({
|
||||
paths: ['/dashboard', '/empty', '/legacy', '/long', '/no-element', '/sibling', 'settings'],
|
||||
bindings: ['/legacy->Settings', '/long->Shell', '/sibling->Settings', 'settings->Settings'],
|
||||
// A nested route's path is relative to its parent's (`settings` under `/dashboard`).
|
||||
paths: ['/dashboard', '/dashboard/settings', '/empty', '/legacy', '/long', '/no-element', '/sibling'],
|
||||
bindings: ['/dashboard/settings->Settings', '/legacy->Settings', '/long->Shell', '/sibling->Settings'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -573,8 +574,8 @@ describe('react-router: route declaration boundaries (#1348)', () => {
|
||||
]);
|
||||
`, extension);
|
||||
expect(result).toEqual({
|
||||
paths: ['/', '/long', '/quoted', '/sibling', 'prefs'],
|
||||
bindings: ['/->DataSettings', '/long->DataSettings', '/quoted->DataSettings', '/sibling->DataSettings', 'prefs->DataSettings'],
|
||||
paths: ['/', '/data/prefs', '/long', '/quoted', '/sibling'],
|
||||
bindings: ['/->DataSettings', '/data/prefs->DataSettings', '/long->DataSettings', '/quoted->DataSettings', '/sibling->DataSettings'],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ guessed.
|
||||
|---|---|---|---|---|
|
||||
| Expo Router | `frameworks/expo-router.ts` | `expo-router-synthesizer.ts` | `expo-router.test.ts` | — |
|
||||
| Next.js | `frameworks/nextjs.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts` | next-saas-starter |
|
||||
| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), proshop-v2 (28), react-redux-realworld (22), react-boilerplate (`styled(Link)`), takenote (v5 `<Redirect>`); bulletproof-react's `paths.x.path` constants and `lazy` routes are not read yet |
|
||||
| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), proshop-v2 (28), react-redux-realworld (22), react-boilerplate (`styled(Link)`), takenote (v5 `<Redirect>`), bulletproof-react (nested `children`, `lazy` routes, `paths.x.path` constants through an app's own tsconfig alias: 0 → 9 named, linked routes); its `paths.x.getHref()` links are not read yet |
|
||||
| TanStack Router | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts` | TanStack examples, fastapi-template frontend |
|
||||
| Vue Router / Nuxt | `frameworks/vue-router.ts` (Nuxt file routes: `nuxtResolver` in `frameworks/vue.ts`) | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges); vue-element-admin (62 routes), vue-admin-template (14), vben (192), halo console (34) — named tables, module files, `children` + layouts; Nuxt: mealie, elk, nuxt/movies |
|
||||
| SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts`, `sveltekit-route-names.test.ts` | sveltekit-realworld (31 edges); shadcn-svelte and skeleton (`(group)` layouts: 13 and 23 edges), svelte.dev (74), kit's test apps (47) |
|
||||
|
||||
@@ -28,11 +28,12 @@
|
||||
* 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.
|
||||
* A nested route's path is relative to its parent's (`<Route path="team">`
|
||||
* inside `<Route path="/dashboard">`, a data router's `children`); the markup
|
||||
* scan composes that tree, a constant path (`paths.app.root.path`) included,
|
||||
* and a `lazy: () => import('./routes/x')` route renders that module's default
|
||||
* export (`frameworks/react.ts`). Known limit, deliberate: a splat
|
||||
* (`/admin/*`) matches anything, so it is never the answer to a concrete href.
|
||||
*/
|
||||
|
||||
import type { Language, Node } from '../../types';
|
||||
@@ -70,13 +71,16 @@ export const reactRouterRoot = appRootFor;
|
||||
*
|
||||
* 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.
|
||||
* (`route:file:12:POST:/login`), a file-based page carries no line. A path
|
||||
* built from a constant keeps the id it was extracted with.
|
||||
*/
|
||||
function isReactRouterRoute(node: Node): boolean {
|
||||
return (
|
||||
(node.language === 'tsx' || node.language === 'jsx') &&
|
||||
node.id === `route:${node.filePath}:${node.startLine}:${node.name}`
|
||||
);
|
||||
const prefix = `route:${node.filePath}:${node.startLine}:`;
|
||||
if ((node.language !== 'tsx' && node.language !== 'jsx') || !node.id.startsWith(prefix)) return false;
|
||||
// Its name, or — for a path built from a constant, renamed after extraction
|
||||
// — the path as the file wrote it; never a server route's `METHOD:`.
|
||||
const rest = node.id.slice(prefix.length);
|
||||
return rest === node.name || (!/^[A-Z]+:/.test(rest) && Boolean(node.signature?.startsWith('route-parts:')));
|
||||
}
|
||||
|
||||
/** `:id?` — a parameter React Router serves the route with or without. */
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { Node } from '../../types';
|
||||
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
|
||||
import { dependsOn } from './package-deps';
|
||||
import { resolveImportPath } from '../import-resolver';
|
||||
|
||||
export const reactResolver: FrameworkResolver = {
|
||||
name: 'react',
|
||||
@@ -27,7 +28,16 @@ export const reactResolver: FrameworkResolver = {
|
||||
return allFiles.some((f) => f.endsWith('.jsx') || f.endsWith('.tsx'));
|
||||
},
|
||||
|
||||
// A data-router `lazy: () => import('./routes/x')` route names a module, not a symbol.
|
||||
claimsReference(name: string): boolean {
|
||||
return name.startsWith(LAZY_ROUTE_PREFIX);
|
||||
},
|
||||
|
||||
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||
if (ref.referenceName.startsWith(LAZY_ROUTE_PREFIX)) {
|
||||
const target = lazyRouteComponent(ref.referenceName.slice(LAZY_ROUTE_PREFIX.length), ref.filePath, context);
|
||||
return target ? { original: ref, targetNodeId: target, confidence: 0.9, resolvedBy: 'framework' } : null;
|
||||
}
|
||||
// A component, hook or context the file IMPORTS is the import's: the
|
||||
// package's (`useQuery` from `@tanstack/react-query`, `<Button>` from a UI
|
||||
// kit), or the module the import names, which import resolution finds.
|
||||
@@ -109,7 +119,7 @@ export const reactResolver: FrameworkResolver = {
|
||||
|
||||
// Read only each opening tag's own attributes, including expression values.
|
||||
const declarations = scanRouteDeclarations(content, !/\.(?:ts|mts|cts)$/.test(filePath));
|
||||
for (const { path: routePath, component, at } of declarations) {
|
||||
for (const { path: routePath, parts, component, lazy, at } of declarations) {
|
||||
const line = content.slice(0, at).split('\n').length;
|
||||
const routeNode: Node = {
|
||||
id: `route:${filePath}:${line}:${routePath}`,
|
||||
@@ -123,12 +133,15 @@ export const reactResolver: FrameworkResolver = {
|
||||
endColumn: 0,
|
||||
language: filePath.endsWith('.tsx') ? 'tsx' : 'jsx',
|
||||
updatedAt: now,
|
||||
// A path built from a constant (`paths.app.root.path`) is named in postExtract.
|
||||
...(parts.some((p) => p.expr) ? { signature: ROUTE_PARTS_PREFIX + JSON.stringify(parts) } : {}),
|
||||
};
|
||||
nodes.push(routeNode);
|
||||
if (component) {
|
||||
const target = component ?? (lazy ? LAZY_ROUTE_PREFIX + lazy : undefined);
|
||||
if (target) {
|
||||
references.push({
|
||||
fromNodeId: routeNode.id,
|
||||
referenceName: component,
|
||||
referenceName: target,
|
||||
referenceKind: 'references',
|
||||
line,
|
||||
column: 0,
|
||||
@@ -142,29 +155,175 @@ export const reactResolver: FrameworkResolver = {
|
||||
|
||||
return { nodes, references };
|
||||
},
|
||||
|
||||
/**
|
||||
* Name the routes whose path is built from a constant — bulletproof-react's
|
||||
* `path: paths.app.discussions.path` under `path: paths.app.root.path` is
|
||||
* `/app/discussions` — by reading the constant's object literal where it is
|
||||
* declared. Idempotent: the parts ride on the node's signature.
|
||||
*/
|
||||
postExtract(context: ResolutionContext): Node[] {
|
||||
const updates: Node[] = [];
|
||||
for (const route of context.getNodesByKind('route')) {
|
||||
if (!route.signature?.startsWith(ROUTE_PARTS_PREFIX)) continue;
|
||||
let parts: RoutePart[];
|
||||
try {
|
||||
parts = JSON.parse(route.signature.slice(ROUTE_PARTS_PREFIX.length)) as RoutePart[];
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const values = parts.map((p) => p.lit ?? (p.expr ? constantPathValue(p.expr, route.filePath, context) : null));
|
||||
if (values.some((v) => v === null)) continue;
|
||||
const name = composeRoutePath(values as string[]);
|
||||
if (name !== route.name) updates.push({ ...route, name });
|
||||
}
|
||||
return updates;
|
||||
},
|
||||
};
|
||||
|
||||
const LAZY_ROUTE_PREFIX = 'lazy-import:';
|
||||
const ROUTE_PARTS_PREFIX = 'route-parts:';
|
||||
|
||||
/** One segment of a nested route's path: a literal, or a constant's member expression. */
|
||||
interface RoutePart {
|
||||
lit?: string;
|
||||
expr?: string;
|
||||
}
|
||||
|
||||
/** React Router's nesting: a child path is relative unless it starts with `/`. */
|
||||
function composeRoutePath(parts: string[]): string {
|
||||
let path = '';
|
||||
for (const part of parts) {
|
||||
if (part.startsWith('/')) path = part;
|
||||
else if (part) path = `${path.replace(/\/+$/, '')}/${part}`;
|
||||
}
|
||||
return ('/' + path.replace(/^\/+/, '')).replace(/\/{2,}/g, '/').replace(/(.)\/$/, '$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* The string a member expression like `paths.app.root.path` reads from a
|
||||
* constant object literal — the constant found through the route file's
|
||||
* import of its root name, or in the file itself.
|
||||
*/
|
||||
function constantPathValue(expr: string, fromFile: string, context: ResolutionContext): string | null {
|
||||
const [root, ...keys] = expr.split('.');
|
||||
if (!root || keys.length === 0) return null;
|
||||
let file = fromFile;
|
||||
let name = root;
|
||||
const mapping = context.getImportMappings(fromFile, 'tsx').find((m) => m.localName === root) ??
|
||||
context.getImportMappings(fromFile, 'typescript').find((m) => m.localName === root);
|
||||
if (mapping) {
|
||||
const resolved = resolveImportPath(mapping.source, fromFile, 'typescript', context);
|
||||
if (!resolved) return null;
|
||||
file = resolved;
|
||||
if (mapping.exportedName && mapping.exportedName !== 'default' && mapping.exportedName !== '*') name = mapping.exportedName;
|
||||
}
|
||||
const decl = context.getNodesInFile(file).find((n) => n.name === name && (n.kind === 'constant' || n.kind === 'variable'));
|
||||
if (!decl) return null;
|
||||
const lines = context.readFile(file)?.split('\n') ?? [];
|
||||
const text = lines.slice(decl.startLine - 1, decl.endLine).join('\n');
|
||||
const open = text.indexOf('{', Math.max(0, text.search(new RegExp(`\\b${name}\\b`))));
|
||||
return open < 0 ? null : readObjectPath(text, open, keys);
|
||||
}
|
||||
|
||||
/** Walk `keys` into the object literal opening at `at`; the string literal at the end, or null. */
|
||||
function readObjectPath(text: string, at: number, keys: string[]): string | null {
|
||||
const skipString = (j: number): number => {
|
||||
const quote = text[j]!;
|
||||
for (j++; j < text.length && text[j] !== quote; j++) if (text[j] === '\\') j++;
|
||||
return j + 1;
|
||||
};
|
||||
const skipValue = (j: number): number => {
|
||||
let depth = 0;
|
||||
for (; j < text.length; j++) {
|
||||
const ch = text[j]!;
|
||||
if (ch === '"' || ch === "'" || ch === '`') { j = skipString(j) - 1; continue; }
|
||||
if (ch === '{' || ch === '[' || ch === '(') depth++;
|
||||
else if (ch === '}' || ch === ']' || ch === ')') { if (depth === 0) return j; depth--; }
|
||||
else if (ch === ',' && depth === 0) return j;
|
||||
}
|
||||
return j;
|
||||
};
|
||||
let i = at + 1;
|
||||
while (i < text.length) {
|
||||
const m = /^\s*(?:([A-Za-z_$][\w$]*)|["']([^"']+)["'])\s*:\s*/.exec(text.slice(i));
|
||||
if (!m) {
|
||||
const next = skipValue(i);
|
||||
if (text[next] !== ',') return null;
|
||||
i = next + 1;
|
||||
continue;
|
||||
}
|
||||
const key = m[1] ?? m[2]!;
|
||||
const valueAt = i + m[0].length;
|
||||
if (key === keys[0]) {
|
||||
if (keys.length === 1) {
|
||||
const lit = /^(["'])((?:\\.|(?!\1).)*)\1/.exec(text.slice(valueAt));
|
||||
return lit ? lit[2]! : null;
|
||||
}
|
||||
return text[valueAt] === '{' ? readObjectPath(text, valueAt, keys.slice(1)) : null;
|
||||
}
|
||||
const end = skipValue(valueAt);
|
||||
if (text[end] !== ',') return null;
|
||||
i = end + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The component a lazy route module renders: its default export, else its `Component` export. */
|
||||
function lazyRouteComponent(spec: string, fromFile: string, context: ResolutionContext): string | null {
|
||||
const file = resolveImportPath(spec, fromFile, 'typescript', context);
|
||||
if (!file) return null;
|
||||
const source = context.readFile(file) ?? '';
|
||||
const named = /\bexport\s+default\s+(?:async\s+)?(?:function\s*\*?\s*|class\s+)?([A-Za-z_$][\w$]*)/.exec(source)?.[1] ??
|
||||
(/\bexport\s+(?:const|function|class)\s+Component\b/.test(source) ? 'Component' : null);
|
||||
if (!named) return null;
|
||||
const node = context.getNodesInFile(file).find((n) => n.name === named &&
|
||||
(n.kind === 'function' || n.kind === 'component' || n.kind === 'class' || n.kind === 'constant' || n.kind === 'variable'));
|
||||
return node?.id ?? null;
|
||||
}
|
||||
|
||||
interface RouteDeclaration {
|
||||
/** The route's full path as far as the file tells: nesting composed, a constant part shown as `{expr}`. */
|
||||
path: string;
|
||||
/** Its path parts, outermost first. */
|
||||
parts: RoutePart[];
|
||||
component?: string;
|
||||
/** A `lazy: () => import('…')` module. */
|
||||
lazy?: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** A path-bearing route object or `<Route>` element, with the extent its children sit in. */
|
||||
interface RouteScope {
|
||||
part: RoutePart;
|
||||
at: number;
|
||||
end: number;
|
||||
component?: string;
|
||||
lazy?: string;
|
||||
/** A `<Route path>` element: a route even with nothing to render (a layout's path). */
|
||||
jsx?: boolean;
|
||||
}
|
||||
|
||||
/** Structural scanner: strings, comments, JSX and balanced expressions are units. */
|
||||
function scanRouteDeclarations(source: string, allowJsx: boolean): RouteDeclaration[] {
|
||||
const routes: RouteDeclaration[] = [];
|
||||
const scopes: RouteScope[] = [];
|
||||
const dataRouter = /\b(?:createBrowserRouter|createHashRouter|createMemoryRouter|createRoutesFromElements)\b/.test(source);
|
||||
if (!dataRouter && !/<Route\b/.test(source)) return routes;
|
||||
const literal = (value: string): string | undefined => {
|
||||
const match = /^(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)')$/.exec(value.trim());
|
||||
return match ? match[1] ?? match[2] : undefined;
|
||||
};
|
||||
// A guard or boundary wrapping the screen (`<ProtectedRoute><AppRoot/></ProtectedRoute>`,
|
||||
// `<Suspense>`) is not what the route renders; the first element inside it is.
|
||||
const WRAPPER = /^(?:Suspense|ErrorBoundary|\w*(?:Guard|Provider)|(?:Protected|Private|Auth|Require\w*)\w*)$/;
|
||||
const componentName = (value: string | undefined, jsx: boolean): string | undefined => {
|
||||
if (!value) return undefined;
|
||||
const match = jsx
|
||||
? /^\s*<\s*([A-Z][\w]*)\s*(?=[\s/>])/.exec(value)
|
||||
: /^\s*([A-Z][\w]*)\s*$/.exec(value);
|
||||
return match?.[1];
|
||||
if (!jsx) return /^\s*([A-Z][\w]*)\s*$/.exec(value)?.[1];
|
||||
const inner = value.replace(/^\s*\(\s*/, '');
|
||||
if (!/^<\s*[A-Z]/.test(inner)) return undefined;
|
||||
const tags = [...inner.matchAll(/<\s*([A-Z][\w]*)\s*(?=[\s/>])/g)].map((m) => m[1]!);
|
||||
return tags.find((t) => !WRAPPER.test(t)) ?? tags[0];
|
||||
};
|
||||
// The few characters before `at`, trailing whitespace skipped: enough for the
|
||||
// end-anchored checks below without copying the whole prefix per `/` or `<`.
|
||||
@@ -246,9 +405,13 @@ function scanRouteDeclarations(source: string, allowJsx: boolean): RouteDeclarat
|
||||
if (dataRouter && ch === '{' && source[i] === close) {
|
||||
const pathField = fields.get('path');
|
||||
const path = pathField && literal(pathField.value);
|
||||
const expr = pathField && path === undefined && /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(pathField.value) ? pathField.value : undefined;
|
||||
const component = componentName(fields.get('element')?.value, true)
|
||||
?? componentName(fields.get('Component')?.value, false);
|
||||
if (path !== undefined && component) routes.push({ path: path || '/', component, at: pathField!.at });
|
||||
const lazy = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/.exec(fields.get('lazy')?.value ?? '')?.[1];
|
||||
if (pathField && (path !== undefined || expr)) {
|
||||
scopes.push({ part: path !== undefined ? { lit: path } : { expr }, at: pathField.at, end: i, component, lazy });
|
||||
}
|
||||
}
|
||||
return i < source.length ? i + 1 : i;
|
||||
}
|
||||
@@ -266,27 +429,49 @@ function scanRouteDeclarations(source: string, allowJsx: boolean): RouteDeclarat
|
||||
i = unit(i);
|
||||
attrs.set(attr[0], source.slice(start, i));
|
||||
}
|
||||
let scope: RouteScope | undefined;
|
||||
if (tag[1] === 'Route' && i < source.length) {
|
||||
const path = literal(attrs.get('path') ?? '');
|
||||
const expression = (name: string) => attrs.get(name)?.replace(/^\{([\s\S]*)\}$/, '$1');
|
||||
const component = componentName(expression('component'), false) ?? componentName(expression('element'), true);
|
||||
if (path) routes.push({ path, component, at });
|
||||
const lazy = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/.exec(expression('lazy') ?? '')?.[1];
|
||||
if (path) {
|
||||
scope = { part: { lit: path }, at, end: source.length, component, lazy, jsx: true };
|
||||
scopes.push(scope);
|
||||
}
|
||||
}
|
||||
if (source.startsWith('/>', i)) {
|
||||
if (scope) scope.end = i + 2;
|
||||
return i + 2;
|
||||
}
|
||||
if (source.startsWith('/>', i)) return i + 2;
|
||||
i++;
|
||||
while (i < source.length) {
|
||||
if (source.startsWith('</', i)) {
|
||||
const end = source.indexOf('>', i + 2);
|
||||
return end < 0 ? source.length : end + 1;
|
||||
const after = end < 0 ? source.length : end + 1;
|
||||
if (scope) scope.end = after;
|
||||
return after;
|
||||
}
|
||||
if (source[i] === '<' && /^<(?:[A-Za-z]|>)/.test(source.slice(i))) i = jsx(i);
|
||||
else if (source[i] === '{') i = unit(i);
|
||||
else i++;
|
||||
}
|
||||
if (scope) scope.end = i;
|
||||
return i;
|
||||
}
|
||||
let at = 0;
|
||||
while ((at = trivia(at)) < source.length) at = unit(at);
|
||||
// A child route's path is relative to the routes around it: compose each
|
||||
// rendering route's path from the path-bearing scopes that contain it.
|
||||
for (const scope of scopes) {
|
||||
if (!scope.component && !scope.lazy && !scope.jsx) continue;
|
||||
const chain = scopes
|
||||
.filter((outer) => outer !== scope && outer.at < scope.at && outer.end >= scope.end)
|
||||
.sort((a, b) => a.at - b.at);
|
||||
const parts = [...chain.map((c) => c.part), scope.part];
|
||||
const shown = parts.map((p) => p.lit ?? `{${p.expr}}`);
|
||||
routes.push({ path: composeRoutePath(shown), parts, component: scope.component, lazy: scope.lazy, at: scope.at });
|
||||
}
|
||||
return routes.sort((a, b) => a.at - b.at);
|
||||
}
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ function resolveImportPathUncached(
|
||||
}
|
||||
|
||||
// Handle absolute/aliased imports (like @/ or src/)
|
||||
const aliased = resolveAliasedImport(importPath, projectRoot, language, context);
|
||||
const aliased = resolveAliasedImport(importPath, projectRoot, language, context, fromFile);
|
||||
if (aliased) return aliased;
|
||||
|
||||
// C/C++ include directory search: when neither relative nor aliased
|
||||
@@ -576,7 +576,8 @@ function resolveAliasedImport(
|
||||
importPath: string,
|
||||
projectRoot: string,
|
||||
language: Language,
|
||||
context: ResolutionContext
|
||||
context: ResolutionContext,
|
||||
fromFile?: string
|
||||
): string | null {
|
||||
const extensions = EXTENSION_RESOLUTION[language] || [];
|
||||
const tryWithExt = (basePath: string): string | null => {
|
||||
@@ -588,9 +589,12 @@ function resolveAliasedImport(
|
||||
return findSourceForEmittedSpecifier(basePath, language, context);
|
||||
};
|
||||
|
||||
// 1. Project tsconfig/jsconfig paths.
|
||||
const aliasMap = context.getProjectAliases?.();
|
||||
if (aliasMap) {
|
||||
// 1. tsconfig/jsconfig paths: the config nearest the importing file (an
|
||||
// app of a monorepo keeps its own `@/*`), then the project root's.
|
||||
const nearest = fromFile ? context.getNearestAliases?.(fromFile) : null;
|
||||
const rootMap = context.getProjectAliases?.();
|
||||
for (const aliasMap of nearest && nearest !== rootMap ? [nearest, rootMap] : [rootMap]) {
|
||||
if (!aliasMap) continue;
|
||||
const candidates = applyAliases(importPath, aliasMap, projectRoot);
|
||||
for (const c of candidates) {
|
||||
const hit = tryWithExt(c);
|
||||
|
||||
@@ -307,6 +307,8 @@ export class ReferenceResolver {
|
||||
// `null` = computed and absent. Treated as immutable for the
|
||||
// resolver's lifetime; callers re-create the resolver if config changes.
|
||||
private projectAliases: AliasMap | null | undefined = undefined;
|
||||
// Per directory: the aliases of the nearest non-root tsconfig declaring `paths`.
|
||||
private dirAliases = new Map<string, AliasMap | null>();
|
||||
// go.mod module path. Same lazy/immutable convention as projectAliases.
|
||||
private goModule: GoModule | null | undefined = undefined;
|
||||
// Monorepo workspace member packages. Same lazy/immutable convention.
|
||||
@@ -776,6 +778,31 @@ export class ReferenceResolver {
|
||||
return this.projectAliases;
|
||||
},
|
||||
|
||||
getNearestAliases: (fromFile: string) => {
|
||||
let dir = path.posix.dirname(fromFile.replace(/\\/g, '/'));
|
||||
const walked: string[] = [];
|
||||
let found: AliasMap | null = null;
|
||||
while (dir && dir !== '.' && dir !== '/') {
|
||||
const hit = this.dirAliases.get(dir);
|
||||
if (hit !== undefined) {
|
||||
found = hit;
|
||||
break;
|
||||
}
|
||||
walked.push(dir);
|
||||
const abs = path.join(this.projectRoot, dir);
|
||||
if (['tsconfig.json', 'jsconfig.json'].some((name) => fs.existsSync(path.join(abs, name)))) {
|
||||
const aliases = loadProjectAliases(abs);
|
||||
if (aliases) {
|
||||
found = aliases;
|
||||
break;
|
||||
}
|
||||
}
|
||||
dir = path.posix.dirname(dir);
|
||||
}
|
||||
for (const d of walked) this.dirAliases.set(d, found);
|
||||
return found;
|
||||
},
|
||||
|
||||
getGoModule: () => {
|
||||
if (this.goModule === undefined) {
|
||||
this.goModule = loadGoModule(this.projectRoot);
|
||||
|
||||
@@ -181,6 +181,11 @@ export interface ResolutionContext {
|
||||
* compile without modification; production resolver implements it.
|
||||
*/
|
||||
getProjectAliases?(): import('./path-aliases').AliasMap | null;
|
||||
/**
|
||||
* The aliases of the tsconfig / jsconfig nearest `fromFile` below the
|
||||
* project root that declares `paths` — a monorepo app's own `@/*` — or null.
|
||||
*/
|
||||
getNearestAliases?(fromFile: string): import('./path-aliases').AliasMap | null;
|
||||
/**
|
||||
* Go module info from `go.mod` at the project root. Returns `null`
|
||||
* when the project has no `go.mod` (non-Go projects, pre-modules
|
||||
|
||||
Reference in New Issue
Block a user