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

- Waku filesystem pages now produce exact named or anonymous component roots, with dynamic parameters and literal static-path expansion; layouts, slices, APIs and unsupported configuration are excluded.

- Vike default `+Page` modules now link to exact local components, with nearest inherited literal `+route` overrides and guards against guessed paths under unsupported routing configuration.

- Qwik City default index pages and method exports now produce exact route roots, including anonymous `component$` defaults and their body calls, with layouts and generic middleware excluded.
Expand Down
217 changes: 217 additions & 0 deletions __tests__/waku-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { afterEach, 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 { routeRoots } from '../src/ui-server/api/route-roots';

describe('Waku filesystem routes', () => {
let cg: CodeGraph | undefined;
let dir: string;
const write = (file: string, content: string) => {
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
fs.writeFileSync(path.join(dir, file), content);
};
const setup = () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-waku-'));
write('package.json', JSON.stringify({ dependencies: { waku: '1.0.0-rc.0' } }));
};
const page = (file: string, config = '') =>
write(`src/pages/${file}.tsx`, `export default function Page(){return <p/>}\n${config}`);
const routes = () =>
cg!.getNodesByKind('route').filter((node) => node.id.startsWith('route:waku:'));
const names = () =>
routes()
.map((node) => node.name)
.sort();
afterEach(() => {
cg?.close();
cg = undefined;
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
it('indexes the pinned official home component through generated and explicit adapters', async () => {
setup();
// wakujs/waku@9f425e94996e018983cb629709544397b5f1e93b fs-router-build-split.
write('src/pages/index.tsx', 'export default function Home() {\n return <h1>Home</h1>;\n}\n');
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual(['/']);
expect(routeRoots(cg, routes()).get(routes()[0]!.id)!.node.name).toBe('Home');
write(
'src/waku.server.tsx',
`import { fsRouter } from 'waku';
import adapter from 'waku/adapters/cloudflare';
import { queueState } from './queue-state.js';
export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')), {
handlers: {async queue(batch: {messages: {body: unknown}[]}) {queueState.message = String(batch.messages[0]?.body ?? '');}},
});`,
);
await cg.sync();
expect(names()).toEqual(['/']);
});
it('preserves ordinary underscores/dots and excludes framework files and directories', async () => {
setup();
for (const file of [
'index',
'(group)/about',
'parent/index',
'parent/child',
'_private',
'file.json',
'_layout',
'_root',
'_slices/header',
'_interceptors/auth',
'_api/hello',
'x/_components/A',
'_hooks/useA',
'_actions/a',
'[path]',
])
page(file);
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual(['/', '/_private', '/about', '/file.json', '/parent', '/parent/child']);
});
it('requires staticPaths for static parameters and expands mixed and catchall slugs', async () => {
setup();
page('users/[id]');
page('dynamic/[id]', `export const getConfig = () => ({render:'dynamic'});`);
page(
'@[username]',
`export async function getConfig(){return {staticPaths:['Jane Doe','Sam']};}`,
);
page(
'post/[id].json',
`export function getConfig(){return {render:'static',staticPaths:['42']};}`,
);
page('docs/[...rest]', `export const getConfig=()=>({staticPaths:[['a','b'],['c']]});`);
page('wild/[...rest]', `export const getConfig=()=>({render:'dynamic'});`);
page('optional/[[id]]', `export const getConfig=()=>({render:'dynamic'});`);
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual([
'/@Jane-Doe',
'/@Sam',
'/docs/a/b',
'/docs/c',
'/dynamic/:id',
'/post/42.json',
'/wild/*rest',
]);
});
it.each([
'export const getConfig=()=>({path:"/changed"});',
'export const getConfig=()=>({...options});',
'export const getConfig=()=>({render:mode});',
'export const getConfig=async()=>await loadConfig();',
'export {getConfig} from "./config";',
'export * from "./config";',
'export function getConfig(){if(flag)return {};return {};}',
])('suppresses unknown or overriding configuration: %s', async (config) => {
setup();
page('about', config);
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual([]);
});
it.each([
`export default {srcDir:'custom'};`,
`export default {basePath:'/base'};`,
`export default {...other};`,
`export default load();`,
`const config={};const alias=config;alias.srcDir='custom';export default config;`,
`const config={};const a=config;const b=a;Object.assign(b,{srcDir:'custom'});export default config;`,
])('suppresses nondefault project configuration: %s', async (config) => {
setup();
page('index');
write('waku.config.ts', config);
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual([]);
});
it('honors explicit glob extensions, aliases, and replacement server registrations', async () => {
setup();
page('index');
write('src/pages/a.js', 'export default function A(){}');
const entry = `import {fsRouter as routes} from 'waku';import app from 'waku/adapters/default';export default app(routes(import.meta.glob('./pages/**/*.{tsx,ts}')));`;
write('src/waku.server.tsx', entry);
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual(['/']);
for (const source of [
entry.replace('routes(import.meta.glob', 'other(import.meta.glob'),
entry
.replace('{tsx,ts}', '{js}')
.replace('routes(import.meta.glob', 'routes(import.meta.glob')
.replace(')));', '), {pagesDir:"custom"}));'),
`export default programmatic;`,
]) {
write('src/waku.server.tsx', source);
await cg.sync();
expect(names()).toEqual([]);
}
});
it('links anonymous component calls and multiline named exports to exact nodes', async () => {
setup();
write(
'src/pages/index.tsx',
'function load(){}\nexport default () => {load();return <p onClick={()=>load()}/>};',
);
write('src/pages/named.tsx', 'const Page =\n () => <p/>;\nexport default Page;');
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual(['/', '/named']);
const roots = routeRoots(cg, routes());
const anonymous = roots.get(routes().find((node) => node.name === '/')!.id)!.node;
expect(anonymous.name).toBe('default');
expect(
cg
.getOutgoingEdges(anonymous.id)
.filter((edge) => edge.kind === 'calls')
.map((edge) => cg!.getNode(edge.target)?.name),
).toEqual(['load', 'load']);
expect(roots.get(routes().find((node) => node.name === '/named')!.id)!.node.name).toBe('Page');
});
it('updates introduced framework, page edits/deletes and config across scoped reopened sync', async () => {
setup();
write('package.json', '{}');
page('index');
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual([]);
write('package.json', '{"dependencies":{"waku":"1.0.0-rc.0"}}');
write(
'src/waku.server.tsx',
`import {fsRouter} from 'waku';import adapter from 'waku/adapters/default';export default adapter(fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')));`,
);
await cg.sync({ paths: ['src/waku.server.tsx'] });
expect(names()).toEqual(['/']);
page('about');
await cg.sync();
expect(names()).toEqual(['/', '/about']);
write('src/pages/about.tsx', 'export default 1;');
await cg.sync();
expect(names()).toEqual(['/']);
cg.close();
cg = await CodeGraph.open(dir);
write('waku.config.ts', 'export default {basePath:"/new"};');
await cg.sync({ paths: ['waku.config.ts'] });
expect(names()).toEqual([]);
fs.unlinkSync(path.join(dir, 'waku.config.ts'));
await cg.sync();
expect(names()).toEqual(['/']);
fs.unlinkSync(path.join(dir, 'src/pages/index.tsx'));
await cg.sync();
expect(names()).toEqual([]);
});
it('binds anonymous defaults and body calls in fresh compiled workers', () => {
setup();
write('src/pages/index.tsx', 'function load(){return 1}export default ()=> <p>{load()}</p>;');
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').find(r=>r.id.startsWith('route:waku:'));const e=r&&cg.getOutgoingEdges(r.id).find(e=>e.kind==='references');const component=e&&cg.getNode(e.target);console.log(JSON.stringify([r?.name,component?.kind,component&&cg.getOutgoingEdges(component.id).filter(e=>e.kind==='calls').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_PARSE_WORKERS: '2',
CODEGRAPH_PARALLEL_RESOLVE_MIN: '1',
CODEGRAPH_RESOLVE_WORKERS: '2',
},
});
expect(JSON.parse(output.trim().split('\n').at(-1)!)).toEqual(['/', 'component', ['load']]);
});
});
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: 11/13 — Vike validated; publishing step 11
Status: 12/13 — Waku filesystem validated; publishing step 12

