From e96d30cc2fd97511de704fc2a9af614d3f9192d5 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Mon, 7 Sep 2026 03:05:21 -0600 Subject: [PATCH] feat: discover registered Waku programmatic pages --- CHANGELOG.md | 2 + __tests__/waku-programmatic.test.ts | 234 ++++++++++++++ .../PLAN-application-router-coverage.md | 4 +- docs/design/framework-coverage.md | 4 +- .../content/docs/guides/framework-routes.md | 4 +- src/extraction/index.ts | 6 +- src/index.ts | 4 +- src/resolution/frameworks/waku.ts | 294 +++++++++++++++++- 8 files changed, 526 insertions(+), 26 deletions(-) create mode 100644 __tests__/waku-programmatic.test.ts 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;