From b62da73550fea1e153d94116c902ffe7ee7d3af2 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 06:50:41 +0000 Subject: [PATCH] fix(sveltekit): route names drop (group) folders and parameter matchers (#2131) A `(group)` directory shares a layout and is never part of the URL, and a parameter's `=matcher` checks its value. Kept in the name, shadcn-svelte's `src/routes/(app)/(layout)/blocks/+page.svelte` was `/(app)/(layout)/blocks`, so no `goto('/blocks')` or `` ever reached it and its Screens showed no navigation at all. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 2 +- __tests__/sveltekit-route-names.test.ts | 61 +++++++++++++++++++++++++ docs/design/framework-coverage.md | 2 +- src/resolution/frameworks/svelte.ts | 13 ++++-- 5 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 __tests__/sveltekit-route-names.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a7dd3a4bf..7fb6d9602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- SvelteKit routes are now named by the address a browser asks for. A `(group)` folder like `(app)` or `(marketing)` is no longer part of a route's path, and a parameter with a matcher, like `[id=integer]`, is just `:id`. So `goto('/blocks')` and `` now reach a page that lives in `src/routes/(app)/blocks/`, where before they reached nothing and the app's screens showed no navigation. Re-index SvelteKit projects after upgrading. - Vue Router apps built like vue-element-admin, vue-admin-template or vben now have their routes and screens. Routes declared in a named table (`export const constantRoutes = [...]`, `const routes: RouteRecordRaw[] = [...]`), in per-module route files, or through `new Router(...)` are now read. Child routes are joined onto their parent's path, and the parent's component is the layout around them. A lazily loaded view (`() => import('@/views/dashboard/index')`) is bound to the file it names, and `this.$router.push(...)` counts as navigation. Nuxt's file-based routes now come only from a Nuxt app, so a plain Vue app's `pages/` folder no longer turns into made-up screens, and a Nuxt app at the root of its repository now gets its routes. Re-index Vue projects after upgrading. - In a monorepo with an Expo app next to a Next.js app, like create-t3-turbo or tamagui's starter, each router now reads only its own app. Expo Router used to read the Next.js app's files too, so `layout.tsx`, `page.tsx`, a `_components` folder and an API route showed up as screens named `/layout`, `/page` or `/_components/posts`, and a shared package's files became screens as well. Re-index such monorepos after upgrading. - Calls in Vue, Svelte and Astro components now follow the same rules as JavaScript and TypeScript. A `t('…')` from `useI18n()` or a `ref(…)` from `vue` used to link to an interface property or a local variable of the same name elsewhere in the project, so on halo two unrelated symbols had over a thousand made-up callers each. A call imported from a package is also no longer mistaken for a project symbol when the package is declared in a nested app's `package.json` or a Deno import map. An import through an alias CodeGraph can't follow, like SvelteKit's `$lib/…` or a nested Nuxt app's `~/…`, is still treated as project code. Re-index Vue, Svelte, Astro and JavaScript/TypeScript projects after upgrading. diff --git a/README.md b/README.md index 3d02481dd..a02bfc030 100644 --- a/README.md +++ b/README.md @@ -345,7 +345,7 @@ These frameworks additionally emit **`navigates`** edges: the function that send | **React Router** | `` (v5 and v6) and `createBrowserRouter([{ path, element }])` | `history.push` / `replace`, `useNavigate`'s `navigate`, a loader's `redirect`, `` / `` / `` / react-router-bootstrap's `` | | **TanStack Router** | `createFileRoute('/posts/$postId')` (file-based) and `createRoute({ path, getParentRoute })` composed up its parent chain (code-based); `_pathless` segments, `(group)` folders, `__root` and `` layouts are not addresses | `navigate({ to })`, a thrown `redirect({ to })`, `` / `` — where `to` is the route PATTERN and the values ride beside it in `params` | | **Vue Router** / **Nuxt** | `createRouter({ routes: [...] })` / `new Router(...)` and the route tables it's given (`export const constantRoutes = [...]`, per-module route files), with the view each entry names — a lazy `() => import(…)` bound to its file — and `children` joined onto their parent's path, the parent being the layout around them; plus Nuxt `pages/` file-based routes, `server/api/` endpoints and route middleware | `router.push` / `replace`, `this.$router.push`, Nuxt's `navigateTo`, `` / `` / `` — **by route name** (`push({ name: 'profile' })`) as well as by path | -| **SvelteKit** | `src/routes/**/+page.svelte` (`[slug]` → `:slug`, `[[opt]]` → `:opt?`), joined to the `+page.server.js` beside it so a loader's guard belongs to its page | `goto('/x')`, `redirect(status, '/x')` from a load or form action, and the plain `` that is a link in a SvelteKit app | +| **SvelteKit** | `src/routes/**/+page.svelte` (`[slug]` → `:slug`, `[[opt]]` → `:opt?`, `[id=matcher]` → `:id`, `(group)` folders stripped), joined to the `+page.server.js` beside it so a loader's guard belongs to its page | `goto('/x')`, `redirect(status, '/x')` from a load or form action, and the plain `` that is a link in a SvelteKit app | | **Angular** | `Routes` arrays (`RouterModule.forRoot` / `forChild`, `provideRouter`, a routes file's default export) with `component` or a lazy `loadComponent`; `children` and lazy `loadChildren` (an NgModule's through its routing module) joined into full paths; paths written as route constants or `$localize` strings; a route with children is a layout around the screens inside it | `router.navigate([...])`, `navigateByUrl`, a guard's `createUrlTree` / `parseUrl` — a command array, a route constant, or a component property holding one — `routerLink` / `[routerLink]` in the component's template, and `redirectTo`. Each template's child components (``) are linked to the component that renders them | In a repository holding several apps, each app's routes are matched only against navigation written inside that app. diff --git a/__tests__/sveltekit-route-names.test.ts b/__tests__/sveltekit-route-names.test.ts new file mode 100644 index 000000000..1f7ab5e46 --- /dev/null +++ b/__tests__/sveltekit-route-names.test.ts @@ -0,0 +1,61 @@ +/** + * SvelteKit route names are the URL a browser asks for. + * + * - A `(group)` directory organizes layouts and never appears in the URL: + * shadcn-svelte's `src/routes/(app)/(layout)/blocks/+page.svelte` is `/blocks`. + * - `[param=matcher]` is a parameter whose value a matcher checks: `/view/:view`. + * + * Named with the groups, no `goto('/blocks')` or `` ever + * matched its page. + */ +import { describe, it, expect, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const roots: string[] = []; +afterAll(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); +}); + +describe('SvelteKit route names', () => { + it('drop (group) directories and parameter matchers', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sveltekit-groups-')); + roots.push(root); + const files: Record = { + 'package.json': JSON.stringify({ name: 'site', devDependencies: { '@sveltejs/kit': '*', svelte: '*' } }), + 'src/routes/(app)/(layout)/blocks/+page.svelte': `

Blocks

+`, + 'src/routes/(app)/(layout)/+page.svelte': ` + +
Card +`, + 'src/routes/(view)/view/[view=view]/+page.svelte': `

view

+`, + 'src/routes/docs/[...slug=doc]/+page.svelte': `

doc

+`, + 'src/params/view.ts': `export function match(p: string) { return p.length > 0; } +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + const cg = await CodeGraph.init(root, { index: true }); + try { + const routes = cg.getNodesByKind('route').map((n) => n.name).sort(); + expect(routes).toEqual(['/', '/blocks', '/docs/*slug', '/view/:view']); + const home = cg.getNodesByKind('route').find((n) => n.name === '/')!; + const navigates = cg + .getOutgoingEdgesFrom(cg.getNodesInFile(home.filePath).map((n) => n.id), ['navigates' as never]) + .map((e) => cg.getNode(e.target)!.name) + .sort(); + expect(navigates).toEqual(['/blocks', '/view/:view']); + } finally { + cg.close(); + } + }); +}); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index b149ccb7d..e0be9a92b 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -41,7 +41,7 @@ guessed. | React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges) | | TanStack Router | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts` | TanStack examples, fastapi-template frontend | | Vue Router / Nuxt | `frameworks/vue-router.ts` (Nuxt file routes: `nuxtResolver` in `frameworks/vue.ts`) | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges); vue-element-admin (62 routes), vue-admin-template (14), vben (192), halo console (34) — named tables, module files, `children` + layouts; Nuxt: mealie, elk, nuxt/movies | -| SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts` | sveltekit-realworld (31 edges) | +| SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts`, `sveltekit-route-names.test.ts` | sveltekit-realworld (31 edges); shadcn-svelte and skeleton (`(group)` layouts: 13 and 23 edges), svelte.dev (74), kit's test apps (47) | | Angular | `frameworks/angular-router.ts` | `angular-template-synthesizer.ts` | `angular-router.test.ts` | angular-realworld (31 edges, 18 renders), Ghostfolio (189 edges, 170 renders), ngx-admin (routes and renders; its menus are config) | Shared machinery all seven use, in `frameworks/expo-router.ts`: `RouteTable` / diff --git a/src/resolution/frameworks/svelte.ts b/src/resolution/frameworks/svelte.ts index d3271a6fc..9598f9810 100644 --- a/src/resolution/frameworks/svelte.ts +++ b/src/resolution/frameworks/svelte.ts @@ -276,11 +276,14 @@ function filePathToSvelteKitRoute(filePath: string): string | null { const lastSlash = afterRoutes.lastIndexOf('/'); const dirPath = lastSlash === -1 ? '' : afterRoutes.substring(0, lastSlash); - // Convert SvelteKit param syntax [param] to :param - let route = '/' + dirPath - .replace(/\[\.\.\.([^\]]+)\]/g, '*$1') // [...rest] -> *rest - .replace(/\[{2}([^\]]+)\]{2}/g, ':$1?') // [[optional]] -> :optional? - .replace(/\[([^\]]+)\]/g, ':$1'); // [param] -> :param + // A `(group)` directory shares a layout and never appears in the URL: + // `(app)/(layout)/blocks` is `/blocks`. A parameter's `=matcher` checks + // its value and is no part of its name. + const segments = dirPath.split('/').filter((seg) => seg.length > 0 && !/^\(.*\)$/.test(seg)); + let route = '/' + segments.join('/') + .replace(/\[\.\.\.([^\]=]+)(?:=[^\]]*)?\]/g, '*$1') // [...rest] / [...rest=m] -> *rest + .replace(/\[{2}([^\]=]+)(?:=[^\]]*)?\]{2}/g, ':$1?') // [[optional]] -> :optional? + .replace(/\[([^\]=]+)(?:=[^\]]*)?\]/g, ':$1'); // [param] / [param=m] -> :param if (route === '/') return '/'; // Remove trailing slash