]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
feat: typed definePage params.path (#2716)
authorEduardo San Martin Morote <posva@users.noreply.github.com>
Tue, 19 May 2026 15:43:01 +0000 (17:43 +0200)
committerGitHub <noreply@github.com>
Tue, 19 May 2026 15:43:01 +0000 (17:43 +0200)
packages/playground-file-based/src/pages/u[name].vue
packages/playground-file-based/src/pages/u[name]/24.vue
packages/playground-file-based/src/routes.d.ts
packages/router/src/experimental/index.ts
packages/router/src/experimental/runtime.ts
packages/router/src/unplugin/codegen/generateDTS.spec.ts
packages/router/src/unplugin/codegen/generateDTS.ts
packages/router/src/unplugin/codegen/generateRouteFileInfoMap.spec.ts
packages/router/src/unplugin/codegen/generateRouteFileInfoMap.ts
packages/router/src/volar/entries/sfc-typed-router.ts
packages/router/vue-router-auto-routes.d.mts

index 2d5ad68470cef6037e2a55e548c2b252c95fa933..952b9f3449db0dff336e8c6bc4af8c56675e3391 100644 (file)
@@ -1,4 +1,17 @@
-<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>
index b5778f70eb7e23e81e7c7245990b5633274a61e5..c85bc8ebea7874f7dc682dc86c5d16a0a4b573e5 100644 (file)
@@ -1,4 +1,17 @@
-<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>
index e3c079f1a6fd3f4adf6d798f2e8b824ac33f102f..271fa1dab2bcb2fc7e742923942e15ae30583cee 100644 (file)
@@ -36,6 +36,7 @@ declare module 'vue-router' {
       | 'semver'
       | 'version-range'
     RouteNamedMap: import('vue-router/auto-routes').RouteNamedMap
+    _RouteFileInfoMap: import('vue-router/auto-routes')._RouteFileInfoMap
   }
 }
 
@@ -229,23 +230,23 @@ declare module 'vue-router/auto-routes' {
     '/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<
@@ -301,6 +302,8 @@ declare module 'vue-router/auto-routes' {
         | '/(home)'
       views:
         | never
+      pathParamNames:
+        | never
     }
     'src/pages/(packages)/_parent.vue': {
       routes:
@@ -310,120 +313,170 @@ declare module 'vue-router/auto-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:
@@ -431,42 +484,56 @@ declare module 'vue-router/auto-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:
@@ -475,48 +542,66 @@ declare module 'vue-router/auto-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
     }
   }
 
index f0dbfc12693d46c0ab9a0cc23d6062bb410c30a1..45fce30a6c7f7b733611872d5800a994022933ee 100644 (file)
@@ -62,6 +62,7 @@ export {
   type ParamParserType,
   type ParamParserType_Native,
   type DefinePageQueryParamOptions,
+  type PathParamNamesForFilePath as _PathParamNamesForFilePath,
 } from './runtime'
 
 // Data loaders exports
index d7dd12d4a57fe5914fc75907167d6dabb98d6c4c..e20a39548e52b051445119f5b9045f5f88cc06ee 100644 (file)
@@ -6,11 +6,38 @@ import type { RouteRecordRaw } from '../types'
  * 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.
