From 3aa9715322c9bb61743e561ea5e90268c14680a9 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Mon, 7 Sep 2026 00:07:22 -0600 Subject: [PATCH] feat: index default Remix file routes and page links --- CHANGELOG.md | 2 + __tests__/remix-routes.test.ts | 259 +++++++++++++++++ .../PLAN-application-router-coverage.md | 4 +- docs/design/framework-coverage.md | 4 +- .../content/docs/guides/framework-routes.md | 4 +- src/extraction/index.ts | 43 ++- src/index.ts | 2 +- src/resolution/frameworks/index.ts | 3 +- src/resolution/frameworks/react-router.ts | 262 +++++++++++++++++- 9 files changed, 555 insertions(+), 28 deletions(-) create mode 100644 __tests__/remix-routes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 039731ebb..ea4eafbae 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 +- 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. - React Router framework-mode pages now appear with their page components and navigation, including nested routes and pathless layouts. diff --git a/__tests__/remix-routes.test.ts b/__tests__/remix-routes.test.ts new file mode 100644 index 000000000..e5fea4ade --- /dev/null +++ b/__tests__/remix-routes.test.ts @@ -0,0 +1,259 @@ +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 { + remixFileRoutePath, + usesDefaultFlatRoutes, + reactRouterFilesResolver, +} from '../src/resolution/frameworks/react-router'; +import { routeRoots } from '../src/ui-server/api/route-roots'; +import type { ResolutionContext } from '../src/resolution/types'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']); +}); +// React Router 7aea711dd1ae2bc5a076d13ff17291829690fa74, +// packages/react-router-fs-routes/flatRoutes.ts: filename parsing and default scan. +describe('default flat filenames', () => { + it.each([ + ['_index.tsx', '/'], + ['concerts.trending.tsx', '/concerts/trending'], + ['concerts.$city.tsx', '/concerts/:city'], + ['concerts._index.tsx', '/concerts'], + ['_auth.login.tsx', '/login'], + ['concerts_.mine.tsx', '/concerts/mine'], + ['($lang).categories.tsx', '/:lang?/categories'], + ['(lang).categories.tsx', '/lang?/categories'], + ['files.$.tsx', '/files/*'], + ['sitemap[.]xml.tsx', '/sitemap.xml'], + ['hello[(]world[)].tsx', '/hello(world)'], + ['weird-url.[_index].tsx', '/weird-url/_index'], + ['concerts.$city/route.tsx', '/concerts/:city'], + ['concerts.$city/card.tsx', null], + ['concerts/$city.tsx', null], + ['_auth.tsx', null], + ['.hidden.tsx', null], + ['about.md', null], + ['broken[.tsx', null], + ['parent._index.child.tsx', null], + ])('%s -> %s', (file, url) => expect(remixFileRoutePath('app/routes/' + file)).toBe(url)); + it('does not apply root conventions to another app directory', () => { + expect(remixFileRoutePath('packages/web/app/routes/_index.tsx')).toBeNull(); + }); +}); + +const imports = `import {flatRoutes as files} from '@react-router/fs-routes';\n`; +describe('flatRoutes registration', () => { + it.each([ + 'files()', + 'await files()', + 'files(); const unrelated = 1', + '[...(await files())]', + '[route("extra","./extra.tsx"), ...files()] satisfies RouteConfig', + ])('accepts %s', (expression) => { + expect(usesDefaultFlatRoutes(imports + 'export default ' + expression + ';')).toBe(true); + }); + it.each([ + '[]', + 'files(options)', + 'files().map(change)', + '[...(await files())].map(change)', + '[layout("./layout.tsx", [...files()])]', + '[files()]', + 'dynamic', + ])('rejects %s', (expression) => { + expect(usesDefaultFlatRoutes(imports + 'export default ' + expression + ';')).toBe(false); + }); + it('ignores type-only, commented, unrelated and unregistered calls', () => { + expect(usesDefaultFlatRoutes(imports + 'const unused=files();\nexport default [];')).toBe( + false, + ); + expect( + usesDefaultFlatRoutes( + "import type {flatRoutes} from '@react-router/fs-routes';\nexport default flatRoutes();", + ), + ).toBe(false); + expect( + usesDefaultFlatRoutes("import {flatRoutes} from 'other';\nexport default flatRoutes();"), + ).toBe(false); + expect(usesDefaultFlatRoutes(imports + '// export default files();')).toBe(false); + expect( + usesDefaultFlatRoutes(imports + 'const text=`export default files()`;\nexport default [];'), + ).toBe(false); + }); + it('requires registration for React Router and rejects route config overrides', () => { + const files = new Map([ + [ + 'package.json', + JSON.stringify({ dependencies: { '@react-router/fs-routes': '*', 'react-router': '*' } }), + ], + ]); + const context = { readFile: (f: string) => files.get(f) ?? null } as ResolutionContext; + expect(reactRouterFilesResolver.detect(context)).toBe(false); + files.set('app/routes.ts', imports + 'export default files();'); + expect(reactRouterFilesResolver.detect(context)).toBe(true); + for (const config of [ + 'export default {appDirectory:"src"}', + 'export default {"appDirectory":"src"}', + 'export default {...options}', + 'export default configuration', + ]) { + files.set('react-router.config.ts', config); + expect(reactRouterFilesResolver.detect(context)).toBe(false); + } + }); +}); + +describe('file-route apps through indexing', () => { + let cg: CodeGraph | undefined; + let dir: string; + afterEach(() => { + cg?.close(); + cg = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + const write = (file: string, source: string) => { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), source); + }; + it.each([false, true])( + 'refreshes existing pages when only configuration changes (scoped=%s)', + async (scoped) => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-remix-config-')); + write( + 'package.json', + JSON.stringify({ dependencies: { 'react-router': '7', '@react-router/dev': '7' } }), + ); + write('app/routes.ts', imports + 'export default [];'); + write( + 'app/routes/about.tsx', + 'function Unused(){return ;} export default function About(){return
;}', + ); + cg = await CodeGraph.init(dir, { index: true }); + expect(cg.getNodesByKind('route')).toHaveLength(0); + write('app/routes.ts', imports + 'export default files();'); + const enabled = await cg.sync(scoped ? { paths: ['app/routes.ts'] } : undefined); + expect(enabled.filesModified).toBe(2); + expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/about']); + write('app/routes.ts', imports + 'export default [];'); + await cg.sync(scoped ? { paths: ['app/routes.ts'] } : undefined); + expect(cg.getNodesByKind('route')).toHaveLength(0); + write('app/routes.ts', imports + 'export default files();'); + await cg.sync(); + cg.close(); + fs.unlinkSync(path.join(dir, 'app/routes.ts')); + cg = await CodeGraph.open(dir); + await cg.sync(scoped ? { paths: ['app/routes.ts'] } : undefined); + expect(cg.getNodesByKind('route')).toHaveLength(0); + }, + ); + it.runIf(fs.existsSync(path.resolve('dist/index.js')))( + 'indexes file conventions in fresh compiled workers', + () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-remix-workers-')); + write('package.json', JSON.stringify({ dependencies: { '@remix-run/react': '2' } })); + write('app/routes/_index.tsx', 'export default function Home(){return
;}'); + const script = `const {CodeGraph}=require(${JSON.stringify(path.resolve('dist/index.js'))}); +(async()=>{const cg=await CodeGraph.init(${JSON.stringify(dir)},{index:true}); +const route=cg.getNodesByKind('route')[0]; +console.log(JSON.stringify([route.name,cg.getOutgoingEdges(route.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']]); + }, + ); + it.each(['remix', 'framework'])( + '%s binds pages and optional navigation, excluding resources/layouts/colocation', + async (mode) => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-remix-')); + write( + 'package.json', + JSON.stringify({ + dependencies: + mode === 'remix' + ? { '@remix-run/react': '2', '@remix-run/dev': '2' } + : { 'react-router': '7', '@react-router/dev': '7', '@react-router/fs-routes': '7' }, + }), + ); + if (mode === 'framework') { + write( + 'app/routes.ts', + imports + + `import {route} from '@react-router/dev/routes';\nexport default [...(await files()), route('extra','./extra.tsx')];`, + ); + write('app/extra.tsx', 'export default function Extra(){return
;}'); + } + for (const file of [ + '_index', + 'concerts.$city', + '_auth.login', + '($lang).categories', + '(lang).static', + 'folder/route', + ]) + write(`app/routes/${file}.tsx`, 'export default function Page(){return
;}'); + write('app/routes/_auth.tsx', 'export default function Layout(){return ;}'); + write('app/routes/concerts.tsx', 'export default function Layout(){return ;}'); + write('app/routes/health.ts', 'export function loader(){return new Response("ok");}'); + write('app/routes/folder/card.tsx', 'export default function Card(){return
;}'); + write( + 'app/nav.ts', + `import {redirect} from '${mode === 'remix' ? '@remix-run/react' : 'react-router'}'; +export function city(){return redirect('/concerts/paris')} +export function lang(){return redirect('/en/categories')} +export function noLang(){return redirect('/categories')} +export function fixed(){return redirect('/lang/static')} +export function noFixed(){return redirect('/static')}`, + ); + cg = await CodeGraph.init(dir, { index: true }); + const routes = cg.getNodesByKind('route'); + expect(routes.map((n) => n.name).sort()).toEqual( + [ + '/', + '/concerts/:city', + '/login', + '/:lang?/categories', + '/lang?/static', + '/folder', + ...(mode === 'framework' ? ['/extra'] : []), + ].sort(), + ); + const roots = routeRoots(cg, routes); + expect(roots.size).toBe(routes.length); + for (const [fn, url] of [ + ['city', '/concerts/:city'], + ['lang', '/:lang?/categories'], + ['noLang', '/:lang?/categories'], + ['fixed', '/lang?/static'], + ['noFixed', '/lang?/static'], + ]) { + const from = cg.getNodesByKind('function').find((n) => n.name === fn)!; + expect(cg.getOutgoingEdges(from.id)).toContainEqual( + expect.objectContaining({ + kind: 'navigates', + target: routes.find((n) => n.name === url)!.id, + }), + ); + } + write('app/routes/new.tsx', 'export default function NewPage(){return
;}'); + await cg.sync(); + expect(cg.getNodesByKind('route').some((n) => n.name === '/new')).toBe(true); + fs.unlinkSync(path.join(dir, 'app/routes/new.tsx')); + await cg.sync(); + expect(cg.getNodesByKind('route').some((n) => n.name === '/new')).toBe(false); + }, + ); +}); diff --git a/docs/design/PLAN-application-router-coverage.md b/docs/design/PLAN-application-router-coverage.md index 792a9071a..85f3a67c6 100644 --- a/docs/design/PLAN-application-router-coverage.md +++ b/docs/design/PLAN-application-router-coverage.md @@ -1,8 +1,8 @@ -Status: 2/13 — step 2 verified, preparing its PR; step 1 published as PR #3 +Status: 3/13 — preparing step 3 PR (Remix / React Router file routes); steps 1–2 published as PRs #3–4 - [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. -- [ ] 3 Remix and React Router file conventions — support the pinned default file convention, including index, nested, parameter, pathless, and splat cases; require evidence that the convention is enabled. Gate: shared proof, explicit-config/file-route coexistence, and layout-only controls. +- [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. - [ ] 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. diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index 8a3d318fc..8f47b361e 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -38,11 +38,13 @@ guessed. |---|---|---|---|---| | Expo Router | `frameworks/expo-router.ts` | `expo-router-synthesizer.ts` | `expo-router.test.ts` | — | | Next.js | `frameworks/nextjs.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts` | next-saas-starter | -| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts`, `react-router-framework.test.ts` | proshop; pinned official framework config (7 pages) | +| React Router / Remix | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts`, `react-router-framework.test.ts`, `remix-routes.test.ts` | proshop; pinned official framework config and flat filenames | | 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) | +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. + React Router framework mode reads default exported literal arrays in `app/routes.ts` or `app/routes.js`, using imported `route`, `index`, `layout`, and spread `prefix` helpers. Module paths bind named default components; nested index pages take precedence over their parent. [Official source fixture](https://github.com/remix-run/react-router/blob/7aea711dd1ae2bc5a076d13ff17291829690fa74/docs/start/framework/routing.md#L28): seven expected pages, verified through indexing and navigation. Tests also cover module/config sync and a fresh compiled process using parse/resolver workers. Custom app directories, computed arrays, `relative`, anonymous defaults, and re-exports remain unsupported. TanStack Start reads imported `createFileRoute` calls assigned to exported `const Route`: literal `server.handlers` tables, including `ANY`, and the destructured `createHandlers` callback form. Named handlers and direct inline calls bind through the existing HTTP reader. Page/API combinations retain both nodes; server-only routes do not become pages. [Pinned official handler syntax](https://github.com/TanStack/router/blob/a58e01c604e2d189ef8c8c1ad6ac8747e03aa88c/docs/start/framework/react/guide/server-routes.md#L172), [executable middleware fixture](https://github.com/TanStack/router/blob/a58e01c604e2d189ef8c8c1ad6ac8747e03aa88c/e2e/react-start/server-routes/src/routes/api/middleware-context.ts). Computed/spread tables, member handlers, custom factories and server `update` chains remain unresolved. `createServerFn` has no declared public route and gets no fabricated endpoint. diff --git a/site/src/content/docs/guides/framework-routes.md b/site/src/content/docs/guides/framework-routes.md index 5d11e6c29..25c07a128 100644 --- a/site/src/content/docs/guides/framework-routes.md +++ b/site/src/content/docs/guides/framework-routes.md @@ -30,7 +30,7 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by | **Axum / actix / Rocket** | `.route("/x", get(handler))` | | **ASP.NET** | `[HttpGet("/x")]` attributes on action methods | | **Vapor** | `app.get("x", use: handler)` | -| **React Router** | JSX/data-router pages; literal framework-mode `app/routes.ts` arrays with `route`, `index`, `layout`, and `prefix`, linked to named default components | +| **React Router / Remix** | JSX/data-router pages; literal framework-mode arrays; default Remix and registered `flatRoutes()` file pages, linked to named default components | | **SvelteKit** | Route component nodes | | **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 | @@ -41,6 +41,8 @@ Route resolution is automatic — there's nothing to configure. If a framework f 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. + The JavaScript HTTP readers require a recognized package import (ES modules or CommonJS), except for the global `Bun.serve`. They follow immutable local router bindings and literal declarations, without executing your application. Named handlers produce references; direct calls inside inline handlers produce call edges. Static responses have an endpoint without an invented handler. Member handlers remain unresolved by this reader. TanStack Start server routes support imported `createFileRoute` calls assigned to exported `const Route`. Computed/spread tables, custom factories, member handlers, and `update` chains remain unsupported. RPC functions created with `createServerFn` are not presented as public route URLs. diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 5c026c740..14e1c7e91 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -1448,6 +1448,7 @@ export class ExtractionOrchestrator { * hasn't run yet so single-file re-index paths can detect on the spot. */ private detectedFrameworkNames: string[] | null = null; + private conventionInvalidatedFiles = new Set(); /** * Scope matcher for SCOPED syncs, memoized on the mtimes of the two root * files it is derived from (`codegraph.json`, `.gitignore`). See @@ -2394,7 +2395,7 @@ export class ExtractionOrchestrator { // successful retry's symbols — a permanent empty file presented as // recovered (the #1541 wipe, reintroduced through the marker path). const existingFile = this.queries.getFileByPath(filePath); - if (existingFile && existingFile.contentHash === contentHash) { + if (existingFile && existingFile.contentHash === contentHash && !this.conventionInvalidatedFiles.has(filePath)) { const existingIsMarker = existingFile.nodeCount === 0 && (existingFile.errors?.length ?? 0) > 0; const incomingHasContent = result.nodes.length > 0; @@ -2863,6 +2864,33 @@ export class ExtractionOrchestrator { } } + // File conventions can change without editing any page modules. + if (filesToIndex.length > 0 || filesRemoved > 0) { + const previous = this.detectedFrameworkNames ?? []; + const hadFileRoutes = this.detectedFrameworkNames === null + ? this.queries.getNodesByKind('route').some((n) => n.id.startsWith(`route:react-router:${n.filePath}:file:`)) + : previous.includes('react-router-files'); + this.detectedFrameworkNames = null; + const detected = this.ensureDetectedFrameworks(currentFiles); + if (scopedPaths?.length) { + this.detectedFrameworkNames = [...new Set([ + ...previous.filter((name) => name !== 'react-router-files'), ...detected, + ])]; + } + if (hadFileRoutes !== detected.includes('react-router-files')) { + const scope = this.scopedSyncMatcher(); + for (const filePath of new Set([...this.queries.getAllFilePaths(), ...currentFiles])) { + if (!/^app\/routes\//.test(filePath) || filesToIndex.includes(filePath) || + scope.ignores(filePath) || + !fs.existsSync(path.join(this.rootDir, filePath))) continue; + filesToIndex.push(filePath); + this.conventionInvalidatedFiles.add(filePath); + changedFilePaths.push(filePath); + filesModified++; + } + } + } + // Sampled here — after the add/modify classification, before any file is // re-extracted — because `storeExtractionResult` deletes a file's nodes // before inserting the new ones, so this is the last point the pre-edit @@ -2873,11 +2901,6 @@ export class ExtractionOrchestrator { // Load only grammars needed for changed files if (filesToIndex.length > 0) { - const previous = this.detectedFrameworkNames ?? []; - this.detectedFrameworkNames = null; - const detected = this.ensureDetectedFrameworks(currentFiles); - // A watcher scope sees only changed files; retain other packages' frameworks. - if (scopedPaths?.length) this.detectedFrameworkNames = [...new Set([...previous, ...detected])]; const overrides = loadExtensionOverrides(this.rootDir); const neededLanguages = [...new Set(filesToIndex.map((f) => detectLanguage(f, undefined, overrides)))]; // .h files default to 'c' but may be C++ — ensure cpp grammar is loaded @@ -2898,8 +2921,12 @@ export class ExtractionOrchestrator { currentFile: filePath, }); - const result = await this.indexFile(filePath); - nodesUpdated += result.nodes.length; + try { + const result = await this.indexFile(filePath); + nodesUpdated += result.nodes.length; + } finally { + this.conventionInvalidatedFiles.delete(filePath); + } } // Names whose definition set this sync changed: a `file\0name` pair present diff --git a/src/index.ts b/src/index.ts index 617d6bfba..70eb4d330 100644 --- a/src/index.ts +++ b/src/index.ts @@ -959,7 +959,7 @@ export class CodeGraph { const configFiles = new Set( this.queries .getNodesByKind('route') - .filter((n) => n.id.startsWith('route:react-router:')) + .filter((n) => n.id.startsWith('route:react-router:') && /(?:^|\/)app\/routes\.[jt]s$/.test(n.filePath)) .map((n) => n.filePath), ); for (const file of configFiles) { diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 6983cacc0..5c168188a 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -13,7 +13,7 @@ import { httpRoutingResolver } from './http-routing'; import { nestjsResolver } from './nestjs'; import { reactResolver } from './react'; import { nextjsResolver } from './nextjs'; -import { reactRouterResolver } from './react-router'; +import { reactRouterResolver, reactRouterFilesResolver } from './react-router'; import { tanstackRouterResolver } from './tanstack-router'; import { vueRouterResolver } from './vue-router'; import { svelteKitRouterResolver } from './sveltekit-router'; @@ -51,6 +51,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ reactResolver, // React Router — `` routes are `reactResolver`'s; `history.push('/x')` / `navigate('/x')` → navigates edges reactRouterResolver, + reactRouterFilesResolver, // TanStack Router — `createFileRoute('/x')` / `createRoute({ path })` → route nodes; `navigate({ to })` → navigates edges tanstackRouterResolver, // Next.js — `app/**/page.tsx` + `pages/**` → route nodes; `route.ts` exports → endpoints; `router.push('/x')` / `redirect('/x')` → navigates edges diff --git a/src/resolution/frameworks/react-router.ts b/src/resolution/frameworks/react-router.ts index 94ac1b29d..5f7cac02a 100644 --- a/src/resolution/frameworks/react-router.ts +++ b/src/resolution/frameworks/react-router.ts @@ -39,6 +39,8 @@ import type { Language, Node } from '../../types'; import type { Node as SyntaxNode } from 'web-tree-sitter'; import { detectLanguage, getParser } from '../../extraction/grammars'; import { resolveImportPath } from '../import-resolver'; +import { stripCommentsForRegex } from '../strip-comments'; +import { matchBracket } from './object-literal'; import type { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types'; import { dependsOn } from './package-deps'; import { @@ -59,6 +61,233 @@ import { destinationsForHref } from './nextjs'; const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'tsx', 'jsx']; +/** Default Remix flat filenames; bracket escapes retain their literal meaning. */ +export function remixFileRoutePath(filePath: string): string | null { + const file = filePath.replace(/\\/g, '/'); + const match = /^app\/routes\/([^/]+)(?:\/(route))?\.[jt]sx?$/.exec(file); + if (!match || match[1]!.startsWith('.')) return null; + const segments: { raw: string; text: string }[] = []; + let raw = '', + text = ''; + const stem = match[1]!; + for (let i = 0; i < stem.length; i++) { + const char = stem[i]!; + if (char === '[') { + const end = stem.indexOf(']', i + 1); + if (end < 0) return null; + raw += stem.slice(i, end + 1); + text += stem.slice(i + 1, end); + i = end; + } else if (char === '.') { + segments.push({ raw, text }); + raw = ''; + text = ''; + } else { + raw += char; + text += char; + } + } + segments.push({ raw, text }); + const path: string[] = []; + for (const [i, segment] of segments.entries()) { + let { raw, text } = segment; + if (!raw) return null; + const optional = raw.startsWith('(') && raw.endsWith(')'); + if (optional) { + raw = raw.slice(1, -1); + text = text.slice(1, -1); + } else if (/[()]/.test(raw.replace(/\[[^\]]*\]/g, ''))) return null; + if (raw === '_index') { + if (i !== segments.length - 1) return null; + continue; + } + if (raw.startsWith('_')) { + if (i === segments.length - 1) return null; + continue; + } + if (raw.endsWith('_')) text = text.slice(0, -1); + if (raw === '$') text = '*'; + else if (raw.startsWith('$')) text = ':' + text.slice(1); + if (optional) text += '?'; + path.push(text); + } + return '/' + path.join('/'); +} + +/** Only a direct default call or a top-level spread registers default flat routes. */ +export function usesDefaultFlatRoutes(content: string): boolean { + // Template-driven configuration is outside this literal registration reader. + if (content.includes('`')) return false; + const safe = stripCommentsForRegex(content, 'typescript'); + const masked = safe.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, (m) => + m.replace(/[^\r\n]/g, ' '), + ); + const imports = /^[ \t]*import\s*\{([^}]+)\}\s*from\s*['"]@react-router\/fs-routes['"]/gm; + const aliases: string[] = []; + for (const match of safe.matchAll(imports)) { + if (!/^\s*import\b/.test(masked.slice(match.index!, match.index! + match[0].indexOf('{')))) + continue; + for (const spec of match[1]!.split(',')) { + const binding = /^\s*flatRoutes(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*$/.exec(spec); + if (binding) aliases.push(binding[1] ?? 'flatRoutes'); + } + } + const exported = /^[ \t]*export\s+default\s+/m.exec(masked); + if (!exported) return false; + const expression = masked + .slice(exported.index + exported[0].length) + .split(';', 1)[0]! + .trim(); + for (const alias of aliases) { + const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (new RegExp(`^(?:await\\s+)?${escaped}\\s*\\(\\s*\\)\\s*;?\\s*$`).test(expression)) + return true; + if (!expression.startsWith('[')) continue; + const end = matchBracket(expression, 0); + if (end < 0) continue; + if (!/^\s*(?:satisfies\s+[A-Za-z_$][\w$]*)?\s*;?\s*$/.test(expression.slice(end + 1))) continue; + let i = 1; + while (i < end) { + while (/[\s,]/.test(expression[i] ?? '')) i++; + if ( + new RegExp( + `^\\.\\.\\.\\s*\\(?\\s*(?:await\\s+)?${escaped}\\s*\\(\\s*\\)\\s*\\)?\\s*(?:,|\\])`, + ).test(expression.slice(i)) + ) + return true; + while (i < end && expression[i] !== ',') { + if ('([{'.includes(expression[i]!)) { + const close = matchBracket(expression, i); + if (close < 0) return false; + i = close + 1; + } else i++; + } + i++; + } + } + return false; +} + +/** Root-level default conventions; custom roots and route overrides are not inferred. */ +export const reactRouterFilesResolver: FrameworkResolver = { + name: 'react-router-files', + languages: [...ROUTE_LANGUAGES], + detect(context) { + for (const name of ['remix.config', 'react-router.config', 'vite.config']) { + for (const extension of ['js', 'cjs', 'mjs', 'ts']) { + const config = context.readFile(`${name}.${extension}`); + if (config) { + const safe = stripCommentsForRegex(config, 'typescript'); + if ( + /\b(?:appDirectory|rootDirectory|ignoredRouteFiles|v3_routeConfig|routes)['"]?\s*:|\.\.\./.test( + safe, + ) + ) + return false; + if ( + !/(?:export\s+default\s+(?:defineConfig\s*\(\s*)?\{|module\.exports\s*=\s*\{)/.test( + safe, + ) + ) + return false; + if ( + [...safe.matchAll(/\b(?:remix|reactRouter)\s*\(([^)]*)\)/g)].some((m) => m[1]!.trim()) + ) + return false; + } + } + } + const config = context.readFile('app/routes.ts') ?? context.readFile('app/routes.js'); + if (config !== null) return usesDefaultFlatRoutes(config); + try { + const pkg = JSON.parse(context.readFile('package.json') ?? '{}'); + const deps = { ...pkg.dependencies, ...pkg.devDependencies }; + return Boolean(deps['@remix-run/dev'] || deps['@remix-run/react']); + } catch { + return false; + } + }, + resolve: () => null, + extract(filePath, content) { + const routePath = remixFileRoutePath(filePath); + if (routePath === null) return { nodes: [], references: [] }; + const language = detectLanguage(filePath)!; + const parser = getParser(language); + if (!parser) throw new Error(`File-route extraction requires the ${language} grammar`); + const tree = parser.parse(content); + if (!tree) return { nodes: [], references: [] }; + try { + const exported = tree.rootNode.namedChildren.find( + (n) => n.type === 'export_statement' && n.children.some((c) => c.type === 'default'), + ); + if (!exported) return { nodes: [], references: [] }; + let declaration = + exported.childForFieldName('declaration') ?? exported.childForFieldName('value'); + if (declaration?.type === 'identifier') { + const name = declaration.text; + declaration = + tree.rootNode.namedChildren + .map((n) => + n.type === 'export_statement' ? (n.childForFieldName('declaration') ?? n) : n, + ) + .flatMap((n) => (n.type === 'lexical_declaration' ? n.namedChildren : [n])) + .find((n) => n.childForFieldName('name')?.text === name) ?? null; + } + const component = + declaration?.type === 'variable_declarator' + ? declaration.childForFieldName('value') + : declaration; + const body = component?.childForFieldName('body'); + const returned = + body?.type === 'statement_block' + ? body.namedChildren + .filter((n) => n.type === 'return_statement') + .map((n) => n.namedChildren[0]) + : [body]; + if ( + returned.length > 0 && + returned.every((n) => { + while (n?.type === 'parenthesized_expression') n = n.namedChildren[0]; + return ( + n?.type === 'jsx_self_closing_element' && n.childForFieldName('name')?.text === 'Outlet' + ); + }) + ) + return { nodes: [], references: [] }; + const node: Node = { + id: `route:react-router:${filePath}:file:${routePath}`, + kind: 'route', + name: routePath, + qualifiedName: `${filePath}::${routePath}`, + filePath, + language, + startLine: exported.startPosition.row + 1, + endLine: exported.endPosition.row + 1, + startColumn: exported.startPosition.column, + endColumn: exported.endPosition.column, + updatedAt: Date.now(), + }; + const module = filePath.replace(/\\/g, '/').split('/').pop()!; + return { + nodes: [node], + references: [ + { + fromNodeId: node.id, + referenceName: `react-router-module:${module}`, + referenceKind: 'references', + filePath, + language, + line: node.startLine, + column: node.startColumn, + }, + ], + }; + } finally { + tree.delete(); + } + }, +}; + // ============================================================================= // Route table — the routes `frameworks/react.ts` read out of the markup // ============================================================================= @@ -83,9 +312,19 @@ function isReactRouterRoute(node: Node): boolean { ); } -/** `:id?` — a parameter React Router serves the route with or without. */ -function isOptionalParam(seg: string): boolean { - return seg.startsWith(':') && seg.endsWith('?'); +/** Expand bounded optional segments, including Remix's optional language prefix. */ +function optionalRoutePaths(path: string): string[] { + const segments = path.split('/').slice(1); + if (segments.filter((s) => s.endsWith('?')).length > 4) return []; + let variants = ['']; + for (const segment of segments) { + variants = segment.endsWith('?') + ? variants.flatMap((p) => [p + '/' + segment.slice(0, -1), p]) + : variants.map((p) => p + '/' + segment); + } + const unique = new Map(); + for (const variant of variants) unique.set(variant.replace(/:[^/]+/g, ':'), variant || '/'); + return [...unique.values()]; } const tables = new WeakMap(); @@ -109,17 +348,10 @@ export function reactRouterTable(context: ResolutionContext): ReactRouterTable { const root = reactRouterRoot(node.filePath); const path = node.name.length > 1 && node.name.endsWith('/') ? node.name.slice(0, -1) : node.name; - addRouteTo(tableAt(root), path, node); - // React Router's optional parameter: `/cart/:id?` is the screen for - // `/cart/5` AND for a bare `/cart`, which the navbar's cart icon links - // to. The matcher pairs a route with an href of the same length, so the - // shorter form is its own entry — collected now, registered after every - // literal path, so a route someone actually wrote always wins. - let segs = path.split('/').slice(1); - while (segs.length > 1 && isOptionalParam(segs[segs.length - 1]!)) { - segs = segs.slice(0, -1); - shortened.push({ root, path: '/' + segs.join('/'), node }); - } + tableAt(root); + if (!path.includes('?')) addRouteTo(tableAt(root), path, node); + else + for (const variant of optionalRoutePaths(path)) shortened.push({ root, path: variant, node }); } for (const s of shortened) { const t = byRoot.get(s.root); @@ -167,6 +399,8 @@ export const reactRouterResolver: FrameworkResolver = { 'react-router-dom', 'react-router-native', '@react-router/dev', + '@remix-run/react', + '@remix-run/dev', ); },