Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- Solid Router registered JSX and configuration trees now link to exact components, including static lazy imports, nested paths and router bases, without duplicate React routes.

- Analog default file pages now link to their component classes, preserving its directory layouts, dotted paths and dynamic segments when the platform plugin and file router are registered.

- Angular Router registered route arrays now link to exact component classes, including nested children and static lazy imports; imported route edits refresh their registrations during sync.
Expand Down
210 changes: 210 additions & 0 deletions __tests__/solid-router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
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 { extractSolidRoutes } from '../src/resolution/frameworks/solid-router';
import { routeRoots } from '../src/ui-server/api/route-roots';

describe('Solid Router registered declarations', () => {
let cg: CodeGraph | undefined;
let dir: string;
beforeAll(async () => {
await initGrammars();
await loadGrammarsForLanguages(['tsx']);
});
afterEach(() => {
cg?.close();
cg = undefined;
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
const imports = `import {Router, Route} from '@solidjs/router';`;
const names = (source: string) =>
extractSolidRoutes('src/app.tsx', imports + source)
.nodes.map((n) => n.name)
.sort();
it('composes nested paths and base while excluding layout components', () => {
expect(
names(
`<Router base="/app"><Route path="/users" component={Layout}><Route path="/" component={Index}/><Route path="/:id" component={User}/></Route></Router>`,
),
).toEqual(['/app/users', '/app/users/:id']);
});
it('accepts pathless parents, arrays and registered local config', () => {
expect(
names(
`const routes = [{children:[{path:['/one','/two'],component:Page}]}]; export const App = () => <Router>{routes}</Router>;`,
),
).toEqual(['/one', '/two']);
});
it('removes a parent splat before composing children', () => {
expect(
names(
`<Router><Route path="/docs/*" component={Layout}><Route path="/child" component={Page}/></Route></Router>`,
),
).toEqual(['/docs/child']);
});
it('recognizes imported aliases', () => {
expect(
extractSolidRoutes(
'src/app.tsx',
`import {Router as R, Route as P} from '@solidjs/router'; const App=()=> <R><P path="/a" component={Page}/></R>;`,
).nodes.map((n) => n.name),
).toEqual(['/a']);
});
it('recognizes static lazy imports in identifiers and inline config', () => {
const result = extractSolidRoutes(
'src/app.tsx',
imports +
`import {lazy} from 'solid-js'; const Page=lazy(()=>import('./page')); const routes=[{path:'/inline',component:lazy(()=>import('./other'))}]; const App=()=> <Router><Route path="/named" component={Page}/>{routes}</Router>;`,
);
expect(result.nodes.map((n) => n.name)).toEqual(['/named', '/inline']);
expect(result.references.map((n) => n.referenceName)).toEqual([
'solid-lazy:./page',
'solid-lazy:./other',
]);
});
it.each([
`<Route path="/orphan" component={Page}/>`,
`const unused = [{path:'/orphan',component:Page}];`,
`function App(Router) {return <Router><Route path="/x" component={Page}/></Router>}`,
`function App(Route) {return <Router><Route path="/x" component={Page}/></Router>}`,
`<Router base={unknown}><Route path="/x" component={Page}/></Router>`,
`<Router><Route path={unknown} component={Page}/></Router>`,
`<Router><Other path="/x" component={Page}/></Router>`,
`<Router><Route {...props} path="/x" component={Page}/></Router>`,
`<Router>{[{path:'/x',...props,component:Page}]}</Router>`,
`const routes=[{path:'/x',component:Page}]; routes.push(other); <Router>{routes}</Router>`,
`const routes=[{path:'/x',component:Page}]; const alias=routes; alias[0].path='/other'; <Router>{routes}</Router>`,
`const Page=foreign(()=>import('./page')); <Router><Route path="/x" component={Page}/></Router>`,
`function App(){const {Route}=other;return <Router><Route path="/wrong" component={Page}/></Router>}`,
`const children=[]; children.push({path:'child',component:Page}); const routes=[{path:'/parent',component:Page,children:children}]; <Router>{routes}</Router>`,
])('does not invent routes for unsupported declarations: %s', (source) => {
expect(names(source)).toEqual([]);
});
it('indexes exact imported components without duplicate React routes and syncs edits/deletions', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-solid-'));
fs.mkdirSync(path.join(dir, 'src'));
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ dependencies: { '@solidjs/router': '0.16.3', 'solid-js': '1.9.9' } }),
);
fs.writeFileSync(
path.join(dir, 'src/page.tsx'),
'export default function Page(){return <div/>}',
);
fs.writeFileSync(
path.join(dir, 'src/other.tsx'),
'export default function Page(){return <div/>}',
);
const app = path.join(dir, 'src/app.tsx');
fs.writeFileSync(
app,
imports +
`import Page from './page'; export const App=()=> <Router><Route path="/first" component={Page}/></Router>;`,
);
cg = await CodeGraph.init(dir, { index: true });
let routes = cg.getNodesByKind('route');
expect(routes.map((n) => n.name)).toEqual(['/first']);
expect(routeRoots(cg, routes).get(routes[0]!.id)!.node.filePath).toBe('src/page.tsx');
fs.writeFileSync(
app,
imports +
`import Page from './page'; export const App=()=> <Router><Route path="/second" component={Page}/></Router>;`,
);
await cg.sync();
routes = cg.getNodesByKind('route');
expect(routes.map((n) => n.name)).toEqual(['/second']);
fs.unlinkSync(app);
await cg.sync();
expect(cg.getNodesByKind('route')).toEqual([]);
});
it('indexes the pinned upstream README lazy example through exact defaults', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-solid-real-'));
fs.mkdirSync(path.join(dir, 'pages'));
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ dependencies: { '@solidjs/router': '0.16.3', 'solid-js': '1.9.9' } }),
);
// solidjs/solid-router@e8d3a7f719020ef01f8879a0110d2123b8597caa README.md, lazy-load example.
fs.writeFileSync(
path.join(dir, 'app.tsx'),
`import { lazy } from "solid-js";
import { render } from "solid-js/web";
import { Router, Route } from "@solidjs/router";
const Users = lazy(() => import("./pages/Users"));
const Home = lazy(() => import("./pages/Home"));
const App = (props) => (<><h1>My Site with lots of pages</h1>{props.children}</>);
render(() => (<Router root={App}><Route path="/users" component={Users} /><Route path="/" component={Home} /></Router>),document.getElementById("app"));`,
);
fs.writeFileSync(
path.join(dir, 'pages/Home.tsx'),
'export default function Home(){return <div/>}',
);
fs.writeFileSync(
path.join(dir, 'pages/Users.tsx'),
'export default function Users(){return <div/>}',
);
cg = await CodeGraph.init(dir, { index: true });
const routes = cg.getNodesByKind('route');
expect(routes.map((n) => n.name).sort()).toEqual(['/', '/users']);
const roots = routeRoots(cg, routes);
expect(routes.map((n) => [n.name, roots.get(n.id)!.node.name]).sort()).toEqual([
['/', 'Home'],
['/users', 'Users'],
]);
cg.close();
cg = await CodeGraph.open(dir);
fs.writeFileSync(path.join(dir, 'pages/Home.tsx'), 'function Decoy(){}; export default 1;');
await cg.sync();
const home = cg.getNodesByKind('route').find((n) => n.name === '/')!;
expect(cg.getOutgoingEdges(home.id).filter((e) => e.kind === 'references')).toEqual([]);
});
it('detects Solid introduced by scoped sync and adds new registered routes', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-solid-sync-'));
fs.writeFileSync(path.join(dir, 'package.json'), '{}');
fs.writeFileSync(path.join(dir, 'seed.ts'), 'export const seed=1;');
cg = await CodeGraph.init(dir, { index: true });
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ dependencies: { '@solidjs/router': '0.16.3' } }),
);
fs.writeFileSync(path.join(dir, 'Home.tsx'), 'export default function Home(){return <div/>}');
fs.writeFileSync(
path.join(dir, 'app.tsx'),
imports +
`import Home from './Home'; export const App=()=> <Router><Route path="/added" component={Home}/></Router>;`,
);
await cg.sync({ paths: ['app.tsx', 'Home.tsx', 'package.json'] });
const routes = cg.getNodesByKind('route');
expect(routes.map((n) => n.name)).toEqual(['/added']);
expect(routeRoots(cg, routes).get(routes[0]!.id)!.node.name).toBe('Home');
});
it('extracts and resolves lazy components in fresh compiled workers', () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-solid-workers-'));
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ dependencies: { '@solidjs/router': '0.16.3' } }),
);
fs.writeFileSync(path.join(dir, 'Home.tsx'), 'export default function Home(){return <div/>}');
fs.writeFileSync(
path.join(dir, 'app.tsx'),
imports +
`import {lazy} from 'solid-js'; const Home=lazy(()=>import('./Home')); export const App=()=> <Router><Route path="/" component={Home}/></Router>;`,
);
const script = `const {CodeGraph}=require(${JSON.stringify(path.resolve('dist/index.js'))});(async()=>{const cg=await CodeGraph.init(${JSON.stringify(dir)},{index:true});const r=cg.getNodesByKind('route')[0];console.log(JSON.stringify([r?.name,r&&cg.getOutgoingEdges(r.id).filter(e=>e.kind==='references').map(e=>cg.getNode(e.target)?.name)]));cg.close();})().catch(e=>{console.error(e);process.exit(1)});`;
const output = execFileSync(process.execPath, ['-e', script], {
encoding: 'utf8',
timeout: 60000,
env: {
...process.env,
CODEGRAPH_PARALLEL_RESOLVE_MIN: '1',
CODEGRAPH_RESOLVE_WORKERS: '2',
CODEGRAPH_PARSE_WORKERS: '2',
},
});
expect(JSON.parse(output.trim().split('\n').at(-1)!)).toEqual(['/', ['Home']]);
});
});
4 changes: 2 additions & 2 deletions docs/design/PLAN-application-router-coverage.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Status: 7/13 — Analog verified; publishing step 7 before Solid Router
Status: 8/13 — Solid Router verified; publishing step 8 before SolidStart

