fix(vue-router): admin-template route tables, children and layouts; Nuxt routes only in Nuxt (#2130)

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 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 06:46:26 +00:00
committed by GitHub
parent 92e14eda40
commit 54a1698caa
8 changed files with 386 additions and 62 deletions
+1
View File
@@ -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.
+1 -1
View File
@@ -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, `<Link href>` and internal `<a href>` |
| **React Router** | `<Route path component/element>` (v5 and v6) and `createBrowserRouter([{ path, element }])` | `history.push` / `replace`, `useNavigate`'s `navigate`, a loader's `redirect`, `<Link to>` / `<NavLink to>` / `<Navigate to>` / react-router-bootstrap's `<LinkContainer to>` |
| **TanStack Router** | `createFileRoute('/posts/$postId')` (file-based) and `createRoute({ path, getParentRoute })` composed up its parent chain (code-based); `_pathless` segments, `(group)` folders, `__root` and `<Outlet/>` layouts are not addresses | `navigate({ to })`, a thrown `redirect({ to })`, `<Link to>` / `<Navigate 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`, `<router-link>` / `<RouterLink>` / `<NuxtLink>` — **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`, `<router-link>` / `<RouterLink>` / `<NuxtLink>` — **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 `<a href>` 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 (`<app-foo>`) are linked to the component that renders them |
+162 -2
View File
@@ -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<string, string>): Promise<CodeGraph> {
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) => `<template><div>${name}</div></template>\n<script>\nexport default { name: '${name}' }\n</script>\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': `<template><button @click="go">in</button></template>
<script>
export default {
name: 'Login',
methods: {
go() { this.$router.push({ name: 'Dashboard' }) }
}
}
</script>
`,
'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': `<template><div>analytics</div></template>
<script setup lang="ts">
const title = 'Analytics';
</script>
`,
});
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': '<template><div/></template>\n',
'pages/users/[id].vue': '<template><div/></template>\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': '<template><div/></template>\n',
});
try {
expect(routeNames(plain)).toEqual([]);
} finally {
plain.close();
}
});
});
+5 -1
View File
@@ -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.
+4 -2
View File
@@ -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';
+181 -48
View File
@@ -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<string>();
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<number>();
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<string>();
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;
+31 -7
View File
@@ -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
+1 -1
View File
@@ -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<WireScreensPayload> {