- [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 @@ -11,7 +11,7 @@ Status: 11/13 — Vike validated; publishing step 11
- [x] 9 SolidStart — pinned default pages and HTTP handlers bind exact targets; file hierarchy, page/API coexistence, scoped/reopened sync and fresh workers pass; build passes, 115 WASM focused/control tests pass, full native suite 4,542 pass / 46 skip; independent review clear.
- [x] 10 Qwik City — default pages and method exports bind exact roots, including anonymous components and named/anonymous callback calls; config/scoped/reopened sync and fresh workers pass; build passes, 112 WASM focused/control tests pass, full native suite 4,562 pass / 46 skip; independent review clear.
- [x] 11 Vike — default `+Page` conventions and inherited literal `+route` overrides bind exact components; config/scoped/reopened sync and fresh workers pass; build passes, 126 WASM focused/control tests pass, full native suite 4,592 pass / 46 skip; independent review clear.
- [ ] 12 Waku filesystem routes — default pages, parameters, and layout exclusions for a pinned version. Gate: shared proof and `_root`/`_layout`/`_slices` controls.
- [x] 12 Waku filesystem routes — pinned default pages bind exact named/anonymous roots with staticPaths and dynamic parameters; config/scoped/reopened sync and fresh workers pass; build passes, 126 WASM focused/control tests pass, full native suite 4,612 pass / 46 skip; independent review clear.
- [ ] 13 Waku programmatic routes — literal `createPage` declarations within the documented `createPages` registration. Gate: shared proof, async registration syntax without executing it, and computed path negatives.

All 13 steps approved. Each step gets a separate stacked PR so its diff remains reviewable. Existing [PR #2](https://github.com/bompus/codegraph/pull/2) remains unchanged; the first new PR is based on its branch.
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 @@ -50,6 +50,9 @@ guessed.
| SolidStart | `frameworks/solid-start.ts` | — | `solid-start.test.ts` | pinned 2.0.4 About/API fixtures; page/API coexistence, file hierarchy and config sync |
| Qwik City | `frameworks/qwik-city.ts` | — | `qwik-city.test.ts` | pinned 1.20.0 page/API sources; anonymous component roots, body-call ownership, sync and workers |
| Vike | `frameworks/vike.ts` | — | `vike-routes.test.ts` | pinned 0.4.266 About/product examples; nearest route inheritance, config guards, sync and workers |
| Waku | `frameworks/waku.ts` | — | `waku-routes.test.ts` | pinned 1.0.0-rc.0 home/Cloudflare fixtures; exact anonymous roots, staticPaths, config sync and workers |

Waku filesystem coverage targets **1.0.0-rc.0**, with default `src/pages` discovery or a literal default/Cloudflare adapter around `fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}'))` (default extension subsets are honored). [Official home](https://github.com/wakujs/waku/blob/9f425e94996e018983cb629709544397b5f1e93b/e2e/fixtures/fs-router-build-split/src/pages/index.tsx), [Cloudflare entry](https://github.com/wakujs/waku/blob/9f425e94996e018983cb629709544397b5f1e93b/e2e/fixtures/cloudflare-adapter/src/waku.server.tsx). Local default functions, including anonymous functions, bind exact component roots. Pages default to static rendering; literal `getConfig` return objects may set `render` and `staticPaths`, with static parameters expanded only for declared values. Dynamic parameters, mixed slug segments, terminal catchalls and groups are supported. `_root`, `_layout`, top-level `_slices`/`_interceptors`/`_api`, and `_actions`/`_components`/`_hooks` directories are excluded. Custom roots/globs/options, component/path overrides, unknown config, optional parameters, wrappers and re-exports are unsupported; no navigation is inferred.

Vike recognizes local named default or `Page` ES-module exports in JS/TS `+Page` files when a default-root Vite config registers option-free `vike()`. Filesystem URLs remove complete `pages`, `src`, `index`, `renderer` and group segments; configuration inheritance removes only `pages`/`renderer`. The nearest inherited literal `+route` wins and `@` parameters become named graph parameters. [About source](https://github.com/vikejs/vike/blob/715c15d11be196caa69159b929ae09697f3ce734/examples/react-minimal/pages/about/%2BPage.tsx), [product override](https://github.com/vikejs/vike/blob/715c15d11be196caa69159b929ae09697f3ce734/examples/file-structure-domain-driven/product/pages/index/%2Broute.js). Canonical `extends: vikeReact` and literal arrays from `vike-react/config` are accepted; its [0.6.26 config](https://github.com/vikejs/vike-react/blob/b51cdccaba8cda8cb951f66edc61e359f77ae239/packages/vike-react/src/config.ts) affects rendering rather than paths. Dynamic/ambiguous route overrides, configured Page/root overrides, unknown extensions and metadata do not receive guessed paths; `onBeforeRoute` blocks app-wide inference. Custom roots/plugin options, inherited Page-only targets, anonymous/wrapped/re-exported components and non-JS/TS template files are 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 @@ -43,6 +43,9 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
| **SolidStart** | Default file pages and HTTP-method exports; exact local targets, nested layouts, groups, parameters and GET-to-HEAD fallback |
| **Qwik City** | Default index pages, named/anonymous `component$` components and method-specific endpoint exports; groups, parameters and catchalls |
| **Vike** | Default JS/TS `+Page` modules and nearest inherited literal `+route` overrides; exact local named components and `@` parameters |
| **Waku** | Default filesystem pages with exact named/anonymous components, dynamic parameters and literal `staticPaths` expansion |

Waku coverage targets 1.0.0-rc.0. Default `src/pages` and literal default/Cloudflare `fsRouter` adapters are supported. Parameter pages need explicit dynamic rendering or declared static paths. Layouts, roots, slices, interceptors and API modules are excluded. Custom routing configuration, unknown `getConfig`, wrappers and re-exports remain unsupported.

Vike coverage targets 0.4.266 with default roots and option-free `vike()` configuration. Canonical `vike-react/config` rendering configuration is supported. Dynamic overrides, custom roots/options, configured or inherited-only Page targets, unknown extensions/metadata, anonymous/re-exported/wrapped components and template-language pages remain unsupported; these do not receive fallback paths.

Expand Down
11 changes: 7 additions & 4 deletions src/extraction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { extractAnalogRoutes, isAnalogPage } from '../resolution/frameworks/anal
import { extractSolidStartRoutes, isSolidStartRoute } from '../resolution/frameworks/solid-start';
import { extractQwikCityRoutes, isQwikCityRoute } from '../resolution/frameworks/qwik-city';
import { extractVikeRoutes, isVikePage } from '../resolution/frameworks/vike';
import { extractWakuRoutes, isWakuPage } from '../resolution/frameworks/waku';
import type { ResolutionContext } from '../resolution/types';
import { createYielder, type MaybeYield } from '../resolution/cooperative-yield';

Expand Down Expand Up @@ -2380,15 +2381,17 @@ export class ExtractionOrchestrator {
const solidStart = frameworks.includes('solid-start') && isSolidStartRoute(filePath);
const qwikCity = frameworks.includes('qwik-city') && isQwikCityRoute(filePath);
const vike = frameworks.includes('vike') && isVikePage(filePath);
if (!angular && !analog && !solidStart && !qwikCity && !vike) return result;
await loadGrammarsForLanguages(solidStart || qwikCity || vike ? ['typescript', 'javascript', 'tsx', 'jsx'] : ['typescript', 'javascript']);
const waku = frameworks.includes('waku') && isWakuPage(filePath);
if (!angular && !analog && !solidStart && !qwikCity && !vike && !waku) return result;
await loadGrammarsForLanguages(solidStart || qwikCity || vike || waku ? ['typescript', 'javascript', 'tsx', 'jsx'] : ['typescript', 'javascript']);
result = materializeKernelResult(result, filePath, detectLanguage(filePath)!);
const context = this.frameworkSourceContext!;
const extracted = angular ? extractAngularRoutes(filePath, content, context)
: analog ? extractAnalogRoutes(filePath, content, context)
: solidStart ? extractSolidStartRoutes(filePath, content, context)
: qwikCity ? extractQwikCityRoutes(filePath, content, context, result)
: extractVikeRoutes(filePath, content, context);
: vike ? extractVikeRoutes(filePath, content, context)
: extractWakuRoutes(filePath, content, context, result);
result.nodes.push(...extracted.nodes);
result.unresolvedReferences.push(...extracted.references);
return result;
Expand Down Expand Up @@ -2905,7 +2908,7 @@ export class ExtractionOrchestrator {
const detected = this.ensureDetectedFrameworks(currentFiles);
for (const [framework, matches] of [
['solid-start', isSolidStartRoute], ['analog', isAnalogPage], ['qwik-city', isQwikCityRoute],
['vike', isVikePage],
['vike', isVikePage], ['waku', isWakuPage],
] as const) {
if (!detected.includes(framework) && !this.queries.getNodesByKind('route').some(n => n.id.startsWith(`route:${framework}:`))) continue;
const scope = this.scopedSyncMatcher();
Expand Down
Loading