- [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.
Expand All @@ -7,7 +7,7 @@ Status: 7/13 — Analog verified; publishing step 7 before Solid Router
- [x] 5 RedwoodSDK — registered literal trees, prefixes and method tables bind exact handlers; JSX evidence classifies pages, interrupters/ambiguous declarations excluded; handler edit/delete and new-framework sync pass; build passes, 91 WASM tests pass, full native suite 4,459 pass / 46 skip; independent review clear.
- [x] 6 Angular Router — registered arrays, nested children and static lazy imports bind exact classes; imported table add/edit/delete after reopening and fresh workers pass; build passes, 117 WASM focused/control tests pass, full native suite 4,484 pass / 46 skip; independent review clear.
- [x] 7 Analog — registered default pages bind exact classes with directory-based layouts and dot/parameter conventions; config/file and reopened/scoped sync plus fresh workers pass; build passes, 133 WASM focused/control tests pass, full native suite 4,500 pass / 46 skip; independent review clear.
- [ ] 8 Solid Router — imported JSX/config declarations, nested path composition, and router base. Gate: shared proof, nested matching semantics, component links, and unrelated JSX negatives.
- [x] 8 Solid Router — registered JSX/config and static lazy imports bind exact components; nested bases/splats, mutations, scoped introduction and fresh workers pass; build passes, 115 WASM focused/control tests pass, full native suite 4,523 pass / 46 skip; independent review clear.
- [ ] 9 SolidStart — pinned-version file routes, default page exports, and HTTP-method exports. Gate: shared proof, page/API coexistence, layouts, and dynamic parameters.
- [ ] 10 Qwik City — default file routes, page components, and method-specific endpoint exports. Gate: shared proof, parameters, layouts, and `onRequest`/middleware exclusions.
- [ ] 11 Vike — default `+Page` conventions and literal `+route` overrides. Gate: shared proof, parameters, exact component links, and no fallback route when an unsupported override changes routing.
Expand Down
3 changes: 3 additions & 0 deletions docs/design/framework-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ guessed.
| RedwoodSDK | `frameworks/redwood.ts` | — | `redwood-routes.test.ts` | pinned 1.7.3 typed-routes worker; exact page and API roots |
| Angular Router | `frameworks/angular.ts` | — | `angular-routes.test.ts` | pinned official tutorial registration; exact class roots and imported-array sync |
| Analog | `frameworks/analog.ts` | — | `analog-routes.test.ts` | pinned 2.7.1 sign-up page; filename/layout and registration sync controls |
| Solid Router | `frameworks/solid-router.ts` | — | `solid-router.test.ts` | pinned 0.16.3 README lazy example; exact component roots, nested paths and fresh workers |

Solid Router reads imported `Router`/`Route` JSX and registered literal or module-constant config trees. It composes bases and nested paths, removes parent splats, and emits only leaf pages. Components bind through imports or same-file declarations; static `solid-js` lazy imports bind named default exports. [Pinned example](https://github.com/solidjs/solid-router/blob/e8d3a7f719020ef01f8879a0110d2123b8597caa/README.md), [path composition](https://github.com/solidjs/solid-router/blob/e8d3a7f719020ef01f8879a0110d2123b8597caa/src/utils.ts). Tests cover shadowed imports, mutated tables, exact targets, edit/delete sync and fresh workers. Other router variants, cross-file config tables, local function config bindings, dynamic declarations, spreads, inline/anonymous components and lazy re-exports remain unsupported. No navigation is inferred.

Analog recognizes `src/app/pages/**/*.page.ts` named default classes when a root Vite config registers the platform plugin and source registers option-free `provideFileRouter()`. Directory hierarchy determines layouts before dots become URL separators; index/pathless names, parameters and catchalls follow the pinned conventions. [Official page fixture](https://github.com/analogjs/analog/blob/0896a7eaaa2acf26443ca184bc1dd9aa1a06f4d6/apps/analog-app/src/app/pages/%28auth%29/sign-up.page.ts), [route construction](https://github.com/analogjs/analog/blob/0896a7eaaa2acf26443ca184bc1dd9aa1a06f4d6/packages/router/src/lib/routes.ts). Fresh workers and config/file add/edit/delete refresh existing pages, including after reopening. Custom roots, extra route directories, `app/routes`, metadata overrides, router options, optional catchalls, Markdown, anonymous defaults and re-exports remain unsupported. No navigation is inferred.

Expand Down
3 changes: 3 additions & 0 deletions site/src/content/docs/guides/framework-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
| **RedwoodSDK** | Registered `defineApp` trees with `route`, `index`, `render`, `layout`, `prefix` and standard method tables; exact handlers and JSX page classification |
| **Angular Router** | `provideRouter` / `RouterModule.forRoot` arrays, nested children, relative component imports and static lazy components/route arrays/NgModules |
| **Analog** | Registered default `src/app/pages/**/*.page.ts` pages, linked to named default classes; directory layouts, dot paths, index/pathless segments and parameters |
| **Solid Router** | Imported `Router`/`Route` JSX and registered literal configuration, nested paths, path arrays and bases; imported/local components and static lazy defaults |

Solid Router emits leaf routes, preserving nested path composition even when a child begins with `/`. Parent components and the router root remain layouts. Cross-file configuration, other router variants, dynamic/spread declarations, inline/anonymous components and lazy re-exports are unsupported.

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.

Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,7 @@ export class CodeGraph {
if (result.success && result.filesIndexed > 0) {
const tReinit = Date.now();
this.resolver.initialize();
if (this.queries.getNodesByKind('route').some(n => /^route:(react-router|redwood):/.test(n.id)))
if (this.queries.getNodesByKind('route').some(n => /^route:(react-router|redwood|solid):/.test(n.id)))
await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']);
// Cross-file finalization (e.g. NestJS RouterModule prefixes). Runs
// before resolution so updated names show up in subsequent reads.
Expand Down Expand Up @@ -834,7 +834,7 @@ export class CodeGraph {
// (regex over *.module.ts only).
if (result.filesAdded > 0 || result.filesModified > 0) {
this.resolver.initialize();
if (this.queries.getNodesByKind('route').some(n => /^route:(react-router|redwood):/.test(n.id)))
if (this.queries.getNodesByKind('route').some(n => /^route:(react-router|redwood|solid):/.test(n.id)))
await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']);
this.resolver.runPostExtract();
} else if (result.filesRemoved > 0) {
Expand Down
2 changes: 2 additions & 0 deletions src/resolution/frameworks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { astroResolver } from './astro';
import { redwoodResolver } from './redwood';
import { angularResolver } from './angular';
import { analogResolver } from './analog';
import { solidRouterResolver } from './solid-router';
import { djangoResolver, flaskResolver, fastapiResolver } from './python';
import { railsResolver } from './ruby';
import { springResolver } from './java';
Expand Down Expand Up @@ -69,6 +70,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
redwoodResolver,
angularResolver,
analogResolver,
solidRouterResolver,
// Python
djangoResolver,
flaskResolver,
Expand Down
Loading