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