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; + }, +};