From e961019a31abe7e9a16fb61202e0c336b17aba17 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Mon, 7 Sep 2026 02:40:32 -0600 Subject: [PATCH] feat: discover Vike pages and route overrides --- CHANGELOG.md | 2 + __tests__/vike-routes.test.ts | 289 ++++++++++++++ .../PLAN-application-router-coverage.md | 4 +- docs/design/framework-coverage.md | 3 + .../content/docs/guides/framework-routes.md | 3 + src/extraction/index.ts | 10 +- src/resolution/frameworks/index.ts | 2 + src/resolution/frameworks/vike.ts | 362 ++++++++++++++++++ 8 files changed, 670 insertions(+), 5 deletions(-) create mode 100644 __tests__/vike-routes.test.ts create mode 100644 src/resolution/frameworks/vike.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e11db8207..d8227369a 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 +- 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. - SolidStart 2 default file routes now link to exact page and HTTP handlers, including page/API coexistence, nested layouts, parameters and GET-to-HEAD fallback. diff --git a/__tests__/vike-routes.test.ts b/__tests__/vike-routes.test.ts new file mode 100644 index 000000000..cd4083a7d --- /dev/null +++ b/__tests__/vike-routes.test.ts @@ -0,0 +1,289 @@ +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('Vike default pages and literal route overrides', () => { + 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 config = `import react from '@vitejs/plugin-react';import vike from 'vike/plugin';export default {plugins:[react(),vike()]};`; + const setup = () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vike-')); + write( + 'package.json', + JSON.stringify({ dependencies: { vike: '0.4.266', 'vike-react': '0.6.26' } }), + ); + write('vite.config.js', config); + }; + const page = (folder: string, name: string) => + write(`${folder}/+Page.tsx`, `export default function ${name}(){return

}`); + const routes = () => cg!.getNodesByKind('route').filter((n) => n.id.startsWith('route:vike:')); + afterEach(() => { + cg?.close(); + cg = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + + // vikejs/vike@715c15d11be196caa69159b929ae09697f3ce734: react-minimal About and + // file-structure-domain-driven/product/pages/index source examples share a minimal app. + it('indexes official Page and route override fixtures with exact targets', async () => { + setup(); + write( + 'pages/about/+Page.tsx', + "export default Page\nimport React from 'react'\n\nfunction Page() {\n return (\n <>\n

About

\n

Example of using Vike.

\n \n )\n}\n", + ); + write( + 'product/pages/index/+Page.jsx', + "export default Page\n\nimport React from 'react'\n\nfunction Page({ routeParams }) {\n return <>Product {routeParams.productId}\n}\n", + ); + write('product/pages/index/+route.js', "export default '/product/@productId'\n"); + cg = await CodeGraph.init(dir, { index: true }); + expect( + routes() + .map((n) => n.name) + .sort(), + ).toEqual(['/about', '/product/:productId']); + const roots = routeRoots(cg, routes()); + expect( + routes() + .map((n) => [n.name, roots.get(n.id)!.node.name, roots.get(n.id)!.node.filePath]) + .sort(), + ).toEqual([ + ['/about', 'Page', 'pages/about/+Page.tsx'], + ['/product/:productId', 'Page', 'product/pages/index/+Page.jsx'], + ]); + }); + it('uses whole-root filesystem conventions and keeps parent pages distinct from layouts', async () => { + setup(); + for (const [folder, name] of [ + ['pages/index', 'Home'], + ['src/pages/parent', 'Parent'], + ['src/pages/parent/child', 'Child'], + ['domain/pages/(shop)/index/@id', 'Item'], + ['src/index/pages/renderer/Foo.bar', 'Dot'], + ['pages/docs/catchall', 'CatchAll'], + ]) + page(folder!, name!); + write('pages/parent/+Layout.tsx', 'export default function Layout(){return

}'); + write('pages/docs/catchall/+route.ts', `export default '/docs/*';`); + write('pages/nothing/Page.tsx', 'export default function NotPlus(){return

}'); + cg = await CodeGraph.init(dir, { index: true }); + expect( + routes() + .map((n) => n.name) + .sort(), + ).toEqual(['/', '/Foo.bar', '/docs/*', '/domain/:id', '/parent', '/parent/child']); + }); + it('uses the nearest inherited route and never falls back through a dynamic override', async () => { + setup(); + page('pages/blog/post', 'Post'); + page('pages/blog/other', 'Other'); + write('pages/blog/+route.ts', `export default '/inherited';`); + write('pages/blog/post/+route.ts', `export default '/specific/@id';`); + cg = await CodeGraph.init(dir, { index: true }); + expect( + routes() + .map((n) => n.name) + .sort(), + ).toEqual(['/inherited', '/specific/:id']); + write('pages/blog/+route.ts', `export default ctx=>ctx.urlPathname;`); + await cg.sync(); + expect(routes().map((n) => n.name)).toEqual(['/specific/:id']); + write('pages/blog/post/+meta.ts', 'export default dynamic;'); + await cg.sync(); + expect(routes()).toEqual([]); + }); + it('links a multiline default function value to its actual symbol', async () => { + setup(); + write('pages/index/+Page.tsx', 'const Page =\n () =>

;\nexport default Page;'); + cg = await CodeGraph.init(dir, { index: true }); + expect(routeRoots(cg, routes()).get(routes()[0]!.id)?.node.name).toBe('Page'); + }); + it('supports named Page exports and route aliases', async () => { + setup(); + write('pages/one/+Page.tsx', 'function Screen(){return

};export {Screen as Page};'); + write('pages/one/+route.ts', `const value='/one/@id';export {value as route};`); + write('pages/two/+Page.tsx', 'export const Page=()=>

;'); + write('pages/two/+route.ts', `export const route='/two';`); + cg = await CodeGraph.init(dir, { index: true }); + expect( + routes() + .map((n) => n.name) + .sort(), + ).toEqual(['/one/:id', '/two']); + expect( + routeRoots(cg, routes()).get(routes().find((n) => n.name === '/one/:id')!.id)!.node.name, + ).toBe('Screen'); + }); + it.each([ + `export default pageContext=>({match:pageContext.urlPathname==='/x'});`, + `export default prefix + '/x';`, + `export {default} from './other';`, + `export default '/one';export const route='/two';`, + `function route(){};export type {route};`, + ])('does not fall back when a route override is unsupported (%s)', async (source) => { + setup(); + page('pages/guessed', 'Guessed'); + write('pages/guessed/+route.ts', source); + cg = await CodeGraph.init(dir, { index: true }); + expect(routes()).toEqual([]); + }); + it('uses inheritance locations rather than normalized URLs when suppressing configs', async () => { + setup(); + page('src/pages/a', 'A'); + page('src/admin/pages/b', 'B'); + page('pages/c', 'C'); + write('src/pages/+config.ts', `export default {filesystemRoutingRoot:'/custom'};`); + cg = await CodeGraph.init(dir, { index: true }); + expect(routes().map((n) => n.name)).toEqual(['/c']); + write('pages/+config.ts', `export default {route:'/root'};`); + await cg.sync(); + expect(routes()).toEqual([]); + }); + it('accepts canonical vike-react rendering config but excludes unknown extensions and configured pages', async () => { + setup(); + page('pages/one', 'One'); + page('pages/two', 'Two'); + write( + 'pages/+config.ts', + `import vikeReact from 'vike-react/config';export default {extends:vikeReact,ssr:true,title:'Site'};`, + ); + cg = await CodeGraph.init(dir, { index: true }); + expect( + routes() + .map((n) => n.name) + .sort(), + ).toEqual(['/one', '/two']); + write( + 'pages/+config.ts', + `import vikeReact from 'vike-react/config';export default {extends:[vikeReact],ssr:false};`, + ); + await cg.sync(); + expect(routes()).toHaveLength(2); + write('pages/one/+config.ts', `import other from './custom';export default {extends:other};`); + await cg.sync(); + expect(routes()).toEqual([]); + fs.unlinkSync(path.join(dir, 'pages/one/+config.ts')); + write('pages/two/+config.ts', `export default {Page:Other};`); + await cg.sync(); + expect(routes().map((n) => n.name)).toEqual(['/one']); + }); + it.each([ + `export default {extends:[{onBeforeRoute:()=>({pageContext:{urlLogical:'/changed'}})}]};`, + `const hidden={onBeforeRoute:()=>({})};export default {...hidden};`, + `export default {meta:custom};`, + `export default {Page:Other,extends:[{onBeforeRoute:()=>({})}]};`, + `const config={};Object.assign(config,{onBeforeRoute:()=>({})});export default config;`, + `import vikeReact from 'vike-react/config';Object.assign(vikeReact,{onBeforeRoute:()=>({})});export default {extends:vikeReact};`, + ])('unknown config cannot hide a global routing hook (%s)', async (source) => { + setup(); + page('pages/one', 'One'); + page('pages/two', 'Two'); + write('pages/one/+config.ts', source); + cg = await CodeGraph.init(dir, { index: true }); + expect(routes()).toEqual([]); + }); + it('treats onBeforeRoute as global and removes stale paths', async () => { + setup(); + page('pages/one', 'One'); + page('src/pages/two', 'Two'); + cg = await CodeGraph.init(dir, { index: true }); + expect(routes()).toHaveLength(2); + write( + 'src/pages/+onBeforeRoute.ts', + 'export function onBeforeRoute(){return {pageContext:{}}}', + ); + await cg.sync({ paths: ['src/pages/+onBeforeRoute.ts'] }); + expect(routes()).toEqual([]); + fs.unlinkSync(path.join(dir, 'src/pages/+onBeforeRoute.ts')); + await cg.sync(); + expect(routes()).toHaveLength(2); + write('pages/+config.ts', 'export default {onBeforeRoute:custom};'); + await cg.sync(); + expect(routes()).toEqual([]); + }); + it('excludes ambiguous, type-only, reassigned and re-exported page targets', async () => { + setup(); + write( + 'pages/ambiguous/+Page.tsx', + 'export default function Default(){return

}export function Page(){return

}', + ); + write('pages/typed/+Page.tsx', 'function Page(){return

}export type {Page};'); + write('pages/mutated/+Page.tsx', 'export function Page(){return

}Page=Other;'); + write('pages/reexport/+Page.tsx', `export {default} from './real';`); + write('pages/decoy/+Page.tsx', 'function Decoy(){return

}export default 1;'); + cg = await CodeGraph.init(dir, { index: true }); + expect(routes()).toEqual([]); + }); + it.each([ + `import vike from 'other';export default {plugins:[vike()]};`, + `import vike from 'vike/plugin';export default {root:'other',plugins:[vike()]};`, + `import vike from 'vike/plugin';export default {plugins:[vike({pages:['custom']})]};`, + `import vike from 'vike/plugin';export default {plugins:[vike()],...config};`, + ])('requires supported plugin registration (%s)', async (source) => { + setup(); + page('pages/index', 'Home'); + write('vite.config.js', source); + cg = await CodeGraph.init(dir, { index: true }); + expect(routes()).toEqual([]); + }); + it('syncs route and target changes after reopening, including scoped config changes', async () => { + setup(); + page('pages/index', 'Home'); + cg = await CodeGraph.init(dir, { index: true }); + cg.close(); + cg = await CodeGraph.open(dir); + write('pages/index/+route.ts', `export default '/moved/@id';`); + await cg.sync({ paths: ['pages/index/+route.ts'] }); + expect(routes().map((n) => n.name)).toEqual(['/moved/:id']); + write('pages/index/+route.ts', 'export default dynamic;'); + await cg.sync(); + expect(routes()).toEqual([]); + fs.unlinkSync(path.join(dir, 'pages/index/+route.ts')); + await cg.sync(); + expect(routes().map((n) => n.name)).toEqual(['/']); + page('pages/index', 'Renamed'); + await cg.sync(); + expect(routeRoots(cg, routes()).get(routes()[0]!.id)!.node.name).toBe('Renamed'); + write('vite.config.js', 'export default {}'); + await cg.sync({ paths: ['vite.config.js'] }); + expect(routes()).toEqual([]); + }); + it.each([false, true])('detects newly introduced Vike (scoped=%s)', async (scoped) => { + setup(); + write('package.json', '{}'); + page('pages/index', 'Home'); + cg = await CodeGraph.init(dir, { index: true }); + expect(routes()).toEqual([]); + write('package.json', JSON.stringify({ dependencies: { vike: '0.4.266' } })); + write('vite.config.js', config + '\n'); + await cg.sync(scoped ? { paths: ['vite.config.js'] } : undefined); + expect(routes().map((n) => n.name)).toEqual(['/']); + }); + it.runIf(fs.existsSync(path.resolve('dist/index.js')))( + 'uses fresh compiled workers for exact page roots', + () => { + setup(); + page('pages/index', 'Home'); + 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:vike:'));console.log(JSON.stringify([r?.name,r&&cg.getOutgoingEdges(r.id).filter(e=>e.kind==='references').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(['/', ['Home']]); + }, + ); +}); diff --git a/docs/design/PLAN-application-router-coverage.md b/docs/design/PLAN-application-router-coverage.md index 5fe1c3f29..70b83f28b 100644 --- a/docs/design/PLAN-application-router-coverage.md +++ b/docs/design/PLAN-application-router-coverage.md @@ -1,4 +1,4 @@ -Status: 10/13 — Qwik City validated; publishing step 10 +Status: 11/13 — Vike validated; publishing step 11 - [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. @@ -10,7 +10,7 @@ Status: 10/13 — Qwik City validated; publishing step 10 - [x] 8 Solid Router — registered JSX/config and static lazy imports bind exact components; nested bases/splats, mutations, scoped introduction and fresh workers pass; build passes, 115 WASM focused/control tests pass, full native suite 4,523 pass / 46 skip; independent review clear. - [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. -- [ ] 11 Vike — default `+Page` conventions and literal `+route` overrides. Gate: shared proof, parameters, exact component links, and no fallback route when an unsupported override changes routing. +- [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. - [ ] 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. diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index b54428dbd..73cb7c24c 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -49,6 +49,9 @@ guessed. | Solid Router | `frameworks/solid-router.ts` | — | `solid-router.test.ts` | pinned 0.16.3 README lazy example; exact component roots, nested paths and fresh workers | | 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 | + +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. Qwik City recognizes default `src/routes/**/index.{js,jsx,ts,tsx}` with option-free `qwikCity()` in literal Vite configuration, including direct-return synchronous/async config callbacks. Pages additionally require imported `QwikCityProvider`/`RouterOutlet` in the default root component. Named functions, named `component$` bindings and anonymous default `component$` calls have exact roots; anonymous component symbols own only their callback's existing file-level references. [Official page](https://github.com/QwikDev/qwik/blob/971465f941e44e5adf2b2c2e44566b590d0990d8/starters/apps/qwikcity-test/src/routes/issue2441/abc.page/index.tsx), [API fixture](https://github.com/QwikDev/qwik/blob/971465f941e44e5adf2b2c2e44566b590d0990d8/packages/docs/src/routes/demo/qwikcity/middleware/json/index.tsx). Groups, legacy `__` directories, dynamic/mixed parameters, catchalls and default trailing slashes follow 1.20.0. Index method exports create endpoints without implicit HEAD. Layout methods and `onRequest` remain middleware, not independent endpoints. Custom roots/options, route rewrites, layout-override index names, optional parameters, Markdown/MDX, re-exports and arbitrary component wrappers 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 f0c8618ff..b29b4dd40 100644 --- a/site/src/content/docs/guides/framework-routes.md +++ b/site/src/content/docs/guides/framework-routes.md @@ -42,6 +42,9 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by | **Solid Router** | Imported `Router`/`Route` JSX and registered literal configuration, nested paths, path arrays and bases; imported/local components and static lazy defaults | | **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 | + +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 298682c8c..e38a59966 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -37,6 +37,7 @@ import { extractAngularRoutes, isAngularRegistrationFile } from '../resolution/f import { extractAnalogRoutes, isAnalogPage } from '../resolution/frameworks/analog'; import { extractSolidStartRoutes, isSolidStartRoute } from '../resolution/frameworks/solid-start'; import { extractQwikCityRoutes, isQwikCityRoute } from '../resolution/frameworks/qwik-city'; +import { extractVikeRoutes, isVikePage } from '../resolution/frameworks/vike'; import type { ResolutionContext } from '../resolution/types'; import { createYielder, type MaybeYield } from '../resolution/cooperative-yield'; @@ -2378,14 +2379,16 @@ export class ExtractionOrchestrator { const analog = frameworks.includes('analog') && isAnalogPage(filePath); const solidStart = frameworks.includes('solid-start') && isSolidStartRoute(filePath); const qwikCity = frameworks.includes('qwik-city') && isQwikCityRoute(filePath); - if (!angular && !analog && !solidStart && !qwikCity) return result; - await loadGrammarsForLanguages(solidStart || qwikCity ? ['typescript', 'javascript', 'tsx', 'jsx'] : ['typescript', 'javascript']); + 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']); 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) - : extractQwikCityRoutes(filePath, content, context, result); + : qwikCity ? extractQwikCityRoutes(filePath, content, context, result) + : extractVikeRoutes(filePath, content, context); result.nodes.push(...extracted.nodes); result.unresolvedReferences.push(...extracted.references); return result; @@ -2902,6 +2905,7 @@ export class ExtractionOrchestrator { const detected = this.ensureDetectedFrameworks(currentFiles); for (const [framework, matches] of [ ['solid-start', isSolidStartRoute], ['analog', isAnalogPage], ['qwik-city', isQwikCityRoute], + ['vike', isVikePage], ] 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 f670dfb60..589c83625 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -26,6 +26,7 @@ import { analogResolver } from './analog'; import { solidRouterResolver } from './solid-router'; import { solidStartResolver } from './solid-start'; import { qwikCityResolver } from './qwik-city'; +import { vikeResolver } from './vike'; import { djangoResolver, flaskResolver, fastapiResolver } from './python'; import { railsResolver } from './ruby'; import { springResolver } from './java'; @@ -75,6 +76,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ solidRouterResolver, solidStartResolver, qwikCityResolver, + vikeResolver, // Python djangoResolver, flaskResolver, diff --git a/src/resolution/frameworks/vike.ts b/src/resolution/frameworks/vike.ts new file mode 100644 index 000000000..7c04a25e2 --- /dev/null +++ b/src/resolution/frameworks/vike.ts @@ -0,0 +1,362 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import type { FrameworkResolver, FrameworkExtractionResult, ResolutionContext } from '../types'; +import { detectLanguage, getParser } from '../../extraction/grammars'; +import { dependsOn } from './package-deps'; + +const EXT = '(?:[cm]?[jt]s|[jt]sx)'; +const namedFile = (name: string): RegExp => new RegExp(`(?:^|/)\\+${name}\\.${EXT}$`); +const PAGE = namedFile('Page'); +const ROUTE = namedFile('route'); +const CONFIG = namedFile('config'); +const BEFORE_ROUTE = namedFile('onBeforeRoute'); +const ROOT_OVERRIDE = namedFile('filesystemRoutingRoot'); +const META = namedFile('meta'); +const EXTENDS = namedFile('extends'); +export const isVikePage = (file: string): boolean => PAGE.test(file); +const directory = (file: string): string => + file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : ''; +const location = (file: string): string => + directory(file) + .split('/') + .filter((part) => part !== 'pages' && part !== 'renderer') + .join('/'); +const within = (file: string, parent: string): boolean => + !parent || file === parent || file.startsWith(parent + '/'); +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 imported(root: SyntaxNode, source: string, name = 'default'): 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 mutated(root: SyntaxNode, name: string): boolean { + return root + .descendantsOfType([ + 'assignment_expression', + 'augmented_assignment_expression', + 'update_expression', + 'call_expression', + ]) + .some((node) => { + if (node.type === 'call_expression') { + const callee = node.childForFieldName('function'); + const args = node.childForFieldName('arguments'); + return ( + !!args?.namedChildren.some((n) => n.type === 'identifier' && n.text === name) || + (callee?.type === 'member_expression' && + callee.childForFieldName('object')?.text === name) + ); + } + const target = node.childForFieldName('left') ?? node.childForFieldName('argument'); + return ( + target?.text === name || + !!target + ?.descendantsOfType(['identifier', 'shorthand_property_identifier_pattern']) + .some((n) => n.text === name) + ); + }); +} + +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; +} + +type Binding = { node: SyntaxNode; value: SyntaxNode }; +function exported(root: SyntaxNode, exportName: string): Binding | null { + const locals = 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 && ['function_declaration', 'class_declaration'].includes(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 candidates: (Binding | null)[] = []; + 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); + candidates.push(name ? (locals.get(name) ?? null) : value ? { node: value, value } : null); + } else if (declaration?.childForFieldName('name')?.text === exportName) + candidates.push(locals.get(exportName) ?? null); + else if (declaration?.type === 'lexical_declaration') + for (const variable of declaration.namedChildren) + if (variable.childForFieldName('name')?.text === exportName) + candidates.push(locals.get(exportName) ?? null); + for (const spec of statement.descendantsOfType('export_specifier')) { + if (spec.children.some((n) => n.type === 'type')) continue; + const name = spec.childForFieldName('name')?.text ?? ''; + const exportedName = spec.childForFieldName('alias')?.text ?? name; + if (exportedName === exportName || exportedName === 'default') + candidates.push(statement.childForFieldName('source') ? null : (locals.get(name) ?? null)); + } + } + const selected = candidates.length === 1 ? candidates[0] : null; + const name = selected?.node.childForFieldName('name')?.text; + return selected && (!name || !mutated(root, name)) ? selected : null; +} + +function plugin(context: ResolutionContext): boolean { + for (const ext of ['ts', 'js', 'mts', 'mjs']) { + const file = `vite.config.${ext}`; + const content = context.readFile(file); + if (content === null) continue; + const tree = getParser(detectLanguage(file))?.parse(content); + if (!tree) return false; + try { + const root = tree.rootNode; + const helpers = imported(root, 'vike/plugin'); + let config = exported(root, 'config')?.value; + if (config?.type === 'call_expression') { + if ( + !imported(root, 'vite', 'defineConfig').has( + config.childForFieldName('function')?.text ?? '', + ) + ) + return false; + config = config.childForFieldName('arguments')?.namedChildren[0]; + } + const options = fields(config); + if (!options || options.has('root') || options.has('base')) return false; + const plugins = options.get('plugins'); + if ( + plugins?.type !== 'array' || + plugins.namedChildren.some((n) => n.type === 'spread_element') + ) + return false; + return plugins.namedChildren.some((call) => { + const name = call.childForFieldName('function')?.text ?? ''; + if (call.type !== 'call_expression' || !helpers.has(name) || mutated(root, name)) + return false; + const args = + call.childForFieldName('arguments')?.namedChildren.filter((n) => n.type !== 'comment') ?? + []; + return !args.length || (args.length === 1 && fields(args[0])?.size === 0); + }); + } finally { + tree.delete(); + } + } + return false; +} + +function configEffect(content: string, file: string): 'safe' | 'local' | 'global' { + const tree = getParser(detectLanguage(file))?.parse(content); + if (!tree) return 'global'; + try { + const root = tree.rootNode; + const options = fields(exported(root, 'config')?.value); + if (!options) return 'global'; + if (options.has('onBeforeRoute') || options.has('meta')) return 'global'; + const extension = unwrap(options.get('extends')); + if (extension) { + const helpers = imported(root, 'vike-react/config'); + const values = + extension.type === 'array' + ? extension.namedChildren.filter((n) => n.type !== 'comment') + : [extension]; + if ( + !values.every( + (node) => + node.type === 'identifier' && helpers.has(node.text) && !mutated(root, node.text), + ) + ) + return 'global'; + } + return ['route', 'filesystemRoutingRoot', 'Page'].some((name) => options.has(name)) + ? 'local' + : 'safe'; + } finally { + tree.delete(); + } +} + +const states = new WeakMap< + ResolutionContext, + { active: boolean; files: string[]; blocked: string[] } +>(); +function project(context: ResolutionContext) { + const cached = states.get(context); + if (cached) return cached; + const state = { + active: plugin(context), + files: context.getAllFiles().filter((file) => context.fileExists(file)), + blocked: [] as string[], + }; + if (state.active) + for (const file of state.files) { + if (BEFORE_ROUTE.test(file) || META.test(file) || EXTENDS.test(file)) state.active = false; + if (ROOT_OVERRIDE.test(file)) state.blocked.push(location(file)); + if (CONFIG.test(file)) { + const content = context.readFile(file); + const effect = content === null ? 'global' : configEffect(content, file); + if (effect === 'global') state.active = false; + if (effect === 'local') state.blocked.push(location(file)); + } + } + states.set(context, state); + return state; +} + +export function extractVikeRoutes( + filePath: string, + content: string, + context: ResolutionContext, +): FrameworkExtractionResult { + const result: FrameworkExtractionResult = { nodes: [], references: [] }; + if (!isVikePage(filePath)) return result; + const state = project(context); + if (!state.active || state.blocked.some((parent) => within(location(filePath), parent))) + return result; + if ( + state.files.filter((file) => isVikePage(file) && location(file) === location(filePath)) + .length !== 1 + ) + return result; + let routePath = + '/' + + directory(filePath) + .split('/') + .filter( + (part) => + part && !['pages', 'src', 'index', 'renderer'].includes(part) && !/^\(.*\)$/.test(part), + ) + .join('/'); + const overrides = state.files + .filter((file) => ROUTE.test(file) && within(location(filePath), location(file))) + .sort((a, b) => location(b).length - location(a).length); + if (overrides.length > 1 && location(overrides[0]!) === location(overrides[1]!)) return result; + if (overrides.length) { + const file = overrides[0]!; + const source = context.readFile(file); + if (source === null) return result; + const tree = getParser(detectLanguage(file))?.parse(source); + if (!tree) return result; + try { + const path = literal(exported(tree.rootNode, 'route')?.value); + if (path === null || !path.startsWith('/')) return result; + routePath = path; + } finally { + tree.delete(); + } + } + routePath = routePath.replace(/(^|\/)@([^/]+)/g, '$1:$2'); + const language = detectLanguage(filePath); + const tree = getParser(language)?.parse(content); + if (!tree) return result; + try { + const binding = exported(tree.rootNode, 'Page'); + const name = binding?.node.childForFieldName('name')?.text; + if ( + !binding || + !name || + ![ + 'function_declaration', + 'function_expression', + 'arrow_function', + 'class_declaration', + ].includes(binding.value.type) + ) + return result; + const node = binding.value; + const id = `route:vike:${filePath}`; + result.nodes.push({ + id, + kind: 'route', + name: routePath, + qualifiedName: `${filePath}::${routePath}`, + 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: `vike-page:${name}`, + referenceKind: 'references', + filePath, + language, + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + return result; + } finally { + tree.delete(); + } +} + +export const vikeResolver: FrameworkResolver = { + name: 'vike', + languages: ['typescript', 'javascript', 'tsx', 'jsx'], + detect: (context) => dependsOn(context, 'vike'), + claimsReference: (name) => name.startsWith('vike-page:'), + resolve(ref, context) { + if (!ref.fromNodeId.startsWith('route:vike:') || !ref.referenceName.startsWith('vike-page:')) + return null; + const candidates = context + .getNodesInFile(ref.filePath) + .filter( + (node) => + node.name === ref.referenceName.slice('vike-page:'.length) && + ['function', 'component', 'class'].includes(node.kind) && + node.startLine === ref.line, + ); + return candidates.length === 1 + ? { original: ref, targetNodeId: candidates[0]!.id, confidence: 1, resolvedBy: 'framework' } + : null; + }, +};