Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- Angular Router registered route arrays now link to exact component classes, including nested children and static lazy imports; imported route edits refresh their registrations during sync.

- RedwoodSDK registered routes now link to their page or API handlers, preserving prefixes and method tables while excluding middleware from page roots.

- Astro pages now link to their exact components and source-declared navigation; exported HTTP methods in `.ts`/`.js` endpoints link to their handlers, while type-only declarations and `.mjs` files are excluded.
Expand Down
222 changes: 222 additions & 0 deletions __tests__/angular-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { execFileSync } from 'child_process';
import { CodeGraph } from '../src';
import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
import { routeRoots } from '../src/ui-server/api/route-roots';

beforeAll(async () => {
await initGrammars();
await loadGrammarsForLanguages(['typescript', 'javascript']);
});
describe('registered Angular routes', () => {
let cg: CodeGraph | undefined;
let dir: string;
const write = (file: string, source: string) => {
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
fs.writeFileSync(path.join(dir, file), source);
};
const setup = () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-angular-'));
write('package.json', JSON.stringify({ dependencies: { '@angular/router': '21.0.0' } }));
write('src/home.ts', 'export class Home {}');
write('src/user.ts', 'export class User {}');
write('src/other/home.ts', 'export class Home {}');
};
afterEach(() => {
cg?.close();
cg = undefined;
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
// angular/angular@9a58353b1b680f162a55969965ae6a90ae20316d:
// adev/src/content/tutorials/learn-angular/steps/14-routerLink/answer/src/app/app.routes.ts
it('binds the official tutorial registration and refreshes imported arrays after reopening', async () => {
setup();
write(
'src/app.routes.ts',
`import {Routes} from '@angular/router'; import {Home} from './home'; import {User} from './user'; export const routes: Routes = [{path:'',component:Home},{path:'user',component:User}];`,
);
write(
'src/app.config.ts',
`import {provideRouter} from '@angular/router'; import {routes} from './app.routes'; export const appConfig = {providers:[provideRouter(routes)]};`,
);
cg = await CodeGraph.init(dir, { index: true });
const routes = cg.getNodesByKind('route');
expect(routes.map((n) => n.name).sort()).toEqual(['/', '/user']);
const roots = routeRoots(cg, routes);
for (const [url, name, file] of [
['/', 'Home', 'src/home.ts'],
['/user', 'User', 'src/user.ts'],
]) {
const root = roots.get(routes.find((n) => n.name === url)!.id)!.node;
expect([root.name, root.filePath]).toEqual([name, file]);
}
cg.close();
cg = await CodeGraph.open(dir);
write(
'src/app.routes.ts',
`import {Home} from './home'; export const routes = [{path:'new',component:Home}];`,
);
await cg.sync();
expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/new']);
fs.unlinkSync(path.join(dir, 'src/app.routes.ts'));
await cg.sync();
expect(cg.getNodesByKind('route')).toEqual([]);
write(
'src/app.routes.ts',
`import {Home} from './home'; export const routes = [{path:'added',component:Home}];`,
);
await cg.sync();
expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/added']);
});
it('composes children and lazy components and arrays without indexing orphans', async () => {
setup();
write(
'src/lazy.ts',
`import {User} from './user'; export const CHILDREN = [{path:':id',component:User}];`,
);
write('src/default.ts', 'export default class Default {}');
write(
'src/app.ts',
`import {provideRouter as router} from '@angular/router'; import {Home} from './home'; const unused = [{path:'orphan',component:Home}]; export const providers = [router([{path:'admin',children:[{path:'',component:Home},{path:'user',loadComponent:()=>import('./user').then(m=>m.User)}]},{path:'lazy',loadChildren:()=>import('./lazy').then(m=>m.CHILDREN)},{path:'default',loadComponent:()=>import('./default')},{path:'**',component:Home}])];`,
);
cg = await CodeGraph.init(dir, { index: true });
expect(
cg
.getNodesByKind('route')
.map((n) => n.name)
.sort(),
).toEqual(['/*', '/admin', '/admin/user', '/default', '/lazy/:id']);
});
it('follows forChild only through a mounted lazy NgModule', async () => {
setup();
write(
'src/child.ts',
`import {NgModule} from '@angular/core'; import {RouterModule} from '@angular/router'; import {User} from './user'; @NgModule({imports:[RouterModule.forChild([{path:'user',component:User}])]}) export class ChildModule {}`,
);
write(
'src/orphan.ts',
`import {RouterModule} from '@angular/router'; import {Home} from './home'; const orphan = RouterModule.forChild([{path:'orphan',component:Home}]);`,
);
write(
'src/app.ts',
`import {RouterModule as Router} from '@angular/router'; export const routes = Router.forRoot([{path:'admin',loadChildren:()=>import('./child').then(m=>m.ChildModule)}]);`,
);
cg = await CodeGraph.init(dir, { index: true });
expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/admin/user']);
});
it.each([
`const routes = [{path:'orphan',component:Home}];`,
`function wrapper(provideRouter){return provideRouter([{path:'shadow',component:Home}])}`,
`provideRouter([{path:dynamic,component:Home}]);`,
`provideRouter([{path:'x',matcher:match,component:Home}]);`,
`provideRouter([{path:'x',outlet:'other',component:Home}]);`,
`provideRouter([{path:'x',redirectTo:'other',component:Home}]);`,
`provideRouter([{path:'x',component:Home,...extra}]);`,
`provideRouter([{path:'x',component:Home,path:'other'}]);`,
`provideRouter([{path:'x',loadComponent:()=>factory()}]);`,
`{ const provideRouter = other; provideRouter([{path:'shadow',component:Home}]); }`,
`false && provideRouter([{path:'never',component:Home}]);`,
`provideRouter({path:'object',component:Home});`,
`const routes = [{path:'old',component:Home}]; routes.pop(); provideRouter(routes);`,
`const routes = [{path:'old',component:Home}]; routes[0].path = 'new'; provideRouter(routes);`,
])('rejects unsupported declarations: %s', async (body) => {
setup();
write(
'src/app.ts',
`import {provideRouter} from '@angular/router'; import {Home} from './home'; ${body}`,
);
cg = await CodeGraph.init(dir, { index: true });
expect(cg.getNodesByKind('route')).toEqual([]);
});
it.each([false, true])(
'detects newly introduced Angular registrations (scoped=%s)',
async (scoped) => {
setup();
write('package.json', '{}');
cg = await CodeGraph.init(dir, { index: true });
write('package.json', JSON.stringify({ dependencies: { '@angular/router': '21.0.0' } }));
write(
'src/app.ts',
`import {provideRouter} from '@angular/router'; import {Home} from './home'; export const providers = [provideRouter([{path:'new',component:Home}])];`,
);
await cg.sync(scoped ? { paths: ['src/app.ts'] } : undefined);
expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/new']);
},
);
it('keeps matching parents, preferring a default child as the page root', async () => {
setup();
write(
'src/app.ts',
`import {provideRouter} from '@angular/router'; import {Home} from './home'; import {User} from './user'; provideRouter([{path:'empty',component:Home,children:[]},{path:'parent',component:Home,children:[{path:'child',component:User}]},{path:'index',component:Home,children:[{path:'',component:User}]}]);`,
);
cg = await CodeGraph.init(dir, { index: true });
const routes = cg.getNodesByKind('route');
expect(routes.map((n) => n.name).sort()).toEqual([
'/empty',
'/index',
'/parent',
'/parent/child',
]);
expect(routeRoots(cg, routes).get(routes.find((n) => n.name === '/index')!.id)!.node.name).toBe(
'User',
);
});
it.each(['routes.length=0;', 'const alias=routes; alias.length=0;', `routes['pop']();`])(
'ignores mutated imported arrays: %s',
async (mutation) => {
setup();
write(
'src/routes.ts',
`import {Home} from './home'; export const routes=[{path:'stale',component:Home}];`,
);
write(
'src/app.ts',
`import {provideRouter} from '@angular/router'; import {routes} from './routes'; ${mutation} provideRouter(routes);`,
);
cg = await CodeGraph.init(dir, { index: true });
expect(cg.getNodesByKind('route')).toEqual([]);
},
);
it('ignores forChild outside NgModule imports', async () => {
setup();
write(
'src/child.ts',
`import {NgModule} from '@angular/core'; import {RouterModule} from '@angular/router'; import {Home} from './home'; @NgModule({providers:[{provide:'token',useValue:RouterModule.forChild([{path:'fake',component:Home}])}]}) export class Child {}`,
);
write(
'src/app.ts',
`import {provideRouter} from '@angular/router'; provideRouter([{path:'parent',loadChildren:()=>import('./child').then(m=>m.Child)}]);`,
);
cg = await CodeGraph.init(dir, { index: true });
expect(cg.getNodesByKind('route')).toEqual([]);
});
it.runIf(fs.existsSync(path.resolve('dist/index.js')))(
'enriches fresh compiled parse/store workers',
() => {
setup();
write(
'src/routes.ts',
`import {Home} from './home'; export const routes = [{path:'',component:Home}];`,
);
write(
'src/app.ts',
`import {provideRouter} from '@angular/router'; import {routes} from './routes'; export const providers=[provideRouter(routes)];`,
);
const script = `const {CodeGraph}=require(${JSON.stringify(path.resolve('dist/index.js'))}); (async()=>{const cg=await CodeGraph.init(${JSON.stringify(dir)},{index:true});const r=cg.getNodesByKind('route')[0];console.log(JSON.stringify([r.name,cg.getOutgoingEdges(r.id).filter(e=>e.kind==='references').map(e=>cg.getNode(e.target)?.name)]));cg.close();})().catch(e=>{console.error(e);process.exit(1)});`;
const output = execFileSync(process.execPath, ['-e', script], {
encoding: 'utf8',
timeout: 60000,
env: {
...process.env,
CODEGRAPH_PARALLEL_RESOLVE_MIN: '1',
CODEGRAPH_RESOLVE_WORKERS: '2',
CODEGRAPH_PARSE_WORKERS: '2',
},
});
expect(JSON.parse(output.trim().split('\n').at(-1)!)).toEqual(['/', ['Home']]);
},
);
});
4 changes: 2 additions & 2 deletions docs/design/PLAN-application-router-coverage.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
Status: 5/13 — preparing step 5 PR (RedwoodSDK); steps 1–4 published as PRs #3–6
Status: 6/13 — Angular Router verified; publishing step 6 before Analog

- [x] 1 React Router framework mode — seven official-fixture pages bind exact components; nested index/navigation, config/module sync and fresh compiled workers verified; build passes, 112 WASM focused/control tests pass, full native suite 4,353 pass / 46 skip; independent review clear.
- [x] 2 TanStack Start server routes — literal method tables and `createHandlers` bind handlers/calls; page/API coexistence and full/scoped sync verified; build passes, 62 WASM focused/control tests pass, full native suite 4,375 pass / 46 skip; independent review clear.
- [x] 3 Remix and React Router file conventions — registered default conventions bind exact page components, optional navigation and explicit config coexistence; layout/resource controls and config-only full/scoped/reopened sync pass; build passes, 102 WASM tests pass, full native suite 4,415 pass / 46 skip; independent review clear.
- [x] 4 Astro route completion — exact page components, declared method handlers and local source navigation; false-anchor/type/export controls and full/scoped/new-framework sync pass; build passes, 195 WASM tests pass, full native suite 4,435 pass / 46 skip; independent review clear.
- [x] 5 RedwoodSDK — registered literal trees, prefixes and method tables bind exact handlers; JSX evidence classifies pages, interrupters/ambiguous declarations excluded; handler edit/delete and new-framework sync pass; build passes, 91 WASM tests pass, full native suite 4,459 pass / 46 skip; independent review clear.
- [ ] 6 Angular Router — registered literal route tables, nested children, component references, and statically resolvable lazy modules/components. Gate: shared proof, router registration and nesting, plus unregistered objects and custom matcher negatives.
- [x] 6 Angular Router — registered arrays, nested children and static lazy imports bind exact classes; imported table add/edit/delete after reopening and fresh workers pass; build passes, 117 WASM focused/control tests pass, full native suite 4,484 pass / 46 skip; independent review clear.
- [ ] 7 Analog — default file routes and page components using Analog-specific index, dot, parameter, and layout conventions. Gate: shared proof and fixtures that distinguish parent layouts from matching pages.
- [ ] 8 Solid Router — imported JSX/config declarations, nested path composition, and router base. Gate: shared proof, nested matching semantics, component links, and unrelated JSX negatives.
- [ ] 9 SolidStart — pinned-version file routes, default page exports, and HTTP-method exports. Gate: shared proof, page/API coexistence, layouts, and dynamic parameters.
Expand Down
3 changes: 3 additions & 0 deletions docs/design/framework-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ guessed.
| SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts` | sveltekit-realworld (31 edges) |
| Astro | `frameworks/astro.ts` | — | `astro-routes.test.ts` | pinned endpoint fixture; exact page components and source navigation |
| RedwoodSDK | `frameworks/redwood.ts` | — | `redwood-routes.test.ts` | pinned 1.7.3 typed-routes worker; exact page and API roots |
| Angular Router | `frameworks/angular.ts` | — | `angular-routes.test.ts` | pinned official tutorial registration; exact class roots and imported-array sync |

Angular reads registered `provideRouter` / `RouterModule.forRoot` literal or constant arrays, nested children and relative imports. Static lazy imports can select component classes, route arrays or NgModules registering `forChild`. [Pinned tutorial](https://github.com/angular/angular/blob/9a58353b1b680f162a55969965ae6a90ae20316d/adev/src/content/tutorials/learn-angular/steps/14-routerLink/answer/src/app/app.routes.ts), [loading semantics](https://angular.dev/guide/routing/loading-strategies). Parent-side extraction enriches both ordinary and fresh worker storage without depending on database insertion order. Routes belong to their registration file; sync refreshes registrations after source changes, including after reopening. Redirects, named outlets, custom matchers, conditional registrations, dynamic loaders, path aliases, re-export modules and spread objects are excluded. No navigation is inferred.

RedwoodSDK follows imported `defineApp` registrations with literal/local-constant arrays, `route`, `index`, `render`, `layout` and `prefix`. Handler arrays bind only their last handler. Standard method tables remain method-qualified; ordinary handlers remain `ANY` unless the handler returns JSX. [Pinned worker fixture](https://github.com/redwoodjs/sdk/blob/39da7118f712bd86450e493cb2c213815b1893bb/playground/typed-routes/src/worker.tsx), [router semantics](https://github.com/redwoodjs/sdk/blob/39da7118f712bd86450e493cb2c213815b1893bb/sdk/src/runtime/lib/router.ts#L682). Tests cover handler classification changes/deletion and fresh workers. Cross-file route arrays, custom methods, mutated builders, dynamic paths, duplicate/computed/spread method tables and wrapped/anonymous exported components remain unsupported; no navigation is inferred.

Expand Down
3 changes: 3 additions & 0 deletions site/src/content/docs/guides/framework-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,12 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
| **Vue Router** / **Nuxt** | Vue route tables; `.vue` pages in `pages/` or Nuxt 4 `app/pages/`, dynamic/optional/catch-all segments and route groups; `server/api/` and `server/routes/` with method suffixes; route middleware |
| **Astro** | `src/pages/` `.astro` pages linked to components; `.ts`/`.js` HTTP-method exports linked to handlers; anchors and `Astro.redirect` link to local pages |
| **RedwoodSDK** | Registered `defineApp` trees with `route`, `index`, `render`, `layout`, `prefix` and standard method tables; exact handlers and JSX page classification |
| **Angular Router** | `provideRouter` / `RouterModule.forRoot` arrays, nested children, relative component imports and static lazy components/route arrays/NgModules |

Route resolution is automatic — there's nothing to configure. If a framework file is recognized, its routes appear in the graph after the next index or sync.

Angular follows literal or constant route arrays from runtime router imports. `forChild` contributes routes only through a statically imported lazy NgModule. Route nodes belong to the registration file, and imported table changes refresh that owner. Redirects, named outlets, custom matchers, dynamic factories, conditional registrations, spread objects, path aliases and re-export modules remain unsupported. No navigation is inferred.

RedwoodSDK handler arrays use the final handler as the route root. A route stays `ANY /path` until its handler is shown to return JSX. Cross-file route arrays, custom methods, mutations, dynamic paths, ambiguous method tables and wrapped/anonymous exported components remain unsupported.

Astro supports default roots, `[param]`/`[...rest]` filenames and exported handlers, including `ALL` as `ANY`. Type-only exports, underscore-prefixed paths and `.mjs` endpoints are excluded. Custom routing configuration, Markdown/MDX, cross-file re-exports, client transition calls and navigation to rest routes remain unsupported.
Expand Down
Loading