Skip to content

Commit

Permalink
feat: experimental typed routes (#3142)
Browse files Browse the repository at this point in the history
  • Loading branch information
BobbieGoede authored Sep 30, 2024
1 parent f61b540 commit c103d13
Show file tree
Hide file tree
Showing 16 changed files with 416 additions and 37 deletions.
2 changes: 1 addition & 1 deletion build.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineBuildConfig } from 'unbuild'

export default defineBuildConfig({
externals: ['node:fs', 'node:url', 'webpack', '@babel/parser']
externals: ['node:fs', 'node:url', 'webpack', '@babel/parser', 'unplugin-vue-router', 'unplugin-vue-router/options']
})
3 changes: 3 additions & 0 deletions docs/content/docs/5.v9/3.options/10.misc.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ About how to define the locale detector, see the [`defineI18nLocaleDetector` API
- `autoImportTranslationFunctions` (default: `false`) - Automatically imports/initializes `$t`, `$rt`, `$d`, `$n`, `$tm` and `$te` functions in `<script setup>` when used.
::callout{icon="i-heroicons-exclamation-triangle" color="amber"}
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.
- `typedPages` (default: `true`) - Generates route types used in composables and configuration, this feature is enabled by default when Nuxt's `experimental.typedRoutes` is enabled.
::callout{icon="i-heroicons-exclamation-triangle" color="amber"}
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.
::


Expand Down
8 changes: 4 additions & 4 deletions docs/content/docs/5.v9/4.api/3.compiler-macros.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ defineI18nRoute({
defineI18nRoute(route: I18nRoute | false) => void

interface I18nRoute {
paths?: Record<string, string>
locales?: string[]
paths?: Record<Locale, `/${string}`>
locales?: Locale[]
}
```

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

- **`paths`**

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

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

- **`locales`**

- **Type**: `string[]`
- **Type**: `Locale[]`

Some locales to which the page component should be localized.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
"sucrase": "^3.35.0",
"ufo": "^1.3.1",
"unplugin": "^1.10.1",
"unplugin-vue-router": "^0.10.8",
"vue-i18n": "^10.0.3",
"vue-router": "^4.4.5"
},
Expand Down
3 changes: 2 additions & 1 deletion playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ export default defineNuxtConfig({
experimental: {
localeDetector: './localeDetector.ts',
switchLocalePathLinkSSR: true,
autoImportTranslationFunctions: true
autoImportTranslationFunctions: true,
typedPages: true
},
compilation: {
// jit: false,
Expand Down
3 changes: 2 additions & 1 deletion playground/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ definePageMeta({
<p>{{ $t('bar.buz', { name: 'buz' }) }}</p>
<h2>Pages</h2>
<nav>
<NuxtLink :to="localePath('/')">Home</NuxtLink> | <NuxtLink :to="localePath({ name: 'about' })">About</NuxtLink> |
<NuxtLink :to="localePath('index')">Home</NuxtLink> |
<NuxtLink :to="localePath({ name: 'about' })">About</NuxtLink> |
<NuxtLink :to="localePath({ name: 'blog' })">Blog</NuxtLink> |
<NuxtLink :to="localePath({ name: 'server' })">Server</NuxtLink> |
<NuxtLink :to="localePath({ name: 'category-id', params: { id: 'foo' } })">Category</NuxtLink> |
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

77 changes: 77 additions & 0 deletions specs/experimental/typed_pages.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { test, expect, describe } from 'vitest'
import { fileURLToPath } from 'node:url'
import { setup } from '../utils'
import fs from 'node:fs/promises'

await setup({
rootDir: fileURLToPath(new URL(`../fixtures/basic_usage`, import.meta.url)),
browser: false,
// overrides
nuxtConfig: {
experimental: {
typedPages: true
}
}
})

describe('`experimental.typedPages` undefined or enabled', async () => {
test('generates route types', async () => {
expect(
await fs.readFile(
fileURLToPath(
new URL(
`../fixtures/basic_usage/.nuxt/___experimental_typed_pages_spec_ts/types/typed-router-i18n.d.ts`,
import.meta.url
)
),
'utf-8'
)
).toMatchInlineSnapshot(`
"/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
// It's recommended to commit this file.
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
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 RouteNamedMapI18n {
'index': RouteRecordInfo<'index', '/', Record<never, never>, Record<never, never>>,
'pathMatch': RouteRecordInfo<'pathMatch', '/:pathMatch(.*)*', { pathMatch?: ParamValueZeroOrMore<true> }, { pathMatch?: ParamValueZeroOrMore<false> }>,
'about': RouteRecordInfo<'about', '/about', Record<never, never>, Record<never, never>>,
'api-products': RouteRecordInfo<'api-products', '/api/products', Record<never, never>, Record<never, never>>,
'api-products-product': RouteRecordInfo<'api-products-product', '/api/products/:product()', { product: ParamValue<true> }, { product: ParamValue<false> }>,
'api-products-data': RouteRecordInfo<'api-products-data', '/api/products-data', Record<never, never>, Record<never, never>>,
'category-slug': RouteRecordInfo<'category-slug', '/category/:slug()', { slug: ParamValue<true> }, { slug: ParamValue<false> }>,
'composables': RouteRecordInfo<'composables', '/composables', Record<never, never>, Record<never, never>>,
'experimental-slug': RouteRecordInfo<'experimental-slug', '/experimental/:slug(.*)*', { slug?: ParamValueZeroOrMore<true> }, { slug?: ParamValueZeroOrMore<false> }>,
'experimental-auto-import-translation-functions': RouteRecordInfo<'experimental-auto-import-translation-functions', '/experimental/auto-import-translation-functions', Record<never, never>, Record<never, never>>,
'greetings': RouteRecordInfo<'greetings', '/greetings', Record<never, never>, Record<never, never>>,
'layer-page': RouteRecordInfo<'layer-page', '/layer-page', Record<never, never>, Record<never, never>>,
'layer-parent': RouteRecordInfo<'layer-parent', '/layer-parent', Record<never, never>, Record<never, never>>,
'layer-parent-layer-child': RouteRecordInfo<'layer-parent-layer-child', '/layer-parent/layer-child', Record<never, never>, Record<never, never>>,
'long-text': RouteRecordInfo<'long-text', '/long-text', Record<never, never>, Record<never, never>>,
'nested-test-route': RouteRecordInfo<'nested-test-route', '/nested/test-route', Record<never, never>, Record<never, never>>,
'nuxt-context-extension': RouteRecordInfo<'nuxt-context-extension', '/nuxt-context-extension', Record<never, never>, Record<never, never>>,
'page with spaces': RouteRecordInfo<'page with spaces', '/page%20with%20spaces', Record<never, never>, Record<never, never>>,
'post-id': RouteRecordInfo<'post-id', '/post/:id()', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'products': RouteRecordInfo<'products', '/products', Record<never, never>, Record<never, never>>,
'products-slug': RouteRecordInfo<'products-slug', '/products/:slug()', { slug: ParamValue<true> }, { slug: ParamValue<false> }>,
'user-profile': RouteRecordInfo<'user-profile', '/user/profile', Record<never, never>, Record<never, never>>,
}
}
"
`)
})
})
35 changes: 35 additions & 0 deletions specs/experimental/typed_pages_explicit_disable.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { test, expect, describe } from 'vitest'
import { fileURLToPath } from 'node:url'
import { setup } from '../utils'
import fs from 'node:fs/promises'

await setup({
rootDir: fileURLToPath(new URL(`../fixtures/basic_usage`, import.meta.url)),
browser: false,
// overrides
nuxtConfig: {
experimental: {
typedPages: true
},
i18n: {
experimental: {
typedPages: false
}
}
}
})

describe('`experimental.typedPages` explicitly disabled', async () => {
test('does not generate types', async () => {
await expect(
fs.access(
fileURLToPath(
new URL(
`../fixtures/basic_usage/.nuxt/___experimental_typed_pages_explicit_disable_spec_ts/types/typed-router-i18n.d.ts`,
import.meta.url
)
)
)
).rejects.toThrowError()
})
})
3 changes: 2 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ export const DEFAULT_OPTIONS = {
experimental: {
localeDetector: '',
switchLocalePathLinkSSR: false,
autoImportTranslationFunctions: false
autoImportTranslationFunctions: false,
typedPages: true
},
bundle: {
compositionOnly: true,
Expand Down
88 changes: 88 additions & 0 deletions src/gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,92 @@ function genImportSpecifier(
return getLoadPath()
}

/**
* From vuejs/router
* https://github.com/vuejs/router/blob/14219b01bee142423265a3aaacd1eac0dcc95071/packages/router/src/typed-routes/route-map.ts
* https://github.com/vuejs/router/blob/14219b01bee142423265a3aaacd1eac0dcc95071/packages/router/src/typed-routes/route-location.ts
*
* Depends on `TypesConfig`
* https://github.com/vuejs/router/blob/14219b01bee142423265a3aaacd1eac0dcc95071/packages/router/src/config.ts#L14
* Depends on the same mechanism of `RouteNamedMap
* https://github.com/vuejs/router/blob/14219b01bee142423265a3aaacd1eac0dcc95071/packages/router/vue-router-auto.d.ts#L4
*/
const typedRouterAugmentations = `
declare module 'vue-router' {
import type { RouteNamedMapI18n } from 'vue-router/auto-routes'
export interface TypesConfig {
RouteNamedMapI18n: RouteNamedMapI18n
}
export type RouteMapI18n =
TypesConfig extends Record<'RouteNamedMapI18n', infer RouteNamedMap> ? RouteNamedMap : RouteMapGeneric
// Prefer named resolution for i18n
export type RouteLocationNamedI18n<Name extends keyof RouteMapI18n = keyof RouteMapI18n> =
| Name
| Omit<RouteLocationAsRelativeI18n, 'path'> & { path?: string }
/**
* Note: disabled route path string autocompletion, this can break depending on \`strategy\`
* this can be enabled again after route resolve has been improved.
*/
// | RouteLocationAsStringI18n
// | RouteLocationAsPathI18n
export type RouteLocationRawI18n<Name extends keyof RouteMapI18n = keyof RouteMapI18n> =
RouteMapGeneric extends RouteMapI18n
? RouteLocationAsStringI18n | RouteLocationAsRelativeGeneric | RouteLocationAsPathGeneric
:
| _LiteralUnion<RouteLocationAsStringTypedList<RouteMapI18n>[Name], string>
| RouteLocationAsRelativeTypedList<RouteMapI18n>[Name]
export type RouteLocationResolvedI18n<Name extends keyof RouteMapI18n = keyof RouteMapI18n> =
RouteMapGeneric extends RouteMapI18n
? RouteLocationResolvedGeneric
: RouteLocationResolvedTypedList<RouteMapI18n>[Name]
export interface RouteLocationNormalizedLoadedTypedI18n<
RouteMapI18n extends RouteMapGeneric = RouteMapGeneric,
Name extends keyof RouteMapI18n = keyof RouteMapI18n
> extends RouteLocationNormalizedLoadedGeneric {
name: Extract<Name, string | symbol>
params: RouteMapI18n[Name]['params']
}
export type RouteLocationNormalizedLoadedTypedListI18n<RouteMapOriginal extends RouteMapGeneric = RouteMapGeneric> = {
[N in keyof RouteMapOriginal]: RouteLocationNormalizedLoadedTypedI18n<RouteMapOriginal, N>
}
export type RouteLocationNormalizedLoadedI18n<Name extends keyof RouteMapI18n = keyof RouteMapI18n> =
RouteMapGeneric extends RouteMapI18n
? RouteLocationNormalizedLoadedGeneric
: RouteLocationNormalizedLoadedTypedListI18n<RouteMapI18n>[Name]
type _LiteralUnion<LiteralType, BaseType extends string = string> = LiteralType | (BaseType & Record<never, never>)
export type RouteLocationAsStringI18n<Name extends keyof RouteMapI18n = keyof RouteMapI18n> =
RouteMapGeneric extends RouteMapI18n
? string
: _LiteralUnion<RouteLocationAsStringTypedList<RouteMapI18n>[Name], string>
export type RouteLocationAsRelativeI18n<Name extends keyof RouteMapI18n = keyof RouteMapI18n> =
RouteMapGeneric extends RouteMapI18n
? RouteLocationAsRelativeGeneric
: RouteLocationAsRelativeTypedList<RouteMapI18n>[Name]
export type RouteLocationAsPathI18n<Name extends keyof RouteMapI18n = keyof RouteMapI18n> =
RouteMapGeneric extends RouteMapI18n ? RouteLocationAsPathGeneric : RouteLocationAsPathTypedList<RouteMapI18n>[Name]
/**
* Helper to generate a type safe version of the {@link RouteLocationAsRelative} type.
*/
export interface RouteLocationAsRelativeTypedI18n<
RouteMapI18n extends RouteMapGeneric = RouteMapGeneric,
Name extends keyof RouteMapI18n = keyof RouteMapI18n
> extends RouteLocationAsRelativeGeneric {
name?: Extract<Name, string | symbol>
params?: RouteMapI18n[Name]['paramsRaw']
}
}`

export function generateI18nTypes(nuxt: Nuxt, options: NuxtI18nOptions) {
const vueI18nTypes = options.types === 'legacy' ? ['VueI18n'] : ['ExportedGlobalComposer', 'Composer']
const generatedLocales = simplifyLocaleOptions(nuxt, options)
Expand Down Expand Up @@ -203,6 +289,8 @@ declare module '#app' {
}
}
${typedRouterAugmentations}
${(options.experimental?.autoImportTranslationFunctions && globalTranslationTypes) || ''}
export {}`
Expand Down
2 changes: 1 addition & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export default defineNuxtModule<NuxtI18nOptions>({
/**
* setup nuxt/pages
*/
setupPages(ctx, nuxt)
await setupPages(ctx, nuxt)

/**
* ignore `/` during prerender when using prefixed routing
Expand Down
Loading

0 comments on commit c103d13

Please sign in to comment.