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

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

- TanStack Start server routes now link to their HTTP handlers while keeping API-only files out of the page map, including routes introduced after initial indexing.
Expand Down
203 changes: 203 additions & 0 deletions __tests__/astro-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
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 { astroResolver } from '../src/resolution/frameworks/astro';
import { routeRoots } from '../src/ui-server/api/route-roots';

beforeAll(async () => {
await initGrammars();
await loadGrammarsForLanguages(['astro']);
});

// withastro/astro@9870f95601690d9d98799b6fa78a0bc76165ee06:
// packages/astro/test/fixtures/api-routes/src/pages/binary.dat.ts and
// packages/astro/src/core/routing/create-manifest.ts (page/endpoint extensions).
describe('Astro declared endpoint methods', () => {
it('reads typed function values, named exports and imported aliases', () => {
const result = astroResolver.extract!(
'src/pages/binary.dat.ts',
`import type { APIRoute } from 'astro';
import {list as read} from '../../server';
export const GET: APIRoute = async () => new Response('binary');
export async function POST(){return new Response('posted')}
export {read as HEAD};
export const ALL = read;
export const prerender = false;`,
);
expect(result.nodes.map((n) => n.name)).toEqual([
'GET /binary.dat',
'POST /binary.dat',
'HEAD /binary.dat',
'ANY /binary.dat',
]);
expect(result.references.map((r) => r.referenceName)).toEqual(['GET', 'POST', 'read', 'read']);
});
it.each([
'const GET = () => new Response();',
'export default function GET(){return new Response()}',
'export const GET = dynamic();',
'export const GET = {handler: handle};',
'export function getStaticPaths(){return []}',
'const text = "export function GET(){}";',
'// export function GET(){}',
'export {GET} from "../../server";',
'export interface POST {x: string}',
'export type GET = () => Response;',
'export type {GET};',
'export {type GET};',
])('ignores unsupported or non-handler declarations: %s', (source) => {
expect(astroResolver.extract!('src/pages/api.ts', source).nodes).toEqual([]);
});
it.each(['src/pages/api.mjs', 'src/pages/_hidden/api.ts', 'src/server/api.ts'])(
'excludes %s',
(file) => {
expect(astroResolver.extract!(file, 'export function GET(){}').nodes).toEqual([]);
},
);
});

