Skip to content

feat: add children route names generic to generated RouteNamedMap and adjust docs and tests #602

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jun 4, 2025
Merged
20 changes: 16 additions & 4 deletions docs/.vitepress/twoslash-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,37 @@ declare module 'vue-router/auto-routes' {
ParamValueZeroOrOne,
} from 'vue-router'

/**
* Route name map generated by unplugin-vue-router
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>
'/': RouteRecordInfo<
'/',
'/',
Record<never, never>,
Record<never, never>,
never
>
'/users': RouteRecordInfo<
'/users',
'/users',
Record<never, never>,
Record<never, never>
Record<never, never>,
never
>
'/users/[id]': RouteRecordInfo<
'/users/[id]',
'/users/:id',
{ id: ParamValue<true> },
{ id: ParamValue<false> }
{ id: ParamValue<false> },
'/users/[id]/edit'
>
'/users/[id]/edit': RouteRecordInfo<
'/users/[id]/edit',
'/users/:id/edit',
{ id: ParamValue<true> },
{ id: ParamValue<false> }
{ id: ParamValue<false> },
never
>
}
}
Expand Down
17 changes: 13 additions & 4 deletions docs/.vitepress/twoslash/code/typed-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,33 @@ declare module 'vue-router/auto-routes' {
} from 'vue-router'

export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>
'/': RouteRecordInfo<
'/',
'/',
Record<never, never>,
Record<never, never>,
never
>
'/users': RouteRecordInfo<
'/users',
'/users',
Record<never, never>,
Record<never, never>
Record<never, never>,
never
>
'/users/[id]': RouteRecordInfo<
'/users/[id]',
'/users/:id',
{ id: ParamValue<true> },
{ id: ParamValue<false> }
{ id: ParamValue<false> },
'/users/[id]/edit'
>
'/users/[id]/edit': RouteRecordInfo<
'/users/[id]/edit',
'/users/:id/edit',
{ id: ParamValue<true> },
{ id: ParamValue<false> }
{ id: ParamValue<false> },
never
>
}
}
37 changes: 27 additions & 10 deletions docs/guide/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,17 @@ declare module 'vue-router/auto-routes' {
// these are the raw param types (accept numbers, strings, booleans, etc)
{ path: ParamValue<true> },
// these are the normalized params as found in useRoute().params
{ path: ParamValue<false> }
{ path: ParamValue<false> },
// this is a union of all children route names
// if the route does not have nested routes, pass `never` or omit this generic entirely
'custom-dynamic-child-name'
>
'custom-dynamic-child-name': RouteRecordInfo<
'custom-dynamic-child-name',
'/added-during-runtime/[...path]/child',
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should improve this example:

  • keep the original [...path] route
  • Add a new one that makes more sense in a nested fashion like /users/[id]/edit
    • Omit /users
    • /users/[id] should have edit as a child

{ path: ParamValue<true> },
{ path: ParamValue<false> },
never
>
}
}
Expand All @@ -76,13 +86,20 @@ import { useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
// ---cut-end---
// @errors: 2322 2339
// @moduleResolution: bundler
// these are all valid
const userWithIdCasted = useRoute() as RouteLocationNormalizedLoaded<'/users/[id]'>
userWithIdCasted.params.id
const userWithIdTypeParam = useRoute<'/users/[id]'>()
userWithIdTypeParam.params.id
// 👇 this one is the easiest to write because it autocompletes
const userWithIdParam = useRoute('/users/[id]')
userWithIdParam.params
// ^?
// These are all valid ways to get a typed route and return the
// provided route's and any of its child routes' typings.
// Note that `/users/[id]/edit` is a child route
// of `/users/[id]` in this example.

// Not recommended, since this leaves out any child routes' typings.
const userRouteWithIdCasted =
useRoute() as RouteLocationNormalizedLoaded<'/users/[id]'>
userRouteWithIdCasted.params.id
// Better way, but no autocompletion.
const userRouteWithIdTypeParam = useRoute<'/users/[id]'>()
userRouteWithIdTypeParam.params.id
// 👇 This one is the easiest to write because it autocompletes.
const userRouteWithIdParam = useRoute('/users/[id]')
userRouteWithIdParam.name
// ^?
```
34 changes: 21 additions & 13 deletions docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,31 +102,39 @@ After adding this plugin, **start the dev server** (usually `npm run dev`) **to
// It's recommended to commit this file.
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.

import type {
RouteRecordInfo,
ParamValue,
ParamValueOneOrMore,
ParamValueZeroOrMore,
ParamValueZeroOrOne,
} from 'vue-router'

declare module 'vue-router/auto-routes' {
import type {
RouteRecordInfo,
ParamValue,
ParamValueOneOrMore,
ParamValueZeroOrMore,
ParamValueZeroOrOne,
} from 'vue-router'

/**
* Route name map generated by unplugin-vue-router
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>
'/': RouteRecordInfo<
'/',
'/',
Record<never, never>,
Record<never, never>,
never
>
'/about': RouteRecordInfo<
'/about',
'/about',
Record<never, never>,
Record<never, never>
Record<never, never>,
never
>
'/users/[id]': RouteRecordInfo<
'/[id]',
'/:id',
'/users/[id]',
'/users/:id',
{ id: ParamValue<true> },
{ id: ParamValue<false> }
{ id: ParamValue<false> },
never
>
}
}
Expand Down
12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
"vitest": "vitest --typecheck",
"docs": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
"lint": "prettier -c '{src,test,e2e,examples,playground}/**/*.{ts,vue}'",
"play": "npm -C playground run dev",
"play:build": "npm -C playground run build",
Expand Down Expand Up @@ -148,7 +149,7 @@
"yaml": "^2.8.0"
},
"peerDependencies": {
"vue-router": "^4.4.0"
"vue-router": "^4.5.1"
},
"peerDependenciesMeta": {
"vue-router": {
Expand Down Expand Up @@ -199,5 +200,14 @@
"vuefire": "^3.2.1",
"webpack": "^5.99.9",
"yorkie": "^2.0.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"esbuild",
"protobufjs",
"vue-demi",
"yorkie"
]
}
}
10 changes: 9 additions & 1 deletion playground/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,15 @@ declare module 'vue-router/auto-routes' {
'custom-dynamic-name',
'/added-during-runtime/[...path]',
{ path: ParamValue<true> },
{ path: ParamValue<false> }
{ path: ParamValue<false> },
'custom-dynamic-child-name'
>
'custom-dynamic-child-name': RouteRecordInfo<
'custom-dynamic-child-name',
'/added-during-runtime/[...path]/child',
{ path: ParamValue<true> },
{ path: ParamValue<false> },
never
>
}
}
4 changes: 2 additions & 2 deletions playground/typed-router.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ declare module 'vue-router/auto-routes' {
* Route name map generated by unplugin-vue-router
*/
export interface RouteNamedMap {
'/(test-group)': RouteRecordInfo<'/(test-group)', '/', Record<never, never>, Record<never, never>>,
'/(test-group)': RouteRecordInfo<'/(test-group)', '/', Record<never, never>, Record<never, never>, '/(test-group)/test-group-child'>,
'/(test-group)/test-group-child': RouteRecordInfo<'/(test-group)/test-group-child', '/test-group-child', Record<never, never>, Record<never, never>>,
'home': RouteRecordInfo<'home', '/', Record<never, never>, Record<never, never>>,
'/[name]': RouteRecordInfo<'/[name]', '/:name', { name: ParamValue<true> }, { name: ParamValue<false> }>,
Expand All @@ -27,7 +27,7 @@ declare module 'vue-router/auto-routes' {
'/@[profileId]': RouteRecordInfo<'/@[profileId]', '/@:profileId', { profileId: ParamValue<true> }, { profileId: ParamValue<false> }>,
'/about': RouteRecordInfo<'/about', '/about', Record<never, never>, Record<never, never>>,
'/about.extra.nested': RouteRecordInfo<'/about.extra.nested', '/about/extra/nested', Record<never, never>, Record<never, never>>,
'/articles': RouteRecordInfo<'/articles', '/articles', Record<never, never>, Record<never, never>>,
'/articles': RouteRecordInfo<'/articles', '/articles', Record<never, never>, Record<never, never>, '/articles/' | '/articles/[id]' | '/articles/[id]+'>,
'/articles/': RouteRecordInfo<'/articles/', '/articles', Record<never, never>, Record<never, never>>,
'/articles/[id]': RouteRecordInfo<'/articles/[id]', '/articles/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/articles/[id]+': RouteRecordInfo<'/articles/[id]+', '/articles/:id+', { id: ParamValueOneOrMore<true> }, { id: ParamValueOneOrMore<false> }>,
Expand Down
93 changes: 78 additions & 15 deletions src/codegen/generateRouteMap.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,9 @@ describe('generateRouteNamedMap', () => {
tree.insert('a/[id]/index', 'a/[id]/index.vue')
expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(`
"export interface RouteNamedMap {
'/a': RouteRecordInfo<'/a', '/a', Record<never, never>, Record<never, never>>,
'/a': RouteRecordInfo<'/a', '/a', Record<never, never>, Record<never, never>, '/a/' | '/a/[id]/' | '/a/[id]'>,
'/a/': RouteRecordInfo<'/a/', '/a', Record<never, never>, Record<never, never>>,
'/a/[id]': RouteRecordInfo<'/a/[id]', '/a/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/a/[id]': RouteRecordInfo<'/a/[id]', '/a/:id', { id: ParamValue<true> }, { id: ParamValue<false> }, '/a/[id]/'>,
'/a/[id]/': RouteRecordInfo<'/a/[id]/', '/a/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
}"
`)
Expand All @@ -172,12 +172,75 @@ describe('generateRouteNamedMap', () => {
expect(child.fullPath).toBe('/child')
expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(`
"export interface RouteNamedMap {
'/parent': RouteRecordInfo<'/parent', '/', Record<never, never>, Record<never, never>>,
'/parent': RouteRecordInfo<'/parent', '/', Record<never, never>, Record<never, never>, '/parent/child'>,
'/parent/child': RouteRecordInfo<'/parent/child', '/child', Record<never, never>, Record<never, never>>,
}"
`)
})

it('adds children route names', () => {
const tree = new PrefixTree(DEFAULT_OPTIONS)
tree.insert('parent', 'parent.vue')
tree.insert('parent/child', 'parent/child.vue')
tree.insert('parent/child/subchild', 'parent/child/subchild.vue')
tree.insert(
'parent/child/subchild/grandchild',
'parent/child/subchild/grandchild.vue'
)
tree.insert('parent/other-child', 'parent/other-child.vue')
expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(`
"export interface RouteNamedMap {
'/parent': RouteRecordInfo<'/parent', '/parent', Record<never, never>, Record<never, never>, '/parent/child' | '/parent/child/subchild/grandchild' | '/parent/other-child' | '/parent/child/subchild'>,
'/parent/child': RouteRecordInfo<'/parent/child', '/parent/child', Record<never, never>, Record<never, never>, '/parent/child/subchild/grandchild' | '/parent/child/subchild'>,
'/parent/child/subchild': RouteRecordInfo<'/parent/child/subchild', '/parent/child/subchild', Record<never, never>, Record<never, never>, '/parent/child/subchild/grandchild'>,
'/parent/child/subchild/grandchild': RouteRecordInfo<'/parent/child/subchild/grandchild', '/parent/child/subchild/grandchild', Record<never, never>, Record<never, never>>,
'/parent/other-child': RouteRecordInfo<'/parent/other-child', '/parent/other-child', Record<never, never>, Record<never, never>>,
}"
`)
})

it('skips children without components', () => {
const tree = new PrefixTree(DEFAULT_OPTIONS)
tree.insert('parent', 'parent.vue')
tree.insert('parent/child/a/b/c', 'parent/child/a/b/c.vue')
expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(`
"export interface RouteNamedMap {
'/parent': RouteRecordInfo<'/parent', '/parent', Record<never, never>, Record<never, never>, '/parent/child/a/b/c'>,
'/parent/child/a/b/c': RouteRecordInfo<'/parent/child/a/b/c', '/parent/child/a/b/c', Record<never, never>, Record<never, never>>,
}"
`)
})

it('skips the children in the index route', () => {
const tree = new PrefixTree(DEFAULT_OPTIONS)
tree.insert('parent/index', 'parent/index.vue')
tree.insert('parent/child', 'parent/child.vue')
expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(`
"export interface RouteNamedMap {
'/parent/': RouteRecordInfo<'/parent/', '/parent', Record<never, never>, Record<never, never>>,
'/parent/child': RouteRecordInfo<'/parent/child', '/parent/child', Record<never, never>, Record<never, never>>,
}"
`)
})

it('does not mix children of an adjacent route', () => {
const tree = new PrefixTree(DEFAULT_OPTIONS)
tree.insert('parent/index', 'parent/index.vue')
tree.insert('parent/a/index', 'parent/a/index.vue')
tree.insert('parent/a/b', 'parent/a/b.vue')
tree.insert('parent/a/b/index', 'parent/a/b/index.vue')
tree.insert('parent/a/b/c', 'parent/a/b/c.vue')
expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(`
"export interface RouteNamedMap {
'/parent/': RouteRecordInfo<'/parent/', '/parent', Record<never, never>, Record<never, never>>,
'/parent/a/': RouteRecordInfo<'/parent/a/', '/parent/a', Record<never, never>, Record<never, never>>,
'/parent/a/b': RouteRecordInfo<'/parent/a/b', '/parent/a/b', Record<never, never>, Record<never, never>, '/parent/a/b/' | '/parent/a/b/c'>,
'/parent/a/b/': RouteRecordInfo<'/parent/a/b/', '/parent/a/b', Record<never, never>, Record<never, never>>,
'/parent/a/b/c': RouteRecordInfo<'/parent/a/b/c', '/parent/a/b/c', Record<never, never>, Record<never, never>>,
}"
`)
})

it('adds params from the path option', () => {
const tree = new PrefixTree(
resolveOptions({
Expand All @@ -204,10 +267,10 @@ describe('generateRouteNamedMap', () => {
tree.insert('(group)/a', 'a.vue')

expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(`
"export interface RouteNamedMap {
'/(group)/a': RouteRecordInfo<'/(group)/a', '/a', Record<never, never>, Record<never, never>>,
}"
`)
"export interface RouteNamedMap {
'/(group)/a': RouteRecordInfo<'/(group)/a', '/a', Record<never, never>, Record<never, never>>,
}"
`)
})

it('ignores nested folder names in parentheses', () => {
Expand All @@ -216,10 +279,10 @@ describe('generateRouteNamedMap', () => {
tree.insert('(group)/(subgroup)/c', 'c.vue')

expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(`
"export interface RouteNamedMap {
'/(group)/(subgroup)/c': RouteRecordInfo<'/(group)/(subgroup)/c', '/c', Record<never, never>, Record<never, never>>,
}"
`)
"export interface RouteNamedMap {
'/(group)/(subgroup)/c': RouteRecordInfo<'/(group)/(subgroup)/c', '/c', Record<never, never>, Record<never, never>>,
}"
`)
})

it('treats files named with parentheses as index inside static folder', () => {
Expand All @@ -228,10 +291,10 @@ describe('generateRouteNamedMap', () => {
tree.insert('folder/(group)', 'folder/(group).vue')

expect(formatExports(generateRouteNamedMap(tree))).toMatchInlineSnapshot(`
"export interface RouteNamedMap {
'/folder/(group)': RouteRecordInfo<'/folder/(group)', '/folder', Record<never, never>, Record<never, never>>,
}"
`)
"export interface RouteNamedMap {
'/folder/(group)': RouteRecordInfo<'/folder/(group)', '/folder', Record<never, never>, Record<never, never>>,
}"
`)
})
})

Expand Down
Loading