diff --git a/CHANGELOG.md b/CHANGELOG.md
index 27f39e1d4..cc06d36fa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/__tests__/waku-programmatic.test.ts b/__tests__/waku-programmatic.test.ts
new file mode 100644
index 000000000..47a7dd82a
--- /dev/null
+++ b/__tests__/waku-programmatic.test.ts
@@ -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
Home
;\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 }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 }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 () => ;');
+ 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']);
+ });
+});
diff --git a/docs/design/PLAN-application-router-coverage.md b/docs/design/PLAN-application-router-coverage.md
index 8828330f0..2b943c586 100644
--- a/docs/design/PLAN-application-router-coverage.md
+++ b/docs/design/PLAN-application-router-coverage.md
@@ -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.
@@ -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.
diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md
index 8b4a2f1ae..e54806090 100644
--- a/docs/design/framework-coverage.md
+++ b/docs/design/framework-coverage.md
@@ -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.
diff --git a/site/src/content/docs/guides/framework-routes.md b/site/src/content/docs/guides/framework-routes.md
index a1ffe75c3..dbfde8ec2 100644
--- a/site/src/content/docs/guides/framework-routes.md
+++ b/site/src/content/docs/guides/framework-routes.md
@@ -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.
diff --git a/src/extraction/index.ts b/src/extraction/index.ts
index ee2bcb59f..37b318cc6 100644
--- a/src/extraction/index.ts
+++ b/src/extraction/index.ts
@@ -38,7 +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 { extractWakuRoutes, isWakuRouteFile } from '../resolution/frameworks/waku';
import type { ResolutionContext } from '../resolution/types';
import { createYielder, type MaybeYield } from '../resolution/cooperative-yield';
@@ -2381,7 +2381,7 @@ 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);
- const waku = frameworks.includes('waku') && isWakuPage(filePath);
+ const waku = frameworks.includes('waku') && isWakuRouteFile(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)!);
@@ -2908,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], ['waku', isWakuPage],
+ ['vike', isVikePage], ['waku', isWakuRouteFile],
] as const) {
if (!detected.includes(framework) && !this.queries.getNodesByKind('route').some(n => n.id.startsWith(`route:${framework}:`))) continue;
const scope = this.scopedSyncMatcher();
diff --git a/src/index.ts b/src/index.ts
index a42bfa028..c7ff50b23 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -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|solid):/.test(n.id)))
+ if (this.queries.getNodesByKind('route').some(n => /^route:(react-router|redwood|solid|waku):/.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.
@@ -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|solid):/.test(n.id)))
+ if (this.queries.getNodesByKind('route').some(n => /^route:(react-router|redwood|solid|waku):/.test(n.id)))
await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']);
this.resolver.runPostExtract();
} else if (result.filesRemoved > 0) {
diff --git a/src/resolution/frameworks/waku.ts b/src/resolution/frameworks/waku.ts
index da2be7673..fb15f024a 100644
--- a/src/resolution/frameworks/waku.ts
+++ b/src/resolution/frameworks/waku.ts
@@ -1,8 +1,9 @@
-import type { Node as SyntaxNode } from 'web-tree-sitter';
+import type { Node as SyntaxNode, Tree as SyntaxTree } from 'web-tree-sitter';
import type { ExtractionResult } from '../../types';
import type { FrameworkResolver, FrameworkExtractionResult, ResolutionContext } from '../types';
import { detectLanguage, getParser } from '../../extraction/grammars';
import { generateNodeId } from '../../extraction/tree-sitter-helpers';
+import { resolveImportPath } from '../import-resolver';
import { dependsOn } from './package-deps';
const ROOT = 'src/pages/';
@@ -10,6 +11,8 @@ const EXTENSIONS = ['js', 'ts', 'tsx', 'jsx', 'mjs', 'cjs'];
const FUNCTIONS = new Set(['function_declaration', 'function_expression', 'arrow_function']);
export const isWakuPage = (file: string): boolean =>
file.startsWith(ROOT) && /\.(?:[cm]?[jt]s|[jt]sx)$/.test(file);
+const isServer = (file: string): boolean => /^src\/waku\.server\.(?:[cm]?[jt]s|[jt]sx)$/.test(file);
+export const isWakuRouteFile = (file: string): boolean => isWakuPage(file) || isServer(file);
const literal = (node: SyntaxNode | null | undefined): string | null =>
node?.type === 'string' && !node.text.includes('\\') ? node.text.slice(1, -1) : null;
const unwrap = (node: SyntaxNode | null | undefined): SyntaxNode | null => {
@@ -34,7 +37,7 @@ function fields(node: SyntaxNode | null | undefined): Map |
}
return result;
}
-function mutated(root: SyntaxNode, name: string): boolean {
+function mutated(root: SyntaxNode, name: string, allowedCall?: SyntaxNode): boolean {
const aliases = new Set([name]);
const declarations = root.descendantsOfType('variable_declarator');
let previous = 0;
@@ -61,6 +64,7 @@ function mutated(root: SyntaxNode, name: string): boolean {
'call_expression',
])
.some((node) => {
+ if (node.id === allowedCall?.id) return false;
if (node.type === 'call_expression')
return (
node
@@ -166,18 +170,14 @@ function returned(node: SyntaxNode): SyntaxNode | null {
? unwrap(statements[0].namedChildren[0])
: null;
}
-const states = new WeakMap | null>();
-function project(context: ResolutionContext): Set | null {
- if (states.has(context)) return states.get(context)!;
- let extensions: Set | null = new Set(EXTENSIONS);
+function defaultConfig(context: ResolutionContext): boolean {
for (const ext of EXTENSIONS) {
const file = `waku.config.${ext}`;
const source = context.readFile(file);
if (source === null) continue;
const tree = getParser(detectLanguage(file))?.parse(source);
if (!tree) {
- extensions = null;
- break;
+ return false;
}
try {
const root = tree.rootNode;
@@ -190,12 +190,17 @@ function project(context: ResolutionContext): Set | null {
)
config = config.childForFieldName('arguments')?.namedChildren[0];
const options = fields(config);
- if (!options || ['srcDir', 'basePath', 'vite'].some((key) => options.has(key)))
- extensions = null;
+ if (!options || ['srcDir', 'basePath', 'vite'].some((key) => options.has(key))) return false;
} finally {
tree.delete();
}
}
+ return true;
+}
+const states = new WeakMap | null>();
+function project(context: ResolutionContext): Set | null {
+ if (states.has(context)) return states.get(context)!;
+ let extensions: Set | null = defaultConfig(context) ? new Set(EXTENSIONS) : null;
const servers = EXTENSIONS.filter((ext) => context.readFile(`src/waku.server.${ext}`) !== null);
if (servers.length > 1) extensions = null;
if (extensions && servers.length === 1) {
@@ -241,11 +246,20 @@ function project(context: ResolutionContext): Set | null {
/** Waku 1.0.0-rc.0 registers static parameter pages only for their declared static paths. */
function paths(path: string, options: Map): string[] {
- if ([...options.keys()].some((key) => !['render', 'staticPaths'].includes(key))) return [];
+ if (!path.startsWith('/') || /[?#\\]/.test(path)) return [];
+ if ([...options.keys()].some((key) => !['render', 'staticPaths', 'exactPath'].includes(key)))
+ return [];
const render = options.has('render') ? literal(options.get('render')) : 'static';
if (render !== 'static' && render !== 'dynamic') return [];
- const segments = path.split('/').filter((segment) => !segment.startsWith('('));
+ const exact = options.get('exactPath');
+ if (exact && exact.type !== 'true' && exact.type !== 'false') return [];
+ const segments = path
+ .replace(/\/$/, '')
+ .split('/')
+ .filter((segment) => exact?.type === 'true' || !segment.startsWith('('));
if (segments.at(-1) === 'index.html') segments.pop();
+ const join = (parts: string[]) => parts.join('/').replace(/\/$/, '') || '/';
+ if (exact?.type === 'true') return [join(segments)];
const params: { index: number; prefix: string; suffix: string; name: string; rest: boolean }[] =
[];
for (let index = 0; index < segments.length; index++) {
@@ -255,7 +269,6 @@ function paths(path: string, options: Map): string[] {
if (!match || (match[2] && (index !== segments.length - 1 || match[1] || match[4]))) return [];
params.push({ index, prefix: match[1]!, suffix: match[4]!, name: match[3]!, rest: !!match[2] });
}
- const join = (parts: string[]) => parts.join('/').replace(/\/$/, '') || '/';
if (!params.length) return [join(segments)];
if (render === 'dynamic') {
for (const param of params)
@@ -289,12 +302,257 @@ function paths(path: string, options: Map): string[] {
}
return [...new Set(result)];
}
+function localBinding(root: SyntaxNode, name: string): Binding | null {
+ for (const statement of root.namedChildren) {
+ const node =
+ statement.type === 'export_statement'
+ ? statement.childForFieldName('declaration')
+ : statement;
+ if (node?.type === 'function_declaration' && node.childForFieldName('name')?.text === name)
+ return { node, value: node };
+ if (node?.type === 'lexical_declaration' && node.children.some((n) => n.type === 'const'))
+ for (const variable of node.namedChildren) {
+ const value = unwrap(variable.childForFieldName('value'));
+ if (variable.childForFieldName('name')?.text === name && value)
+ return { node: variable, value };
+ }
+ }
+ return null;
+}
+function declares(root: SyntaxNode, name: string): boolean {
+ return root
+ .descendantsOfType([
+ 'variable_declarator',
+ 'function_declaration',
+ 'required_parameter',
+ 'optional_parameter',
+ ])
+ .some((node) => {
+ const pattern = node.childForFieldName('name') ?? node.childForFieldName('pattern');
+ return (
+ pattern?.text === name ||
+ !!pattern
+ ?.descendantsOfType(['identifier', 'shorthand_property_identifier_pattern'])
+ .some((n) => n.text === name)
+ );
+ });
+}
+function extractProgrammatic(
+ filePath: string,
+ content: string,
+ context: ResolutionContext,
+): FrameworkExtractionResult {
+ const result: FrameworkExtractionResult = { nodes: [], references: [] };
+ if (
+ !defaultConfig(context) ||
+ EXTENSIONS.filter((ext) => context.readFile(`src/waku.server.${ext}`) !== null).length !== 1
+ )
+ return result;
+ const language = detectLanguage(filePath);
+ const tree = getParser(language)?.parse(content);
+ if (!tree) return result;
+ try {
+ const root = tree.rootNode;
+ const adapters = new Set([
+ ...imports(root, 'waku/adapters/default', 'default'),
+ ...imports(root, 'waku/adapters/cloudflare', 'default'),
+ ]);
+ const creators = imports(root, 'waku', 'createPages');
+ const entry = exportsIn(root).get('default')?.value;
+ if (
+ entry?.type !== 'call_expression' ||
+ !adapters.has(entry.childForFieldName('function')?.text ?? '') ||
+ [...adapters, ...creators].some((name) => mutated(root, name))
+ )
+ return result;
+ let registration = unwrap(entry.childForFieldName('arguments')?.namedChildren[0]);
+ if (registration?.type === 'identifier') {
+ if (mutated(root, registration.text, entry)) return result;
+ registration = localBinding(root, registration.text)?.value ?? null;
+ }
+ if (
+ registration?.type !== 'call_expression' ||
+ !creators.has(registration.childForFieldName('function')?.text ?? '')
+ )
+ return result;
+ const args =
+ registration
+ .childForFieldName('arguments')
+ ?.namedChildren.filter((n) => n.type !== 'comment') ?? [];
+ const callback = args[0];
+ if (!callback || !FUNCTIONS.has(callback.type) || args.length > 2) return result;
+ if (args.length === 2) {
+ const options = fields(args[1]);
+ if (!options || [...options.keys()].some((key) => key !== 'unstable_skipBuild'))
+ return result;
+ }
+ const parameters =
+ callback.childForFieldName('parameters')?.namedChildren.filter((n) => n.type !== 'comment') ??
+ [];
+ const parameter = parameters.length === 1 ? parameters[0] : null;
+ const pattern =
+ parameter?.type === 'object_pattern' ? parameter : parameter?.childForFieldName('pattern');
+ if (pattern?.type !== 'object_pattern') return result;
+ let helper: string | undefined;
+ for (const binding of pattern.namedChildren) {
+ if (binding.type === 'shorthand_property_identifier_pattern' && binding.text === 'createPage')
+ helper = binding.text;
+ if (
+ binding.type === 'pair_pattern' &&
+ binding.childForFieldName('key')?.text === 'createPage' &&
+ binding.childForFieldName('value')?.type === 'identifier'
+ )
+ helper = binding.childForFieldName('value')!.text;
+ }
+ const body = callback.childForFieldName('body');
+ if (!helper || !body || declares(body, helper) || mutated(callback, helper)) return result;
+ const mayStop = (node: SyntaxNode): boolean =>
+ [
+ 'return_statement',
+ 'throw_statement',
+ 'while_statement',
+ 'do_statement',
+ 'for_statement',
+ 'for_in_statement',
+ ].includes(node.type) ||
+ (!FUNCTIONS.has(node.type) && node.namedChildren.some(mayStop));
+ const visit = (node: SyntaxNode) => {
+ if (node.type === 'call_expression') {
+ if (node.childForFieldName('function')?.text !== helper) return;
+ const args =
+ node.childForFieldName('arguments')?.namedChildren.filter((n) => n.type !== 'comment') ??
+ [];
+ const options = args.length === 1 ? fields(args[0]) : null;
+ if (!options || !options.has('render')) return;
+ const routePath = literal(options.get('path'));
+ const component = options.get('component');
+ if (
+ !routePath ||
+ component?.type !== 'identifier' ||
+ declares(callback, component.text) ||
+ mutated(root, component.text)
+ )
+ return;
+ options.delete('path');
+ options.delete('component');
+ for (const route of paths(routePath, options)) {
+ const id = `route:waku:${filePath}:${node.startIndex}:${route}`;
+ result.nodes.push({
+ id,
+ kind: 'route',
+ name: route,
+ qualifiedName: `${filePath}::${route}`,
+ filePath,
+ language,
+ startLine: node.startPosition.row + 1,
+ startColumn: node.startPosition.column,
+ endLine: node.endPosition.row + 1,
+ endColumn: node.endPosition.column,
+ updatedAt: Date.now(),
+ });
+ result.references.push({
+ fromNodeId: id,
+ referenceName: `waku-component:${component.text}`,
+ referenceKind: 'references',
+ filePath,
+ language,
+ line: component.startPosition.row + 1,
+ column: component.startPosition.column,
+ });
+ }
+ return;
+ }
+ if (
+ ![
+ 'statement_block',
+ 'return_statement',
+ 'array',
+ 'expression_statement',
+ 'await_expression',
+ 'parenthesized_expression',
+ 'lexical_declaration',
+ 'variable_declarator',
+ ].includes(node.type)
+ )
+ return;
+ for (const child of node.namedChildren) {
+ visit(child);
+ if (mayStop(child)) break;
+ }
+ };
+ visit(body);
+ return result;
+ } finally {
+ tree.delete();
+ }
+}
+function programmaticTarget(file: string, name: string, context: ResolutionContext) {
+ const source = context.readFile(file);
+ const tree = source === null ? null : getParser(detectLanguage(file))?.parse(source);
+ if (!tree) return null;
+ try {
+ const root = tree.rootNode;
+ if (mutated(root, name)) return null;
+ let binding = localBinding(root, name);
+ let targetTree: SyntaxTree | null = null;
+ try {
+ for (const statement of root.namedChildren) {
+ if (
+ statement.type !== 'import_statement' ||
+ statement.children.some((n) => n.type === 'type')
+ )
+ continue;
+ let importedName: string | undefined;
+ if (
+ statement.namedChildren
+ .find((n) => n.type === 'import_clause')
+ ?.namedChildren.some((n) => n.type === 'identifier' && n.text === name)
+ )
+ importedName = 'default';
+ for (const spec of statement.descendantsOfType('import_specifier'))
+ if (
+ (spec.childForFieldName('alias')?.text ?? spec.childForFieldName('name')?.text) ===
+ name &&
+ !spec.children.some((n) => n.type === 'type')
+ )
+ importedName = spec.childForFieldName('name')?.text;
+ if (!importedName) continue;
+ const source = literal(statement.childForFieldName('source'));
+ const targetFile = source && resolveImportPath(source, file, detectLanguage(file), context);
+ const content = targetFile ? context.readFile(targetFile) : null;
+ if (!targetFile || content === null) return null;
+ targetTree = getParser(detectLanguage(targetFile))?.parse(content) ?? null;
+ if (!targetTree) return null;
+ file = targetFile;
+ binding = exportsIn(targetTree.rootNode).get(importedName) ?? null;
+ break;
+ }
+ if (!binding || !FUNCTIONS.has(binding.value.type)) return null;
+ const targetName = binding.node.childForFieldName('name')?.text;
+ const candidates = context
+ .getNodesInFile(file)
+ .filter(
+ (node) =>
+ node.name === targetName &&
+ ['function', 'component'].includes(node.kind) &&
+ node.startLine === binding!.value.startPosition.row + 1 &&
+ node.startColumn === binding!.value.startPosition.column,
+ );
+ return candidates.length === 1 ? candidates[0]! : null;
+ } finally {
+ targetTree?.delete();
+ }
+ } finally {
+ tree.delete();
+ }
+}
export function extractWakuRoutes(
filePath: string,
content: string,
context: ResolutionContext,
existing: ExtractionResult,
): FrameworkExtractionResult {
+ if (isServer(filePath)) return extractProgrammatic(filePath, content, context);
const result: FrameworkExtractionResult = { nodes: [], references: [] };
if (!isWakuPage(filePath) || !project(context)?.has(filePath.split('.').at(-1)!)) return result;
const segments = filePath
@@ -394,12 +652,14 @@ export const wakuResolver: FrameworkResolver = {
name: 'waku',
languages: ['typescript', 'javascript', 'tsx', 'jsx'],
detect: (context) => dependsOn(context, 'waku'),
- claimsReference: (name) => name.startsWith('waku-target:'),
+ claimsReference: (name) => name.startsWith('waku-target:') || name.startsWith('waku-component:'),
resolve(ref, context) {
if (!ref.fromNodeId.startsWith('route:waku:')) return null;
- const target = context
- .getNodesInFile(ref.filePath)
- .find((node) => node.id === ref.referenceName.slice('waku-target:'.length));
+ const target = ref.referenceName.startsWith('waku-component:')
+ ? programmaticTarget(ref.filePath, ref.referenceName.slice('waku-component:'.length), context)
+ : context
+ .getNodesInFile(ref.filePath)
+ .find((node) => node.id === ref.referenceName.slice('waku-target:'.length));
return target
? { original: ref, targetNodeId: target.id, confidence: 1, resolvedBy: 'framework' }
: null;