-<script lang="ts" setup></script>
+<script lang="ts" setup>
+import { useRoute } from 'vue-router'
+
+definePage({
+ params: {
+ path: {
+ name: 'date',
+ },
+ },
+})
+
+const route = useRoute()
+route.params.name
+</script>
<template>
<h1>Named param</h1>
-<script lang="ts" setup></script>
+<script lang="ts" setup>
+import { useRoute } from 'vue-router'
+
+definePage({
+ params: {
+ path: {
+ // name: 'date',
+ },
+ },
+})
+
+const route = useRoute()
+route.params.name
+</script>
<template>
<h2>Nested [name] page</h2>
| 'semver'
| 'version-range'
RouteNamedMap: import('vue-router/auto-routes').RouteNamedMap
+ _RouteFileInfoMap: import('vue-router/auto-routes')._RouteFileInfoMap
}
}
'/u[name]': RouteRecordInfo<
'/u[name]',
'/u:name',
- { name: string },
- { name: string },
+ { name: Exclude<Param_date, unknown[] | null> },
+ { name: Exclude<Param_date, unknown[] | null> },
| '/u[name]/24'
| '/u[name]/[userId=int]'
>,
'/u[name]/[userId=int]': RouteRecordInfo<
'/u[name]/[userId=int]',
'/u:name/:userId',
- { name: string, userId: number },
- { name: string, userId: number },
+ { name: Exclude<Param_date, unknown[] | null>, userId: number },
+ { name: Exclude<Param_date, unknown[] | null>, userId: number },
| never
>,
'/u[name]/24': RouteRecordInfo<
'/u[name]/24',
'/u:name/24',
- { name: string },
- { name: string },
+ { name: Exclude<Param_date, unknown[] | null> },
+ { name: Exclude<Param_date, unknown[] | null> },
| never
>,
'/users/[userId=int]': RouteRecordInfo<
| '/(home)'
views:
| never
+ pathParamNames:
+ | never
}
'src/pages/(packages)/_parent.vue': {
routes:
| '/(packages)/package/[[org=npm-org]]/[pkgName]/[pkgVersion=semver]'
views:
| 'default'
+ pathParamNames:
+ | never
}
'src/pages/(packages)/package/[[org=npm-org]]/[pkgName]/[pkgVersion=semver].vue': {
routes:
| '/(packages)/package/[[org=npm-org]]/[pkgName]/[pkgVersion=semver]'
views:
| never
+ pathParamNames:
+ | 'org'
+ | 'pkgName'
+ | 'pkgVersion'
}
'src/pages/(packages)/package-old/[[org]]/[pkgName]/[pkgVersion].vue': {
routes:
| '/(packages)/package-old/[[org]]/[pkgName]/[pkgVersion]'
views:
| never
+ pathParamNames:
+ | 'org'
+ | 'pkgName'
+ | 'pkgVersion'
}
'src/pages/(packages)/package-range/[[org=npm-org]]/[pkgName]/[pkgVersion=version-range].vue': {
routes:
| '/(packages)/package-range/[[org=npm-org]]/[pkgName]/[pkgVersion=version-range]'
views:
| never
+ pathParamNames:
+ | 'org'
+ | 'pkgName'
+ | 'pkgVersion'
}
'src/pages/(packages)/package-zod/[[org=npm-org]]/[pkgName]/[pkgVersion].vue': {
routes:
| '/(packages)/package-zod/[[org=npm-org]]/[pkgName]/[pkgVersion]'
views:
| never
+ pathParamNames:
+ | 'org'
+ | 'pkgName'
+ | 'pkgVersion'
}
'src/pages/[...path].vue': {
routes:
| 'not-found'
views:
| never
+ pathParamNames:
+ | 'path'
}
'src/pages/a.[b].c.[d].vue': {
routes:
| '/a.[b].c.[d]'
views:
| never
+ pathParamNames:
+ | 'b'
+ | 'd'
}
'src/pages/about.vue': {
routes:
| '/about'
views:
| never
+ pathParamNames:
+ | never
}
'src/pages/b.vue': {
routes:
| '/b'
views:
| never
+ pathParamNames:
+ | never
}
'src/pages/blog/[slug]+.vue': {
routes:
| '/blog/[slug]+'
views:
| never
+ pathParamNames:
+ | 'slug'
}
'src/pages/blog/[[slugOptional]]+.vue': {
routes:
| '/blog/[[slugOptional]]+'
views:
| never
+ pathParamNames:
+ | 'slugOptional'
}
'src/pages/blog/info/(info).vue': {
routes:
| '/blog/info/(info)'
views:
| never
+ pathParamNames:
+ | never
}
'src/pages/blog/info/[[section]].vue': {
routes:
| '/blog/info/[[section]]'
views:
| never
+ pathParamNames:
+ | 'section'
}
'src/pages/emoji-🤡.vue': {
routes:
| '/emoji-🤡'
views:
| never
+ pathParamNames:
+ | never
}
'src/pages/events/[when=date].vue': {
routes:
| '/events/[when=date]'
views:
| never
+ pathParamNames:
+ | 'when'
}
'src/pages/events/repeat/[when=date]+.vue': {
routes:
| '/events/repeat/[when=date]+'
views:
| never
+ pathParamNames:
+ | 'when'
}
'src/pages/it\'s-fine/(lol).vue': {
routes:
| '/it\'s-fine/(lol)'
views:
| never
+ pathParamNames:
+ | never
}
'src/pages/months/valibot-[month=month-valibot].vue': {
routes:
| '/months/valibot-[month=month-valibot]'
views:
| never
+ pathParamNames:
+ | 'month'
}
'src/pages/months/zod-[month=month-zod].vue': {
routes:
| '/months/zod-[month=month-zod]'
views:
| never
+ pathParamNames:
+ | 'month'
}
'src/pages/multi.[a].[b].vue': {
routes:
| '/multi.[a].[b]'
views:
| never
+ pathParamNames:
+ | 'a'
+ | 'b'
}
'src/pages/nested/_parent.vue': {
routes:
| '/nested/other'
views:
| 'default'
+ pathParamNames:
+ | never
}
'src/pages/nested/index.vue': {
routes:
| '/nested/'
views:
| never
+ pathParamNames:
+ | never
}
'src/pages/nested/other.vue': {
routes:
| '/nested/other'
views:
| never
+ pathParamNames:
+ | never
}
'src/pages/opt.[[num=int]].vue': {
routes:
| '/opt.[[num=int]]'
views:
| never
+ pathParamNames:
+ | 'num'
}
'src/pages/tests/[[optional]]/end.vue': {
routes:
| '/tests/[[optional]]/end'
views:
| never
+ pathParamNames:
+ | 'optional'
}
'src/pages/tests/users/[username]/(user-home)/(user-home).vue': {
routes:
| '/tests/users/[username]/(user-home)/(user-home)'
views:
| never
+ pathParamNames:
+ | 'username'
}
'src/pages/tests/users/[username]/(user)/profile.vue': {
routes:
| '/tests/users/[username]/(user)/profile'
views:
| never
+ pathParamNames:
+ | 'username'
}
'src/pages/u[name].vue': {
routes:
| '/u[name]/[userId=int]'
views:
| 'default'
+ pathParamNames:
+ | 'name'
}
'src/pages/u[name]/[userId=int].vue': {
routes:
| '/u[name]/[userId=int]'
views:
| never
+ pathParamNames:
+ | 'name'
+ | 'userId'
}
'src/pages/u[name]/24.vue': {
routes:
| '/u[name]/24'
views:
| never
+ pathParamNames:
+ | 'name'
}
'src/pages/users/[userId=int].vue': {
routes:
| '/users/[userId=int]'
views:
| never
+ pathParamNames:
+ | 'userId'
}
'src/pages/users/sub-[first]-[second].vue': {
routes:
| '/users/sub-[first]-[second]'
views:
| never
+ pathParamNames:
+ | 'first'
+ | 'second'
}
'src/pages/with-layout/(home).vue': {
routes:
| '/with-layout/(home)'
views:
| never
+ pathParamNames:
+ | never
}
'src/pages/with-layout/+layout.vue': {
routes:
| '/with-layout/+layout'
views:
| never
+ pathParamNames:
+ | never
}
'src/pages/with-layout/other.vue': {
routes:
| '/with-layout/other'
views:
| never
+ pathParamNames:
+ | never
}
}
type ParamParserType,
type ParamParserType_Native,
type DefinePageQueryParamOptions,
+ type PathParamNamesForFilePath as _PathParamNamesForFilePath,
} from './runtime'
// Data loaders exports
* Helper to define page properties with file-based routing.
* **Doesn't do anything**, used for types only.
*
+ * The `FilePath` type parameter is injected by the `sfc-typed-router` Volar
+ * plugin so that `params.path` keys are restricted to the file's actual path
+ * params. When omitted, `params.path` falls back to a loose record.
+ *
* @param route - route information to be added to this page
*
* @internal
*/
-export const definePage = (route: DefinePage) => route
+export function definePage<FilePath extends string = string>(
+ route: DefinePage<FilePath>
+): DefinePage<FilePath> {
+ return route
+}
+
+/**
+ * Resolves the union of valid path-param names for a given file path. Falls
+ * back to `string` when no entry is augmented (default Volar-less usage).
+ *
+ * Wired via the `_RouteFileInfoMap` slot in the user's augmented
+ * {@link TypesConfig} so the lookup survives the bundler that otherwise
+ * inlines an empty version of the base interface.
+ *
+ * @internal
+ */
+export type PathParamNamesForFilePath<FilePath extends string> =
+ TypesConfig extends {
+ _RouteFileInfoMap: {
+ [K in FilePath]: { pathParamNames: infer N extends string }
+ }
+ }
+ ? N
+ : string
/**
* Merges route records.
/**
* Type to define a page. Can be augmented to add custom properties.
+ *
+ * @typeParam FilePath - File path of the SFC declaring this page, used to
+ * narrow `params.path` keys to the actual path parameters of the route. When
+ * left as the default `string`, keys are unrestricted.
*/
-export interface DefinePage extends Partial<
+export interface DefinePage<FilePath extends string = string> extends Partial<
Omit<RouteRecordRaw, 'children' | 'components' | 'component' | 'name'>
> {
/**
* @experimental
*/
params?: {
- path?: Record<string, ParamParserType>
+ /**
+ * Parameters extracted from the path. Allows to setup custom parsers without changing the filename.
+ */
+ path?: { [K in PathParamNamesForFilePath<FilePath>]?: ParamParserType }
/**
* Parameters extracted from the query.
ParamParsers:
RouteNamedMap: import('vue-router/auto-routes').RouteNamedMap
+ _RouteFileInfoMap: import('vue-router/auto-routes')._RouteFileInfoMap
}
}
"expected a 'declare module \\'vue-router\\'' block"
).toBeTruthy()
expect(vueRouterBlock).toContain('RouteNamedMap:')
+ expect(vueRouterBlock).toContain('_RouteFileInfoMap:')
})
})
ParamParsers:
${customParamsTypeList.map(literal => ' '.repeat(6) + '| ' + literal).join('\n')}
RouteNamedMap: import('${routesModule}').RouteNamedMap
+ _RouteFileInfoMap: import('${routesModule}')._RouteFileInfoMap
}
}
| '/'
views:
| never
+ pathParamNames:
+ | never
}
'src/pages/a.vue': {
routes:
| '/a'
views:
| never
+ pathParamNames:
+ | never
}
'src/pages/b.vue': {
routes:
| '/b'
views:
| never
+ pathParamNames:
+ | never
}
'src/pages/c.vue': {
routes:
| '/c'
views:
| never
+ pathParamNames:
+ | never
}
}"
`)
expect(formatExports(generateRouteFileInfoMap(tree, { root: '' })))
.toMatchInlineSnapshot(`
- "export interface _RouteFileInfoMap {
- '(auth).vue': {
- routes:
- | '/(auth)'
- | '/(auth)/another'
- | '/(auth)/deposit'
- | '/(auth)/foo'
- | '/(auth)/foo/bar'
- | '/(auth)/foo/foo'
- | '/(auth)/home'
- | '/(auth)/login-another'
- | '/(auth)/settings'
- | '/(auth)/settings/edit-account'
- | '/(auth)/settings/edit-email'
- | '/(auth)/settings/edit-password'
- | '/(auth)/settings/edit-phone-number'
- | '/(auth)/settings/two-factor'
- | '/(auth)/settings/verify-phone-number'
- views:
- | 'default'
- }
- '(auth)/another/index.vue': {
- routes:
- | '/(auth)/another'
- views:
- | never
- }
- '(auth)/deposit/index.vue': {
- routes:
- | '/(auth)/deposit'
- views:
- | never
- }
- '(auth)/foo/index.vue': {
- routes:
- | '/(auth)/foo'
- | '/(auth)/foo/bar'
- | '/(auth)/foo/foo'
- views:
- | 'default'
- }
- '(auth)/foo/bar.vue': {
- routes:
- | '/(auth)/foo/bar'
- views:
- | never
- }
- '(auth)/foo/foo.vue': {
- routes:
- | '/(auth)/foo/foo'
- views:
- | never
- }
- '(auth)/home/index.vue': {
- routes:
- | '/(auth)/home'
- views:
- | never
- }
- '(auth)/login-another/index.vue': {
- routes:
- | '/(auth)/login-another'
- views:
- | never
- }
- '(auth)/settings/index.vue': {
- routes:
- | '/(auth)/settings'
- | '/(auth)/settings/edit-account'
- | '/(auth)/settings/edit-email'
- | '/(auth)/settings/edit-password'
- | '/(auth)/settings/edit-phone-number'
- | '/(auth)/settings/two-factor'
- | '/(auth)/settings/verify-phone-number'
- views:
- | 'default'
- }
- '(auth)/settings/edit-account.vue': {
- routes:
- | '/(auth)/settings/edit-account'
- views:
- | never
- }
- '(auth)/settings/edit-email.vue': {
- routes:
- | '/(auth)/settings/edit-email'
- views:
- | never
- }
- '(auth)/settings/edit-password.vue': {
- routes:
- | '/(auth)/settings/edit-password'
- views:
- | never
- }
- '(auth)/settings/edit-phone-number.vue': {
- routes:
- | '/(auth)/settings/edit-phone-number'
- views:
- | never
- }
- '(auth)/settings/two-factor.vue': {
- routes:
- | '/(auth)/settings/two-factor'
- views:
- | never
- }
- '(auth)/settings/verify-phone-number.vue': {
- routes:
- | '/(auth)/settings/verify-phone-number'
- views:
- | never
- }
- }"
- `)
+ "export interface _RouteFileInfoMap {
+ '(auth).vue': {
+ routes:
+ | '/(auth)'
+ | '/(auth)/another'
+ | '/(auth)/deposit'
+ | '/(auth)/foo'
+ | '/(auth)/foo/bar'
+ | '/(auth)/foo/foo'
+ | '/(auth)/home'
+ | '/(auth)/login-another'
+ | '/(auth)/settings'
+ | '/(auth)/settings/edit-account'
+ | '/(auth)/settings/edit-email'
+ | '/(auth)/settings/edit-password'
+ | '/(auth)/settings/edit-phone-number'
+ | '/(auth)/settings/two-factor'
+ | '/(auth)/settings/verify-phone-number'
+ views:
+ | 'default'
+ pathParamNames:
+ | never
+ }
+ '(auth)/another/index.vue': {
+ routes:
+ | '/(auth)/another'
+ views:
+ | never
+ pathParamNames:
+ | never
+ }
+ '(auth)/deposit/index.vue': {
+ routes:
+ | '/(auth)/deposit'
+ views:
+ | never
+ pathParamNames:
+ | never
+ }
+ '(auth)/foo/index.vue': {
+ routes:
+ | '/(auth)/foo'
+ | '/(auth)/foo/bar'
+ | '/(auth)/foo/foo'
+ views:
+ | 'default'
+ pathParamNames:
+ | never
+ }
+ '(auth)/foo/bar.vue': {
+ routes:
+ | '/(auth)/foo/bar'
+ views:
+ | never
+ pathParamNames:
+ | never
+ }
+ '(auth)/foo/foo.vue': {
+ routes:
+ | '/(auth)/foo/foo'
+ views:
+ | never
+ pathParamNames:
+ | never
+ }
+ '(auth)/home/index.vue': {
+ routes:
+ | '/(auth)/home'
+ views:
+ | never
+ pathParamNames:
+ | never
+ }
+ '(auth)/login-another/index.vue': {
+ routes:
+ | '/(auth)/login-another'
+ views:
+ | never
+ pathParamNames:
+ | never
+ }
+ '(auth)/settings/index.vue': {
+ routes:
+ | '/(auth)/settings'
+ | '/(auth)/settings/edit-account'
+ | '/(auth)/settings/edit-email'
+ | '/(auth)/settings/edit-password'
+ | '/(auth)/settings/edit-phone-number'
+ | '/(auth)/settings/two-factor'
+ | '/(auth)/settings/verify-phone-number'
+ views:
+ | 'default'
+ pathParamNames:
+ | never
+ }
+ '(auth)/settings/edit-account.vue': {
+ routes:
+ | '/(auth)/settings/edit-account'
+ views:
+ | never
+ pathParamNames:
+ | never
+ }
+ '(auth)/settings/edit-email.vue': {
+ routes:
+ | '/(auth)/settings/edit-email'
+ views:
+ | never
+ pathParamNames:
+ | never
+ }
+ '(auth)/settings/edit-password.vue': {
+ routes:
+ | '/(auth)/settings/edit-password'
+ views:
+ | never
+ pathParamNames:
+ | never
+ }
+ '(auth)/settings/edit-phone-number.vue': {
+ routes:
+ | '/(auth)/settings/edit-phone-number'
+ views:
+ | never
+ pathParamNames:
+ | never
+ }
+ '(auth)/settings/two-factor.vue': {
+ routes:
+ | '/(auth)/settings/two-factor'
+ views:
+ | never
+ pathParamNames:
+ | never
+ }
+ '(auth)/settings/verify-phone-number.vue': {
+ routes:
+ | '/(auth)/settings/verify-phone-number'
+ views:
+ | never
+ pathParamNames:
+ | never
+ }
+ }"
+ `)
})
it('produces stable output for sibling group folders with same path', () => {
| '/parent/child'
views:
| 'default'
+ pathParamNames:
+ | never
}
'src/pages/parent/child.vue': {
routes:
| '/parent/child'
views:
| never
+ pathParamNames:
+ | never
}
}"
`)
views:
| 'default'
| 'test'
+ pathParamNames:
+ | never
}
'src/pages/parent/child.vue': {
routes:
| '/parent/child'
views:
| never
+ pathParamNames:
+ | never
}
'src/pages/parent/child@test.vue': {
routes:
| '/parent/child'
views:
| never
+ pathParamNames:
+ | never
}
}"
`)
| '/home'
views:
| never
+ pathParamNames:
+ | never
}
'nested/index.vue': {
routes:
| '/unnested'
views:
| never
+ pathParamNames:
+ | never
}
}"
`)
| '/optional/[[id]]'
views:
| never
+ pathParamNames:
+ | 'id'
}
'optional-repeatable/[[id]]+.vue': {
routes:
| '/optional-repeatable/[[id]]+'
views:
| never
+ pathParamNames:
+ | 'id'
}
'repeatable/[id]+.vue': {
routes:
| '/repeatable/[id]+'
views:
| never
+ pathParamNames:
+ | 'id'
}
}"
`)
| '/parent/repeatable/[id]+'
views:
| 'default'
+ pathParamNames:
+ | never
}
'parent/optional/[[id]].vue': {
routes:
| '/parent/optional/[[id]]'
views:
| never
+ pathParamNames:
+ | 'id'
}
'parent/optional-repeatable/[[id]]+.vue': {
routes:
| '/parent/optional-repeatable/[[id]]+'
views:
| never
+ pathParamNames:
+ | 'id'
}
'parent/repeatable/[id]+.vue': {
routes:
| '/parent/repeatable/[id]+'
views:
| never
+ pathParamNames:
+ | 'id'
+ }
+ }"
+ `)
+ })
+
+ it('lists path param names owned by the file segment only', () => {
+ const tree = new PrefixTree(DEFAULT_OPTIONS)
+ tree.insert('a.[b].c.[d]', 'src/pages/a.[b].c.[d].vue')
+ tree.insert('users/[userId]', 'src/pages/users/[userId].vue')
+ tree.insert(
+ 'users/[userId]/posts/[postId]',
+ 'src/pages/users/[userId]/posts/[postId].vue'
+ )
+
+ expect(formatExports(generateRouteFileInfoMap(tree, { root: '' })))
+ .toMatchInlineSnapshot(`
+ "export interface _RouteFileInfoMap {
+ 'src/pages/a.[b].c.[d].vue': {
+ routes:
+ | '/a.[b].c.[d]'
+ views:
+ | never
+ pathParamNames:
+ | 'b'
+ | 'd'
+ }
+ 'src/pages/users/[userId].vue': {
+ routes:
+ | '/users/[userId]'
+ | '/users/[userId]/posts/[postId]'
+ views:
+ | 'default'
+ pathParamNames:
+ | 'userId'
+ }
+ 'src/pages/users/[userId]/posts/[postId].vue': {
+ routes:
+ | '/users/[userId]/posts/[postId]'
+ views:
+ | never
+ pathParamNames:
+ | 'postId'
+ }
+ }"
+ `)
+ })
+
+ it('excludes both ancestor and descendant path params from each file', () => {
+ const tree = new PrefixTree(DEFAULT_OPTIONS)
+ tree.insert('[a]', 'src/pages/[a].vue')
+ tree.insert('[a]/[b]', 'src/pages/[a]/[b].vue')
+ tree.insert('[a]/[b]/[c]', 'src/pages/[a]/[b]/[c].vue')
+
+ expect(formatExports(generateRouteFileInfoMap(tree, { root: '' })))
+ .toMatchInlineSnapshot(`
+ "export interface _RouteFileInfoMap {
+ 'src/pages/[a].vue': {
+ routes:
+ | '/[a]'
+ | '/[a]/[b]'
+ | '/[a]/[b]/[c]'
+ views:
+ | 'default'
+ pathParamNames:
+ | 'a'
+ }
+ 'src/pages/[a]/[b].vue': {
+ routes:
+ | '/[a]/[b]'
+ | '/[a]/[b]/[c]'
+ views:
+ | 'default'
+ pathParamNames:
+ | 'b'
+ }
+ 'src/pages/[a]/[b]/[c].vue': {
+ routes:
+ | '/[a]/[b]/[c]'
+ views:
+ | never
+ pathParamNames:
+ | 'c'
}
}"
`)
expect(formatExports(generateRouteFileInfoMap(tree, { root: '' })))
.toMatchInlineSnapshot(`
- "export interface _RouteFileInfoMap {
- 'src/pages/it\\'s fine.vue': {
- routes:
- | '/path'
- views:
- | never
- }
- }"
- `)
+ "export interface _RouteFileInfoMap {
+ 'src/pages/it\\'s fine.vue': {
+ routes:
+ | '/path'
+ views:
+ | never
+ pathParamNames:
+ | never
+ }
+ }"
+ `)
})
})
.flatMap(child => generateRouteFileInfoLines(child, root))
// because the same file can be used for multiple routes, we need to group them
- const routesInfo = new Map<string, { routes: string[]; views: string[] }>()
+ const routesInfo = new Map<
+ string,
+ { routes: string[]; views: string[]; pathParamNames: string[] }
+ >()
for (const routeInfo of routesInfoList) {
// ensure we have an entry for the file
let info = routesInfo.get(routeInfo.key)
(info = {
routes: [],
views: [],
+ pathParamNames: [],
})
)
}
info.routes.push(...routeInfo.routeNames)
info.views.push(...(routeInfo.childrenNamedViews || []))
+ info.pathParamNames.push(...routeInfo.pathParamNames)
}
const code = Array.from(routesInfo.entries())
.map(
- ([file, { routes, views }]) =>
+ ([file, { routes, views, pathParamNames }]) =>
`
${toStringLiteral(file)}: {
routes:
${formatMultilineUnion(routes.sort().map(toStringLiteral), 6)}
views:
${formatMultilineUnion(views.sort().map(toStringLiteral), 6)}
+ pathParamNames:
+ ${formatMultilineUnion(
+ Array.from(new Set(pathParamNames)).sort().map(toStringLiteral),
+ 6
+ )}
}`
)
.join('\n')
key: string
routeNames: string[]
childrenNamedViews: string[] | null
+ pathParamNames: string[]
}> {
const deepChildren =
node.children.size > 0 ? node.getChildrenDeepSorted() : null
return acc
}, [])
+ // Only params owned by this node's own segment. Ancestor params belong to
+ // their own files (and cannot be retyped from here via `definePage()`), and
+ // children's params live in their own files and are recursed below.
+ const pathParamNames = node.value.isParam()
+ ? node.value.pathParams.map(p => p.paramName)
+ : []
+
// Most of the time we only have one view, but with named views we can have multiple.
const currentRouteInfo =
routeNames.length === 0
key: relative(rootDir, file).replaceAll('\\', '/'),
routeNames,
childrenNamedViews: deepChildrenNamedViews,
+ pathParamNames,
}))
const childrenRouteInfo = node
// NOTE: this might not work if different from the root passed to VueRouter unplugin
const relativeFilePath = rootDir ? relative(rootDir, fileName) : fileName
- const useRouteNameType = `import('vue-router/auto-routes')._RouteNamesForFilePath<'${relativeFilePath}'>`
+ // Escape backslashes/apostrophes so we can safely embed the file path
+ // inside a single-quoted TS string literal type argument.
+ const escapedFilePath = relativeFilePath
+ .replace(/\\/g, '\\\\')
+ .replace(/'/g, "\\'")
+
+ const useRouteNameType = `import('vue-router/auto-routes')._RouteNamesForFilePath<'${escapedFilePath}'>`
const useRouteNameTypeParam = `<${useRouteNameType}>`
+ const definePageFilePathTypeParam = `<'${escapedFilePath}'>`
+
if (sfc.scriptSetup) {
visit(sfc.scriptSetup.ast)
}
` as ReturnType<typeof useRoute${useRouteNameTypeParam}>)`
)
}
+ } else if (
+ ts.isCallExpression(node) &&
+ ts.isIdentifier(node.expression) &&
+ ts.idText(node.expression) === 'definePage' &&
+ !node.typeArguments &&
+ node.arguments.length === 1 &&
+ !sfc.scriptSetup!.lang.startsWith('js')
+ ) {
+ // Inject the file path so `definePage`'s `params.path` keys can be
+ // narrowed to this file's actual path params.
+ replaceSourceRange(
+ embeddedCode.content,
+ sfc.scriptSetup!.name,
+ node.expression.end,
+ node.expression.end,
+ definePageFilePathTypeParam
+ )
} else {
ts.forEachChild(node, visit)
}
// TODO(v6): rename to `RouteMap` and host the interface directly on `vue-router`.
export interface RouteNamedMap {}
+/**
+ * Map of route file paths (relative to the project root) to information about
+ * the routes they declare. Augmented from the user's generated `routes.d.ts`
+ * by the unplugin. Used by the `definePage` macro typing and the
+ * `sfc-typed-router` Volar plugin to type `useRoute()` per file.
+ *
+ * @internal
+ */
+export interface _RouteFileInfoMap {}
+
// Make the macros globally available
declare global {
const definePage: (typeof import('vue-router/experimental'))['definePage']