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 @@ -27,6 +27,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

- 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.

- Waku programmatic `createPages` registrations now expose literal `createPage` routes and exact local/imported component roots, including async callbacks, static paths and exact-path declarations.

- 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
234 changes: 234 additions & 0 deletions __tests__/waku-programmatic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
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 registered programmatic pages', () => {
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-programmatic-'));
write('package.json', '{"dependencies":{"waku":"1.0.0-rc.0"}}');
write(
'src/pages/index.tsx',
'export default function HomePage() {\n return <div>Home</div>;\n}\n',
);
};
const entry = (body: string, parameter = '{createPage}', imports = '') =>
`import {createPages} from 'waku';import adapter from 'waku/adapters/default';import HomePage from './pages/index.js';${imports}const pages=createPages(async (${parameter})=>${body});export default adapter(pages);`;
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 typegen fixture with the exact imported component', async () => {
setup();
// wakujs/waku@9f425e94996e018983cb629709544397b5f1e93b plugin-fs-router-typegen-with-createpages.
write(
'src/waku.server.tsx',
`import { createPages } from 'waku';
import adapter from 'waku/adapters/default';
import HomePage from './pages/index.js';
const pages = createPages(async ({ createPage }) => [
createPage({render: 'static', path: '/', component: HomePage}),
]);
export default adapter(pages);`,
);
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual(['/']);
const target = routeRoots(cg, routes()).get(routes()[0]!.id)!.node;
expect([target.name, target.filePath]).toEqual(['HomePage', 'src/pages/index.tsx']);
});
it('registers direct calls after await, helper aliases and local components', async () => {
setup();
write(
'src/waku.server.tsx',
entry(
`{await ready();page({render:'dynamic',path:'/users/[id]',component:HomePage});return [page({render:'static',path:'/local',component:Local})];}`,
'{createPage:page}',
'function Local(){return <p/>}function ready(){}',
),
);
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual(['/local', '/users/:id']);
expect(
routeRoots(cg, routes()).get(routes().find((node) => node.name === '/local')!.id)!.node.name,
).toBe('Local');
});
it('expands static paths and preserves exact brackets', async () => {
setup();
write(
'src/waku.server.tsx',
entry(`[
createPage({render:'static',path:'/@[user]',staticPaths:['Jane Doe'],component:HomePage}),
createPage({render:'dynamic',path:'/docs/[...rest]',component:HomePage}),
createPage({render:'static',path:'/literal/[id]',exactPath:true,component:HomePage}),
createPage({render:'dynamic',path:'/(group)/nested/index.html',component:HomePage}),
]`),
);
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual(['/@Jane-Doe', '/docs/*rest', '/literal/[id]', '/nested']);
});
it.each([
`createPage({render:'dynamic',path:dynamic,component:HomePage})`,
`createPage({render:'dynamic',path:'/x',component:HomePage,...options})`,
`createPage({render:mode,path:'/x',component:HomePage})`,
`createPage({render:'static',path:'/[id]',component:HomePage})`,
`createPage({render:'dynamic',path:'/x',component:null})`,
`createPage({render:'dynamic',path:'/x',component:HomePage,exactPath:flag})`,
`createPage({render:'dynamic',path:'/x',component:wrap(HomePage)})`,
])('rejects unknown declarations: %s', async (call) => {
setup();
write('src/waku.server.tsx', entry(`[${call}]`));
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual([]);
});
it('excludes conditional calls, uncalled helpers, layouts and shadowed declarations', async () => {
setup();
write(
'src/waku.server.tsx',
entry(
`{
function nested(){createPage({render:'static',path:'/nested',component:HomePage})}
if(flag) createPage({render:'static',path:'/conditional',component:HomePage});
createLayout({render:'static',path:'/layout',component:HomePage});
createPage({render:'static',path:'/real',component:HomePage});return [];
}`,
'{createPage,createLayout}',
),
);
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual(['/real']);
write(
'src/waku.server.tsx',
entry(
`{{const createPage=other;createPage({render:'static',path:'/fake',component:HomePage});}return [];}`,
),
);
await cg.sync();
expect(names()).toEqual([]);
});
it.each(['if(true)return [];', 'if(flag)throw new Error();', 'while(true){}'])(
'does not infer declarations after potentially terminating control flow: %s',
async (prefix) => {
setup();
write(
'src/waku.server.tsx',
entry(
`{${prefix}return [createPage({render:'static',path:'/never',component:HomePage})];}`,
),
);
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual([]);
},
);
it('requires the returned adapter registration and real imports', async () => {
setup();
const source = entry(`[createPage({render:'static',path:'/',component:HomePage})]`);
for (const changed of [
source.replace("from 'waku'", "from 'other'"),
source.replace('adapter(pages)', 'adapter(other)'),
source.replace('export default adapter(pages)', 'export default other'),
source.replace('export default', 'pages.push(extra);export default'),
]) {
write('src/waku.server.tsx', changed);
if (cg) await cg.sync();
else cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual([]);
}
});
it('updates declaration and imported target changes through scoped reopened sync', async () => {
setup();
write(
'src/waku.server.tsx',
entry(`[createPage({render:'static',path:'/one',component:HomePage})]`),
);
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual(['/one']);
write(
'src/waku.server.tsx',
entry(`[createPage({render:'static',path:'/two',component:HomePage})]`),
);
await cg.sync({ paths: ['src/waku.server.tsx'] });
expect(names()).toEqual(['/two']);
cg.close();
cg = await CodeGraph.open(dir);
write('src/pages/index.tsx', 'function Decoy(){return <p/>}export default 1;');
await cg.sync({ paths: ['src/pages/index.tsx'] });
expect(
[...routeRoots(cg, routes()).values()].filter((root) => root.node.kind === 'function'),
).toEqual([]);
fs.unlinkSync(path.join(dir, 'src/waku.server.tsx'));
await cg.sync();
expect(names()).toEqual([]);
});
it('supports imported aliases, named exports, multiline functions and skip-build options', async () => {
setup();
write('src/components.tsx', 'export const Named =\n () => <p/>;');
write(
'src/waku.server.tsx',
`import {createPages as routes} from 'waku';import app from 'waku/adapters/default';import {Named as Page} from './components';const pages=routes(async({createPage:add})=>[add({render:'static',path:'/named',component:Page})],{unstable_skipBuild:()=>true});export default app(pages);`,
);
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual(['/named']);
expect(routeRoots(cg, routes()).get(routes()[0]!.id)!.node.name).toBe('Named');
});
it('switches from filesystem to programmatic routes and suppresses custom config', async () => {
setup();
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual(['/']);
write(
'src/waku.server.tsx',
entry(`[createPage({render:'static',path:'/registered',component:HomePage})]`),
);
await cg.sync({ paths: ['src/waku.server.tsx'] });
expect(names()).toEqual(['/registered']);
write('waku.config.ts', 'export default {basePath:"/base"};');
await cg.sync({ paths: ['waku.config.ts'] });
expect(names()).toEqual([]);
});
it('does not bind an imported component name shadowed by callback parameters', async () => {
setup();
write(
'src/waku.server.tsx',
entry(
`[createPage({render:'static',path:'/fake',component:HomePage})]`,
'{createPage,unknown:HomePage}',
),
);
cg = await CodeGraph.init(dir, { index: true });
expect(names()).toEqual([]);
});
it('resolves exact imported components in fresh compiled workers', () => {
setup();
write(
'src/waku.server.tsx',
entry(`[createPage({render:'static',path:'/',component:HomePage})]`),
);
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');console.log(JSON.stringify([r?.name,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(['/', 'HomePage']);
});
});
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: 12/13 — Waku filesystem validated; publishing step 12
Status: 13/13 — all approved router coverage implemented and validated

