From 54a1698caa219563d136453ddcf4b573f9c170b0 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 06:46:26 +0000 Subject: [PATCH] fix(vue-router): admin-template route tables, children and layouts; Nuxt routes only in Nuxt (#2130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vue-element-admin, vue-admin-template and vben had no routes: their tables are named arrays (`export const constantRoutes = [...]`, `const routes: RouteRecordRaw[] = [...]`) or per-module objects (`const tableRouter = {…}`) handed to `new Router(...)`, and most of their screens are `children`. The Vue Router reader now reads those tables in any file that builds a router, imports vue-router or lives in a router/ directory; joins children onto their parent's path (a parent is a screen only when no child claims its address and it doesn't redirect); binds a lazy view by the FILE it imports (all of vue-element-admin's are `…/index.vue`), through an alias the resolver can't follow by the one file in the app with that path; links each screen to its parents' components as layouts; and takes `this.$router.push`. Nuxt's file routes move into their own `nuxt` resolver, detected only in a Nuxt app: halo's plain-Vue console got 30 made-up screens from its `pages/` folders. A Nuxt app at the repository root now gets its routes, and a top-level `pages/index.vue` is `/`, not `/index`. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 2 +- __tests__/vue-router.test.ts | 164 ++++++++++++++++- docs/design/framework-coverage.md | 6 +- src/resolution/frameworks/index.ts | 6 +- src/resolution/frameworks/vue-router.ts | 229 +++++++++++++++++++----- src/resolution/frameworks/vue.ts | 38 +++- src/ui-server/api/screens.ts | 2 +- 8 files changed, 386 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c549985a8..a7dd3a4bf 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 +- 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. - A bare Java call like `verify(mock)`, `assertThat(x)` or `hashCode()` now links only to a method of the class it's in, of that class's parents, or one the file imports statically. It used to link to any class's method of that name: in halo, Mockito's `verify` and `eq` gave one service's `verify` over a thousand made-up callers, and in retrofit Truth's `assertThat` did the same to a test helper. Calls to inherited methods and static imports from the project now link to the right one, like jsoup's `attr(…)` through `LeafNode` or halo's `and(…)` / `equal(…)` through `import static …Queries.*`. Re-index Java projects after upgrading. diff --git a/README.md b/README.md index e0aae0886..3d02481dd 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ These frameworks additionally emit **`navigates`** edges: the function that send | **Next.js** | App Router `app/**/page.tsx` and Pages Router pages (`(group)` stripped, `[slug]` → `:slug`); `app/api/**/route.ts` exports and `pages/api/*` are endpoints, not screens | `router.push` / `replace` / `prefetch`, `redirect()` / `permanentRedirect()` in a server action or page, `NextResponse.redirect(new URL(…))` in middleware, `` and internal `` | | **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: [...] })` with the view each entry names, plus Nuxt `pages/` file-based routes, `server/api/` endpoints and route middleware | `router.push` / `replace`, `$router.push`, Nuxt's `navigateTo`, `` / `` / `` — **by route name** (`push({ name: 'profile' })`) as well as by path | +| **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 | | **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 | diff --git a/__tests__/vue-router.test.ts b/__tests__/vue-router.test.ts index abe087186..de94f2989 100644 --- a/__tests__/vue-router.test.ts +++ b/__tests__/vue-router.test.ts @@ -65,15 +65,18 @@ describe('vue-router: parseVueRoutes', () => { ['home', '/'], ['login', '/login'], ['settings', '/settings'], + [null, '/profile/:username/favorites'], ['profile', '/profile/:username'], ]); }); it('reads the component from a lazy import and from an identifier', () => { - expect(entries.map((e) => e.component)).toEqual(['Home', 'Login', 'Settings', 'Profile']); + expect(entries.map((e) => e.component)).toEqual(['Home', 'Login', 'Settings', 'Favorites', 'Profile']); }); - it('skips a child route, whose path is relative to a parent this does not compose', () => { + it('joins a child route onto its parent, which is the layout around it', () => { + const child = entries.find((e) => e.path === '/profile/:username/favorites')!; + expect(child.layouts.map((l) => l.component)).toEqual(['Profile']); expect(entries.some((e) => e.path === 'favorites')).toBe(false); }); @@ -299,3 +302,160 @@ describe('vue-router: a routed app end to end', () => { expect(screens.dropped).toBe(0); }); }); + +// ============================================================================= +// Admin-template route tables, and Nuxt's own file routes +// ============================================================================= + +describe('vue-router: route tables beyond an inline createRouter', () => { + const roots: string[] = []; + afterAll(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); + }); + + async function project(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vue-tables-')); + roots.push(root); + 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); + } + return CodeGraph.init(root, { index: true }); + } + + const routeNames = (cg: CodeGraph): string[] => cg.getNodesByKind('route').map((n) => n.name).sort(); + const edgesFrom = (cg: CodeGraph, routeName: string, kind: 'calls' | 'references') => { + const r = cg.getNodesByKind('route').find((n) => n.name === routeName)!; + return cg.getOutgoingEdges(r.id).filter((e) => e.kind === kind).map((e) => ({ edge: e, target: cg.getNode(e.target)! })); + }; + + it('vue-element-admin: named tables, `new Router`, module files, children and layouts', async () => { + const view = (name: string) => `\n\n`; + const cg = await project({ + 'package.json': JSON.stringify({ name: 'admin', dependencies: { vue: '^2.6.0', 'vue-router': '^3.0.0' } }), + 'src/router/index.js': `import Vue from 'vue' +import Router from 'vue-router' +import Layout from '@/layout' +import tableRouter from './modules/table' +Vue.use(Router) + +export const constantRoutes = [ + { path: '/login', component: () => import('@/views/login/index'), hidden: true }, + { + path: '/', + component: Layout, + redirect: '/dashboard', + children: [ + { path: 'dashboard', component: () => import('@/views/dashboard/index'), name: 'Dashboard' } + ] + } +] + +export const asyncRoutes = [tableRouter, { path: '*', redirect: '/404', hidden: true }] + +const createRouter = () => new Router({ routes: constantRoutes }) +export default createRouter() +`, + 'src/router/modules/table.js': `import Layout from '@/layout' + +const tableRouter = { + path: '/table', + component: Layout, + redirect: '/table/complex-table', + name: 'Table', + children: [ + { path: 'complex-table', component: () => import('@/views/table/complex-table'), name: 'ComplexTable' } + ] +} +export default tableRouter +`, + 'src/layout/index.vue': view('Layout'), + 'src/views/login/index.vue': ` + +`, + 'src/views/dashboard/index.vue': view('Dashboard'), + 'src/views/table/complex-table.vue': view('ComplexTable'), + }); + try { + // `/` and `/table` redirect into their children: frames, not pages. + expect(routeNames(cg)).toEqual(['/dashboard', '/login', '/table/complex-table']); + // Each lazy view binds to ITS file's component, though every one is `index`. + expect(edgesFrom(cg, '/dashboard', 'calls').map((e) => e.target.filePath)).toEqual(['src/views/dashboard/index.vue']); + expect(edgesFrom(cg, '/login', 'calls').map((e) => e.target.filePath)).toEqual(['src/views/login/index.vue']); + // The parent's component is the layout around it. + const layouts = edgesFrom(cg, '/table/complex-table', 'references'); + expect(layouts.map((e) => e.target.filePath)).toEqual(['src/layout/index.vue']); + expect(layouts[0]!.edge.metadata).toMatchObject({ layout: true }); + // Navigation by name reaches the composed child. + const nav = cg + .getOutgoingEdgesFrom(cg.getNodesInFile('src/views/login/index.vue').map((n) => n.id), ['navigates' as never]) + .filter((e) => e.kind === 'navigates'); + expect(nav.map((e) => cg.getNode(e.target)!.name)).toEqual(['/dashboard']); + } finally { + cg.close(); + } + }); + + it('vben: a typed routes module with an alias the resolver cannot follow', async () => { + const cg = await project({ + 'package.json': JSON.stringify({ name: 'mono', private: true }), + 'apps/web/package.json': JSON.stringify({ name: 'web', imports: { '#/*': './src/*' }, dependencies: { vue: '*', 'vue-router': '*' } }), + 'apps/web/src/router/routes/modules/dashboard.ts': `import type { RouteRecordRaw } from 'vue-router'; + +const routes: RouteRecordRaw[] = [ + { + name: 'Dashboard', + path: '/dashboard', + children: [ + { name: 'Analytics', path: 'analytics', component: () => import('#/views/dashboard/analytics/index.vue') }, + ], + }, +]; + +export default routes; +`, + 'apps/web/src/views/dashboard/analytics/index.vue': ` + +`, + }); + try { + expect(routeNames(cg)).toEqual(['/dashboard/analytics']); + expect(edgesFrom(cg, '/dashboard/analytics', 'calls').map((e) => e.target.filePath)).toEqual([ + 'apps/web/src/views/dashboard/analytics/index.vue', + ]); + } finally { + cg.close(); + } + }); + + it('Nuxt pages at the repository root are routes; a plain Vue app’s pages/ folder is not', async () => { + const nuxt = await project({ + 'package.json': JSON.stringify({ name: 'site', dependencies: { nuxt: '*', vue: '*' } }), + 'pages/index.vue': '\n', + 'pages/users/[id].vue': '\n', + }); + try { + expect(routeNames(nuxt)).toEqual(['/', '/users/:id']); + } finally { + nuxt.close(); + } + const plain = await project({ + 'ui/package.json': JSON.stringify({ name: 'console', dependencies: { vue: '*', 'vue-router': '*' } }), + 'ui/src/modules/contents/pages/SinglePageList.vue': '\n', + }); + try { + expect(routeNames(plain)).toEqual([]); + } finally { + plain.close(); + } + }); +}); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index febc60a30..b149ccb7d 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -40,7 +40,7 @@ guessed. | Next.js | `frameworks/nextjs.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts` | next-saas-starter | | 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` | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges) | +| 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) | | 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) | @@ -191,6 +191,10 @@ Each of these cost real debugging time; they are not hypothetical. `name` is written above its `path`, so a text window handed every entry its predecessor's name — silently, for every route in the file. Use `frameworks/object-literal.ts`. + A lazy view binds by the FILE it imports, never by the import's last + segment: vue-element-admin's views are all `…/index.vue`. + Nuxt's `pages/` convention belongs to a Nuxt app only — a plain Vue app's + `pages/` folder (halo's console) holds components a router config names. 5. **A receiver is required for a generic verb.** `push` and `replace` are two of the most common method names in JavaScript; claiming a bare one puts every `paths.push('/tmp/x')` one string-match away from a route. diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 97af4183f..88221fdfa 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -18,7 +18,7 @@ import { vueRouterResolver } from './vue-router'; import { angularRouterResolver } from './angular-router'; import { svelteKitRouterResolver } from './sveltekit-router'; import { svelteResolver } from './svelte'; -import { vueResolver } from './vue'; +import { vueResolver, nuxtResolver } from './vue'; import { astroResolver } from './astro'; import { djangoResolver, flaskResolver, fastapiResolver } from './python'; import { railsResolver } from './ruby'; @@ -58,6 +58,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ // SvelteKit — `src/routes/**/+page.svelte` routes are `svelteResolver`'s; `goto('/x')` / `redirect(303, '/x')` → navigates edges svelteKitRouterResolver, vueResolver, + // Nuxt — `pages/**` screens, `server/api/**` endpoints and `middleware/`, in a Nuxt app only + nuxtResolver, // Vue Router — `createRouter({ routes })` → route nodes; `router.push({ name })` / `router.push('/x')` → navigates edges vueRouterResolver, angularRouterResolver, @@ -162,7 +164,7 @@ export { vueRouterResolver } from './vue-router'; export { angularRouterResolver } from './angular-router'; export { svelteKitRouterResolver } from './sveltekit-router'; export { svelteResolver } from './svelte'; -export { vueResolver } from './vue'; +export { vueResolver, nuxtResolver } from './vue'; export { astroResolver } from './astro'; export { djangoResolver, flaskResolver, fastapiResolver } from './python'; export { railsResolver } from './ruby'; diff --git a/src/resolution/frameworks/vue-router.ts b/src/resolution/frameworks/vue-router.ts index 1a9884e8b..84a1a9beb 100644 --- a/src/resolution/frameworks/vue-router.ts +++ b/src/resolution/frameworks/vue-router.ts @@ -64,6 +64,7 @@ import { type RouteTable, } from './expo-router'; import { destinationsForHref } from './nextjs'; +import { resolveImportPath } from '../import-resolver'; const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'vue']; @@ -72,75 +73,137 @@ const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'vue'] // ============================================================================= export interface VueRouteEntry { - /** `/profile/:username` — the path, in the form every other framework's routes use. */ + /** `/profile/:username` — the full path, a child's joined onto its parents'. */ path: string; /** `profile` — what `router.push({ name })` names, when the entry has one. */ name: string | null; /** The component the entry names, by identifier or by the tail of its lazy import. */ component: string | null; + /** `@/views/dashboard/index` — the file a lazy `() => import(…)` component names. */ + spec: string | null; + /** The components of the parent routes it renders inside, outermost first. */ + layouts: VueLayout[]; line: number; } +export interface VueLayout { + component: string; + spec: string | null; +} + /** A file that builds a router — the cheap gate before parsing anything. */ -const ROUTER_FACTORY = /\b(?:createRouter|createWebHistory|createWebHashHistory|createMemoryHistory)\s*\(|\bnew\s+VueRouter\s*\(/; +const ROUTER_FACTORY = /\b(?:createRouter|createWebHistory|createWebHashHistory|createMemoryHistory)\s*\(|\bnew\s+(?:VueRouter|Router)\s*\(/; /** `routes: [` / `routes = [` — the array itself, for a file that only holds the table. */ const ROUTES_ARRAY = /\broutes\s*[:=]\s*\[/; +/** A file that imports from `vue-router`. */ +const VUE_ROUTER_IMPORT = /\bfrom\s*['"]vue-router['"]/; + +/** A file in a `router/` or `routes/` directory, or named `router.js` / `routes.ts`. */ +const ROUTE_FILE_PATH = /(?:^|\/)(?:router|routes)(?:\/|\.[cm]?[jt]s$)/; + /** - * Every top-level entry of a `routes: [...]` array. + * Where a routes table starts: a `routes: [` field, or a declaration named + * for what it holds — `export const constantRoutes = [`, `const routes: + * RouteRecordRaw[] = [`, vue-element-admin's per-module `const tableRouter = {`. + */ +const TABLE_OPENERS = /\broutes\s*[:=]\s*\[|\b(?:const|let|var)\s+(?:[A-Za-z_$][\w$]*)?(?:[Rr]outes?|[Rr]outers?)\s*(?::[^=;\n]*)?=\s*([[{])/g; + +/** + * Every route a file's routes tables declare. * - * The array is walked, not pattern-matched: a `name` is written ABOVE the + * The tables are walked, not pattern-matched: a `name` is written ABOVE the * `path` it belongs to, so reading fields out of a window around each `path` * hands an entry its PREDECESSOR's name — vue-realworld's `login` came out as - * `/register`, silently, for every route in the file. So each top-level `{…}` - * is matched as a unit and only its own depth-1 fields are read; a nested - * `children:` array, a `meta: {…}` and a lazy `component: () => import(…)` - * are stepped over rather than searched. + * `/register`, silently, for every route in the file. So each `{…}` is + * matched as a unit and only its own depth-1 fields are read; a `meta: {…}` + * and a lazy `component: () => import(…)` are stepped over rather than + * searched, and `children` are walked in turn with the parent's path in front + * of theirs. * - * An entry whose path does not start with `/` is a child route, relative to a - * parent this does not compose, and is not a destination on its own. + * A route with children is a layout: its component renders the outlet they + * fill. It is a screen of its own only when no child claims its address and + * it does not `redirect` elsewhere — vue-element-admin's `{ path: '/table', + * component: Layout, redirect: '/table/complex-table', children }` is the + * frame around four tables, not a fifth page. + * + * A top-level entry whose path does not start with `/` (`*`, a catch-all) is + * not an address. A table is read from a file that builds a router, imports + * `vue-router`, or lives in a `router/` directory. */ -export function parseVueRoutes(content: string): VueRouteEntry[] { - if (!ROUTER_FACTORY.test(content) && !ROUTES_ARRAY.test(content)) return []; +export function parseVueRoutes(content: string, filePath = ''): VueRouteEntry[] { + const routeFile = ROUTER_FACTORY.test(content) || ROUTES_ARRAY.test(content) || VUE_ROUTER_IMPORT.test(content) || ROUTE_FILE_PATH.test(filePath); + if (!routeFile) return []; const safe = stripCommentsForRegex(content, 'typescript'); const out: VueRouteEntry[] = []; const seen = new Set(); - const arrays = /\broutes\s*[:=]\s*\[/g; - let a: RegExpExecArray | null; - while ((a = arrays.exec(safe)) !== null) { - const open = a.index + a[0].length - 1; - const close = matchBracket(safe, open); - if (close < 0) continue; - for (const obj of topLevelObjects(safe, open + 1, close)) { - const fields = readFields(safe, obj.start, obj.end); - const pathField = fields.get('path'); - if (!pathField) continue; - const path = readStringAt(pathField.text.trimStart(), 0); - if (path === null || !path.startsWith('/')) continue; - const componentField = fields.get('component') ?? fields.get('components'); - if (!componentField) continue; // no component in the entry → not a route object - const component = componentName(componentField.text); - if (!component) continue; - const nameField = fields.get('name'); - const name = nameField ? readStringAt(nameField.text.trimStart(), 0) : null; - const line = safe.slice(0, pathField.at).split('\n').length; - const key = `${path} ${name ?? ''}`; - if (seen.has(key)) continue; - seen.add(key); - out.push({ path, name, component, line }); + const walked = new Set(); + const lineOf = (at: number) => safe.slice(0, at).split('\n').length; + + const visit = (obj: { start: number; end: number }, prefix: string | null, layouts: VueLayout[], depth: number): void => { + const fields = readFields(safe, obj.start, obj.end); + const pathField = fields.get('path'); + if (!pathField) return; + const own = readStringAt(pathField.text.trimStart(), 0); + if (own === null) return; + let path: string; + if (own.startsWith('/')) path = own; + else if (prefix === null) return; // a top-level `*` or relative path is no address + else path = own === '' ? prefix : `${prefix === '/' ? '' : prefix}/${own}`; + if (path.length > 1) path = path.replace(/\/+$/, ''); + + const componentField = fields.get('component') ?? fields.get('components'); + const component = componentField ? componentOf(componentField.text) : null; + + const children = fields.get('children'); + let childClaimsAddress = false; + if (children && depth < 12) { + const open = safe.indexOf('[', children.at); + const close = open < 0 ? -1 : matchBracket(safe, open); + if (open >= 0 && close > open) { + const inner = component ? [...layouts, component] : layouts; + for (const child of topLevelObjects(safe, open + 1, close)) { + const childPath = readFields(safe, child.start, child.end).get('path'); + const childOwn = childPath ? readStringAt(childPath.text.trimStart(), 0) : null; + if (childOwn === '' || childOwn === path) childClaimsAddress = true; + visit(child, path, inner, depth + 1); + } + } } - arrays.lastIndex = close; + if (!component) return; + if (children && (childClaimsAddress || fields.has('redirect'))) return; + const nameField = fields.get('name'); + const name = nameField ? readStringAt(nameField.text.trimStart(), 0) : null; + const key = `${path} ${name ?? ''}`; + if (seen.has(key)) return; + seen.add(key); + out.push({ path, name, component: component.component, spec: component.spec, layouts: [...layouts], line: lineOf(pathField.at) }); + }; + + TABLE_OPENERS.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = TABLE_OPENERS.exec(safe)) !== null) { + const open = m.index + m[0].length - 1; + const close = matchBracket(safe, open); + if (close < 0 || walked.has(open)) continue; + walked.add(open); + if (safe[open] === '{') visit({ start: open, end: close }, null, [], 0); + else for (const obj of topLevelObjects(safe, open + 1, close)) visit(obj, null, [], 0); + TABLE_OPENERS.lastIndex = close; } return out; } /** The component an entry names: an identifier, or the file a lazy import names. */ -function componentName(value: string): string | null { +function componentOf(value: string): VueLayout | null { const lazy = /\bimport\s*\(\s*['"`]([^'"`]+)['"`]/.exec(value); - if (lazy) return (lazy[1]!.split('/').pop() ?? '').replace(/\.\w+$/, '') || null; + if (lazy) { + const tail = (lazy[1]!.split('/').pop() ?? '').replace(/\.\w+$/, ''); + return tail ? { component: tail, spec: lazy[1]! } : null; + } const ident = /^\s*([A-Z][A-Za-z0-9_]*)\s*$/.exec(value); - return ident?.[1] ?? null; + return ident ? { component: ident[1]!, spec: null } : null; } function languageForFile(filePath: string): Language { @@ -175,7 +238,7 @@ function isVueConfigRoute(node: Node): boolean { function isNuxtPage(node: Node): boolean { return ( node.language === 'vue' && - node.filePath.includes('/pages/') && + `/${node.filePath}`.includes('/pages/') && node.id === `route:${node.filePath}:${node.name}:1` ); } @@ -212,7 +275,7 @@ export function vueRouteTable(context: ResolutionContext): VueRouteTable { if (!content) continue; const byName = tableAt(group.root).byName; const byPath = new Map(group.nodes.map((n) => [n.name, n])); - for (const entry of parseVueRoutes(content)) { + for (const entry of parseVueRoutes(content, filePath)) { if (!entry.name) continue; const node = byPath.get(entry.path); if (node && !byName.has(entry.name)) byName.set(entry.name, node); @@ -228,13 +291,13 @@ export function vueRouteTable(context: ResolutionContext): VueRouteTable { // ============================================================================= /** - * `router.push` / `.replace` (the Composition API), `$router.push` / - * `.replace` (the Options API and templates), and Nuxt's `navigateTo`. + * `router.push` / `.replace` (the Composition API), `this.$router.push` / + * `$router.push` (the Options API and templates), and Nuxt's `navigateTo`. * * As everywhere else, `push` and `replace` need a receiver that names a * router: an unqualified `push` is an array's. */ -const NAV_CALL = /^\$?router\.(?:push|replace)$|^navigateTo$/; +const NAV_CALL = /^(?:this\.)?\$?router\.(?:push|replace)$|^navigateTo$/; /** The verb a navigation call name stands for, or null. */ export function vueNavVerb(name: string): string | null { @@ -282,6 +345,56 @@ function vueComponentNamed(name: string, fromFile: string, context: ResolutionCo return near.length === 1 ? near[0]! : null; } +/** `import:@/views/Login#Login` for a lazy component, the bare name for an identifier. */ +function componentRefName(component: string, spec: string | null): string { + return spec === null ? component : `import:${spec}#${component}`; +} + +/** + * The component a route's reference names. A lazy import names a FILE, and + * the component is that file's: vue-element-admin's views are all + * `…/index.vue`, so the name alone (`index`) fits dozens. The import is + * resolved as the router file would resolve it, or — through an alias the + * resolver doesn't know, like vben's `#/views/…` — by the one file in the + * same app whose path ends the way the specifier does. + */ +function routeComponent(encoded: string, fromFile: string, context: ResolutionContext): Node | null { + const lazy = /^import:(.+)#([^#]+)$/.exec(encoded); + // `component: Layout` — the file's own import of it says which file that is + // (`import Layout from '@/layout'`, a `layout/index.vue`). + const spec = lazy ? lazy[1]! : (context.getImportMappings(fromFile, languageForFile(fromFile)).find((m) => m.localName === encoded)?.source ?? null); + const name = lazy ? lazy[2]! : encoded; + if (spec !== null) { + const file = resolveImportPath(spec, fromFile, 'vue', context) ?? fileBySuffix(spec, fromFile, context); + const component = file ? componentInFile(file, context) : null; + if (component) return component; + } + return name === 'index' ? null : vueComponentNamed(name, fromFile, context); +} + +function componentInFile(file: string, context: ResolutionContext): Node | null { + const nodes = context.getNodesInFile(file); + return nodes.find((n) => n.kind === 'component') ?? nodes.find((n) => n.kind === 'function' && /^[A-Z]/.test(n.name)) ?? null; +} + +const COMPONENT_EXTENSIONS = ['', '.vue', '.tsx', '.jsx', '.ts', '.js', '/index.vue', '/index.tsx', '/index.ts', '/index.js']; + +/** The one file under `fromFile`'s app whose path ends like `spec` minus its alias (`#/`, `@/`, `~/`). */ +function fileBySuffix(spec: string, fromFile: string, context: ResolutionContext): string | null { + const rest = spec.replace(/^(?:[@#~$][\w-]*\/|\.{1,2}\/)+/, ''); + if (rest === spec || rest.length === 0) return null; + const root = appRootFor(fromFile); + const hits = new Set(); + for (const file of context.getAllFiles()) { + if (!file.startsWith(root)) continue; + for (const ext of COMPONENT_EXTENSIONS) { + const tail = rest + ext; + if (file === tail || file.endsWith('/' + tail)) hits.add(file); + } + } + return hits.size === 1 ? [...hits][0]! : null; +} + // ============================================================================= // The resolver // ============================================================================= @@ -289,17 +402,18 @@ function vueComponentNamed(name: string, fromFile: string, context: ResolutionCo export const vueRouterResolver: FrameworkResolver = { name: 'vue-router', languages: [...ROUTE_LANGUAGES], + appDependencies: ['vue-router', 'nuxt', 'nuxt3'], detect(context: ResolutionContext): boolean { return dependsOn(context, 'vue-router', 'nuxt', 'nuxt3'); }, claimsReference(name: string): boolean { - return NAV_CALL.test(name); + return NAV_CALL.test(name) || name.startsWith('import:') || name.startsWith('layout:'); }, extract(filePath: string, content: string): FrameworkExtractionResult { - const entries = parseVueRoutes(content); + const entries = parseVueRoutes(content, filePath); if (entries.length === 0) return { nodes: [], references: [] }; const language = languageForFile(filePath); const now = Date.now(); @@ -329,7 +443,7 @@ export const vueRouterResolver: FrameworkResolver = { // a `calls` edge to a component as the page a screen renders. references.push({ fromNodeId: node.id, - referenceName: entry.component, + referenceName: componentRefName(entry.component, entry.spec), referenceKind: 'calls', line: entry.line, column: 0, @@ -338,11 +452,30 @@ export const vueRouterResolver: FrameworkResolver = { candidates: [entry.component], }); } + // The layouts around it: what they render — a sidebar, a navbar — is on + // this screen too. + for (const layout of entry.layouts) { + references.push({ + fromNodeId: node.id, + referenceName: `layout:${componentRefName(layout.component, layout.spec)}`, + referenceKind: 'references', + line: entry.line, + column: 0, + filePath, + language, + }); + } } return { nodes, references }; }, resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + if (ref.referenceKind === 'references' && isVueRouteRef(ref) && ref.referenceName.startsWith('layout:')) { + const layout = routeComponent(ref.referenceName.slice('layout:'.length), ref.filePath, context); + return layout + ? { original: ref, targetNodeId: layout.id, confidence: 0.95, resolvedBy: 'framework', metadata: { layout: true } } + : null; + } if (ref.referenceKind !== 'calls') return null; // A route naming the component it renders — this resolver's own reference, @@ -350,7 +483,7 @@ export const vueRouterResolver: FrameworkResolver = { // `Login` view AND a `login` action in a store, and only one of them is // the screen. if (isVueRouteRef(ref)) { - const component = vueComponentNamed(ref.referenceName, ref.filePath, context); + const component = routeComponent(ref.referenceName, ref.filePath, context); return component ? { original: ref, targetNodeId: component.id, confidence: 0.95, resolvedBy: 'framework' } : null; diff --git a/src/resolution/frameworks/vue.ts b/src/resolution/frameworks/vue.ts index c830885be..03e50509a 100644 --- a/src/resolution/frameworks/vue.ts +++ b/src/resolution/frameworks/vue.ts @@ -1,12 +1,13 @@ /** * Vue / Nuxt Framework Resolver * - * Handles Vue component references, compiler macros (defineProps, etc.), - * Nuxt auto-imports, and Nuxt file-based routing patterns. + * Handles Vue component references, compiler macros (defineProps, etc.) and + * Nuxt auto-imports; `nuxtResolver` reads Nuxt's file-based routes. */ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { dependsOn } from './package-deps'; /** * Vue 3 compiler macros — compiler-provided, not user code @@ -186,13 +187,34 @@ export const vueResolver: FrameworkResolver = { return null; }, +}; + +/** + * Nuxt's file-based routes: `pages/` screens, `server/api/` endpoints and + * `middleware/`. Its own resolver, detected only in a Nuxt app: a plain Vue + * app keeps its views in a `pages/` folder just as often (halo's console + * does), and those are components a router config names, not addresses. + */ +export const nuxtResolver: FrameworkResolver = { + name: 'nuxt', + appDependencies: ['nuxt', 'nuxt3', '@nuxt/kit'], + + detect(context: ResolutionContext): boolean { + if (dependsOn(context, 'nuxt', 'nuxt3', '@nuxt/kit')) return true; + return context.getAllFiles().some((f) => /(?:^|\/)nuxt\.config\.(?:[cm]?[jt]s)$/.test(f)); + }, + + resolve(): ResolvedRef | null { + return null; + }, extract(filePath: string, _content: string) { const nodes: Node[] = []; const now = Date.now(); - // Normalize to forward slashes - const normalized = filePath.replace(/\\/g, '/'); + // Forward slashes, and a leading `/` so an app at the repository root + // (`pages/index.vue`) is found by the same `/pages/` search as a nested one. + const normalized = '/' + filePath.replace(/\\/g, '/'); // Detect Nuxt page routes (pages/ directory) const pagesIndex = normalized.indexOf('/pages/'); @@ -221,8 +243,10 @@ export const vueResolver: FrameworkResolver = { const afterApi = normalized.substring(apiIndex + '/server/api/'.length); const routeName = afterApi .replace(/\.[^/.]+$/, '') // Remove extension - .replace(/\/index$/, ''); // index -> parent path - const apiRoute = '/api/' + routeName; + .replace(/(?:^|\/)index$/, '') // index -> parent path + .replace(/\[\.\.\.([^\]]+)\]/g, '*$1') // [...slug] -> *slug + .replace(/\[([^\]]+)\]/g, ':$1'); // [id] -> :id, as a page's params are + const apiRoute = routeName === '' ? '/api' : '/api/' + routeName; nodes.push({ id: `route:${filePath}:${apiRoute}:1`, @@ -317,7 +341,7 @@ function filePathToNuxtRoute(normalized: string, afterPagesStart: number): strin const withoutExt = afterPages.replace(/\.vue$/, ''); // Remove /index suffix (index.vue -> parent route) - const withoutIndex = withoutExt.replace(/\/index$/, ''); + const withoutIndex = withoutExt.replace(/(?:^|\/)index$/, ''); // Convert Nuxt param syntax [param] to :param let route = '/' + withoutIndex diff --git a/src/ui-server/api/screens.ts b/src/ui-server/api/screens.ts index 94b70761a..8a79ff2d8 100644 --- a/src/ui-server/api/screens.ts +++ b/src/ui-server/api/screens.ts @@ -218,7 +218,7 @@ function writtenHere(edge: Edge, holder: Node): boolean { * Entry points, which is the list of what a request or a user can arrive at. */ function isScreenRoute(route: Node): boolean { - return route.name.startsWith('/') && !route.filePath.includes('/server/api/'); + return route.name.startsWith('/') && !`/${route.filePath}`.includes('/server/api/'); } export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise {