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 load()}>{x} }); 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;
+ },
+};