diff --git a/CHANGELOG.md b/CHANGELOG.md
index d8227369a..27f39e1d4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### New Features
+- 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.
diff --git a/__tests__/waku-routes.test.ts b/__tests__/waku-routes.test.ts
new file mode 100644
index 000000000..5e4b83fec
--- /dev/null
+++ b/__tests__/waku-routes.test.ts
@@ -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
}\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 Home
;\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 load()}/>};',
+ );
+ write('src/pages/named.tsx', 'const Page =\n () =>
;\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 ()=> {load()}
;');
+ 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']]);
+ });
+});
diff --git a/docs/design/PLAN-application-router-coverage.md b/docs/design/PLAN-application-router-coverage.md
index 70b83f28b..8828330f0 100644
--- a/docs/design/PLAN-application-router-coverage.md
+++ b/docs/design/PLAN-application-router-coverage.md
@@ -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.
@@ -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.
diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md
index 73cb7c24c..8b4a2f1ae 100644
--- a/docs/design/framework-coverage.md
+++ b/docs/design/framework-coverage.md
@@ -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.
diff --git a/site/src/content/docs/guides/framework-routes.md b/site/src/content/docs/guides/framework-routes.md
index b29b4dd40..a1ffe75c3 100644
--- a/site/src/content/docs/guides/framework-routes.md
+++ b/site/src/content/docs/guides/framework-routes.md
@@ -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.
diff --git a/src/extraction/index.ts b/src/extraction/index.ts
index e38a59966..ee2bcb59f 100644
--- a/src/extraction/index.ts
+++ b/src/extraction/index.ts
@@ -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';
@@ -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;
@@ -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();
diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts
index 589c83625..dca07a92a 100644
--- a/src/resolution/frameworks/index.ts
+++ b/src/resolution/frameworks/index.ts
@@ -27,6 +27,7 @@ import { solidRouterResolver } from './solid-router';
import { solidStartResolver } from './solid-start';
import { qwikCityResolver } from './qwik-city';
import { vikeResolver } from './vike';
+import { wakuResolver } from './waku';
import { djangoResolver, flaskResolver, fastapiResolver } from './python';
import { railsResolver } from './ruby';
import { springResolver } from './java';
@@ -77,6 +78,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
solidStartResolver,
qwikCityResolver,
vikeResolver,
+ wakuResolver,
// Python
djangoResolver,
flaskResolver,
diff --git a/src/resolution/frameworks/waku.ts b/src/resolution/frameworks/waku.ts
new file mode 100644
index 000000000..da2be7673
--- /dev/null
+++ b/src/resolution/frameworks/waku.ts
@@ -0,0 +1,407 @@
+import type { Node as SyntaxNode } 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 { dependsOn } from './package-deps';
+
+const ROOT = 'src/pages/';
+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 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 => {
+ while (
+ node &&
+ ['parenthesized_expression', 'as_expression', 'satisfies_expression'].includes(node.type)
+ )
+ node = node.namedChildren[0];
+ return node ?? null;
+};
+function fields(node: SyntaxNode | null | undefined): Map | null {
+ node = unwrap(node);
+ if (node?.type !== 'object') return null;
+ const result = new Map();
+ for (const item of node.namedChildren) {
+ if (item.type === 'comment') continue;
+ const key = item.childForFieldName('key');
+ const name = literal(key) ?? (key?.type === 'property_identifier' ? key.text : null);
+ const value = item.childForFieldName('value');
+ if (item.type !== 'pair' || !name || !value || result.has(name)) return null;
+ result.set(name, value);
+ }
+ return result;
+}
+function mutated(root: SyntaxNode, name: string): boolean {
+ const aliases = new Set([name]);
+ const declarations = root.descendantsOfType('variable_declarator');
+ let previous = 0;
+ while (previous !== aliases.size) {
+ previous = aliases.size;
+ for (const declaration of declarations) {
+ const local = declaration.childForFieldName('name');
+ const value = unwrap(declaration.childForFieldName('value'));
+ if (
+ local?.type === 'identifier' &&
+ value?.type === 'identifier' &&
+ (aliases.has(local.text) || aliases.has(value.text))
+ ) {
+ aliases.add(local.text);
+ aliases.add(value.text);
+ }
+ }
+ }
+ return root
+ .descendantsOfType([
+ 'assignment_expression',
+ 'augmented_assignment_expression',
+ 'update_expression',
+ 'call_expression',
+ ])
+ .some((node) => {
+ if (node.type === 'call_expression')
+ return (
+ node
+ .childForFieldName('arguments')
+ ?.namedChildren.some((n) => n.type === 'identifier' && aliases.has(n.text)) ||
+ (node.childForFieldName('function')?.type === 'member_expression' &&
+ aliases.has(
+ node.childForFieldName('function')?.childForFieldName('object')?.text ?? '',
+ ))
+ );
+ const target = node.childForFieldName('left') ?? node.childForFieldName('argument');
+ return (
+ aliases.has(target?.text ?? '') ||
+ !!target
+ ?.descendantsOfType(['identifier', 'shorthand_property_identifier_pattern'])
+ .some((n) => aliases.has(n.text))
+ );
+ });
+}
+type Binding = { node: SyntaxNode; value: SyntaxNode };
+function exportsIn(root: SyntaxNode): Map {
+ const locals = new Map();
+ const exported = new Map();
+ for (const statement of root.namedChildren) {
+ const node =
+ statement.type === 'export_statement'
+ ? statement.childForFieldName('declaration')
+ : statement;
+ const name = node?.childForFieldName('name');
+ if (node && FUNCTIONS.has(node.type) && name) locals.set(name.text, { node, value: node });
+ if (node?.type === 'lexical_declaration' && node.children.some((n) => n.type === 'const'))
+ for (const variable of node.namedChildren) {
+ const name = variable.childForFieldName('name');
+ const value = unwrap(variable.childForFieldName('value'));
+ if (name?.type === 'identifier' && value) locals.set(name.text, { node: variable, value });
+ }
+ }
+ const add = (name: string, binding: Binding | null) => {
+ const local = binding?.node.childForFieldName('name')?.text;
+ exported.set(name, exported.has(name) || (local && mutated(root, local)) ? null : binding);
+ };
+ for (const statement of root.namedChildren) {
+ if (statement.type !== 'export_statement' || statement.children.some((n) => n.type === 'type'))
+ continue;
+ const declaration = statement.childForFieldName('declaration');
+ if (statement.children.some((n) => n.type === 'default')) {
+ const value = unwrap(statement.childForFieldName('value') ?? declaration);
+ const name =
+ declaration?.childForFieldName('name')?.text ??
+ (value?.type === 'identifier' ? value.text : null);
+ add('default', name ? (locals.get(name) ?? null) : value ? { node: value, value } : null);
+ } else if (declaration?.childForFieldName('name')) {
+ const name = declaration.childForFieldName('name')!.text;
+ add(name, locals.get(name) ?? null);
+ } else if (declaration?.type === 'lexical_declaration')
+ for (const variable of declaration.namedChildren) {
+ const name = variable.childForFieldName('name')?.text;
+ if (name) add(name, locals.get(name) ?? null);
+ }
+ for (const spec of statement.descendantsOfType('export_specifier')) {
+ if (spec.children.some((n) => n.type === 'type')) continue;
+ const name = spec.childForFieldName('name')?.text ?? '';
+ add(
+ spec.childForFieldName('alias')?.text ?? name,
+ statement.childForFieldName('source') ? null : (locals.get(name) ?? null),
+ );
+ }
+ if (
+ statement.childForFieldName('source') &&
+ !statement.descendantsOfType('export_specifier').length
+ )
+ add('getConfig', null);
+ }
+ return exported;
+}
+function imports(root: SyntaxNode, source: string, name: string): Set {
+ const result = new Set();
+ for (const statement of root.namedChildren) {
+ if (
+ statement.type !== 'import_statement' ||
+ literal(statement.childForFieldName('source')) !== source ||
+ statement.children.some((n) => n.type === 'type')
+ )
+ continue;
+ if (name === 'default')
+ for (const item of statement.namedChildren.find((n) => n.type === 'import_clause')
+ ?.namedChildren ?? [])
+ if (item.type === 'identifier') result.add(item.text);
+ for (const spec of statement.descendantsOfType('import_specifier'))
+ if (
+ spec.childForFieldName('name')?.text === name &&
+ !spec.children.some((n) => n.type === 'type')
+ )
+ result.add(spec.childForFieldName('alias')?.text ?? name);
+ }
+ return result;
+}
+function returned(node: SyntaxNode): SyntaxNode | null {
+ const body = unwrap(node.childForFieldName('body'));
+ if (body?.type !== 'statement_block') return body;
+ const statements = body.namedChildren.filter((n) => n.type !== 'comment');
+ return statements.length === 1 && statements[0]?.type === 'return_statement'
+ ? 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);
+ 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;
+ }
+ try {
+ const root = tree.rootNode;
+ let config = exportsIn(root).get('default')?.value;
+ if (
+ config?.type === 'call_expression' &&
+ imports(root, 'waku/config', 'defineConfig').has(
+ config.childForFieldName('function')?.text ?? '',
+ )
+ )
+ config = config.childForFieldName('arguments')?.namedChildren[0];
+ const options = fields(config);
+ if (!options || ['srcDir', 'basePath', 'vite'].some((key) => options.has(key)))
+ extensions = null;
+ } finally {
+ tree.delete();
+ }
+ }
+ const servers = EXTENSIONS.filter((ext) => context.readFile(`src/waku.server.${ext}`) !== null);
+ if (servers.length > 1) extensions = null;
+ if (extensions && servers.length === 1) {
+ const file = `src/waku.server.${servers[0]}`;
+ const tree = getParser(detectLanguage(file))?.parse(context.readFile(file)!);
+ extensions = null;
+ if (tree)
+ try {
+ const root = tree.rootNode;
+ const adapter = new Set([
+ ...imports(root, 'waku/adapters/default', 'default'),
+ ...imports(root, 'waku/adapters/cloudflare', 'default'),
+ ]);
+ const routers = imports(root, 'waku', 'fsRouter');
+ const entry = exportsIn(root).get('default')?.value;
+ const router = entry?.childForFieldName('arguments')?.namedChildren[0];
+ const args = router?.childForFieldName('arguments')?.namedChildren ?? [];
+ const glob = args[0];
+ const globArgs = glob?.childForFieldName('arguments')?.namedChildren ?? [];
+ const pattern = literal(globArgs[0]);
+ const match = pattern?.match(/^\.\/pages\/\*\*\/\*\.\{([a-z,]+)\}$/);
+ if (
+ entry?.type === 'call_expression' &&
+ adapter.has(entry.childForFieldName('function')?.text ?? '') &&
+ router?.type === 'call_expression' &&
+ routers.has(router.childForFieldName('function')?.text ?? '') &&
+ args.length === 1 &&
+ glob?.type === 'call_expression' &&
+ glob.childForFieldName('function')?.text === 'import.meta.glob' &&
+ globArgs.length === 1 &&
+ match &&
+ match[1]!.split(',').every((ext) => EXTENSIONS.includes(ext)) &&
+ ![...adapter, ...routers].some((name) => mutated(root, name))
+ )
+ extensions = new Set(match[1]!.split(','));
+ } finally {
+ tree.delete();
+ }
+ }
+ states.set(context, extensions);
+ return extensions;
+}
+
+/** 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 [];
+ const render = options.has('render') ? literal(options.get('render')) : 'static';
+ if (render !== 'static' && render !== 'dynamic') return [];
+ const segments = path.split('/').filter((segment) => !segment.startsWith('('));
+ if (segments.at(-1) === 'index.html') segments.pop();
+ const params: { index: number; prefix: string; suffix: string; name: string; rest: boolean }[] =
+ [];
+ for (let index = 0; index < segments.length; index++) {
+ const segment = segments[index]!;
+ if (!/[\[\]]/.test(segment)) continue;
+ const match = segment.match(/^([^\[\]]*)\[(\.\.\.)?([A-Za-z_$][\w$]*)\]([^\[\]]*)$/);
+ 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)
+ segments[param.index] =
+ `${param.prefix}${param.rest ? '*' : ':'}${param.name}${param.suffix}`;
+ return [join(segments)];
+ }
+ const staticPaths = options.get('staticPaths');
+ if (staticPaths?.type !== 'array') return [];
+ const result: string[] = [];
+ for (const item of staticPaths.namedChildren) {
+ if (item.type === 'comment') continue;
+ const values =
+ item.type === 'array'
+ ? item.namedChildren.filter((n) => n.type !== 'comment').map(literal)
+ : [literal(item)];
+ if (
+ values.some((value) => value === null || /[/?#]/.test(value)) ||
+ (params.at(-1)!.rest ? values.length < params.length : values.length !== params.length)
+ )
+ return [];
+ const expanded = [...segments];
+ params.forEach((param, index) => {
+ const value = (param.rest ? values.slice(index).join('/') : values[index]!).replace(
+ / /g,
+ '-',
+ );
+ expanded[param.index] = param.prefix + value + param.suffix;
+ });
+ result.push(join(expanded));
+ }
+ return [...new Set(result)];
+}
+export function extractWakuRoutes(
+ filePath: string,
+ content: string,
+ context: ResolutionContext,
+ existing: ExtractionResult,
+): FrameworkExtractionResult {
+ const result: FrameworkExtractionResult = { nodes: [], references: [] };
+ if (!isWakuPage(filePath) || !project(context)?.has(filePath.split('.').at(-1)!)) return result;
+ const segments = filePath
+ .slice(ROOT.length)
+ .replace(/\.[^.]+$/, '')
+ .split('/');
+ if (
+ segments.some((part) => ['_actions', '_components', '_hooks'].includes(part)) ||
+ ['_api', '_slices', '_interceptors'].includes(segments[0]!) ||
+ ['_layout', '_root', '[path]'].includes(segments.at(-1)!)
+ )
+ return result;
+ if (segments.at(-1) === 'index') segments.pop();
+ const language = detectLanguage(filePath);
+ const tree = getParser(language)?.parse(content);
+ if (!tree) return result;
+ try {
+ const exported = exportsIn(tree.rootNode);
+ const binding = exported.get('default');
+ if (!binding || !FUNCTIONS.has(binding.value.type)) return result;
+ let options = new Map();
+ if (exported.has('getConfig')) {
+ const config = exported.get('getConfig');
+ const object =
+ config && FUNCTIONS.has(config.value.type) ? fields(returned(config.value)) : null;
+ if (!object) return result;
+ options = object;
+ }
+ const routes = paths('/' + segments.join('/'), options);
+ if (!routes.length) return result;
+ const anchor = binding.value;
+ const name = binding.node.childForFieldName('name')?.text;
+ let target = name
+ ? existing.nodes.find(
+ (node) =>
+ node.name === name &&
+ ['function', 'component'].includes(node.kind) &&
+ node.startLine === anchor.startPosition.row + 1 &&
+ node.startColumn === anchor.startPosition.column,
+ )
+ : undefined;
+ if (!name) {
+ const id = generateNodeId(filePath, 'component', 'default', anchor.startPosition.row + 1);
+ target = {
+ id,
+ kind: 'component',
+ name: 'default',
+ qualifiedName: `${filePath}::default`,
+ filePath,
+ language,
+ startLine: anchor.startPosition.row + 1,
+ startColumn: anchor.startPosition.column,
+ endLine: anchor.endPosition.row + 1,
+ endColumn: anchor.endPosition.column,
+ updatedAt: Date.now(),
+ };
+ result.nodes.push(target);
+ const body = anchor.childForFieldName('body')!;
+ for (const ref of existing.unresolvedReferences) {
+ const row = ref.line - 1;
+ if (
+ ref.fromNodeId === `file:${filePath}` &&
+ row >= body.startPosition.row &&
+ row <= body.endPosition.row &&
+ (row !== body.startPosition.row || ref.column >= body.startPosition.column) &&
+ (row !== body.endPosition.row || ref.column < body.endPosition.column)
+ )
+ ref.fromNodeId = id;
+ }
+ }
+ if (!target) return result;
+ for (const routePath of routes) {
+ const id = `route:waku:${filePath}:${routePath}`;
+ result.nodes.push({
+ ...target,
+ id,
+ kind: 'route',
+ name: routePath,
+ qualifiedName: `${filePath}::${routePath}`,
+ });
+ result.references.push({
+ fromNodeId: id,
+ referenceName: `waku-target:${target.id}`,
+ referenceKind: 'references',
+ filePath,
+ language,
+ line: target.startLine,
+ column: target.startColumn,
+ });
+ }
+ return result;
+ } finally {
+ tree.delete();
+ }
+}
+export const wakuResolver: FrameworkResolver = {
+ name: 'waku',
+ languages: ['typescript', 'javascript', 'tsx', 'jsx'],
+ detect: (context) => dependsOn(context, 'waku'),
+ claimsReference: (name) => name.startsWith('waku-target:'),
+ 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));
+ return target
+ ? { original: ref, targetNodeId: target.id, confidence: 1, resolvedBy: 'framework' }
+ : null;
+ },
+};