describe('Astro routes 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 });
});
it('binds exact components and endpoint handlers, navigation and sync', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-astro-routes-'));
write('package.json', JSON.stringify({ dependencies: { astro: '5' } }));
write(
'src/pages/index.astro',
`---
function forward(){return Astro.redirect('/blog/first')}
function external(){return Astro.redirect('https://other.test/hidden')}
---
<a href="/about">About</a><a href={'/blog/first'}>Post</a>
<a data-href="/hidden">Data</a><a title='href="/hidden"'>Title</a>
<div title='<a href="/hidden">'>Attribute</div>
{"<a href='/hidden'>String</a>"}
<a href="https://other.test/hidden">External</a><a href="//other.test/hidden">Protocol</a>
<!-- <a href="/hidden">Comment</a> -->
<script>const fake = '<a href="/hidden">Fake</a>';</script>`,
);
write('src/pages/about.astro', '<h1>About</h1>');
write('src/pages/blog/[slug].astro', '<h1>Post</h1>');
write('src/pages/hidden.astro', '<h1>Hidden</h1>');
write('src/pages/_layout.astro', '<slot/>');
write('src/pages/_hidden/page.astro', '<h1>Excluded</h1>');
write('src/components/index.astro', '<h1>Unrelated same name</h1>');
write('src/server.ts', 'export function list(){return new Response("ok")}');
write(
'src/pages/api/items.ts',
`import {list as getItems} from '../../server';
export const GET = getItems;
export function POST(){return getItems()}`,
);
write('src/pages/rss.xml.js', 'export function GET(){return new Response("rss")}');
cg = await CodeGraph.init(dir, { index: true });
const routes = cg.getNodesByKind('route');
expect(routes.map((n) => n.name).sort()).toEqual(
[
'/',
'/about',
'/blog/:slug',
'/hidden',
'GET /api/items',
'POST /api/items',
'GET /rss.xml',
].sort(),
);
const roots = routeRoots(cg, routes);
for (const route of routes.filter((n) => n.language === 'astro')) {
const component = cg.getNodesByKind('component').find((n) => n.filePath === route.filePath)!;
expect(roots.get(route.id)?.node.id).toBe(component.id);
}
const get = routes.find((n) => n.name === 'GET /api/items')!;
const list = cg.getNodesByKind('function').find((n) => n.name === 'list')!;
expect(cg.getOutgoingEdges(get.id)).toContainEqual(
expect.objectContaining({ target: list.id, kind: 'references' }),
);
expect(routes.find((n) => n.name === 'GET /rss.xml')!.language).toBe('javascript');
const home = cg
.getNodesByKind('component')
.find((n) => n.filePath === 'src/pages/index.astro')!;
const destinations = cg
.getOutgoingEdges(home.id)
.filter((e) => e.kind === 'navigates')
.map((e) => cg!.getNode(e.target)?.name)
.sort();
expect(destinations).toEqual(['/about', '/blog/:slug']);
const external = cg.getNodesByKind('function').find((n) => n.name === 'external')!;
expect(cg.getOutgoingEdges(external.id).filter((e) => e.kind === 'navigates')).toEqual([]);
const forward = cg.getNodesByKind('function').find((n) => n.name === 'forward')!;
expect(cg.getOutgoingEdges(forward.id)).toContainEqual(
expect.objectContaining({
kind: 'navigates',
target: routes.find((n) => n.name === '/blog/:slug')!.id,
}),
);
write('src/pages/new.astro', '<h1>New</h1>');
await cg.sync();
expect(cg.getNodesByKind('route').some((n) => n.name === '/new')).toBe(true);
write(
'src/pages/api/items.ts',
`import {list} from '../../server'; export const DELETE = list;`,
);
await cg.sync({ paths: ['src/pages/api/items.ts'] });
expect(
cg
.getNodesByKind('route')
.filter((n) => n.filePath.endsWith('items.ts'))
.map((n) => n.name),
).toEqual(['DELETE /api/items']);
fs.unlinkSync(path.join(dir, 'src/pages/new.astro'));
await cg.sync();
expect(cg.getNodesByKind('route').some((n) => n.name === '/new')).toBe(false);
});
it.runIf(fs.existsSync(path.resolve('dist/index.js')))(
'binds endpoints and pages in fresh compiled workers',
() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-astro-workers-'));
write('package.json', JSON.stringify({ dependencies: { astro: '5' } }));
write('src/pages/index.astro', '<a href="/">Home</a>');
write('src/pages/api.ts', 'export function GET(){return new Response("ok")}');
const script = `const {CodeGraph}=require(${JSON.stringify(path.resolve('dist/index.js'))});
(async()=>{const cg=await CodeGraph.init(${JSON.stringify(dir)},{index:true});
console.log(JSON.stringify(cg.getNodesByKind('route').map(r=>[r.name,cg.getOutgoingEdges(r.id).filter(e=>e.kind==='references').map(e=>cg.getNode(e.target)?.name)]).sort()));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([
['/', ['index']],
['GET /api', ['GET']],
]);
},
);
it.each([false, true])('discovers newly introduced Astro routes (scoped=%s)', async (scoped) => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-astro-new-'));
write('package.json', '{}');
write('src/plain.ts', 'export function plain(){}');
cg = await CodeGraph.init(dir, { index: true });
write('src/pages/index.astro', '<h1>Home</h1>');
await cg.sync(scoped ? { paths: ['src/pages/index.astro'] } : undefined);
const route = cg.getNodesByKind('route')[0]!;
expect(route.name).toBe('/');
expect(routeRoots(cg, [route]).get(route.id)?.node.filePath).toBe('src/pages/index.astro');
});
});
6 changes: 3 additions & 3 deletions __tests__/frameworks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1629,9 +1629,9 @@ describe('astroResolver.extract — src/pages file-based routing', () => {
expect(routeNames('src/pages/[...path].astro')).toEqual(['/*path']);
});

it('maps .ts endpoints under src/pages to routes', () => {
expect(routeNames('src/pages/api/posts.ts')).toEqual(['/api/posts']);
expect(routeNames('src/pages/rss.xml.js')).toEqual(['/rss.xml']);
it('does not invent endpoints without exported methods', () => {
expect(routeNames('src/pages/api/posts.ts')).toEqual([]);
expect(routeNames('src/pages/rss.xml.js')).toEqual([]);
});

it('excludes underscore-prefixed segments and config files', () => {
Expand Down
4 changes: 2 additions & 2 deletions docs/design/PLAN-application-router-coverage.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
Status: 3/13 — preparing step 3 PR (Remix / React Router file routes); steps 1–2 published as PRs #3–4
Status: 4/13 — preparing step 4 PR (Astro route completion); steps 1–3 published as PRs #3–5

- [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.
- [ ] 4 Astro route completion — connect existing page routes to components and API method exports, then navigation where the source declares it. Gate: shared proof, page/API distinction, underscore exclusions, and cross-file handler resolution.
- [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.
- [ ] 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.
Expand Down
17 changes: 6 additions & 11 deletions docs/design/framework-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ guessed.
| TanStack Router / Start | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts`, `tanstack-start.test.ts` | TanStack examples, fastapi-template frontend; pinned Start server-handler syntax |
| 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 |

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 Expand Up @@ -97,17 +98,11 @@ for the supported declaration shapes and Nuxt configuration limits.
Ordered by cost-to-value. Each row says what is missing, not merely that
something is.

### 1. Astro — the last web framework with routes but no navigation

**Has:** `src/pages/` file routes (`.astro` pages + `.ts` endpoints,
`[param]`/`[...rest]`), in `frameworks/astro.ts`.
**Missing:** `navigates` edges. Astro is an MPA — navigation is a plain
`<a href="/about">`, plus `Astro.redirect('/x')` in frontmatter and
`redirect` entries in `astro.config`.
**Size:** smallest job on this list. `sveltekit-synthesizer.ts`'s
`svelteKitLinkEdges` is the same pass over the same tag against a different
table; the resolver half is one `Astro.redirect` reader.
**Validate on:** any `withastro/astro` example, or the Astro docs site.
### 1. Astro — custom configuration and additional page formats

Default `.astro` pages bind exact same-file components; `.ts`/`.js` exported HTTP methods bind handlers (`ALL` becomes `ANY`). Literal/bound anchor destinations and `Astro.redirect` link to local pages. External URLs, non-href attributes, type-only exports and underscore-prefixed paths are excluded. [Pinned endpoint fixture](https://github.com/withastro/astro/blob/9870f95601690d9d98799b6fa78a0bc76165ee06/packages/astro/test/fixtures/api-routes/src/pages/binary.dat.ts), [extension rules](https://github.com/withastro/astro/blob/9870f95601690d9d98799b6fa78a0bc76165ee06/packages/astro/src/core/routing/create-manifest.ts#L140).

Remaining: custom roots/base/config redirects, Markdown/MDX pages, cross-file re-exports, client transition calls, and navigation to rest routes. These are not inferred from default file conventions.

### 2. Server-rendered frameworks — a redirect is a transition, not just a response

Expand Down
4 changes: 3 additions & 1 deletion site/src/content/docs/guides/framework-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
| **TanStack Router / Start** | Page routes plus literal `server.handlers` method tables and `createHandlers` callbacks on exported file routes; middleware is excluded from handler links |
| **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/` file-based routes (`.astro` pages + `.ts` endpoints, `[param]`/`[...rest]` syntax) |
| **Astro** | `src/pages/` `.astro` pages linked to components; `.ts`/`.js` HTTP-method exports linked to handlers; anchors and `Astro.redirect` link to local pages |

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.

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.

Remix default `app/routes/` filenames and React Router configs registering an imported, option-free `flatRoutes()` call support JS/TS pages, immediate `folder/route` modules, dot nesting, index/pathless segments, parameters, optional segments, splats, and bracket escapes. Resource-only and direct `Outlet`-only defaults are excluded. Custom configuration, folder `index` fallback, and Markdown/MDX are unsupported.
Expand Down
2 changes: 2 additions & 0 deletions src/extraction/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,8 @@ function expandGrammarLanguages(languages: Language[]): Language[] {
if (languages.some((l) => l === 'svelte' || l === 'vue' || l === 'astro')) {
languages = [...languages, 'typescript', 'javascript'];
}
// Astro's static anchor reader parses template markup as JSX.
if (languages.includes('astro')) languages = [...languages, 'tsx'];
if (languages.some((l) => l === 'cfml')) {
languages = [...languages, 'cfscript', 'cfquery'];
}
Expand Down
Loading