- [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 @@ -12,7 +12,7 @@ Status: 12/13 — Waku filesystem validated; publishing step 12
- [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.
- [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.
- [x] 13 Waku programmatic routes — registered literal `createPage` declarations bind exact named local/imported components; async callbacks, staticPaths, scoped/reopened sync and fresh workers pass; build passes, 146 WASM focused/control tests pass, full native suite 4,632 pass / 46 skip; independent review clear.

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
4 changes: 3 additions & 1 deletion docs/design/framework-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +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 | `frameworks/waku.ts` | — | `waku-routes.test.ts`, `waku-programmatic.test.ts` | pinned 1.0.0-rc.0 home/Cloudflare/createPages fixtures; exact roots, staticPaths, config sync and workers |

Waku programmatic coverage recognizes default/Cloudflare adapter registration of imported `createPages` in the default server module, including a local `const` binding. Direct calls to its injected `createPage` helper (including aliases) are read from synchronous/async callbacks without executing them. Literal paths, render modes, static paths and `exactPath` are supported; roots bind exact named local functions or directly imported named/default function exports. The [official typegen fixture](https://github.com/wakujs/waku/blob/9f425e94996e018983cb629709544397b5f1e93b/packages/waku/tests/fixtures/plugin-fs-router-typegen-with-createpages/waku.server.tsx) supplies the registration pattern. Conditional calls, nested helpers, arbitrary registration wrappers, computed options, component wrappers, re-exports and anonymous imported targets remain unsupported. `createLayout`/`createRoot`/`createSlice`/`createApi` do not become pages; `unstable_skipBuild` does not remove runtime routes.

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.

Expand Down
4 changes: 3 additions & 1 deletion site/src/content/docs/guides/framework-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,12 @@ 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** | Default filesystem pages and registered `createPages`/`createPage` declarations, exact component roots, 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.

Programmatic Waku pages require a registered `createPages` callback in the default server module. Direct `createPage` calls support aliases, async callbacks, literal paths/render/staticPaths and `exactPath`, with exact named local or imported function targets. Computed declarations, conditional calls, nested helpers and arbitrary wrappers are omitted.

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.

Qwik City coverage targets 1.20.0 with default roots and option-free `qwikCity()` configuration. Page discovery requires the root provider/outlet; API endpoints do not. Parent index pages remain pages, while layouts and generic `onRequest` middleware do not become endpoints. Custom routing options, layout-override index names, optional parameters, Markdown/MDX, re-exports and arbitrary wrappers remain unsupported.
Expand Down
Loading