diff --git a/CHANGELOG.md b/CHANGELOG.md
index ea4eafbae..5eade051e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/__tests__/astro-routes.test.ts b/__tests__/astro-routes.test.ts
new file mode 100644
index 000000000..d9948b542
--- /dev/null
+++ b/__tests__/astro-routes.test.ts
@@ -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')}
+---
+AboutPost
+DataTitle
+
Attribute
+{"String"}
+ExternalProtocol
+
+`,
+ );
+ write('src/pages/about.astro', 'About
');
+ write('src/pages/blog/[slug].astro', 'Post
');
+ write('src/pages/hidden.astro', 'Hidden
');
+ write('src/pages/_layout.astro', '');
+ write('src/pages/_hidden/page.astro', 'Excluded
');
+ write('src/components/index.astro', 'Unrelated same name
');
+ 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', 'New
');
+ 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', 'Home');
+ 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', 'Home
');
+ 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');
+ });
+});
diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts
index a1d7fb354..e7dde0b1d 100644
--- a/__tests__/frameworks.test.ts
+++ b/__tests__/frameworks.test.ts
@@ -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', () => {
diff --git a/docs/design/PLAN-application-router-coverage.md b/docs/design/PLAN-application-router-coverage.md
index 85f3a67c6..45a8ddaca 100644
--- a/docs/design/PLAN-application-router-coverage.md
+++ b/docs/design/PLAN-application-router-coverage.md
@@ -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.
diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md
index 8f47b361e..1691a0cd4 100644
--- a/docs/design/framework-coverage.md
+++ b/docs/design/framework-coverage.md
@@ -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.
@@ -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
-``, 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
diff --git a/site/src/content/docs/guides/framework-routes.md b/site/src/content/docs/guides/framework-routes.md
index 25c07a128..c9546e46c 100644
--- a/site/src/content/docs/guides/framework-routes.md
+++ b/site/src/content/docs/guides/framework-routes.md
@@ -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.
diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts
index 37ad0e0a4..23235b1c8 100644
--- a/src/extraction/grammars.ts
+++ b/src/extraction/grammars.ts
@@ -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'];
}
diff --git a/src/resolution/frameworks/astro.ts b/src/resolution/frameworks/astro.ts
index 63e1af132..479200857 100644
--- a/src/resolution/frameworks/astro.ts
+++ b/src/resolution/frameworks/astro.ts
@@ -7,6 +7,55 @@
import { Node } from '../../types';
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
+import { getParser, detectLanguage } from '../../extraction/grammars';
+import { generateNodeId } from '../../extraction/tree-sitter-helpers';
+import { httpHandlerReferences } from './http-routing';
+import {
+ addRouteTo,
+ appRootFor,
+ hrefArms,
+ nthArgumentText,
+ parseHrefExpression,
+ routesForFile,
+ type RootedRouteTable,
+ type RouteTable,
+} from './expo-router';
+import { destinationsForHref } from './nextjs';
+
+const tables = new WeakMap();
+function astroTable(context: ResolutionContext): RootedRouteTable {
+ const source = context.getNodesByKind('route');
+ const cached = tables.get(context);
+ if (cached?.source === source) return cached;
+ const byRoot = new Map();
+ for (const node of source) {
+ if (
+ node.language !== 'astro' ||
+ node.id !== `route:${node.filePath}:${node.name}:1` ||
+ node.name.includes('*')
+ )
+ continue;
+ const root = appRootFor(node.filePath);
+ let table = byRoot.get(root);
+ if (!table) byRoot.set(root, (table = { source, exact: new Map(), dynamic: [] }));
+ addRouteTo(table, node.name, node);
+ }
+ const table = { source, byRoot };
+ tables.set(context, table);
+ return table;
+}
+
+function pageComponentId(filePath: string): string {
+ return generateNodeId(
+ filePath,
+ 'component',
+ filePath
+ .split(/[/\\]/)
+ .pop()!
+ .replace(/\.astro$/, ''),
+ 1,
+ );
+}
/**
* Astro virtual module prefixes — framework-provided, not user code
@@ -25,6 +74,8 @@ const ASTRO_VIRTUAL_MODULES = [
export const astroResolver: FrameworkResolver = {
name: 'astro',
+ claimsReference: (name) =>
+ name === 'astro-page-component' || name.startsWith('astro-href:') || name === 'Astro.redirect',
detect(context: ResolutionContext): boolean {
// Check for astro in package.json
@@ -47,6 +98,49 @@ export const astroResolver: FrameworkResolver = {
},
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+ if (ref.referenceName === 'astro-page-component') {
+ const target = context.getNodeById?.(pageComponentId(ref.filePath));
+ return target
+ ? { original: ref, targetNodeId: target.id, confidence: 1, resolvedBy: 'framework' }
+ : null;
+ }
+ const link = ref.referenceName.startsWith('astro-href:');
+ const redirect =
+ ref.referenceName === 'Astro.redirect' &&
+ ref.referenceKind === 'calls' &&
+ ref.filePath.endsWith('.astro');
+ if (link || redirect) {
+ const routes = routesForFile(astroTable(context), ref.filePath);
+ if (!routes) return null;
+ const lines =
+ context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/);
+ const expression = link
+ ? ref.referenceName.slice('astro-href:'.length)
+ : lines
+ ? nthArgumentText(lines, ref.line, ref.column, ref.referenceName, 0)
+ : null;
+ const href = expression === null ? null : parseHrefExpression(expression);
+ if (!href) return null;
+ const targets = hrefArms(href)
+ .filter((arm) => /^\/(?!\/)/.test(arm.path))
+ .flatMap((arm) => destinationsForHref({ ...arm, alternates: undefined }, routes));
+ if (!targets.length) return null;
+ return {
+ original: ref,
+ targetNodeId: targets[0]!.node.id,
+ confidence: 0.95,
+ resolvedBy: 'framework',
+ edgeKind: 'navigates',
+ metadata: { href: targets[0]!.href.display, navMethod: link ? 'a' : 'Astro.redirect' },
+ ...(targets.length > 1
+ ? {
+ alsoTargets: targets
+ .slice(1)
+ .map((t) => ({ targetNodeId: t.node.id, metadata: { href: t.href.display } })),
+ }
+ : {}),
+ };
+ }
// Pattern 1: the `Astro` global (Astro.props, Astro.url, Astro.params, …)
// — runtime-provided in every component's frontmatter. Resolving it as
// framework-provided keeps it from name-matching a user symbol named Astro.
@@ -92,19 +186,64 @@ export const astroResolver: FrameworkResolver = {
return null;
},
- extract(filePath: string, _content: string) {
+ extract(filePath: string, content: string) {
const nodes: Node[] = [];
+ const references: UnresolvedRef[] = [];
const now = Date.now();
// Normalize to forward slashes
const normalized = filePath.replace(/\\/g, '/');
- // Astro file-based routing lives under src/pages/ — .astro files are
- // pages, .ts/.js files are API endpoints. (.md/.mdx pages exist too but
- // aren't indexed as source.) Underscore-prefixed segments are excluded
- // from routing by Astro.
+ if (normalized.endsWith('.astro') && content.includes('href')) {
+ // Keep offsets while omitting non-markup regions and commented examples.
+ const markup = content
+ .replace(/^---\s*\r?\n[\s\S]*?^---\s*$/m, (s) => s.replace(/[^\r\n]/g, ' '))
+ .replace(
+ /|\{\/\*[\s\S]*?\*\/\}|<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi,
+ (s) => s.replace(/[^\r\n]/g, ' '),
+ );
+ const parser = getParser('tsx');
+ if (!parser) throw new Error('Astro anchor extraction requires the tsx grammar');
+ const tree = parser.parse(`<>${markup}>`);
+ if (tree)
+ try {
+ for (const tag of tree.rootNode.descendantsOfType([
+ 'jsx_opening_element',
+ 'jsx_self_closing_element',
+ ])) {
+ if (
+ tag.childForFieldName('name')?.text !== 'a' ||
+ tag.namedChildren.some(
+ (n) => n.type === 'jsx_expression' && /^\{\s*\.\.\./.test(n.text),
+ )
+ )
+ continue;
+ const attributes = tag.namedChildren.filter(
+ (n) => n.type === 'jsx_attribute' && n.namedChildren[0]?.text === 'href',
+ );
+ if (attributes.length !== 1) continue;
+ const raw = attributes[0]!.namedChildren[1]?.text;
+ if (!raw) continue;
+ const expression = raw.startsWith('{') ? raw.slice(1, -1).trim() : raw;
+ if (!parseHrefExpression(expression)) continue;
+ references.push({
+ fromNodeId: pageComponentId(filePath),
+ referenceName: `astro-href:${expression}`,
+ referenceKind: 'references',
+ filePath,
+ language: 'astro',
+ line: tag.startPosition.row + 1,
+ column: 0,
+ });
+ }
+ } finally {
+ tree.delete();
+ }
+ }
+
+ // Markdown/MDX and custom roots are outside this default convention.
const pagesMatch = /(?:^|\/)src\/pages\//.exec(normalized);
- if (pagesMatch && /\.(astro|ts|js|mjs)$/.test(normalized)) {
+ if (pagesMatch && /\.(astro|ts|js)$/.test(normalized)) {
const afterPages = normalized.substring(pagesMatch.index + pagesMatch[0].length);
const base = afterPages.split('/').pop() || '';
@@ -116,7 +255,7 @@ export const astroResolver: FrameworkResolver = {
) {
const routePath = filePathToAstroRoute(afterPages);
- nodes.push({
+ const node: Node = {
id: `route:${filePath}:${routePath}:1`,
kind: 'route',
name: routePath,
@@ -126,13 +265,84 @@ export const astroResolver: FrameworkResolver = {
endLine: 1,
startColumn: 0,
endColumn: 0,
- language: normalized.endsWith('.astro') ? 'astro' : 'typescript',
+ language: normalized.endsWith('.astro') ? 'astro' : detectLanguage(filePath)!,
updatedAt: now,
- });
+ };
+ if (node.language === 'astro') {
+ nodes.push(node);
+ references.push({
+ fromNodeId: node.id,
+ referenceName: 'astro-page-component',
+ referenceKind: 'references',
+ filePath,
+ language: 'astro',
+ line: 1,
+ column: 0,
+ });
+ } else if (content.includes('export')) {
+ const parser = getParser(node.language);
+ if (!parser)
+ throw new Error(`Astro endpoint extraction requires the ${node.language} grammar`);
+ const tree = parser.parse(content);
+ if (!tree) return { nodes, references };
+ try {
+ const methods = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|ALL)$/;
+ for (const statement of tree.rootNode.namedChildren) {
+ if (
+ statement.type !== 'export_statement' ||
+ statement.children.some(
+ (n) => n.type === 'default' || n.type === 'type' || n.type === 'declare',
+ ) ||
+ statement.childForFieldName('source')
+ )
+ continue;
+ const declaration = statement.childForFieldName('declaration');
+ const entries =
+ declaration?.type === 'lexical_declaration'
+ ? declaration.namedChildren
+ : declaration
+ ? [declaration]
+ : (statement.namedChildren.find((n) => n.type === 'export_clause')
+ ?.namedChildren ?? []);
+ for (const entry of entries) {
+ if (
+ !['function_declaration', 'variable_declarator', 'export_specifier'].includes(
+ entry.type,
+ ) ||
+ entry.children.some((n) => n.type === 'type')
+ )
+ continue;
+ const name = entry.childForFieldName('name');
+ const method = entry.childForFieldName('alias')?.text ?? name?.text;
+ if (!method || !methods.test(method)) continue;
+ const value = entry.childForFieldName('value');
+ if (
+ value &&
+ !['identifier', 'arrow_function', 'function_expression'].includes(value.type)
+ )
+ continue;
+ const endpoint: Node = {
+ ...node,
+ id: `route:${filePath}:${method}:${routePath}`,
+ name: `${method === 'ALL' ? 'ANY' : method} ${routePath}`,
+ qualifiedName: `${filePath}::${method}:${routePath}`,
+ startLine: entry.startPosition.row + 1,
+ endLine: entry.endPosition.row + 1,
+ };
+ nodes.push(endpoint);
+ references.push(
+ ...httpHandlerReferences(endpoint, value?.type === 'identifier' ? value : name),
+ );
+ }
+ }
+ } finally {
+ tree.delete();
+ }
+ }
}
}
- return { nodes, references: [] };
+ return { nodes, references };
},
};
@@ -149,7 +359,7 @@ function isPascalCase(str: string): boolean {
function resolveComponent(
name: string,
fromFile: string,
- context: ResolutionContext
+ context: ResolutionContext,
): string | null {
// Look for component nodes by name
const candidates = context.getNodesByName(name);
@@ -185,9 +395,11 @@ function filePathToAstroRoute(afterPages: string): string {
const withoutIndex = withoutExt.replace(/(^|\/)index$/, '$1').replace(/\/$/, '');
// Convert Astro param syntax
- const route = '/' + withoutIndex
- .replace(/\[\.\.\.([^\]]+)\]/g, '*$1') // [...rest] -> *rest (catch-all)
- .replace(/\[([^\]]+)\]/g, ':$1'); // [param] -> :param
+ const route =
+ '/' +
+ withoutIndex
+ .replace(/\[\.\.\.([^\]]+)\]/g, '*$1') // [...rest] -> *rest (catch-all)
+ .replace(/\[([^\]]+)\]/g, ':$1'); // [param] -> :param
if (route === '/') return '/';
// Remove trailing slash