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

- Analog default file pages now link to their component classes, preserving its directory layouts, dotted paths and dynamic segments when the platform plugin and file router are registered.

- 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.
Expand Down
208 changes: 208 additions & 0 deletions __tests__/analog-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { afterEach, 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 { routeRoots } from '../src/ui-server/api/route-roots';

describe('Analog default page 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 page = (file: string, name: string) =>
write(
'src/app/pages/' + file + '.page.ts',
`import {Component} from '@angular/core'; @Component({template:'page'}) export default class ${name} {}`,
);
const setup = () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-analog-'));
write(
'package.json',
JSON.stringify({
dependencies: { '@analogjs/router': '2.7.1', '@analogjs/platform': '2.7.1' },
}),
);
write(
'vite.config.ts',
`import {defineConfig} from 'vite'; import analog from '@analogjs/platform'; export default defineConfig({plugins:[analog()]});`,
);
write(
'src/app/app.config.ts',
`import {provideFileRouter} from '@analogjs/router'; export const appConfig = {providers:[provideFileRouter()]};`,
);
};
afterEach(() => {
cg?.close();
cg = undefined;
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
// analogjs/analog@0896a7eaaa2acf26443ca184bc1dd9aa1a06f4d6,
// apps/analog-app/src/app/pages/products.[productId].page.ts and (auth).page.ts.
it('composes pinned filename semantics and links exact page classes', async () => {
setup();
for (const [file, name] of [
['(home)', 'Home'],
['products', 'Products'],
['products.[productId]', 'ProductDetailsComponent'],
['(auth)', 'AuthLayoutPageComponent'],
['(auth)/login', 'Login'],
['admin', 'AdminLayout'],
['admin/index', 'AdminIndex'],
['admin/users.[id]', 'User'],
['[...slug]', 'CatchAll'],
['_private', 'Private'],
])
page(file!, name!);
write(
'src/app/pages/(auth)/sign-up.page.ts',
"import { Component } from '@angular/core';\n\n@Component({\n template: ` <h2>SignUp</h2> `,\n})\nexport default class SignupPageComponent {}\n",
);
write('src/other.ts', 'export class ProductDetailsComponent {}');
cg = await CodeGraph.init(dir, { index: true });
const routes = cg.getNodesByKind('route');
expect(routes.map((n) => n.name).sort()).toEqual([
'/',
'/*',
'/_private',
'/admin',
'/admin/users/:id',
'/login',
'/products',
'/products/:productId',
'/sign-up',
]);
const root = routeRoots(cg, routes).get(
routes.find((n) => n.name === '/products/:productId')!.id,
)!.node;
expect([root.name, root.filePath]).toEqual([
'ProductDetailsComponent',
'src/app/pages/products.[productId].page.ts',
]);
expect(
routeRoots(cg, routes).get(routes.find((n) => n.name === '/sign-up')!.id)!.node.name,
).toBe('SignupPageComponent');
});
it('refreshes layouts and registration changes after reopening', async () => {
setup();
page('parent', 'Parent');
cg = await CodeGraph.init(dir, { index: true });
expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/parent']);
cg.close();
cg = await CodeGraph.open(dir);
page('parent/child', 'Child');
await cg.sync({ paths: ['src/app/pages/parent/child.page.ts'] });
expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/parent/child']);
fs.unlinkSync(path.join(dir, 'src/app/pages/parent/child.page.ts'));
await cg.sync();
expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/parent']);
write('src/app/app.config.ts', 'export const appConfig = {providers:[]};');
await cg.sync();
expect(cg.getNodesByKind('route')).toEqual([]);
write(
'src/app/app.config.ts',
`import {provideFileRouter as files} from '@analogjs/router'; export const providers=[files()];`,
);
await cg.sync();
expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/parent']);
write(
'vite.config.ts',
`import analog from '@analogjs/platform'; export default {root:'custom',plugins:[analog()]};`,
);
await cg.sync();
expect(cg.getNodesByKind('route')).toEqual([]);
});
it.each([
[
'vite.config.ts',
`import analog from '@analogjs/platform'; export default {plugins:[analog()],plugins:[]};`,
],
[
'src/app/app.config.ts',
`import {provideFileRouter} from '@analogjs/router'; for (const provideFileRouter of [()=>0]) provideFileRouter();`,
],
['vite.config.ts', `import analog from 'other'; export default {plugins:[analog()]};`],
[
'vite.config.ts',
`import analog from '@analogjs/platform'; export default {plugins:[analog({additionalPagesDirs:['custom']})]};`,
],
[
'vite.config.ts',
`import analog from '@analogjs/platform'; export default makeConfig(analog());`,
],
[
'src/app/app.config.ts',
`import {provideFileRouter} from 'other'; export const providers=[provideFileRouter()];`,
],
[
'src/app/app.config.ts',
`import type {provideFileRouter} from '@analogjs/router'; export const providers=[provideFileRouter()];`,
],
[
'src/app/app.config.ts',
`import {provideFileRouter} from '@analogjs/router'; function wrapper(provideFileRouter){return provideFileRouter()}`,
],
[
'src/app/app.config.ts',
`import {provideFileRouter} from '@analogjs/router'; false && provideFileRouter();`,
],
[
'src/app/app.config.ts',
`import {provideFileRouter,withExtraRoutes} from '@analogjs/router'; export const providers=[provideFileRouter(withExtraRoutes([{path:'other'}]))];`,
],
])('ignores unsupported activation: %s %s', async (file, source) => {
setup();
page('index', 'Home');
write(file!, source!);
cg = await CodeGraph.init(dir, { index: true });
expect(cg.getNodesByKind('route')).toEqual([]);
});
it('excludes metadata, optional catchalls, nondefault and anonymous pages', async () => {
setup();
page('[[...slug]]', 'Optional');
write(
'src/app/pages/meta.page.ts',
`export const routeMeta={redirectTo:'/'}; export default class Meta {}`,
);
write('src/app/pages/named.page.ts', `export class Named {}`);
write('src/app/pages/anonymous.page.ts', `export default class {}`);
cg = await CodeGraph.init(dir, { index: true });
expect(cg.getNodesByKind('route')).toEqual([]);
});
it.each([false, true])('discovers newly introduced Analog (scoped=%s)', async (scoped) => {
setup();
write('package.json', '{}');
page('index', 'Home');
cg = await CodeGraph.init(dir, { index: true });
expect(cg.getNodesByKind('route')).toEqual([]);
write('package.json', JSON.stringify({ dependencies: { '@analogjs/router': '2.0.0' } }));
write(
'src/app/app.config.ts',
`import {provideFileRouter as routes} from '@analogjs/router'; export const providers=[routes()];`,
);
await cg.sync(scoped ? { paths: ['src/app/app.config.ts'] } : undefined);
expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/']);
});
it.runIf(fs.existsSync(path.resolve('dist/index.js')))(
'uses fresh compiled parse and store workers',
() => {
setup();
page('index', 'Home');
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_PARSE_WORKERS: '2',
CODEGRAPH_PARALLEL_RESOLVE_MIN: '1',
CODEGRAPH_RESOLVE_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,12 +1,12 @@
Status: 6/13 — Angular Router verified; publishing step 6 before Analog
Status: 7/13 — Analog verified; publishing step 7 before Solid Router

- [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.
- [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.
- [x] 7 Analog — registered default pages bind exact classes with directory-based layouts and dot/parameter conventions; config/file and reopened/scoped sync plus fresh workers pass; build passes, 133 WASM focused/control tests pass, full native suite 4,500 pass / 46 skip; independent review clear.
- [ ] 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.
- [ ] 10 Qwik City — default file routes, page components, and method-specific endpoint exports. Gate: shared proof, parameters, layouts, and `onRequest`/middleware exclusions.
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 @@ -45,6 +45,9 @@ guessed.
| 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 |
| Analog | `frameworks/analog.ts` | — | `analog-routes.test.ts` | pinned 2.7.1 sign-up page; filename/layout and registration sync controls |

Analog recognizes `src/app/pages/**/*.page.ts` named default classes when a root Vite config registers the platform plugin and source registers option-free `provideFileRouter()`. Directory hierarchy determines layouts before dots become URL separators; index/pathless names, parameters and catchalls follow the pinned conventions. [Official page fixture](https://github.com/analogjs/analog/blob/0896a7eaaa2acf26443ca184bc1dd9aa1a06f4d6/apps/analog-app/src/app/pages/%28auth%29/sign-up.page.ts), [route construction](https://github.com/analogjs/analog/blob/0896a7eaaa2acf26443ca184bc1dd9aa1a06f4d6/packages/router/src/lib/routes.ts). Fresh workers and config/file add/edit/delete refresh existing pages, including after reopening. Custom roots, extra route directories, `app/routes`, metadata overrides, router options, optional catchalls, Markdown, anonymous defaults and re-exports remain unsupported. No navigation is inferred.

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.

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 @@ -38,9 +38,12 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
| **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 |
| **Analog** | Registered default `src/app/pages/**/*.page.ts` pages, linked to named default classes; directory layouts, dot paths, index/pathless segments and parameters |

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.

Analog requires the platform plugin in a default-root Vite config and option-free `provideFileRouter()` registration. Custom roots, extra route directories, `app/routes`, route metadata overrides, router options, optional catchalls, Markdown and anonymous/re-exported defaults remain unsupported. A directory makes its corresponding page a layout; a dotted filename alone does not.

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.
Expand Down
Loading