Skip to content
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

feat: experimental typed routes #3142

Merged
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a37559c
feat: generated route types
BobbieGoede Sep 27, 2024
65becf5
chore: use experimental `typedPages` in playground
BobbieGoede Sep 27, 2024
2bb26c9
fix: type generation throwing error during `prepare` step
BobbieGoede Sep 27, 2024
1214bd2
feat: improve `defineI18nRoute` parameter type
BobbieGoede Sep 27, 2024
63e553f
fix: linting errors
BobbieGoede Sep 27, 2024
d05df07
fix: linting errors
BobbieGoede Sep 27, 2024
d836d51
fix: linting errors
BobbieGoede Sep 27, 2024
88fe1e3
fix: linting errors
BobbieGoede Sep 27, 2024
2f7b3d2
fix: rename conflicting router type
BobbieGoede Sep 28, 2024
10353ac
refactor: split experiment route type feature
BobbieGoede Sep 28, 2024
87101f7
chore: reference to original and dependency router source types
BobbieGoede Sep 28, 2024
d937c52
fix: remove unnecessary logic
BobbieGoede Sep 29, 2024
50030ea
docs: describe `experimental.typedPages` option
BobbieGoede Sep 29, 2024
113a490
fix: add missing i18n suffix to router augmentation
BobbieGoede Sep 29, 2024
5226fc0
refactor: remove internal global type augmentation
BobbieGoede Sep 29, 2024
16ac01a
refactor: rename typed router function
BobbieGoede Sep 29, 2024
e3ec9fa
fix: remove duplicate generated type
BobbieGoede Sep 29, 2024
40a6543
fix: auto enable when nuxt `typedPages` is enabled
BobbieGoede Sep 30, 2024
d661a2d
test: add some very basic tests
BobbieGoede Sep 30, 2024
91539df
chore: add `unplugin-vue-router` dependency
BobbieGoede Sep 30, 2024
4a407ac
docs: update default description
BobbieGoede Sep 30, 2024
cc6de7c
chore: cleanup
BobbieGoede Sep 30, 2024
385db97
fix: `NuxtLinkLocale` type completion
BobbieGoede Sep 30, 2024
56c6f51
fix: prevent completion for paths in i18n route utilities
BobbieGoede Sep 30, 2024
312efe2
fix: allow passing `path` but provide no completion
BobbieGoede Sep 30, 2024
352ce15
fix: linting errors
BobbieGoede Sep 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: `false`) - Generates route types used in composables and configuration.
::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.
BobbieGoede marked this conversation as resolved.
Show resolved Hide resolved
::


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.
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: 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: false
},
bundle: {
compositionOnly: true,
Expand Down
82 changes: 82 additions & 0 deletions src/gen.ts
Copy link
Collaborator

Choose a reason for hiding this comment

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

wow! This typing is too amazing!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I should reference the original source types πŸ˜… I had to redefine/reimplement a bunch of internal types of vue-router and uvr, so the credits for these go to @posva

Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,86 @@ 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

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 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 +283,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
126 changes: 121 additions & 5 deletions src/pages.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import createDebug from 'debug'
import { extendPages } from '@nuxt/kit'
import { addTemplate, extendPages } from '@nuxt/kit'
import { isString } from '@intlify/shared'
import { parse as parseSFC, compileScript } from '@vue/compiler-sfc'
import { walk } from 'estree-walker'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import MagicString from 'magic-string'
import { formatMessage, getRoutePath, parseSegment, readFileSync } from './utils'
import { localizeRoutes } from './routing'
import { mergeLayerPages } from './layers'
import { resolve, parse as parsePath } from 'pathe'
import { resolve, parse as parsePath, dirname } from 'pathe'
import { NUXT_I18N_COMPOSABLE_DEFINE_ROUTE } from './constants'
import { createRoutesContext } from 'unplugin-vue-router'
import { resolveOptions } from 'unplugin-vue-router/options'

import type { Nuxt, NuxtPage } from '@nuxt/schema'
import type { NuxtI18nOptions, CustomRoutePages, ComputedRouteOptions, RouteOptionsResolver } from './types'
import type { Node, ObjectExpression, ArrayExpression } from '@babel/types'
import type { EditableTreeNode, Options as TypedRouterOptions } from 'unplugin-vue-router'
import type { NuxtI18nOptions, CustomRoutePages, ComputedRouteOptions, RouteOptionsResolver } from './types'
import type { I18nNuxtContext } from './context'

const debug = createDebug('@nuxtjs/i18n:pages')
Expand All @@ -35,7 +39,7 @@ export type NuxtPageAnalyzeContext = {
pages: Map<NuxtPage, AnalyzedNuxtPageMeta>
}

export function setupPages({ localeCodes, options, isSSR }: I18nNuxtContext, nuxt: Nuxt) {
export async function setupPages({ localeCodes, options, isSSR }: I18nNuxtContext, nuxt: Nuxt) {
if (!localeCodes.length) return

let includeUnprefixedFallback = !isSSR
Expand All @@ -48,7 +52,9 @@ export function setupPages({ localeCodes, options, isSSR }: I18nNuxtContext, nux
const srcDir = nuxt.options.srcDir
debug(`pagesDir: ${pagesDir}, srcDir: ${srcDir}, trailingSlash: ${options.trailingSlash}`)

extendPages(pages => {
const typedRouter = await setupExperimentalTypedRoutes(options, nuxt)

extendPages(async pages => {
debug('pages making ...', pages)
const ctx: NuxtPageAnalyzeContext = {
stack: [],
Expand All @@ -61,6 +67,10 @@ export function setupPages({ localeCodes, options, isSSR }: I18nNuxtContext, nux
const analyzer = (pageDirOverride: string) => analyzeNuxtPages(ctx, pages, pageDirOverride)
mergeLayerPages(analyzer, nuxt)

if (typedRouter) {
await typedRouter.createContext(pages).scanPages(false)
}

const localizedPages = localizeRoutes(pages, {
...options,
includeUnprefixedFallback,
Expand All @@ -83,6 +93,112 @@ export function setupPages({ localeCodes, options, isSSR }: I18nNuxtContext, nux
})
}

/**
* Expression to to find the `RouteNamedMap` generated by uvr, used to replace with `RouteNamedMapI18n`
*/
const routeNamedMapTypeRE = /RouteNamedMap\b/

/**
* Declaration file containing the generated route types
*/
const declarationFile = './types/typed-router-i18n.d.ts'

/**
* Setup experiment typed routes feature if enabled
*/
async function setupExperimentalTypedRoutes(userOptions: NuxtI18nOptions, nuxt: Nuxt) {
if (!nuxt.options.experimental.typedPages || !userOptions.experimental?.typedPages) {
return undefined
}

const dtsFile = resolve(nuxt.options.buildDir, declarationFile)

/**
* Typed route generation from Nuxt with modifications
* https://github.com/nuxt/nuxt/blob/781d8c4174c410a7aff6b809817b15eae85d3ba8/packages/nuxt/src/pages/module.ts#L160-L208
*/
function createContext(pages: NuxtPage[]) {
const typedRouteroptions: TypedRouterOptions = {
routesFolder: [],
dts: dtsFile,
logs: nuxt.options.debug,
watch: false,
// eslint-disable-next-line @typescript-eslint/require-await
async beforeWriteFiles(rootPage) {
rootPage.children.forEach(child => child.delete())
function addPage(parent: EditableTreeNode, page: NuxtPage) {
// @ts-expect-error TODO: either fix types upstream or figure out another
// way to add a route without a file, which must be possible
const route = parent.insert(page.path, page.file)
if (page.meta) {
route.addToMeta(page.meta)
}
if (page.alias) {
route.addAlias(page.alias)
}
if (page.name) {
route.name = page.name
}
// TODO: implement redirect support
// if (page.redirect) {}
if (page.children) {
page.children.forEach(child => addPage(route, child))
}
}

for (const page of pages) {
addPage(rootPage, page)
}
}
}

const context = createRoutesContext(resolveOptions(typedRouteroptions))

/**
* Wrap `scanPages` to rename interface `RouteNamedMap` => `RouteNamedMapI18n`
*/
const originalScanPages = context.scanPages.bind(context)
context.scanPages = async function (watchers = false) {
await mkdir(dirname(dtsFile), { recursive: true })
await originalScanPages(watchers)

const dtsContent = await readFile(dtsFile, 'utf-8')

if (routeNamedMapTypeRE.test(dtsContent)) {
await writeFile(dtsFile, dtsContent.replace(routeNamedMapTypeRE, 'RouteNamedMapI18n'))
}
}

return context
}

addTemplate({
filename: resolve(nuxt.options.buildDir, './types/i18n-generated-route-types.d.ts'),
getContents: () => {
return `// Generated by @nuxtjs/i18n
declare module 'vue-router' {
import type { RouteNamedMapI18n } from 'vue-router/auto-routes'

export interface TypesConfig {
RouteNamedMapI18n: RouteNamedMapI18n
}
}

export {}`
}
})

nuxt.hook('prepare:types', ({ references }) => {
// This file will be generated by unplugin-vue-router
references.push({ path: declarationFile })
references.push({ types: './types/i18n-generated-route-types.d.ts' })
})

await createContext(nuxt.apps.default?.pages ?? []).scanPages(false)

return { createContext }
}

/**
* Analyze page path
*/
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/components/NuxtLinkLocale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import { hasProtocol } from 'ufo'

import type { PropType } from 'vue'
import type { NuxtLinkProps } from 'nuxt/app'
import type { RouteLocationRawI18n } from 'vue-router'

const NuxtLinkLocale = defineNuxtLink({ componentName: 'NuxtLinkLocale' })

export default defineComponent<NuxtLinkProps & { locale?: Locale }>({
export default defineComponent<Omit<NuxtLinkProps, 'to'> & { to?: RouteLocationRawI18n; locale?: Locale }>({
name: 'NuxtLinkLocale',
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- FIXME
props: {
Expand Down
Loading