diff --git a/CHANGELOG.md b/CHANGELOG.md index aad2556a0..e11db8207 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 +- 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. - Solid Router registered JSX and configuration trees now link to exact components, including static lazy imports, nested paths and router bases, without duplicate React routes. diff --git a/__tests__/qwik-city.test.ts b/__tests__/qwik-city.test.ts new file mode 100644 index 000000000..28f1233d3 --- /dev/null +++ b/__tests__/qwik-city.test.ts @@ -0,0 +1,321 @@ +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('Qwik City default route conventions', () => { + 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 {defineConfig} from 'vite';import {qwikCity as city} from '@builder.io/qwik-city/vite';export default defineConfig(({command,mode})=>{return {plugins:[city()]}});`; + const root = `import {component$} from '@builder.io/qwik';import {QwikCityProvider as Provider,RouterOutlet as Outlet} from '@builder.io/qwik-city';export default component$(()=>{return });`; + const setup = () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-qwik-city-')); + write( + 'package.json', + JSON.stringify({ + dependencies: { '@builder.io/qwik-city': '1.20.0', '@builder.io/qwik': '1.20.0' }, + }), + ); + write('vite.config.ts', config); + write('src/root.tsx', root); + }; + const page = (sub: string, name: string) => + write( + `src/routes/${sub ? sub + '/' : ''}index.tsx`, + `export default function ${name}(){return

}`, + ); + const routes = () => + cg!.getNodesByKind('route').filter((n) => n.id.startsWith('route:qwik-city:')); + afterEach(() => { + cg?.close(); + cg = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + + // QwikDev/qwik@971465f941e44e5adf2b2c2e44566b590d0990d8: + // starters/apps/qwikcity-test/src/routes/issue2441/abc.page/index.tsx, + // packages/docs/src/routes/demo/qwikcity/middleware/json/index.tsx. + // Extracted source examples share a minimal app with the starter's config callback shape. + it('indexes pinned anonymous page and endpoint fixtures with exact roots', async () => { + setup(); + write( + 'src/routes/issue2441/abc.page/index.tsx', + 'import { component$ } from "@builder.io/qwik";\n\nexport default component$(() => {\n return

Issue 2441

;\n});\n', + ); + write( + 'src/routes/demo/qwikcity/middleware/json/index.tsx', + "import { type RequestHandler } from '@builder.io/qwik-city';\n\nexport const onGet: RequestHandler = async ({ json }) => {\n json(200, { hello: 'world' });\n};\n", + ); + cg = await CodeGraph.init(dir, { index: true }); + expect( + routes() + .map((n) => n.name) + .sort(), + ).toEqual(['/issue2441/abc.page/', 'GET /demo/qwikcity/middleware/json/']); + const roots = routeRoots(cg, routes()); + const component = roots.get(routes().find((n) => n.name.startsWith('/'))!.id)!.node; + expect([component.kind, component.name, component.filePath, component.startLine]).toEqual([ + 'component', + 'default', + 'src/routes/issue2441/abc.page/index.tsx', + 3, + ]); + expect(roots.get(routes().find((n) => n.name.startsWith('GET'))!.id)!.node.name).toBe('onGet'); + }); + it('links multiline plain function defaults and method exports', async () => { + setup(); + write( + 'src/routes/index.tsx', + 'const Page =\n () =>

;\nexport default Page;\nexport const onGet =\n () => 1;', + ); + cg = await CodeGraph.init(dir, { index: true }); + const roots = routeRoots(cg, routes()); + expect( + routes() + .map((route) => [route.name, roots.get(route.id)?.node.name]) + .sort(), + ).toEqual([ + ['/', 'Page'], + ['GET /', 'onGet'], + ]); + }); + it.each([false, true])( + 'preserves component body calls without taking neighboring or named nested calls (named=%s)', + async (named) => { + setup(); + write( + 'src/routes/index.tsx', + `import {component$} from '@builder.io/qwik'; +function load(){return 1} function outside(){return 2} function nestedCall(){return 3} +outside(); ${named ? 'const Page =' : 'export default'} component$(()=>{function helper(){return nestedCall()}const x=load();return }); outside(); ${named ? 'export default Page;' : ''}`, + ); + cg = await CodeGraph.init(dir, { index: true }); + const component = routeRoots(cg, routes()).get(routes()[0]!.id)!.node; + const calls = cg + .getOutgoingEdges(component.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg!.getNode(e.target)?.name); + expect(calls).toContain('load'); + expect(calls).not.toContain('outside'); + expect(calls).not.toContain('nestedCall'); + expect(calls).not.toContain('component$'); + const helper = cg.getNodesByKind('function').find((n) => n.name === 'helper')!; + expect( + cg + .getOutgoingEdges(helper.id) + .some((e) => e.kind === 'calls' && cg!.getNode(e.target)?.name === 'nestedCall'), + ).toBe(true); + }, + ); + it('keeps parent indexes, route groups, parameters and explicit methods distinct from middleware', async () => { + setup(); + for (const [sub, name] of [ + ['', 'Home'], + ['users', 'Users'], + ['users/[id]', 'User'], + ['files/[...rest]', 'Files'], + ['(auth)/login', 'Login'], + ['__legacy/account', 'Account'], + ['_private', 'Private'], + ['[user].json', 'Json'], + ]) + page(sub!, name!); + write( + 'src/routes/layout.tsx', + 'export default function Layout(){return

} export function onGet(){}', + ); + write( + 'src/routes/other.tsx', + 'export default function Other(){return

} export function onPost(){}', + ); + write('src/routes/middleware/index.ts', 'export function onRequest(){}'); + write( + 'src/routes/api/index.ts', + 'export function onGet(){}\nexport function onHead(){}\nexport function onOptions(){}', + ); + write( + 'src/routes/users/index.tsx', + `import {component$ as component} from '@builder.io/qwik';const Users=component(()=>

);export default Users;export const onPost=()=>1;export const onRequest=()=>2;`, + ); + cg = await CodeGraph.init(dir, { index: true }); + expect( + routes() + .map((n) => n.name) + .sort(), + ).toEqual([ + '/', + '/:user.json/', + '/_private/', + '/account/', + '/files/*rest/', + '/login/', + '/users/', + '/users/:id/', + 'GET /api/', + 'HEAD /api/', + 'OPTIONS /api/', + 'POST /users/', + ]); + const user = routes().find((n) => n.name === '/users/')!; + expect(routeRoots(cg, routes()).get(user.id)!.node.name).toBe('Users'); + }); + it('does not need page rendering registration for an API endpoint', async () => { + setup(); + write('src/root.tsx', 'export default function Root(){return

}'); + page('page', 'Page'); + write('src/routes/api/index.ts', 'export const onGet=()=>1;'); + cg = await CodeGraph.init(dir, { index: true }); + expect(routes().map((n) => n.name)).toEqual(['GET /api/']); + }); + it('rejects non-runtime exports, reassigned handlers and foreign factories', async () => { + setup(); + write('src/routes/typed/index.ts', 'function onGet(){}; export type {onGet};'); + write('src/routes/mutated/index.ts', 'export function onGet(){};onGet=other;'); + write( + 'src/routes/foreign/index.tsx', + `import {component$} from 'other';export default component$(()=>

);`, + ); + write('src/routes/decoy/index.tsx', 'function Decoy(){return

}export default 1;'); + page('[[id]]', 'Unsupported'); + write('src/routes/index@other.tsx', 'export default function NamedLayout(){return

}'); + cg = await CodeGraph.init(dir, { index: true }); + expect(routes()).toEqual([]); + }); + it.each([ + [ + 'vite.config.ts', + `import {qwikCity} from '@builder.io/qwik-city/vite';export default {plugins:[qwikCity({routesDir:'other'})]}`, + ], + [ + 'vite.config.ts', + `import {qwikCity} from '@builder.io/qwik-city/vite';export default {plugins:[qwikCity({trailingSlash:false})]}`, + ], + [ + 'vite.config.ts', + `import {qwikCity} from '@builder.io/qwik-city/vite';export default {root:'other',plugins:[qwikCity()]}`, + ], + [ + 'vite.config.ts', + `import {qwikCity} from '@builder.io/qwik-city/vite';export default {plugins:[qwikCity()],...extra}`, + ], + [ + 'vite.config.ts', + `import {defineConfig} from 'vite';import {qwikCity} from '@builder.io/qwik-city/vite';export default defineConfig(qwikCity=>({plugins:[qwikCity()]}))`, + ], + [ + 'vite.config.ts', + `import {defineConfig} from 'vite';import {qwikCity} from '@builder.io/qwik-city/vite';export default defineConfig(()=>{if(flag)return {};return {plugins:[qwikCity()]}})`, + ], + [ + 'src/root.tsx', + `import {QwikCityProvider as Provider,RouterOutlet} from '@builder.io/qwik-city';export default function Root(){return {false && }}`, + ], + [ + 'src/root.tsx', + `import {QwikCityProvider,RouterOutlet} from '@builder.io/qwik-city';export default QwikCityProvider=>`, + ], + [ + 'src/root.tsx', + `import {QwikCityProvider,RouterOutlet} from 'other';export default function Root(){return }`, + ], + ])('skips unsupported registration %s (%s)', async (file, source) => { + setup(); + page('', 'Home'); + write(file, source); + cg = await CodeGraph.init(dir, { index: true }); + expect(routes()).toEqual([]); + }); + it('supports an async literal config callback and a named root function', async () => { + setup(); + write('vite.config.ts', config.replace('(({command,mode})', '(async ({command,mode})')); + write( + 'src/root.tsx', + `import {QwikCityProvider,RouterOutlet} from '@builder.io/qwik-city';export default function Root(){return }`, + ); + page('', 'Home'); + cg = await CodeGraph.init(dir, { index: true }); + expect(routes().map((n) => n.name)).toEqual(['/']); + }); + it('updates pages, endpoints and root configuration through scoped sync and reopening', async () => { + setup(); + page('', 'Home'); + cg = await CodeGraph.init(dir, { index: true }); + cg.close(); + cg = await CodeGraph.open(dir); + write('src/routes/api/index.ts', 'export function onGet(){return 1}'); + await cg.sync({ paths: ['src/routes/api/index.ts'] }); + expect( + routes() + .map((n) => n.name) + .sort(), + ).toEqual(['/', 'GET /api/']); + write( + 'src/routes/index.tsx', + `import {component$} from '@builder.io/qwik';export default component$(()=>

);`, + ); + await cg.sync(); + expect(routeRoots(cg, routes()).get(routes().find((n) => n.name === '/')!.id)!.node.kind).toBe( + 'component', + ); + cg.close(); + cg = await CodeGraph.open(dir); + write('src/root.tsx', 'export default function Root(){return

}'); + await cg.sync({ paths: ['src/root.tsx'] }); + expect(routes().map((n) => n.name)).toEqual(['GET /api/']); + expect( + cg + .getNodesByKind('component') + .filter((n) => n.name === 'default' && n.filePath === 'src/routes/index.tsx'), + ).toEqual([]); + write('src/root.tsx', root); + await cg.sync(); + expect( + routes() + .map((n) => n.name) + .sort(), + ).toEqual(['/', 'GET /api/']); + fs.unlinkSync(path.join(dir, 'src/routes/api/index.ts')); + await cg.sync(); + expect(routes().map((n) => n.name)).toEqual(['/']); + }); + it.each([false, true])('discovers newly introduced Qwik City (scoped=%s)', async (scoped) => { + setup(); + write('package.json', '{}'); + page('', 'Home'); + cg = await CodeGraph.init(dir, { index: true }); + expect(routes()).toEqual([]); + write('package.json', JSON.stringify({ dependencies: { '@builder.io/qwik-city': '1.20.0' } })); + write('src/root.tsx', root + '\n'); + await cg.sync(scoped ? { paths: ['src/root.tsx'] } : undefined); + expect(routes().map((n) => n.name)).toEqual(['/']); + }); + it.runIf(fs.existsSync(path.resolve('dist/index.js')))( + 'binds anonymous defaults and their calls in fresh compiled workers', + () => { + setup(); + write( + 'src/routes/index.tsx', + `import {component$} from '@builder.io/qwik';function load(){return 1}export default component$(()=>{return

{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:qwik-city:'));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 2b1538066..5fe1c3f29 100644 --- a/docs/design/PLAN-application-router-coverage.md +++ b/docs/design/PLAN-application-router-coverage.md @@ -1,4 +1,4 @@ -Status: 9/13 — SolidStart validated; publishing step 9 +Status: 10/13 — Qwik City validated; publishing step 10 - [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. @@ -9,7 +9,7 @@ Status: 9/13 — SolidStart validated; publishing step 9 - [x] 7 Analog — registered default pages bind exact classes with directory-based layouts and dot/parameter conventions; config/file and reopened/scoped sync plus fresh workers pass; build passes, 133 WASM focused/control tests pass, full native suite 4,500 pass / 46 skip; independent review clear. - [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. -- [ ] 10 Qwik City — default file routes, page components, and method-specific endpoint exports. Gate: shared proof, parameters, layouts, and `onRequest`/middleware exclusions. +- [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. - [ ] 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 de8ff1966..b54428dbd 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -48,6 +48,9 @@ guessed. | Analog | `frameworks/analog.ts` | — | `analog-routes.test.ts` | pinned 2.7.1 sign-up page; filename/layout and registration sync controls | | 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 | + +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. SolidStart supports default `src/routes/**/*.{js,jsx,ts,tsx}` with option-free `solidStart()` in a literal Vite config. Page discovery additionally requires imported `FileRoutes` directly inside the imported `Router` in the default app component. Named local default functions and method exports bind exact targets. Raw file hierarchy determines page layouts before route groups are removed; API-only descendants do not turn pages into layouts. Dots stay literal, optional page parameters and named catchalls are preserved, and GET supplies HEAD unless explicitly exported. [Official page](https://github.com/solidjs/solid-start/blob/5d23efbcbb47997a70978be8b0e468df50d774a8/apps/fixtures/basic/src/routes/about.tsx), [API fixture](https://github.com/solidjs/solid-start/blob/5d23efbcbb47997a70978be8b0e468df50d774a8/apps/fixtures/experiments/src/routes/api/hello/%5Bname%5D.ts), [route construction](https://github.com/solidjs/solid-start/blob/5d23efbcbb47997a70978be8b0e468df50d774a8/packages/start/src/config/fs-router.ts). Version 2.0.4 excludes OPTIONS-only APIs and rejects optional API parameters. Custom roots, plugin options, dynamic configuration, page `route` overrides, non-default routers, anonymous/re-exported handlers and Markdown 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 86994f2e8..f0c8618ff 100644 --- a/site/src/content/docs/guides/framework-routes.md +++ b/site/src/content/docs/guides/framework-routes.md @@ -41,6 +41,9 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by | **Analog** | Registered default `src/app/pages/**/*.page.ts` pages, linked to named default classes; directory layouts, dot paths, index/pathless segments and parameters | | **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 | + +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. SolidStart coverage targets version 2.0.4: option-free `solidStart()` in Vite and, for pages, `FileRoutes` directly inside the default app's `Router`. A file can supply both a page and endpoints. Named local functions and constant function exports are supported. Custom roots/options, dynamic config, page route overrides, anonymous/re-exported handlers and Markdown remain unsupported. Optional parameters apply to pages only; OPTIONS-only APIs are excluded by this version's runtime. diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 23bca1ba0..298682c8c 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -36,6 +36,7 @@ import { detectFrameworks } from '../resolution/frameworks'; import { extractAngularRoutes, isAngularRegistrationFile } from '../resolution/frameworks/angular'; import { extractAnalogRoutes, isAnalogPage } from '../resolution/frameworks/analog'; import { extractSolidStartRoutes, isSolidStartRoute } from '../resolution/frameworks/solid-start'; +import { extractQwikCityRoutes, isQwikCityRoute } from '../resolution/frameworks/qwik-city'; import type { ResolutionContext } from '../resolution/types'; import { createYielder, type MaybeYield } from '../resolution/cooperative-yield'; @@ -2376,13 +2377,15 @@ export class ExtractionOrchestrator { const angular = frameworks.includes('angular') && isAngularRegistrationFile(content); const analog = frameworks.includes('analog') && isAnalogPage(filePath); const solidStart = frameworks.includes('solid-start') && isSolidStartRoute(filePath); - if (!angular && !analog && !solidStart) return result; - await loadGrammarsForLanguages(solidStart ? ['typescript', 'javascript', 'tsx', 'jsx'] : ['typescript', 'javascript']); + 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']); result = materializeKernelResult(result, filePath, detectLanguage(filePath)!); const context = this.frameworkSourceContext!; const extracted = angular ? extractAngularRoutes(filePath, content, context) : analog ? extractAnalogRoutes(filePath, content, context) - : extractSolidStartRoutes(filePath, content, context); + : solidStart ? extractSolidStartRoutes(filePath, content, context) + : extractQwikCityRoutes(filePath, content, context, result); result.nodes.push(...extracted.nodes); result.unresolvedReferences.push(...extracted.references); return result; @@ -2897,20 +2900,13 @@ export class ExtractionOrchestrator { : previous.includes('react-router-files'); this.detectedFrameworkNames = null; const detected = this.ensureDetectedFrameworks(currentFiles); - if (detected.includes('solid-start') || this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:solid-start:'))) { + for (const [framework, matches] of [ + ['solid-start', isSolidStartRoute], ['analog', isAnalogPage], ['qwik-city', isQwikCityRoute], + ] as const) { + if (!detected.includes(framework) && !this.queries.getNodesByKind('route').some(n => n.id.startsWith(`route:${framework}:`))) continue; const scope = this.scopedSyncMatcher(); for (const filePath of new Set([...this.queries.getAllFilePaths(), ...currentFiles])) { - if (!isSolidStartRoute(filePath) || filesToIndex.includes(filePath) || scope.ignores(filePath) || !fs.existsSync(path.join(this.rootDir, filePath))) continue; - filesToIndex.push(filePath); - this.conventionInvalidatedFiles.add(filePath); - changedFilePaths.push(filePath); - filesModified++; - } - } - if (detected.includes('analog') || this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:analog:'))) { - const scope = this.scopedSyncMatcher(); - for (const filePath of new Set([...this.queries.getAllFilePaths(), ...currentFiles])) { - if (!isAnalogPage(filePath) || filesToIndex.includes(filePath) || scope.ignores(filePath) || !fs.existsSync(path.join(this.rootDir, filePath))) continue; + if (!matches(filePath) || filesToIndex.includes(filePath) || scope.ignores(filePath) || !fs.existsSync(path.join(this.rootDir, filePath))) continue; filesToIndex.push(filePath); this.conventionInvalidatedFiles.add(filePath); changedFilePaths.push(filePath); diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 54398f807..f670dfb60 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -25,6 +25,7 @@ import { angularResolver } from './angular'; import { analogResolver } from './analog'; import { solidRouterResolver } from './solid-router'; import { solidStartResolver } from './solid-start'; +import { qwikCityResolver } from './qwik-city'; import { djangoResolver, flaskResolver, fastapiResolver } from './python'; import { railsResolver } from './ruby'; import { springResolver } from './java'; @@ -73,6 +74,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ analogResolver, solidRouterResolver, solidStartResolver, + qwikCityResolver, // Python djangoResolver, flaskResolver, diff --git a/src/resolution/frameworks/qwik-city.ts b/src/resolution/frameworks/qwik-city.ts new file mode 100644 index 000000000..93f8949b2 --- /dev/null +++ b/src/resolution/frameworks/qwik-city.ts @@ -0,0 +1,424 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import type { Node, 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/routes/'; +const FUNCTIONS = new Set(['function_declaration', 'function_expression', 'arrow_function']); +const CONDITIONAL = new Set([ + 'binary_expression', + 'ternary_expression', + 'if_statement', + 'switch_statement', + 'for_statement', + 'for_in_statement', + 'while_statement', + 'do_statement', +]); +export const isQwikCityRoute = (file: string): boolean => + file.startsWith(ROOT) && /(?:^|\/)index\.[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 imports(root: SyntaxNode, source: string, imported: string): Set { + const names = 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; + for (const spec of statement.descendantsOfType('import_specifier')) + if ( + spec.childForFieldName('name')?.text === imported && + !spec.children.some((n) => n.type === 'type') + ) + names.add(spec.childForFieldName('alias')?.text ?? imported); + } + return names; +} + +function shadowed(root: SyntaxNode, names: Set): boolean { + return root + .descendantsOfType([ + 'required_parameter', + 'optional_parameter', + 'variable_declarator', + 'function_declaration', + 'arrow_function', + 'assignment_expression', + ]) + .some((node) => { + const pattern = + node.childForFieldName('name') ?? + node.childForFieldName('pattern') ?? + node.childForFieldName('parameter') ?? + node.childForFieldName('left'); + return ( + !!pattern && + (names.has(pattern.text) || + pattern + .descendantsOfType(['identifier', 'shorthand_property_identifier_pattern']) + .some((n) => names.has(n.text))) + ); + }); +} + +function fields(node: SyntaxNode | null | undefined): Map | null { + node = unwrap(node); + if (node?.type !== 'object') return null; + const result = new Map(); + for (const entry of node.namedChildren) { + if (entry.type === 'comment') continue; + const key = entry.childForFieldName('key'); + const name = literal(key) ?? (key?.type === 'property_identifier' ? key.text : null); + const value = entry.childForFieldName('value'); + if (entry.type !== 'pair' || !name || !value || result.has(name)) return null; + result.set(name, value); + } + return result; +} + +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 = imports(root, '@builder.io/qwik-city/vite', 'qwikCity'); + if (shadowed(root, helpers)) return false; + const exported = root.namedChildren.find( + (n) => n.type === 'export_statement' && n.children.some((c) => c.type === 'default'), + ); + let config = unwrap(exported?.childForFieldName('value')); + if (config?.type === 'call_expression') { + const define = imports(root, 'vite', 'defineConfig'); + if (!define.has(config.childForFieldName('function')?.text ?? '') || shadowed(root, define)) + return false; + config = unwrap(config.childForFieldName('arguments')?.namedChildren[0]); + } + if (config && FUNCTIONS.has(config.type)) { + let body = unwrap(config.childForFieldName('body')); + if (body?.type === 'statement_block') { + const statements = body.namedChildren.filter((n) => n.type !== 'comment'); + if ( + statements.some((n) => CONDITIONAL.has(n.type)) || + statements.filter((n) => n.type === 'return_statement').length !== 1 + ) + return false; + body = unwrap(statements.find((n) => n.type === 'return_statement')?.namedChildren[0]); + } + config = body; + } + 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) => { + if ( + call.type !== 'call_expression' || + !helpers.has(call.childForFieldName('function')?.text ?? '') + ) + return false; + const args = + call.childForFieldName('arguments')?.namedChildren.filter((n) => n.type !== 'comment') ?? + []; + return args.length === 0 || (args.length === 1 && fields(args[0])?.size === 0); + }); + } finally { + tree.delete(); + } + } + return false; +} + +type Binding = { node: SyntaxNode; value: SyntaxNode }; +function exportsIn(root: SyntaxNode): Map { + 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?.type === 'function_declaration' && name) locals.set(name.text, { node, value: node }); + if (node?.type === 'lexical_declaration' && node.children.some((n) => n.type === 'const')) + for (const binding of node.namedChildren) { + const name = binding.childForFieldName('name'); + const value = unwrap(binding.childForFieldName('value')); + if (name?.type === 'identifier' && value) locals.set(name.text, { node: binding, value }); + } + } + for (const mutation of root.descendantsOfType([ + 'assignment_expression', + 'augmented_assignment_expression', + 'update_expression', + ])) { + const target = mutation.childForFieldName('left') ?? mutation.childForFieldName('argument'); + if (target?.type === 'identifier') locals.delete(target.text); + for (const name of target?.descendantsOfType([ + 'identifier', + 'shorthand_property_identifier_pattern', + ]) ?? []) + locals.delete(name.text); + } + const result = new Map(); + 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); + result.set( + 'default', + name ? (locals.get(name) ?? null) : value ? { node: value, value } : null, + ); + } else if (declaration?.type === 'function_declaration') { + const name = declaration.childForFieldName('name')?.text; + if (name) result.set(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) result.set(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 ?? ''; + result.set( + spec.childForFieldName('alias')?.text ?? name, + statement.childForFieldName('source') ? null : (locals.get(name) ?? null), + ); + } + } + return result; +} + +function componentBody(value: SyntaxNode, root: SyntaxNode): SyntaxNode | null { + if (FUNCTIONS.has(value.type)) return value; + const helpers = imports(root, '@builder.io/qwik', 'component$'); + if ( + value.type !== 'call_expression' || + !helpers.has(value.childForFieldName('function')?.text ?? '') || + shadowed(root, helpers) + ) + return null; + const args = + value.childForFieldName('arguments')?.namedChildren.filter((n) => n.type !== 'comment') ?? []; + return args.length === 1 && FUNCTIONS.has(args[0]!.type) ? args[0]! : null; +} + +function pages(context: ResolutionContext): boolean { + for (const ext of ['tsx', 'jsx']) { + const file = `src/root.${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 value = exportsIn(root).get('default')?.value; + const component = value && componentBody(value, root); + if (!component) return false; + const providers = imports(root, '@builder.io/qwik-city', 'QwikCityProvider'); + const outlets = imports(root, '@builder.io/qwik-city', 'RouterOutlet'); + if (shadowed(root, new Set([...providers, ...outlets]))) return false; + for (const outlet of component.descendantsOfType('jsx_self_closing_element')) { + if ( + !outlets.has(outlet.childForFieldName('name')?.text ?? '') || + outlet.namedChildren.length !== 1 + ) + continue; + let parent = outlet.parent; + let provider = false; + while (parent && parent.id !== component.id) { + if (FUNCTIONS.has(parent.type) || CONDITIONAL.has(parent.type)) break; + if ( + parent.type === 'jsx_element' && + providers.has(parent.namedChildren[0]?.childForFieldName('name')?.text ?? '') && + parent.namedChildren[0]?.namedChildren.length === 1 + ) + provider = true; + parent = parent.parent; + } + if (provider && parent?.id === component.id) return true; + } + } finally { + tree.delete(); + } + } + return false; +} + +const states = new WeakMap(); +function project(context: ResolutionContext) { + const cached = states.get(context); + if (cached) return cached; + const active = plugin(context); + const state = { active, pages: active && pages(context) }; + states.set(context, state); + return state; +} + +/** Factory components own their callback's file-level references, whether named or anonymous. */ +export function extractQwikCityRoutes( + filePath: string, + content: string, + context: ResolutionContext, + existing: ExtractionResult, +): FrameworkExtractionResult { + const result: FrameworkExtractionResult = { nodes: [], references: [] }; + if (!isQwikCityRoute(filePath)) return result; + const state = project(context); + if (!state.active) return result; + const segments = filePath.slice(ROOT.length).split('/').slice(0, -1); + if (segments.some((segment) => segment.includes('[[') || segment.includes(']]'))) return result; + const routePath = + '/' + + segments + .filter((segment) => !/^\(.*\)$/.test(segment) && !segment.startsWith('__')) + .map((segment) => + segment.replace( + /\[(\.\.\.)?(\w+)\]/g, + (_, rest: string, name: string) => (rest ? '*' : ':') + name, + ), + ) + .join('/') + + (segments.some((segment) => !/^\(.*\)$/.test(segment) && !segment.startsWith('__')) ? '/' : ''); + const language = detectLanguage(filePath); + const tree = getParser(language)?.parse(content); + if (!tree) return result; + try { + const root = tree.rootNode; + const exported = exportsIn(root); + const add = (method: string, binding: Binding | null | undefined) => { + if (!binding) return; + const callback = method + ? FUNCTIONS.has(binding.value.type) + ? binding.value + : null + : componentBody(binding.value, root); + if (!callback) return; + const name = binding.node.childForFieldName('name')?.text; + if (method && !name) return; + const anchor = FUNCTIONS.has(binding.value.type) ? binding.value : binding.node; + let targetId: string | undefined; + if (!name) { + // Factory-call defaults have no generic extractor symbol; this is their exported component. + if (binding.value.type !== 'call_expression') return; + targetId = generateNodeId(filePath, 'component', 'default', anchor.startPosition.row + 1); + const component: Node = { + id: targetId, + 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(component); + } + if (binding.value.type === 'call_expression') { + const named = existing.nodes.filter( + (node) => + node.name === name && + node.kind === 'constant' && + node.startLine === anchor.startPosition.row + 1, + ); + const owner = targetId ?? (named.length === 1 ? named[0]!.id : undefined); + const body = callback.childForFieldName('body')!; + for (const ref of existing.unresolvedReferences) { + const row = ref.line - 1; + if ( + !owner || + 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) + ) + continue; + ref.fromNodeId = owner; + } + } + const routeName = method ? `${method} ${routePath}` : routePath; + const id = `route:qwik-city:${filePath}:${method || 'page'}`; + result.nodes.push({ + id, + kind: 'route', + name: routeName, + qualifiedName: `${filePath}::${routeName}`, + filePath, + language, + startLine: anchor.startPosition.row + 1, + startColumn: anchor.startPosition.column, + endLine: anchor.endPosition.row + 1, + endColumn: anchor.endPosition.column, + updatedAt: Date.now(), + }); + result.references.push({ + fromNodeId: id, + referenceName: targetId ? `qwik-default:${targetId}` : `qwik-target:${name}`, + referenceKind: 'references', + filePath, + language, + line: anchor.startPosition.row + 1, + column: anchor.startPosition.column, + }); + }; + if (state.pages) add('', exported.get('default')); + for (const method of ['Get', 'Post', 'Put', 'Patch', 'Delete', 'Options', 'Head']) + add(method.toUpperCase(), exported.get('on' + method)); + return result; + } finally { + tree.delete(); + } +} + +export const qwikCityResolver: FrameworkResolver = { + name: 'qwik-city', + languages: ['typescript', 'javascript', 'tsx', 'jsx'], + detect: (context) => dependsOn(context, '@builder.io/qwik-city'), + claimsReference: (name) => name.startsWith('qwik-target:') || name.startsWith('qwik-default:'), + resolve(ref, context) { + if (!ref.fromNodeId.startsWith('route:qwik-city:')) return null; + const candidates = context + .getNodesInFile(ref.filePath) + .filter((node) => + ref.referenceName.startsWith('qwik-default:') + ? node.id === ref.referenceName.slice('qwik-default:'.length) + : ref.referenceName.startsWith('qwik-target:') && + node.name === ref.referenceName.slice('qwik-target:'.length) && + ['function', 'component', 'constant'].includes(node.kind) && + node.startLine === ref.line, + ); + return candidates.length === 1 + ? { original: ref, targetNodeId: candidates[0]!.id, confidence: 1, resolvedBy: 'framework' } + : null; + }, +};