mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
fix(angular): Nx workspaces, barrels, class-constant paths and root-relative navigate (#2133)
From the router sweep:
- angular-spotify had 12 routes all named `/` and no navigation. Its lazy
routes are `async () => (await import('@ws/home')).HomeModule` through an
Nx library's `src/index.ts` barrel, which the mount pass never looked
through; and every lib is its own `src/`, so each routes file got its own
route table and no template's `routerLink` found the screen it names.
- jira-clone had 3 routes and no navigation: its issue route is
`issue/:${ProjectConst.IssueId}` (a class `static readonly`), its root
redirect lives in `app.routes.ts`, which only mounts, and
`navigate(['project', 'issue', id])` has no leading `/`.
Now: lazy mounts follow `export … from` barrels (and an NgModule's routing
imports); `(await import(x)).M` is read; route constants may be class
statics (strings or objects), enum members, or live behind a barrel, and a
template-literal path takes constant holes; a routes file that only mounts
contributes its redirects at its mount prefix; the route table is keyed by
the `angular.json` / `nx.json` workspace; and `router.navigate` /
`createUrlTree` without `relativeTo` resolve a bare first command from the
root, as Angular does (template `routerLink` stays relative).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Angular apps split across Nx libraries, like angular-spotify, now have their routes and navigation. A lazily loaded route written `async () => (await import('@app/home')).HomeModule` is followed through the library's `index.ts` to the module it re-exports, and a `routerLink` in one library now reaches a screen declared in another. Route paths built from a class constant (`RouterUtil.Configuration.Lyrics`, `` `issue/:${ProjectConst.IssueId}` ``) or an enum are now read. A redirect in a routes file that only lazy-loads others now counts, and `router.navigate(['project', 'issue', id])` without `relativeTo` is read from the root, as Angular does. Re-index Angular projects after upgrading.
|
||||
- React Router apps now show navigation written as React Router v5's `<Redirect to="/login" />` and through a styled link, like react-boilerplate's `HeaderLink = styled(Link)` used as `<HeaderLink to="/features">`. Apps built this way had routes but no links between their screens.
|
||||
- SvelteKit routes are now named by the address a browser asks for. A `(group)` folder like `(app)` or `(marketing)` is no longer part of a route's path, and a parameter with a matcher, like `[id=integer]`, is just `:id`. So `goto('/blocks')` and `<a href="/blocks">` now reach a page that lives in `src/routes/(app)/blocks/`, where before they reached nothing and the app's screens showed no navigation. Re-index SvelteKit projects after upgrading.
|
||||
- Vue Router apps built like vue-element-admin, vue-admin-template or vben now have their routes and screens. Routes declared in a named table (`export const constantRoutes = [...]`, `const routes: RouteRecordRaw[] = [...]`), in per-module route files, or through `new Router(...)` are now read. Child routes are joined onto their parent's path, and the parent's component is the layout around them. A lazily loaded view (`() => import('@/views/dashboard/index')`) is bound to the file it names, and `this.$router.push(...)` counts as navigation. Nuxt's file-based routes now come only from a Nuxt app, so a plain Vue app's `pages/` folder no longer turns into made-up screens, and a Nuxt app at the root of its repository now gets its routes. Re-index Vue projects after upgrading.
|
||||
|
||||
@@ -317,3 +317,143 @@ export class AdminRoutingModule {}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an Angular workspace split across libraries', () => {
|
||||
const roots: string[] = [];
|
||||
afterAll(() => {
|
||||
for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function project(files: Record<string, string>): Promise<CodeGraph> {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-angular-ws-'));
|
||||
roots.push(root);
|
||||
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);
|
||||
}
|
||||
return CodeGraph.init(root, { index: true });
|
||||
}
|
||||
const routeNames = (cg: CodeGraph) => cg.getNodesByKind('route').map((n) => n.name).sort();
|
||||
const navs = (cg: CodeGraph) =>
|
||||
cg
|
||||
.getNodesByKind('route')
|
||||
.flatMap((r) => cg.getIncomingEdges(r.id).filter((e) => e.kind === 'navigates').map((e) => `${cg.getNode(e.source)!.name} -> ${r.name}`))
|
||||
.sort();
|
||||
const component = (name: string, selector: string, template: string) => `import { Component } from '@angular/core';
|
||||
@Component({ selector: '${selector}', template: \`${template}\` })
|
||||
export class ${name} {}
|
||||
`;
|
||||
|
||||
it('angular-spotify: Nx libraries behind barrels, `(await import(…)).M`, a class-static path constant', async () => {
|
||||
const cg = await project({
|
||||
'package.json': JSON.stringify({ name: 'ws', dependencies: { '@angular/core': '*', '@angular/router': '*' } }),
|
||||
'nx.json': '{}',
|
||||
'tsconfig.base.json': JSON.stringify({
|
||||
compilerOptions: {
|
||||
paths: {
|
||||
'@ws/home': ['libs/home/src/index.ts'],
|
||||
'@ws/lyrics': ['libs/lyrics/src/index.ts'],
|
||||
'@ws/utils': ['libs/utils/src/index.ts'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
'libs/utils/src/index.ts': `export * from './lib/router-util';\n`,
|
||||
'libs/utils/src/lib/router-util.ts': `export class RouterUtil {
|
||||
static Configuration = { Lyrics: 'lyrics' };
|
||||
}
|
||||
`,
|
||||
'libs/shell/src/lib/shell.routes.ts': `import { Route } from '@angular/router';
|
||||
import { RouterUtil } from '@ws/utils';
|
||||
|
||||
export const shellRoutes: Route[] = [
|
||||
{ path: '', loadChildren: async () => (await import('@ws/home')).HomeModule },
|
||||
{ path: RouterUtil.Configuration.Lyrics, loadChildren: async () => (await import('@ws/lyrics')).LyricsModule },
|
||||
];
|
||||
`,
|
||||
'libs/home/src/index.ts': `export * from './lib/home.module';\n`,
|
||||
'libs/home/src/lib/home.module.ts': `import { NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { HomeComponent } from './home.component';
|
||||
@NgModule({ imports: [RouterModule.forChild([{ path: '', component: HomeComponent }])] })
|
||||
export class HomeModule {}
|
||||
`,
|
||||
'libs/home/src/lib/home.component.ts': component('HomeComponent', 'as-home', '<a routerLink="/lyrics">Lyrics</a>'),
|
||||
'libs/lyrics/src/index.ts': `export * from './lib/lyrics.module';\n`,
|
||||
'libs/lyrics/src/lib/lyrics.module.ts': `import { NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { LyricsComponent } from './lyrics.component';
|
||||
@NgModule({ imports: [RouterModule.forChild([{ path: '', component: LyricsComponent }])] })
|
||||
export class LyricsModule {}
|
||||
`,
|
||||
'libs/lyrics/src/lib/lyrics.component.ts': component('LyricsComponent', 'as-lyrics', '<a routerLink="/">Home</a>'),
|
||||
});
|
||||
try {
|
||||
expect(routeNames(cg)).toEqual(['/', '/lyrics']);
|
||||
// A template in one library links to a screen another library declares.
|
||||
expect(navs(cg)).toEqual(['HomeComponent -> /lyrics', 'LyricsComponent -> /']);
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('jira-clone: a template-literal path, a redirect in a file that only mounts, navigate() from the root', async () => {
|
||||
const cg = await project({
|
||||
'package.json': JSON.stringify({ name: 'jira', dependencies: { '@angular/core': '*', '@angular/router': '*' } }),
|
||||
'angular.json': '{}',
|
||||
'src/app/app.routes.ts': `import { Routes } from '@angular/router';
|
||||
export const appRoutes: Routes = [
|
||||
{ path: 'project', loadChildren: () => import('./project/project.routes').then((m) => m.PROJECT_ROUTES) },
|
||||
{ path: '', redirectTo: 'project', pathMatch: 'full' },
|
||||
];
|
||||
`,
|
||||
'src/app/project/config/const.ts': `export class ProjectConst {
|
||||
static readonly IssueId = 'issueId';
|
||||
}
|
||||
`,
|
||||
'src/app/project/project.routes.ts': `import { Routes } from '@angular/router';
|
||||
import { ProjectComponent } from './project.component';
|
||||
import { BoardComponent } from './board.component';
|
||||
import { IssueComponent } from './issue.component';
|
||||
import { ProjectConst } from './config/const';
|
||||
export const PROJECT_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ProjectComponent,
|
||||
children: [
|
||||
{ path: 'board', component: BoardComponent },
|
||||
{ path: \`issue/:\${ProjectConst.IssueId}\`, component: IssueComponent },
|
||||
{ path: '', redirectTo: 'board', pathMatch: 'full' },
|
||||
],
|
||||
},
|
||||
];
|
||||
`,
|
||||
'src/app/project/project.component.ts': component('ProjectComponent', 'j-project', '<router-outlet></router-outlet>'),
|
||||
'src/app/project/board.component.ts': `import { Component } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
@Component({ selector: 'j-board', template: '<div></div>' })
|
||||
export class BoardComponent {
|
||||
constructor(private _router: Router) {}
|
||||
openIssuePage(issueId: string) {
|
||||
this._router.navigate(['project', 'issue', issueId]);
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/app/project/issue.component.ts': `import { Component } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
@Component({ selector: 'j-issue', template: '<div></div>' })
|
||||
export class IssueComponent {
|
||||
constructor(private _router: Router) {}
|
||||
backHome() {
|
||||
this._router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
try {
|
||||
expect(routeNames(cg)).toEqual(['/project/board', '/project/issue/:issueId']);
|
||||
expect(navs(cg)).toEqual(['backHome -> /project/board', 'openIssuePage -> /project/issue/:issueId']);
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ guessed.
|
||||
| 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) |
|
||||
| Angular | `frameworks/angular-router.ts` | `angular-template-synthesizer.ts` | `angular-router.test.ts` | angular-realworld (31 edges, 18 renders), Ghostfolio (189 edges, 170 renders), ngx-admin (routes and renders; its menus are config) |
|
||||
| Angular | `frameworks/angular-router.ts` | `angular-template-synthesizer.ts` | `angular-router.test.ts` | angular-realworld (31 edges, 18 renders), Ghostfolio (189 edges, 170 renders), ngx-admin (routes and renders; its menus are config), angular-spotify (Nx libs behind barrels: 14 routes), jira-clone (class-constant paths, mount-only redirects), jhipster (60), ionic-conference (18), Angular-JumpStart (18) |
|
||||
|
||||
Shared machinery all seven use, in `frameworks/expo-router.ts`: `RouteTable` /
|
||||
`RootedRouteTable`, `routesForFile`, `addRouteTo`, `matchRoute`, `appRootFor`,
|
||||
|
||||
@@ -172,6 +172,16 @@ function pathSegments(text: string): string[] | null {
|
||||
return value.split('/').filter((s) => s.length > 0);
|
||||
}
|
||||
if (CONSTANT_PATH.test(trimmed)) return [`{${trimmed.replace(/\s+/g, '')}}`];
|
||||
// `` `issue/:${ProjectConst.IssueId}` ``: a template whose holes are constants.
|
||||
if (trimmed.startsWith('`') && skipString(trimmed, 0) === trimmed.length - 1) {
|
||||
let ok = true;
|
||||
const body = trimmed.slice(1, -1).replace(/\$\{([^}]*)\}/g, (_all, hole: string) => {
|
||||
if (!CONSTANT_PATH.test(hole.trim())) ok = false;
|
||||
return `{${hole.replace(/\s+/g, '')}}`;
|
||||
});
|
||||
if (ok && !body.includes('**')) return body.split('/').filter((seg) => seg.length > 0);
|
||||
return null;
|
||||
}
|
||||
// `internalRoutes.account.subRoutes.access.path + '/:id'`: each part a
|
||||
// string or a constant, the constant a whole segment of its own.
|
||||
if (trimmed.includes('+')) {
|
||||
@@ -225,8 +235,11 @@ function componentRef(field: 'component' | 'loadComponent', text: string): Angul
|
||||
function lazyImport(text: string): { spec: string; member: string | null } | null {
|
||||
const imp = /\bimport\s*\(\s*(['"`])([^'"`]+)\1\s*\)/.exec(text);
|
||||
if (!imp) return null;
|
||||
const then = /\.then\s*\(\s*\(?\s*([A-Za-z_$][\w$]*)\s*\)?\s*=>\s*\(?\s*\1\s*\.\s*([A-Za-z_$][\w$]*)/.exec(text.slice(imp.index + imp[0].length));
|
||||
return { spec: imp[2]!, member: then ? then[2]! : null };
|
||||
const after = text.slice(imp.index + imp[0].length);
|
||||
const then = /^\s*\.then\s*\(\s*\(?\s*([A-Za-z_$][\w$]*)\s*\)?\s*=>\s*\(?\s*\1\s*\.\s*([A-Za-z_$][\w$]*)/.exec(after);
|
||||
// `async () => (await import('./x')).HomeModule`
|
||||
const awaited = /^\s*\)\s*\.\s*([A-Za-z_$][\w$]*)/.exec(after);
|
||||
return { spec: imp[2]!, member: then ? then[2]! : awaited ? awaited[1]! : null };
|
||||
}
|
||||
|
||||
function joinPath(segs: readonly string[]): string {
|
||||
@@ -395,9 +408,12 @@ function resolvedSegments(pathText: string, file: string, context: ResolutionCon
|
||||
.split('/')
|
||||
.filter((seg) => seg.length > 0)
|
||||
.flatMap((seg) => {
|
||||
if (!seg.startsWith('{') || !seg.endsWith('}')) return [seg];
|
||||
const value = constantValue(seg.slice(1, -1), file, context);
|
||||
return value === null ? [seg] : value.split('/').filter((v) => v.length > 0);
|
||||
if (seg.startsWith('{') && seg.endsWith('}') && seg.indexOf('{', 1) < 0) {
|
||||
const value = constantValue(seg.slice(1, -1), file, context);
|
||||
return value === null ? [seg] : value.split('/').filter((v) => v.length > 0);
|
||||
}
|
||||
// `:{ProjectConst.IssueId}` — a constant inside a segment.
|
||||
return [seg.replace(/\{([^{}]+)\}/g, (all, expr: string) => constantValue(expr, file, context) ?? all)];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -413,21 +429,48 @@ function routeComponent(encoded: string, fromFile: string, context: ResolutionCo
|
||||
// The cross-file pass: mounts and constant paths
|
||||
// =============================================================================
|
||||
|
||||
/** The file a routes import names, and — for an NgModule — the routing modules it imports. */
|
||||
/**
|
||||
* The file a routes import names, and — for an NgModule — the routing modules
|
||||
* it imports. A barrel is looked through first: an Nx library is imported by
|
||||
* its alias (`@angular-spotify/web/home/feature`), which names the library's
|
||||
* `src/index.ts`, whose `export * from './lib/home.module'` names the module.
|
||||
*/
|
||||
function routeFilesLoadedBy(spec: string, fromFile: string, context: ResolutionContext, routeFiles: ReadonlySet<string>, mountFiles: ReadonlySet<string>): string[] {
|
||||
const target = resolveImportPath(spec, fromFile, 'typescript', context);
|
||||
if (!target) return [];
|
||||
if (routeFiles.has(target) || mountFiles.has(target)) return [target];
|
||||
// `loadChildren: () => import('./layout/layout.module').then(m => m.LayoutModule)`:
|
||||
// the module holds no routes; the routing module it imports does.
|
||||
const content = context.readFile(target);
|
||||
if (!content) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
const imports = /\bimport\s+[^'"]*?from\s+(['"])([^'"]+)\1/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = imports.exec(content)) !== null) {
|
||||
const file = resolveImportPath(m[2]!, target, 'typescript', context);
|
||||
if (file && (routeFiles.has(file) || mountFiles.has(file))) out.push(file);
|
||||
// A barrel can re-export another barrel; a few hops settle it.
|
||||
let barrels = target ? [target] : [];
|
||||
for (let hop = 0; hop < 4 && barrels.length > 0; hop++) {
|
||||
const next: string[] = [];
|
||||
for (const file of barrels) {
|
||||
if (seen.has(file)) continue;
|
||||
seen.add(file);
|
||||
if (routeFiles.has(file) || mountFiles.has(file)) {
|
||||
out.push(file);
|
||||
continue;
|
||||
}
|
||||
const content = context.readFile(file);
|
||||
if (!content) continue;
|
||||
// `loadChildren: () => import('./layout/layout.module').then(m => m.LayoutModule)`:
|
||||
// the module holds no routes; the routing module it imports does.
|
||||
if (/@NgModule\s*\(/.test(content)) {
|
||||
const imports = /\bimport\s+[^'"]*?from\s+(['"])([^'"]+)\1/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = imports.exec(content)) !== null) {
|
||||
const imported = resolveImportPath(m[2]!, file, 'typescript', context);
|
||||
if (imported && (routeFiles.has(imported) || mountFiles.has(imported)) && !out.includes(imported)) out.push(imported);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const reexports = /\bexport\s+(?:\*|\{[^}]*\})\s*(?:as\s+[\w$]+\s*)?from\s+(['"])([^'"]+)\1/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = reexports.exec(content)) !== null) {
|
||||
const reexported = resolveImportPath(m[2]!, file, 'typescript', context);
|
||||
if (reexported && !seen.has(reexported)) next.push(reexported);
|
||||
}
|
||||
}
|
||||
barrels = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -458,29 +501,68 @@ export function constantText(expr: string, fromFile: string, context: Resolution
|
||||
const [root, ...chain] = expr.split('.').map((part) => part.trim());
|
||||
let value: string | null = null;
|
||||
const file = root ? declaringFile(root, fromFile, context) : null;
|
||||
const content = file ? context.readFile(file) : null;
|
||||
if (content && chain.length > 0) {
|
||||
const content = file && root ? declarationSource(root, file, context, 0) : null;
|
||||
if (content && root && chain.length > 0) {
|
||||
const safe = stripCommentsForRegex(content, 'typescript');
|
||||
const decl = new RegExp(String.raw`\b(?:const|let)\s+${root}\s*(?::[^=]+)?=\s*\{`).exec(safe);
|
||||
if (decl) {
|
||||
let start = decl.index + decl[0].length - 1;
|
||||
const readChain = (open: number, links: readonly string[]): string | null => {
|
||||
let start = open;
|
||||
let end = matchBracket(safe, start);
|
||||
for (let i = 0; i < chain.length && end > start; i++) {
|
||||
const field = readFields(safe, start, end).get(chain[i]!);
|
||||
if (!field) break;
|
||||
if (i === chain.length - 1) {
|
||||
value = field.text.trim();
|
||||
break;
|
||||
}
|
||||
for (let i = 0; i < links.length && end > start; i++) {
|
||||
const field = readFields(safe, start, end).get(links[i]!);
|
||||
if (!field) return null;
|
||||
if (i === links.length - 1) return field.text.trim();
|
||||
start = safe.indexOf('{', field.at);
|
||||
end = start < 0 ? -1 : matchBracket(safe, start);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const decl = new RegExp(String.raw`\b(?:const|let)\s+${root}\s*(?::[^=]+)?=\s*\{`).exec(safe);
|
||||
const enumDecl = decl ? null : new RegExp(String.raw`\benum\s+${root}\s*\{`).exec(safe);
|
||||
const classDecl = decl || enumDecl ? null : new RegExp(String.raw`\bclass\s+${root}\b[^{]*\{`).exec(safe);
|
||||
if (decl) value = readChain(decl.index + decl[0].length - 1, chain);
|
||||
else if (enumDecl && chain.length === 1) {
|
||||
// `enum Paths { Board = 'board' }`
|
||||
const open = enumDecl.index + enumDecl[0].length - 1;
|
||||
const body = safe.slice(open, Math.max(open, matchBracket(safe, open)));
|
||||
value = new RegExp(String.raw`\b${chain[0]}\s*=\s*(['"\`][^'"\`]*['"\`])`).exec(body)?.[1] ?? null;
|
||||
} else if (classDecl) {
|
||||
// `class ProjectConst { static readonly IssueId = 'issueId' }`, and
|
||||
// `class RouterUtil { static Configuration = { Visualizer: 'visualizer' } }`.
|
||||
const open = classDecl.index + classDecl[0].length - 1;
|
||||
const close = matchBracket(safe, open);
|
||||
const body = close > open ? safe.slice(open, close) : '';
|
||||
const field = new RegExp(String.raw`\bstatic\s+(?:readonly\s+)?${chain[0]}\s*(?::[^=;]+)?=\s*`).exec(body);
|
||||
if (field) {
|
||||
const at = open + field.index + field[0].length;
|
||||
if (safe[at] === '{') value = chain.length > 1 ? readChain(at, chain.slice(1)) : null;
|
||||
else if (chain.length === 1) value = /^(['"\`])[^'"\`]*\1/.exec(safe.slice(at))?.[0] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
memo.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The source that declares `root`: `file` itself, or — when `file` is a
|
||||
* barrel (an Nx library's `src/index.ts`) — the module it re-exports it from.
|
||||
*/
|
||||
function declarationSource(root: string, file: string, context: ResolutionContext, hops: number): string | null {
|
||||
const content = context.readFile(file);
|
||||
if (!content) return null;
|
||||
if (new RegExp(String.raw`\b(?:const|let|class|enum)\s+${root}\b`).test(content)) return content;
|
||||
if (hops >= 3) return null;
|
||||
const reexports = /\bexport\s+(\*|\{[^}]*\})\s*from\s+(['"])([^'"]+)\2/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = reexports.exec(content)) !== null) {
|
||||
if (m[1] !== '*' && !new RegExp(String.raw`\b${root}\b`).test(m[1]!)) continue;
|
||||
const target = resolveImportPath(m[3]!, file, 'typescript', context);
|
||||
const found = target ? declarationSource(root, target, context, hops + 1) : null;
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `create.path` as the chain its root was destructured from in `fromFile`, or null when the root is no local alias. */
|
||||
function localAlias(expr: string, fromFile: string, context: ResolutionContext): string | null {
|
||||
const content = context.readFile(fromFile);
|
||||
@@ -522,6 +604,93 @@ function declaringFile(name: string, fromFile: string, context: ResolutionContex
|
||||
// Route table
|
||||
// =============================================================================
|
||||
|
||||
interface AngularMounts {
|
||||
/** Every file that lazy-loads routes, whether or not it declares a screen of its own. */
|
||||
mountFiles: ReadonlySet<string>;
|
||||
/** The path segments a file's routes sit under: its parent's prefix plus the mount's. */
|
||||
prefixOf(file: string): string[];
|
||||
}
|
||||
|
||||
const mountMemo = new WeakMap<ResolutionContext, { source: readonly Node[]; mounts: AngularMounts }>();
|
||||
|
||||
/**
|
||||
* Where every routes file is mounted — the `loadChildren` chain from the app
|
||||
* down, settled from the top. Shared by the cross-file pass, which names the
|
||||
* routes, and the route table, which reads the redirects in files that only
|
||||
* mount others (jira-clone's `app.routes.ts` holds nothing but two mounts and
|
||||
* `'' → 'project'`).
|
||||
*/
|
||||
function angularMounts(context: ResolutionContext, routes: readonly Node[]): AngularMounts {
|
||||
const source = context.getNodesByKind('route');
|
||||
const cached = mountMemo.get(context);
|
||||
if (cached && cached.source === source) return cached.mounts;
|
||||
const routeFiles = new Set(routes.map((r) => r.filePath));
|
||||
const mountsByFile = new Map<string, AngularMount[]>();
|
||||
for (const file of context.getAllFiles()) {
|
||||
if (!/\.[cm]?ts$/.test(file) || !(context.fileContains?.(file, 'loadChildren') ?? context.readFile(file)?.includes('loadChildren'))) continue;
|
||||
const content = context.readFile(file);
|
||||
if (!content) continue;
|
||||
const { mounts } = parseAngularRoutes(content);
|
||||
if (mounts.length > 0) mountsByFile.set(file, mounts);
|
||||
}
|
||||
const mountFiles = new Set(mountsByFile.keys());
|
||||
const loadedBy = new Map<string, { parent: string; prefix: string }>();
|
||||
for (const [file, mounts] of mountsByFile) {
|
||||
for (const mount of mounts) {
|
||||
for (const target of routeFilesLoadedBy(mount.spec, file, context, routeFiles, mountFiles)) {
|
||||
// A file mounted from two places keeps its first mount.
|
||||
if (target !== file && !loadedBy.has(target)) loadedBy.set(target, { parent: file, prefix: mount.prefix });
|
||||
}
|
||||
}
|
||||
}
|
||||
const memo = new Map<string, string[]>();
|
||||
const prefixOf = (file: string, seen: Set<string> = new Set()): string[] => {
|
||||
const hit = memo.get(file);
|
||||
if (hit !== undefined) return hit;
|
||||
const mount = loadedBy.get(file);
|
||||
let prefix: string[] = [];
|
||||
if (mount && !seen.has(file)) {
|
||||
seen.add(file);
|
||||
prefix = [...prefixOf(mount.parent, seen), ...resolvedSegments(mount.prefix, mount.parent, context)];
|
||||
}
|
||||
memo.set(file, prefix);
|
||||
return prefix;
|
||||
};
|
||||
const mounts: AngularMounts = { mountFiles, prefixOf: (file) => prefixOf(file) };
|
||||
mountMemo.set(context, { source, mounts });
|
||||
return mounts;
|
||||
}
|
||||
|
||||
const workspaceRoots = new WeakMap<ResolutionContext, Map<string, string | null>>();
|
||||
|
||||
/**
|
||||
* The app a routes file belongs to. An Angular workspace — the directory an
|
||||
* `angular.json` or an Nx `nx.json` sits in — is one app however its routes
|
||||
* are split: angular-spotify declares its screens across a dozen `libs/*`
|
||||
* packages, each with a `src/` of its own, and keyed by those every
|
||||
* template's `routerLink` looked for its routes in the wrong table. Outside a
|
||||
* workspace, the conventional app root.
|
||||
*/
|
||||
function angularAppRoot(filePath: string, context: ResolutionContext): string {
|
||||
let memo = workspaceRoots.get(context);
|
||||
if (!memo) workspaceRoots.set(context, (memo = new Map()));
|
||||
const slash = filePath.lastIndexOf('/');
|
||||
const dir = slash < 0 ? '' : filePath.slice(0, slash);
|
||||
let found = memo.get(dir);
|
||||
if (found === undefined) {
|
||||
found = null;
|
||||
for (let d: string | null = dir; d !== null; d = d === '' ? null : d.includes('/') ? d.slice(0, d.lastIndexOf('/')) : '') {
|
||||
const at = d === '' ? '' : `${d}/`;
|
||||
if (context.fileExists(`${at}angular.json`) || context.fileExists(`${at}nx.json`)) {
|
||||
found = at;
|
||||
break;
|
||||
}
|
||||
}
|
||||
memo.set(dir, found);
|
||||
}
|
||||
return found ?? appRootFor(filePath);
|
||||
}
|
||||
|
||||
export type AngularRouteTable = RootedRouteTable;
|
||||
|
||||
const tables = new WeakMap<ResolutionContext, AngularRouteTable>();
|
||||
@@ -534,7 +703,7 @@ export function angularRouteTable(context: ResolutionContext): AngularRouteTable
|
||||
const byFile = new Map<string, Node>();
|
||||
for (const node of all) {
|
||||
if (!isAngularRoute(node) || !node.name.startsWith('/')) continue;
|
||||
const root = appRootFor(node.filePath);
|
||||
const root = angularAppRoot(node.filePath, context);
|
||||
let t = byRoot.get(root);
|
||||
if (!t) byRoot.set(root, (t = { source: all, exact: new Map(), dynamic: [] }));
|
||||
addRouteTo(t, node.name, node);
|
||||
@@ -545,13 +714,21 @@ export function angularRouteTable(context: ResolutionContext): AngularRouteTable
|
||||
// in-file paths. A chain (`/` → `/pages` → `/pages/dashboard`) settles in
|
||||
// a few passes.
|
||||
const aliases: Array<{ table: RouteTable; from: string; to: string }> = [];
|
||||
const mounts = byFile.size > 0 ? angularMounts(context, all.filter(isAngularRoute)) : null;
|
||||
const redirectFiles = new Map<string, string[]>();
|
||||
for (const [file, sample] of byFile) {
|
||||
const content = context.readFile(file);
|
||||
if (!content || !content.includes('redirectTo')) continue;
|
||||
const own = resolvedSegments(inFilePath(sample), file, context);
|
||||
const full = sample.name.split('/').filter((seg) => seg.length > 0);
|
||||
const prefix = full.slice(0, Math.max(0, full.length - own.length));
|
||||
const table = byRoot.get(appRootFor(file));
|
||||
redirectFiles.set(file, full.slice(0, Math.max(0, full.length - own.length)));
|
||||
}
|
||||
// A file that only mounts others can still redirect: its prefix is where it is mounted.
|
||||
for (const file of mounts?.mountFiles ?? []) {
|
||||
if (!redirectFiles.has(file)) redirectFiles.set(file, mounts!.prefixOf(file));
|
||||
}
|
||||
for (const [file, prefix] of redirectFiles) {
|
||||
const content = context.readFile(file);
|
||||
if (!content || !content.includes('redirectTo')) continue;
|
||||
const table = byRoot.get(angularAppRoot(file, context)) ?? (byRoot.size === 1 ? [...byRoot.values()][0]! : undefined);
|
||||
if (!table) continue;
|
||||
for (const r of parseAngularRoutes(content).redirects) {
|
||||
const from = joinPath([...prefix, ...resolvedSegments(r.from, file, context)]);
|
||||
@@ -601,7 +778,7 @@ export function angularRoutesFor(table: AngularRouteTable, filePath: string): Ro
|
||||
const NAV_CALL = /^(?:this\.)?_?[rR]outer\.(navigate|navigateByUrl|createUrlTree|parseUrl)$/;
|
||||
|
||||
/** The destination a command array names: `['/article', slug]` is `/article/${…}`; null when it is relative or not a literal array. */
|
||||
export function commandsHref(text: string): HrefLiteral | null {
|
||||
export function commandsHref(text: string, fromRoot = false): HrefLiteral | null {
|
||||
const arr = text.trim();
|
||||
if (arr[0] !== '[') return null;
|
||||
const close = matchBracket(arr, 0);
|
||||
@@ -644,11 +821,13 @@ export function commandsHref(text: string): HrefLiteral | null {
|
||||
i++;
|
||||
}
|
||||
flush();
|
||||
// Only an absolute destination: `['/login']`. `['../', id]` and a bare
|
||||
// `['edit']` are relative to wherever the component is.
|
||||
// Only an absolute destination: `['/login']`. In a template, `['../', id]`
|
||||
// and a bare `['edit']` are relative to wherever the component is; a
|
||||
// `router.navigate(['project', 'issue', id])` with no `relativeTo` starts
|
||||
// from the root.
|
||||
const firstElement = arr.slice(1, close).split(',')[0] ?? '';
|
||||
const first = staticString(firstElement);
|
||||
if (first === null || !first.startsWith('/')) return null;
|
||||
if (first === null || (!first.startsWith('/') && !(fromRoot && !first.startsWith('.')))) return null;
|
||||
const pathText = '/' + parts.filter((p) => p.length > 0).join('/');
|
||||
return namesSomewhere({ path: pathText, display: pathText.split(HOLE).join('${…}') });
|
||||
}
|
||||
@@ -725,14 +904,14 @@ function arrowReturn(fn: string): string | null {
|
||||
*
|
||||
* `owner` is the class the expression is written in, for `this.` properties.
|
||||
*/
|
||||
export function angularDestination(expr: string, file: string, owner: Node | null, context: ResolutionContext, depth = 0): HrefLiteral | null {
|
||||
return namesSomewhere(destinationOf(expr, file, owner, context, depth));
|
||||
export function angularDestination(expr: string, file: string, owner: Node | null, context: ResolutionContext, depth = 0, fromRoot = false): HrefLiteral | null {
|
||||
return namesSomewhere(destinationOf(expr, file, owner, context, depth, fromRoot));
|
||||
}
|
||||
|
||||
function destinationOf(expr: string, file: string, owner: Node | null, context: ResolutionContext, depth: number): HrefLiteral | null {
|
||||
function destinationOf(expr: string, file: string, owner: Node | null, context: ResolutionContext, depth: number, fromRoot: boolean): HrefLiteral | null {
|
||||
const text = expr.trim();
|
||||
if (text.length === 0 || depth > 4) return null;
|
||||
if (text[0] === '[') return commandsHref(text);
|
||||
if (text[0] === '[') return commandsHref(text, fromRoot);
|
||||
const literal = staticString(text);
|
||||
if (literal !== null) return toHref(literal);
|
||||
// `'/editor/' + article.slug`: the literal head, a hole for the rest.
|
||||
@@ -743,7 +922,7 @@ function destinationOf(expr: string, file: string, owner: Node | null, context:
|
||||
// `this.routerLinkAdminControlUsers.concat(userId)`: one segment more.
|
||||
const concat = /^(.*?)\.concat\s*\((.*)\)$/s.exec(text);
|
||||
if (concat) {
|
||||
const base = angularDestination(concat[1]!, file, owner, context, depth + 1);
|
||||
const base = angularDestination(concat[1]!, file, owner, context, depth + 1, fromRoot);
|
||||
const added = concat[2]!.split(',').filter((a) => a.trim().length > 0).length;
|
||||
return base && added > 0 ? toHref(base.path.replace(/\/$/, '') + ('/' + HOLE).repeat(added)) : base;
|
||||
}
|
||||
@@ -753,7 +932,7 @@ function destinationOf(expr: string, file: string, owner: Node | null, context:
|
||||
if (call) {
|
||||
const fn = constantText(call[1]!.replace(/\s+/g, '').replace(/^this\./, ''), file, context);
|
||||
const returned = fn ? arrowReturn(fn) : null;
|
||||
return returned ? commandsHref(returned) : null;
|
||||
return returned ? commandsHref(returned, fromRoot) : null;
|
||||
}
|
||||
const chain = /^(?:this\s*\.\s*)?([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)$/.exec(text);
|
||||
if (!chain) return parseHrefExpression(text);
|
||||
@@ -762,12 +941,12 @@ function destinationOf(expr: string, file: string, owner: Node | null, context:
|
||||
if (content && owner && (text.startsWith('this') || parts.length === 1)) {
|
||||
const init = propertyInitializer(owner, parts[0]!, content);
|
||||
if (init !== null) {
|
||||
if (parts.length === 1) return angularDestination(init, file, owner, context, depth + 1);
|
||||
return angularDestination(`${init}.${parts.slice(1).join('.')}`, file, owner, context, depth + 1);
|
||||
if (parts.length === 1) return angularDestination(init, file, owner, context, depth + 1, fromRoot);
|
||||
return angularDestination(`${init}.${parts.slice(1).join('.')}`, file, owner, context, depth + 1, fromRoot);
|
||||
}
|
||||
}
|
||||
const constant = constantText(parts.join('.'), file, context);
|
||||
return constant ? angularDestination(constant, file, owner, context, depth + 1) : null;
|
||||
return constant ? angularDestination(constant, file, owner, context, depth + 1, fromRoot) : null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -837,44 +1016,8 @@ export const angularRouterResolver: FrameworkResolver = {
|
||||
postExtract(context: ResolutionContext): Node[] {
|
||||
const routes = context.getNodesByKind('route').filter(isAngularRoute);
|
||||
if (routes.length === 0) return [];
|
||||
const routeFiles = new Set(routes.map((r) => r.filePath));
|
||||
// Every file that lazy-loads routes, whether or not it declares a screen of its own.
|
||||
const mountsByFile = new Map<string, AngularMount[]>();
|
||||
for (const file of context.getAllFiles()) {
|
||||
if (!/\.[cm]?ts$/.test(file) || !(context.fileContains?.(file, 'loadChildren') ?? context.readFile(file)?.includes('loadChildren'))) continue;
|
||||
const content = context.readFile(file);
|
||||
if (!content) continue;
|
||||
const { mounts } = parseAngularRoutes(content);
|
||||
if (mounts.length > 0) mountsByFile.set(file, mounts);
|
||||
}
|
||||
const mountFiles = new Set(mountsByFile.keys());
|
||||
// file → the prefix it is mounted under (the parent file's own prefix
|
||||
// plus the mount's), settled from the top down.
|
||||
const loadedBy = new Map<string, { parent: string; prefix: string }>();
|
||||
for (const [file, mounts] of mountsByFile) {
|
||||
for (const mount of mounts) {
|
||||
for (const target of routeFilesLoadedBy(mount.spec, file, context, routeFiles, mountFiles)) {
|
||||
// A file mounted from two places keeps its first mount.
|
||||
if (target !== file && !loadedBy.has(target)) loadedBy.set(target, { parent: file, prefix: mount.prefix });
|
||||
}
|
||||
}
|
||||
}
|
||||
const { prefixOf } = angularMounts(context, routes);
|
||||
const resolved = (pathText: string, file: string): string[] => resolvedSegments(pathText, file, context);
|
||||
|
||||
const memo = new Map<string, string[]>();
|
||||
const prefixOf = (file: string, seen: Set<string> = new Set()): string[] => {
|
||||
const hit = memo.get(file);
|
||||
if (hit !== undefined) return hit;
|
||||
const mount = loadedBy.get(file);
|
||||
let prefix: string[] = [];
|
||||
if (mount && !seen.has(file)) {
|
||||
seen.add(file);
|
||||
prefix = [...prefixOf(mount.parent, seen), ...resolved(mount.prefix, mount.parent)];
|
||||
}
|
||||
memo.set(file, prefix);
|
||||
return prefix;
|
||||
};
|
||||
|
||||
const changed: Node[] = [];
|
||||
for (const route of routes) {
|
||||
const name = joinPath([...prefixOf(route.filePath), ...resolved(inFilePath(route), route.filePath)]);
|
||||
@@ -915,7 +1058,7 @@ export const angularRouterResolver: FrameworkResolver = {
|
||||
.getNodesInFile(ref.filePath)
|
||||
.filter((n) => n.kind === 'class' && n.startLine <= ref.line && n.endLine >= ref.line)
|
||||
.reduce<Node | null>((inner, n) => (!inner || n.startLine >= inner.startLine ? n : inner), null);
|
||||
const href = angularDestination(arg, ref.filePath, owner, context);
|
||||
const href = angularDestination(arg, ref.filePath, owner, context, 0, true);
|
||||
if (!href || !href.path.startsWith('/')) return null;
|
||||
const targets = destinationsForHref(href, routes);
|
||||
const target = targets[0];
|
||||
|
||||
Reference in New Issue
Block a user