Skip to content

Commit c103d13

Browse files
authored
feat: experimental typed routes (#3142)
1 parent f61b540 commit c103d13

File tree

16 files changed

+416
-37
lines changed

16 files changed

+416
-37
lines changed

build.config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { defineBuildConfig } from 'unbuild'
22

33
export default defineBuildConfig({
4-
externals: ['node:fs', 'node:url', 'webpack', '@babel/parser']
4+
externals: ['node:fs', 'node:url', 'webpack', '@babel/parser', 'unplugin-vue-router', 'unplugin-vue-router/options']
55
})

docs/content/docs/5.v9/3.options/10.misc.md

+3
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ About how to define the locale detector, see the [`defineI18nLocaleDetector` API
1818
- `autoImportTranslationFunctions` (default: `false`) - Automatically imports/initializes `$t`, `$rt`, `$d`, `$n`, `$tm` and `$te` functions in `<script setup>` when used.
1919
::callout{icon="i-heroicons-exclamation-triangle" color="amber"}
2020
This feature relies on [Nuxt's Auto-imports](https://nuxt.com/docs/guide/concepts/auto-imports) and will not work if this has been disabled.
21+
- `typedPages` (default: `true`) - Generates route types used in composables and configuration, this feature is enabled by default when Nuxt's `experimental.typedRoutes` is enabled.
22+
::callout{icon="i-heroicons-exclamation-triangle" color="amber"}
23+
This feature relies on [Nuxt's `experimental.typedRoutes`](https://nuxt.com/docs/guide/going-further/experimental-features#typedpages) and will not work if this is not enabled.
2124
::
2225

2326

docs/content/docs/5.v9/4.api/3.compiler-macros.md

+4-4
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ defineI18nRoute({
2525
defineI18nRoute(route: I18nRoute | false) => void
2626

2727
interface I18nRoute {
28-
paths?: Record<string, string>
29-
locales?: string[]
28+
paths?: Record<Locale, `/${string}`>
29+
locales?: Locale[]
3030
}
3131
```
3232

@@ -42,12 +42,12 @@ An object accepting the following i18n route settings:
4242

4343
- **`paths`**
4444

45-
- **Type**: `Record<Locale, string>`
45+
- **Type**: `Record<Locale, `/${string}`>`
4646

4747
Customize page component routes per locale. You can specify static and dynamic paths for vue-router.
4848

4949
- **`locales`**
5050

51-
- **Type**: `string[]`
51+
- **Type**: `Locale[]`
5252

5353
Some locales to which the page component should be localized.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
"sucrase": "^3.35.0",
105105
"ufo": "^1.3.1",
106106
"unplugin": "^1.10.1",
107+
"unplugin-vue-router": "^0.10.8",
107108
"vue-i18n": "^10.0.3",
108109
"vue-router": "^4.4.5"
109110
},

playground/nuxt.config.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ export default defineNuxtConfig({
121121
experimental: {
122122
localeDetector: './localeDetector.ts',
123123
switchLocalePathLinkSSR: true,
124-
autoImportTranslationFunctions: true
124+
autoImportTranslationFunctions: true,
125+
typedPages: true
125126
},
126127
compilation: {
127128
// jit: false,

playground/pages/index.vue

+2-1
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ definePageMeta({
7171
<p>{{ $t('bar.buz', { name: 'buz' }) }}</p>
7272
<h2>Pages</h2>
7373
<nav>
74-
<NuxtLink :to="localePath('/')">Home</NuxtLink> | <NuxtLink :to="localePath({ name: 'about' })">About</NuxtLink> |
74+
<NuxtLink :to="localePath('index')">Home</NuxtLink> |
75+
<NuxtLink :to="localePath({ name: 'about' })">About</NuxtLink> |
7576
<NuxtLink :to="localePath({ name: 'blog' })">Blog</NuxtLink> |
7677
<NuxtLink :to="localePath({ name: 'server' })">Server</NuxtLink> |
7778
<NuxtLink :to="localePath({ name: 'category-id', params: { id: 'foo' } })">Category</NuxtLink> |

pnpm-lock.yaml

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { test, expect, describe } from 'vitest'
2+
import { fileURLToPath } from 'node:url'
3+
import { setup } from '../utils'
4+
import fs from 'node:fs/promises'
5+
6+
await setup({
7+
rootDir: fileURLToPath(new URL(`../fixtures/basic_usage`, import.meta.url)),
8+
browser: false,
9+
// overrides
10+
nuxtConfig: {
11+
experimental: {
12+
typedPages: true
13+
}
14+
}
15+
})
16+
17+
describe('`experimental.typedPages` undefined or enabled', async () => {
18+
test('generates route types', async () => {
19+
expect(
20+
await fs.readFile(
21+
fileURLToPath(
22+
new URL(
23+
`../fixtures/basic_usage/.nuxt/___experimental_typed_pages_spec_ts/types/typed-router-i18n.d.ts`,
24+
import.meta.url
25+
)
26+
),
27+
'utf-8'
28+
)
29+
).toMatchInlineSnapshot(`
30+
"/* eslint-disable */
31+
/* prettier-ignore */
32+
// @ts-nocheck
33+
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
34+
// It's recommended to commit this file.
35+
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
36+
37+
declare module 'vue-router/auto-routes' {
38+
import type {
39+
RouteRecordInfo,
40+
ParamValue,
41+
ParamValueOneOrMore,
42+
ParamValueZeroOrMore,
43+
ParamValueZeroOrOne,
44+
} from 'vue-router'
45+
46+
/**
47+
* Route name map generated by unplugin-vue-router
48+
*/
49+
export interface RouteNamedMapI18n {
50+
'index': RouteRecordInfo<'index', '/', Record<never, never>, Record<never, never>>,
51+
'pathMatch': RouteRecordInfo<'pathMatch', '/:pathMatch(.*)*', { pathMatch?: ParamValueZeroOrMore<true> }, { pathMatch?: ParamValueZeroOrMore<false> }>,
52+
'about': RouteRecordInfo<'about', '/about', Record<never, never>, Record<never, never>>,
53+
'api-products': RouteRecordInfo<'api-products', '/api/products', Record<never, never>, Record<never, never>>,
54+
'api-products-product': RouteRecordInfo<'api-products-product', '/api/products/:product()', { product: ParamValue<true> }, { product: ParamValue<false> }>,
55+
'api-products-data': RouteRecordInfo<'api-products-data', '/api/products-data', Record<never, never>, Record<never, never>>,
56+
'category-slug': RouteRecordInfo<'category-slug', '/category/:slug()', { slug: ParamValue<true> }, { slug: ParamValue<false> }>,
57+
'composables': RouteRecordInfo<'composables', '/composables', Record<never, never>, Record<never, never>>,
58+
'experimental-slug': RouteRecordInfo<'experimental-slug', '/experimental/:slug(.*)*', { slug?: ParamValueZeroOrMore<true> }, { slug?: ParamValueZeroOrMore<false> }>,
59+
'experimental-auto-import-translation-functions': RouteRecordInfo<'experimental-auto-import-translation-functions', '/experimental/auto-import-translation-functions', Record<never, never>, Record<never, never>>,
60+
'greetings': RouteRecordInfo<'greetings', '/greetings', Record<never, never>, Record<never, never>>,
61+
'layer-page': RouteRecordInfo<'layer-page', '/layer-page', Record<never, never>, Record<never, never>>,
62+
'layer-parent': RouteRecordInfo<'layer-parent', '/layer-parent', Record<never, never>, Record<never, never>>,
63+
'layer-parent-layer-child': RouteRecordInfo<'layer-parent-layer-child', '/layer-parent/layer-child', Record<never, never>, Record<never, never>>,
64+
'long-text': RouteRecordInfo<'long-text', '/long-text', Record<never, never>, Record<never, never>>,
65+
'nested-test-route': RouteRecordInfo<'nested-test-route', '/nested/test-route', Record<never, never>, Record<never, never>>,
66+
'nuxt-context-extension': RouteRecordInfo<'nuxt-context-extension', '/nuxt-context-extension', Record<never, never>, Record<never, never>>,
67+
'page with spaces': RouteRecordInfo<'page with spaces', '/page%20with%20spaces', Record<never, never>, Record<never, never>>,
68+
'post-id': RouteRecordInfo<'post-id', '/post/:id()', { id: ParamValue<true> }, { id: ParamValue<false> }>,
69+
'products': RouteRecordInfo<'products', '/products', Record<never, never>, Record<never, never>>,
70+
'products-slug': RouteRecordInfo<'products-slug', '/products/:slug()', { slug: ParamValue<true> }, { slug: ParamValue<false> }>,
71+
'user-profile': RouteRecordInfo<'user-profile', '/user/profile', Record<never, never>, Record<never, never>>,
72+
}
73+
}
74+
"
75+
`)
76+
})
77+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { test, expect, describe } from 'vitest'
2+
import { fileURLToPath } from 'node:url'
3+
import { setup } from '../utils'
4+
import fs from 'node:fs/promises'
5+
6+
await setup({
7+
rootDir: fileURLToPath(new URL(`../fixtures/basic_usage`, import.meta.url)),
8+
browser: false,
9+
// overrides
10+
nuxtConfig: {
11+
experimental: {
12+
typedPages: true
13+
},
14+
i18n: {
15+
experimental: {
16+
typedPages: false
17+
}
18+
}
19+
}
20+
})
21+
22+
describe('`experimental.typedPages` explicitly disabled', async () => {
23+
test('does not generate types', async () => {
24+
await expect(
25+
fs.access(
26+
fileURLToPath(
27+
new URL(
28+
`../fixtures/basic_usage/.nuxt/___experimental_typed_pages_explicit_disable_spec_ts/types/typed-router-i18n.d.ts`,
29+
import.meta.url
30+
)
31+
)
32+
)
33+
).rejects.toThrowError()
34+
})
35+
})

src/constants.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ export const DEFAULT_OPTIONS = {
2929
experimental: {
3030
localeDetector: '',
3131
switchLocalePathLinkSSR: false,
32-
autoImportTranslationFunctions: false
32+
autoImportTranslationFunctions: false,
33+
typedPages: true
3334
},
3435
bundle: {
3536
compositionOnly: true,

src/gen.ts

+88
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,92 @@ function genImportSpecifier(
152152
return getLoadPath()
153153
}
154154

155+
/**
156+
* From vuejs/router
157+
* https://github.com/vuejs/router/blob/14219b01bee142423265a3aaacd1eac0dcc95071/packages/router/src/typed-routes/route-map.ts
158+
* https://github.com/vuejs/router/blob/14219b01bee142423265a3aaacd1eac0dcc95071/packages/router/src/typed-routes/route-location.ts
159+
*
160+
* Depends on `TypesConfig`
161+
* https://github.com/vuejs/router/blob/14219b01bee142423265a3aaacd1eac0dcc95071/packages/router/src/config.ts#L14
162+
* Depends on the same mechanism of `RouteNamedMap
163+
* https://github.com/vuejs/router/blob/14219b01bee142423265a3aaacd1eac0dcc95071/packages/router/vue-router-auto.d.ts#L4
164+
*/
165+
const typedRouterAugmentations = `
166+
declare module 'vue-router' {
167+
import type { RouteNamedMapI18n } from 'vue-router/auto-routes'
168+
169+
export interface TypesConfig {
170+
RouteNamedMapI18n: RouteNamedMapI18n
171+
}
172+
173+
export type RouteMapI18n =
174+
TypesConfig extends Record<'RouteNamedMapI18n', infer RouteNamedMap> ? RouteNamedMap : RouteMapGeneric
175+
176+
// Prefer named resolution for i18n
177+
export type RouteLocationNamedI18n<Name extends keyof RouteMapI18n = keyof RouteMapI18n> =
178+
| Name
179+
| Omit<RouteLocationAsRelativeI18n, 'path'> & { path?: string }
180+
/**
181+
* Note: disabled route path string autocompletion, this can break depending on \`strategy\`
182+
* this can be enabled again after route resolve has been improved.
183+
*/
184+
// | RouteLocationAsStringI18n
185+
// | RouteLocationAsPathI18n
186+
187+
export type RouteLocationRawI18n<Name extends keyof RouteMapI18n = keyof RouteMapI18n> =
188+
RouteMapGeneric extends RouteMapI18n
189+
? RouteLocationAsStringI18n | RouteLocationAsRelativeGeneric | RouteLocationAsPathGeneric
190+
:
191+
| _LiteralUnion<RouteLocationAsStringTypedList<RouteMapI18n>[Name], string>
192+
| RouteLocationAsRelativeTypedList<RouteMapI18n>[Name]
193+
194+
export type RouteLocationResolvedI18n<Name extends keyof RouteMapI18n = keyof RouteMapI18n> =
195+
RouteMapGeneric extends RouteMapI18n
196+
? RouteLocationResolvedGeneric
197+
: RouteLocationResolvedTypedList<RouteMapI18n>[Name]
198+
199+
export interface RouteLocationNormalizedLoadedTypedI18n<
200+
RouteMapI18n extends RouteMapGeneric = RouteMapGeneric,
201+
Name extends keyof RouteMapI18n = keyof RouteMapI18n
202+
> extends RouteLocationNormalizedLoadedGeneric {
203+
name: Extract<Name, string | symbol>
204+
params: RouteMapI18n[Name]['params']
205+
}
206+
export type RouteLocationNormalizedLoadedTypedListI18n<RouteMapOriginal extends RouteMapGeneric = RouteMapGeneric> = {
207+
[N in keyof RouteMapOriginal]: RouteLocationNormalizedLoadedTypedI18n<RouteMapOriginal, N>
208+
}
209+
export type RouteLocationNormalizedLoadedI18n<Name extends keyof RouteMapI18n = keyof RouteMapI18n> =
210+
RouteMapGeneric extends RouteMapI18n
211+
? RouteLocationNormalizedLoadedGeneric
212+
: RouteLocationNormalizedLoadedTypedListI18n<RouteMapI18n>[Name]
213+
214+
type _LiteralUnion<LiteralType, BaseType extends string = string> = LiteralType | (BaseType & Record<never, never>)
215+
216+
export type RouteLocationAsStringI18n<Name extends keyof RouteMapI18n = keyof RouteMapI18n> =
217+
RouteMapGeneric extends RouteMapI18n
218+
? string
219+
: _LiteralUnion<RouteLocationAsStringTypedList<RouteMapI18n>[Name], string>
220+
221+
export type RouteLocationAsRelativeI18n<Name extends keyof RouteMapI18n = keyof RouteMapI18n> =
222+
RouteMapGeneric extends RouteMapI18n
223+
? RouteLocationAsRelativeGeneric
224+
: RouteLocationAsRelativeTypedList<RouteMapI18n>[Name]
225+
226+
export type RouteLocationAsPathI18n<Name extends keyof RouteMapI18n = keyof RouteMapI18n> =
227+
RouteMapGeneric extends RouteMapI18n ? RouteLocationAsPathGeneric : RouteLocationAsPathTypedList<RouteMapI18n>[Name]
228+
229+
/**
230+
* Helper to generate a type safe version of the {@link RouteLocationAsRelative} type.
231+
*/
232+
export interface RouteLocationAsRelativeTypedI18n<
233+
RouteMapI18n extends RouteMapGeneric = RouteMapGeneric,
234+
Name extends keyof RouteMapI18n = keyof RouteMapI18n
235+
> extends RouteLocationAsRelativeGeneric {
236+
name?: Extract<Name, string | symbol>
237+
params?: RouteMapI18n[Name]['paramsRaw']
238+
}
239+
}`
240+
155241
export function generateI18nTypes(nuxt: Nuxt, options: NuxtI18nOptions) {
156242
const vueI18nTypes = options.types === 'legacy' ? ['VueI18n'] : ['ExportedGlobalComposer', 'Composer']
157243
const generatedLocales = simplifyLocaleOptions(nuxt, options)
@@ -203,6 +289,8 @@ declare module '#app' {
203289
}
204290
}
205291
292+
${typedRouterAugmentations}
293+
206294
${(options.experimental?.autoImportTranslationFunctions && globalTranslationTypes) || ''}
207295
208296
export {}`

src/module.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export default defineNuxtModule<NuxtI18nOptions>({
5858
/**
5959
* setup nuxt/pages
6060
*/
61-
setupPages(ctx, nuxt)
61+
await setupPages(ctx, nuxt)
6262

6363
/**
6464
* ignore `/` during prerender when using prefixed routing

0 commit comments

Comments
 (0)