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

- 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.

- Remix default file routes and registered React Router `flatRoutes()` pages now link to their components and navigation, including optional segments and configuration-only sync changes.
Expand Down
205 changes: 205 additions & 0 deletions __tests__/redwood-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
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 { extractRedwoodRoutes } from '../src/resolution/frameworks/redwood';
import { routeRoots } from '../src/ui-server/api/route-roots';

beforeAll(async () => {
await initGrammars();
await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']);
});
const imports = `import {route, index, render, layout, prefix} from 'rwsdk/router';
import {defineApp} from 'rwsdk/worker';\n`;
const extract = (body: string) => extractRedwoodRoutes('src/worker.tsx', imports + body);

describe('registered RedwoodSDK routes', () => {
it('composes aliases, constant arrays, prefixes, index and layouts', () => {
const result = extractRedwoodRoutes(
'src/worker.tsx',
`import {route as r, render as page, prefix as under, index, layout} from 'rwsdk/router';
import {defineApp as app} from 'rwsdk/worker';
const children = [index(Home), r('users/:id', [auth, User])];
const worker = app([page(Document, [under('/admin', layout(Layout, children))])]);
export default {fetch: worker.fetch};`,
);
expect(result.nodes.map((n) => n.name)).toEqual(['ANY /admin', 'ANY /admin/users/:id']);
expect(result.references.map((r) => r.referenceName)).toEqual(['Home', 'User']);
});
it('reads standard method tables without config or interrupter handlers', () => {
const result = extract(
`export default defineApp([route('/api', {get: [auth, list], post: create, head: head, config: {disableOptions: true}})]);`,
);
expect(result.nodes.map((n) => n.name)).toEqual(['GET /api', 'POST /api', 'HEAD /api']);
expect(result.references.map((r) => r.referenceName)).toEqual(['list', 'create', 'head']);
});
it('keeps inline JSX pages and response endpoints distinct', () => {
const result = extract(
`export default defineApp([render(Document, [route('/page', () => <Home/>), route('/health', () => new Response('ok')), route('/nested', () => {const unused = () => <Home/>; return new Response('ok')})])]);`,
);
expect(result.nodes.map((n) => n.name)).toEqual(['/page', 'ANY /health', 'ANY /nested']);
expect(result.references.map((r) => r.referenceName)).toEqual(['Home']);
});
it('reads shorthand handlers and ordinary method definitions', () => {
const result = extract(
`export default defineApp([route('/api', {get, post(){return save()}})]);`,
);
expect(result.nodes.map((n) => n.name)).toEqual(['GET /api', 'POST /api']);
expect(result.references.map((r) => r.referenceName)).toEqual(['get', 'save']);
});
it.each([
`const unused = route('/orphan', Home); export default defineApp([]);`,
`function wrapper(route){return [route('/shadow', Home)]} export default defineApp(wrapper(other));`,
`export default defineApp([prefix(dynamic, [route('/child', Home)])]);`,
`export default defineApp([route(path, Home)]);`,
`export default defineApp([() => route('/middleware', Home)]);`,
`export default defineApp([route('/api', {...methods, get: list})]);`,
`export default defineApp([route('/api', {[method]: list})]);`,
`export default defineApp([route('/api', {get: list, get: other})]);`,
`export default defineApp([route('/api', {get get(){return list}})]);`,
`export default defineApp([route('/api', {get: list, [method](){return other()}})]);`,
`export default defineApp([route('/api', dynamic())]);`,
`export default defineApp([route('/api', [...handlers])]);`,
`const example = "route('/fake', Home)"; export default defineApp([]);`,
`const app = defineApp([route('/fake', Home)]); export default {fetch: app.fetch, fetch: other};`,
`const app = defineApp([route('/fake', Home)]); export default {fetch: app.fetch, ...other};`,
])('ignores unsupported/unregistered declarations: %s', (body) =>
expect(extract(body).nodes).toEqual([]),
);
it('requires runtime framework helper imports', () => {
for (const source of [
`import {route} from 'other'; import {defineApp} from 'rwsdk/worker'; export default defineApp([route('/x', Home)]);`,
`import type {route} from 'rwsdk/router'; import {defineApp} from 'rwsdk/worker'; export default defineApp([route('/x', Home)]);`,
`import {route} from 'rwsdk/router'; import type {defineApp} from 'rwsdk/worker'; export default defineApp([route('/x', Home)]);`,
])
expect(extractRedwoodRoutes('src/worker.tsx', source).nodes).toEqual([]);
});
});