@@ -45,8 +72,12 @@ export function _mergeRouteRecord(
 
 /**
  * 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'>
 > {
   /**
@@ -61,7 +92,10 @@ export interface DefinePage extends Partial<
    * @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.
index 4cc548e9f7514c84d7e8ba2b25e54b5c4a1c2c66..d0b5c18324dd000d22367dcf5c9b92c700b5c019 100644 (file)
@@ -37,6 +37,7 @@ describe('generateDTS', () => {
           ParamParsers:
 
           RouteNamedMap: import('vue-router/auto-routes').RouteNamedMap
+          _RouteFileInfoMap: import('vue-router/auto-routes')._RouteFileInfoMap
         }
       }
 
@@ -98,5 +99,6 @@ describe('generateDTS', () => {
       "expected a 'declare module \\'vue-router\\'' block"
     ).toBeTruthy()
     expect(vueRouterBlock).toContain('RouteNamedMap:')
+    expect(vueRouterBlock).toContain('_RouteFileInfoMap:')
   })
 })
index b992e63f54ece08e0bc9f1931dd7f3a8047c3101..149bd07d4c04a0191353e817c8a876416a1f61f5 100644 (file)
@@ -62,6 +62,7 @@ ${paramsTypesDeclaration}
     ParamParsers:
 ${customParamsTypeList.map(literal => ' '.repeat(6) + '| ' + literal).join('\n')}
     RouteNamedMap: import('${routesModule}').RouteNamedMap
+    _RouteFileInfoMap: import('${routesModule}')._RouteFileInfoMap
   }
 }
 
index a9163f64619ee8f9ea8c691cae7d84b4ed1c23f8..da2bb01c04d3565f729e83544f65b92a2141aca9 100644 (file)
@@ -27,24 +27,32 @@ describe('generateRouteFileInfoMap', () => {
               | '/'
             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
           }
         }"
       `)
@@ -109,121 +117,151 @@ describe('generateRouteFileInfoMap', () => {
 
     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', () => {
@@ -263,12 +301,16 @@ describe('generateRouteFileInfoMap', () => {
               | '/parent/child'
             views:
               | 'default'
+            pathParamNames:
+              | never
           }
           'src/pages/parent/child.vue': {
             routes:
               | '/parent/child'
             views:
               | never
+            pathParamNames:
+              | never
           }
         }"
       `)
@@ -289,18 +331,24 @@ describe('generateRouteFileInfoMap', () => {
             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
           }
         }"
       `)
@@ -324,6 +372,8 @@ describe('generateRouteFileInfoMap', () => {
               | '/home'
             views:
               | never
+            pathParamNames:
+              | never
           }
           'nested/index.vue': {
             routes:
@@ -331,6 +381,8 @@ describe('generateRouteFileInfoMap', () => {
               | '/unnested'
             views:
               | never
+            pathParamNames:
+              | never
           }
         }"
       `)
@@ -353,18 +405,24 @@ describe('generateRouteFileInfoMap', () => {
               | '/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'
           }
         }"
       `)
@@ -391,24 +449,114 @@ describe('generateRouteFileInfoMap', () => {
               | '/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'
           }
         }"
       `)
@@ -420,14 +568,16 @@ describe('generateRouteFileInfoMap', () => {
 
     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
+          }
+        }"
+      `)
   })
 })
index 432b6eb688bc21293a0dceb69b179a310f7c4ce9..4ffc774bbf30f0d979fdbfb18d6696b403f5b448 100644 (file)
@@ -20,7 +20,10 @@ export function generateRouteFileInfoMap(
     .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)
@@ -30,23 +33,30 @@ export function generateRouteFileInfoMap(
         (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')
@@ -66,6 +76,7 @@ function generateRouteFileInfoLines(
   key: string
   routeNames: string[]
   childrenNamedViews: string[] | null
+  pathParamNames: string[]
 }> {
   const deepChildren =
     node.children.size > 0 ? node.getChildrenDeepSorted() : null
@@ -92,6 +103,13 @@ function generateRouteFileInfoLines(
       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
@@ -100,6 +118,7 @@ function generateRouteFileInfoLines(
           key: relative(rootDir, file).replaceAll('\\', '/'),
           routeNames,
           childrenNamedViews: deepChildrenNamedViews,
+          pathParamNames,
         }))
 
   const childrenRouteInfo = node
index 0ea5cc7fe96b12340fa5a95b3e29ddd145b8e2c7..88c83d799b23a690ccf235ab9279728a62e6c44d 100644 (file)
@@ -51,9 +51,17 @@ const plugin: VueLanguagePlugin<{ options?: { rootDir?: string } }> = ({
       // 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)
       }
@@ -91,6 +99,23 @@ const plugin: VueLanguagePlugin<{ options?: { rootDir?: string } }> = ({
               ` 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)
         }
index 9fdeb2238664b3bdee64ccf88b38c3c0586b9b5d..522b519de01279179bc56252f755e840c477062f 100644 (file)
@@ -37,6 +37,16 @@ export declare function handleHotUpdate(
 // 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']