diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c4bb38e..037f61f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` and through a styled link, like react-boilerplate's `HeaderLink = styled(Link)` used as ``. 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 `` 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. diff --git a/__tests__/angular-router.test.ts b/__tests__/angular-router.test.ts index a5309abd..52c13ca4 100644 --- a/__tests__/angular-router.test.ts +++ b/__tests__/angular-router.test.ts @@ -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): Promise { + 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', 'Lyrics'), + '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', 'Home'), + }); + 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', ''), + 'src/app/project/board.component.ts': `import { Component } from '@angular/core'; +import { Router } from '@angular/router'; +@Component({ selector: 'j-board', template: '
' }) +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: '
' }) +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(); + } + }); +}); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index fcb12619..6e123c2f 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -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`, diff --git a/src/resolution/frameworks/angular-router.ts b/src/resolution/frameworks/angular-router.ts index 02f7dc9d..0980c4a5 100644 --- a/src/resolution/frameworks/angular-router.ts +++ b/src/resolution/frameworks/angular-router.ts @@ -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, mountFiles: ReadonlySet): 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(); 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; + /** 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(); + +/** + * 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(); + 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(); + 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(); + const prefixOf = (file: string, seen: Set = 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>(); + +/** + * 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(); @@ -534,7 +703,7 @@ export function angularRouteTable(context: ResolutionContext): AngularRouteTable const byFile = new Map(); 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(); 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(); - 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(); - 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(); - const prefixOf = (file: string, seen: Set = 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((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];