describe('RedwoodSDK through indexing', () => {
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);
};
afterEach(() => {
cg?.close();
cg = undefined;
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
// redwoodjs/sdk@39da7118f712bd86450e493cb2c213815b1893bb (1.7.3),
// playground/typed-routes/src/worker.tsx: expected route/handler pairs.
it('binds the pinned worker pages, API handlers and incremental changes', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-redwood-'));
write('package.json', JSON.stringify({ dependencies: { rwsdk: '1.7.3' } }));
for (const page of ['Home', 'UserProfile', 'FileViewer', 'BlogPost'])
write(`src/pages/${page}.tsx`, `export function ${page}(){return <div/>}`);
write('src/other/Home.tsx', 'export function Home(){return <div/>}');
write(
'src/api.ts',
`export function list(){return new Response('list')}
export function create(){return new Response('created')}
export function Misleading(){return new Response('not a page')}`,
);
const worker =
imports +
`import {Home} from './pages/Home'; import {UserProfile} from './pages/UserProfile';
import {FileViewer} from './pages/FileViewer'; import {BlogPost} from './pages/BlogPost';
import {list,create,Misleading} from './api';
function auth(){return undefined}
export const app = defineApp([render(Document, [route('/old', () => new Response(null,{status:301})), route('/',Home), route('/users/:id',[auth,UserProfile]), route('/files/*',FileViewer), route('/blog/:year/:slug',BlogPost), route('/misleading',Misleading)]), route('/api',{get:list,post:create})]);
export default {fetch: app.fetch};`;
write('src/worker.tsx', worker);
cg = await CodeGraph.init(dir, { index: true });
const routes = cg.getNodesByKind('route');
expect(routes.map((n) => n.name).sort()).toEqual(
[
'ANY /old',
'/',
'/users/:id',
'/files/*',
'/blog/:year/:slug',
'ANY /misleading',
'GET /api',
'POST /api',
].sort(),
);
const roots = routeRoots(cg, routes);
for (const [url, handler, file] of [
['/', 'Home', 'src/pages/Home.tsx'],
['/users/:id', 'UserProfile', 'src/pages/UserProfile.tsx'],
['/files/*', 'FileViewer', 'src/pages/FileViewer.tsx'],
['/blog/:year/:slug', 'BlogPost', 'src/pages/BlogPost.tsx'],
['GET /api', 'list', 'src/api.ts'],
['POST /api', 'create', 'src/api.ts'],
]) {
const root = roots.get(routes.find((n) => n.name === url)!.id)!.node;
expect([root.name, root.filePath]).toEqual([handler, file]);
}
const userRoute = routes.find((n) => n.name === '/users/:id')!;
expect(cg.getOutgoingEdges(userRoute.id).map((e) => cg!.getNode(e.target)?.name)).not.toContain(
'auth',
);
write(
'src/pages/Home.tsx',
`export function Home(){const unused = () => <div/>; return new Response('now an endpoint')}`,
);
await cg.sync({ paths: ['src/pages/Home.tsx'] });
expect(cg.getNodesByKind('route').some((n) => n.name === 'ANY /')).toBe(true);
fs.unlinkSync(path.join(dir, 'src/pages/UserProfile.tsx'));
await cg.sync();
expect(cg.getNodesByKind('route').some((n) => n.name === 'ANY /users/:id')).toBe(true);
write(
'src/worker.tsx',
imports + `export default defineApp([route('/new', () => new Response('ok'))]);`,
);
await cg.sync();
expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['ANY /new']);
fs.unlinkSync(path.join(dir, 'src/worker.tsx'));
await cg.sync();
expect(cg.getNodesByKind('route')).toEqual([]);
});
it.each([false, true])('discovers a newly added SDK registration (scoped=%s)', async (scoped) => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-redwood-new-'));
write('package.json', '{}');
write('src/plain.ts', 'export function plain(){}');
cg = await CodeGraph.init(dir, { index: true });
write('package.json', JSON.stringify({ dependencies: { rwsdk: '1.7.3' } }));
write(
'src/worker.tsx',
imports + `export default defineApp([route('/new', () => new Response('ok'))]);`,
);
await cg.sync(scoped ? { paths: ['src/worker.tsx'] } : undefined);
expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['ANY /new']);
});
it.runIf(fs.existsSync(path.resolve('dist/index.js')))(
'classifies pages in fresh compiled workers',
() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-redwood-workers-'));
write('package.json', JSON.stringify({ dependencies: { rwsdk: '1.7.3' } }));
write('src/Home.tsx', 'export function Home(){return <div/>}');
write(
'src/worker.tsx',
imports + `import {Home} from './Home'; export default defineApp([route('/',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_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,10 +1,10 @@
Status: 4/13 — preparing step 4 PR (Astro route completion); steps 1–3 published as PRs #3–5
Status: 5/13 — preparing step 5 PR (RedwoodSDK); steps 1–4 published as PRs #3–6

- [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.
- [ ] 5 RedwoodSDK — imported `rwsdk/router` declarations, method tables, and statically resolvable registration/prefix context. Gate: shared proof, `defineApp`/`render` composition, and interrupters excluded from page roots.
- [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.
- [ ] 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.
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 @@ -43,6 +43,9 @@ guessed.
| Vue Router / Nuxt | `frameworks/vue-router.ts` | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges) |
| 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 |

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.

Remix default `app/routes/` conventions and React Router configs registering an imported, option-free `flatRoutes()` call support JS/TS pages and immediate `folder/route` modules. [Pinned filename parser](https://github.com/remix-run/react-router/blob/7aea711dd1ae2bc5a076d13ff17291829690fa74/packages/react-router-fs-routes/flatRoutes.ts#L351): dot nesting, index/pathless segments, parameters, optional segments, splats and bracket escapes. Resource-only and direct `Outlet`-only defaults are excluded. Config-only full/scoped sync and reopening an index refresh existing pages. Custom configuration, folder `index` fallback, Markdown/MDX, anonymous defaults and re-exports remain unsupported.

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 @@ -36,9 +36,12 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
| **Next.js** | App Router and Pages Router pages; `app/api/**/route.ts` method exports and `pages/api/**` default handlers |
| **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 |

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.

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.

React Router framework mode assumes the default `app/` directory. Computed arrays, custom app directories, `relative` helpers, anonymous defaults, and re-exports remain unsupported. Layout helpers contribute nesting without creating extra pages; an index page takes precedence over its parent layout.
Expand Down
8 changes: 6 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,7 @@ export class CodeGraph {
if (result.success && result.filesIndexed > 0) {
const tReinit = Date.now();
this.resolver.initialize();
if (this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:react-router:')))
if (this.queries.getNodesByKind('route').some(n => /^route:(react-router|redwood):/.test(n.id)))
await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']);
// Cross-file finalization (e.g. NestJS RouterModule prefixes). Runs
// before resolution so updated names show up in subsequent reads.
Expand Down Expand Up @@ -834,7 +834,7 @@ export class CodeGraph {
// (regex over *.module.ts only).
if (result.filesAdded > 0 || result.filesModified > 0) {
this.resolver.initialize();
if (this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:react-router:')))
if (this.queries.getNodesByKind('route').some(n => /^route:(react-router|redwood):/.test(n.id)))
await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']);
this.resolver.runPostExtract();
} else if (result.filesRemoved > 0) {
Expand All @@ -846,6 +846,10 @@ export class CodeGraph {
// sees the post-removal state. (runPostExtract above clears caches
// itself, so the changed-files branch is already covered.)
this.resolver.clearCaches();
if (this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:redwood:'))) {
await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']);
this.resolver.runPostExtract();
}
}

// Resolve references if files were updated
Expand Down
Loading