diff --git a/build.config.ts b/build.config.ts index ed764f4b1..cdca6d168 100644 --- a/build.config.ts +++ b/build.config.ts @@ -1,5 +1,6 @@ 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'], + failOnWarn: false }) diff --git a/src/gen.ts b/src/gen.ts index da80ece41..a898bb11e 100644 --- a/src/gen.ts +++ b/src/gen.ts @@ -209,6 +209,75 @@ declare module '#app' { } } +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 = + RouteMapGeneric extends RouteMapI18n + ? RouteLocationAsStringI18n | RouteLocationAsRelativeGeneric | RouteLocationAsPathGeneric + : + | _LiteralUnion[Name], string> + | RouteLocationAsRelativeTypedList[Name] + + export type RouteLocationResolved = + RouteMapGeneric extends RouteMapI18n + ? RouteLocationResolvedGeneric + : RouteLocationResolvedTypedList[Name] + + export interface RouteLocationNormalizedLoadedTypedI18n< + RouteMapI18n extends RouteMapGeneric = RouteMapGeneric, + Name extends keyof RouteMapI18n = keyof RouteMapI18n + > extends RouteLocationNormalizedLoadedGeneric { + name: Extract + params: RouteMapI18n[Name]['params'] + } + export type RouteLocationNormalizedLoadedTypedListI18n = { + [N in keyof RouteMapOriginal]: RouteLocationNormalizedLoadedTypedI18n + } + export type RouteLocationNormalizedLoadedI18n = + RouteMapGeneric extends RouteMapI18n + ? RouteLocationNormalizedLoadedGeneric + : RouteLocationNormalizedLoadedTypedListI18n[Name] + + type _LiteralUnion = LiteralType | (BaseType & Record) + + export type RouteLocationAsStringI18n = + RouteMapGeneric extends RouteMapI18n + ? string + : _LiteralUnion[Name], string> + + export type RouteLocationAsStringI18n = + RouteMapGeneric extends RouteMapI18n + ? string + : _LiteralUnion[Name], string> + + export type RouteLocationAsRelativeI18n = + RouteMapGeneric extends RouteMapI18n + ? RouteLocationAsRelativeGeneric + : RouteLocationAsRelativeTypedList[Name] + + export type RouteLocationAsPathI18n = + RouteMapGeneric extends RouteMapI18n ? RouteLocationAsPathGeneric : RouteLocationAsPathTypedList[Name] + + /** + * Helper to generate a type safe version of the {@link RouteLocationAsRelative} type. + */ + export interface RouteLocationAsRelativeTyped< + RouteMapI18n extends RouteMapGeneric = RouteMapGeneric, + Name extends keyof RouteMapI18n = keyof RouteMapI18n + > extends RouteLocationAsRelativeGeneric { + name?: Extract + params?: RouteMapI18n[Name]['paramsRaw'] + } +} + ${(options.experimental?.autoImportTranslationFunctions && globalTranslationTypes) || ''} export {}` diff --git a/src/internal-global-types.d.ts b/src/internal-global-types.d.ts index cce768e84..07198bc96 100644 --- a/src/internal-global-types.d.ts +++ b/src/internal-global-types.d.ts @@ -14,6 +14,78 @@ declare module '#app' { } } +// This needs to be generated, or else `RouteMapI18n` will fall back to `RouteMapGeneric` +// declare module 'vue-router' { +// import type { RouteNamedMapI18n } from 'vue-router/auto-routes' + +// export interface TypesConfig { +// RouteNamedMapI18n: RouteNamedMapI18n +// } +// } + +declare module 'vue-router' { + export type RouteMapI18n = + TypesConfig extends Record<'RouteNamedMapI18n', infer RouteNamedMap> ? RouteNamedMap : RouteMapGeneric + + export type RouteLocationRawI18n = + RouteMapGeneric extends RouteMapI18n + ? RouteLocationAsStringI18n | RouteLocationAsRelativeGeneric | RouteLocationAsPathGeneric + : + | _LiteralUnion[Name], string> + | RouteLocationAsRelativeTypedList[Name] + + export type RouteLocationResolvedI18n = + RouteMapGeneric extends RouteMapI18n + ? RouteLocationResolvedGeneric + : RouteLocationResolvedTypedList[Name] + + export interface RouteLocationNormalizedLoadedTypedI18n< + RouteMapI18n extends RouteMapGeneric = RouteMapGeneric, + Name extends keyof RouteMapI18n = keyof RouteMapI18n + > extends RouteLocationNormalizedLoadedGeneric { + name: Extract + params: RouteMapI18n[Name]['params'] + } + export type RouteLocationNormalizedLoadedTypedListI18n = { + [N in keyof RouteMapOriginal]: RouteLocationNormalizedLoadedTypedI18n + } + export type RouteLocationNormalizedLoadedI18n = + RouteMapGeneric extends RouteMapI18n + ? RouteLocationNormalizedLoadedGeneric + : RouteLocationNormalizedLoadedTypedListI18n[Name] + + type _LiteralUnion = LiteralType | (BaseType & Record) + + export type RouteLocationAsStringI18n = + RouteMapGeneric extends RouteMapI18n + ? string + : _LiteralUnion[Name], string> + + export type RouteLocationAsStringI18n = + RouteMapGeneric extends RouteMapI18n + ? string + : _LiteralUnion[Name], string> + + export type RouteLocationAsRelativeI18n = + RouteMapGeneric extends RouteMapI18n + ? RouteLocationAsRelativeGeneric + : RouteLocationAsRelativeTypedList[Name] + + export type RouteLocationAsPathI18n = + RouteMapGeneric extends RouteMapI18n ? RouteLocationAsPathGeneric : RouteLocationAsPathTypedList[Name] + + /** + * Helper to generate a type safe version of the {@link RouteLocationAsRelative} type. + */ + export interface RouteLocationAsRelativeTyped< + RouteMapI18n extends RouteMapGeneric = RouteMapGeneric, + Name extends keyof RouteMapI18n = keyof RouteMapI18n + > extends RouteLocationAsRelativeGeneric { + name?: Extract + params?: RouteMapI18n[Name]['paramsRaw'] + } +} + declare global { var $t: Composer['t'] var $rt: Composer['rt'] diff --git a/src/module.ts b/src/module.ts index 588d20e8b..35b23833c 100644 --- a/src/module.ts +++ b/src/module.ts @@ -48,7 +48,7 @@ export * from './types' const debug = createDebug('@nuxtjs/i18n:module') -export default defineNuxtModule({ +export default defineNuxtModule & { locales?: string[] | LocaleObject[] }>({ meta: { name: NUXT_I18N_MODULE_ID, configKey: 'i18n', @@ -184,7 +184,7 @@ export default defineNuxtModule({ */ if (options.strategy !== 'no_prefix' && localeCodes.length) { - setupPages(options, nuxt) + await setupPages(options, nuxt) } /** diff --git a/src/pages.ts b/src/pages.ts index 8d0a212b6..f2f5090fe 100644 --- a/src/pages.ts +++ b/src/pages.ts @@ -1,19 +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 Node, type ObjectExpression, type ArrayExpression } from '@babel/types' +import type { EditableTreeNode, Options as TypedRouterOptions } from 'unplugin-vue-router' const debug = createDebug('@nuxtjs/i18n:pages') export type AnalyzedNuxtPageMeta = { @@ -34,7 +37,13 @@ export type NuxtPageAnalyzeContext = { pages: Map } -export function setupPages(options: Required, nuxt: Nuxt) { +/** + * Router type generation from Nuxt repo + * https://github.com/nuxt/nuxt/blob/48a8b18083ecfc5b9514d6683a2958e7a1a41e13/packages/nuxt/src/pages/module.ts#L160 + */ +export async function setupPages(options: Required, nuxt: Nuxt) { + const useExperimentalTypedPages = nuxt.options.experimental.typedPages + let includeUnprefixedFallback = nuxt.options.ssr === false nuxt.hook('nitro:init', () => { debug('enable includeUprefixedFallback') @@ -45,7 +54,88 @@ export function setupPages(options: Required, nuxt: Nuxt) { const srcDir = nuxt.options.srcDir debug(`pagesDir: ${pagesDir}, srcDir: ${srcDir}, trailingSlash: ${options.trailingSlash}`) - extendPages(pages => { + const typedRouterDtsFile = './types/typed-router-i18n.d.ts' + const dtsFile = resolve(nuxt.options.buildDir, typedRouterDtsFile) + function createTypedRouterContext(pages: NuxtPage[]) { + const typedRouteroptions: TypedRouterOptions = { + routesFolder: [], + dts: dtsFile, + logs: nuxt.options.debug, + // 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)) + return context + } + + if (useExperimentalTypedPages) { + // Augment `vue-router` + 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: typedRouterDtsFile }) + references.push({ types: './types/i18n-generated-route-types.d.ts' }) + }) + await mkdir(dirname(dtsFile), { recursive: true }) + + await createTypedRouterContext(nuxt.apps.default?.pages ?? []).scanPages(false) + + // Not sure why Nuxt does something like this + + // if (nuxt.options._prepare || !nuxt.options.dev) { + // // TODO: could we generate this from context instead? + // const dts = await readFile(dtsFile, 'utf-8') + // addTemplate({ + // filename: 'types/typed-router-i18n.d.ts', + // getContents: () => { + // return dts.replace('interface RouteNamedMap', 'interface RouteNamedMapI18n') + // } + // }) + // } + } + + extendPages(async pages => { debug('pages making ...', pages) const ctx: NuxtPageAnalyzeContext = { stack: [], @@ -58,6 +148,22 @@ export function setupPages(options: Required, nuxt: Nuxt) { const analyzer = (pageDirOverride: string) => analyzeNuxtPages(ctx, pages, pageDirOverride) mergeLayerPages(analyzer, nuxt) + if (useExperimentalTypedPages) { + const context = createTypedRouterContext(pages) + + // Wrap `scanPages`, rename interface + const originalScanPages = context.scanPages.bind(context) + context.scanPages = async function (watchers = true) { + await originalScanPages(watchers) + const f = await readFile(resolve(nuxt.options.buildDir, typedRouterDtsFile), 'utf-8') + await writeFile( + resolve(nuxt.options.buildDir, typedRouterDtsFile), + f.replace('interface RouteNamedMap', 'interface RouteNamedMapI18n') + ) + } + await context.scanPages(false) + } + const localizedPages = localizeRoutes(pages, { ...options, includeUnprefixedFallback, diff --git a/src/runtime/components/NuxtLinkLocale.ts b/src/runtime/components/NuxtLinkLocale.ts index e8a1ca6c7..f0c71ce69 100644 --- a/src/runtime/components/NuxtLinkLocale.ts +++ b/src/runtime/components/NuxtLinkLocale.ts @@ -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({ +export default defineComponent & { to?: RouteLocationRawI18n; locale?: Locale }>({ name: 'NuxtLinkLocale', // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- FIXME props: { diff --git a/src/runtime/composables/index.ts b/src/runtime/composables/index.ts index c0ac8c9d2..2f9f466c3 100644 --- a/src/runtime/composables/index.ts +++ b/src/runtime/composables/index.ts @@ -22,7 +22,13 @@ import { getLocale, getLocales, getComposer } from '../compatibility' import type { Ref } from 'vue' import type { Locale } from 'vue-i18n' -import type { RouteLocation, RouteLocationNormalizedLoaded, RouteLocationRaw, Router } from 'vue-router' +import type { + RouteLocationAsPathI18n, + RouteLocationAsRelativeI18n, + // RouteLocationAsStringI18n, + RouteLocationResolvedI18n, + RouteMapI18n +} from 'vue-router' import type { I18nHeadMetaInfo, I18nHeadOptions, SeoAttributesOptions } from '#build/i18n.options.mjs' import type { HeadParam } from '../utils' @@ -193,7 +199,18 @@ export function useLocaleHead({ * * @public */ -export type RouteBaseNameFunction = (givenRoute?: RouteLocationNormalizedLoaded) => string | undefined +export type RouteBaseNameFunction = ( + givenRoute: + | Name + /** + * Note: disabled route path string autocompletion, this can break depending on `strategy` + * this can be enabled again after route resolve has been improved. + */ + // | RouteLocationAsStringI18n + | RouteLocationAsRelativeI18n + | RouteLocationAsPathI18n, + locale?: Locale +) => string /** * The `useRouteBaseName` composable returns a function which returns the route base name. @@ -206,6 +223,7 @@ export type RouteBaseNameFunction = (givenRoute?: RouteLocationNormalizedLoaded) * @public */ export function useRouteBaseName(): RouteBaseNameFunction { + // @ts-expect-error - generated types conflict with the generic types we accept return wrapComposable(getRouteBaseName) } @@ -224,7 +242,19 @@ export function useRouteBaseName(): RouteBaseNameFunction { * * @public */ -export type LocalePathFunction = (route: RouteLocation | RouteLocationRaw, locale?: Locale) => string + +export type LocalePathFunction = ( + route: + | Name + /** + * Note: disabled route path string autocompletion, this can break depending on `strategy` + * this can be enabled again after route resolve has been improved. + */ + // | RouteLocationAsStringI18n + | RouteLocationAsRelativeI18n + | RouteLocationAsPathI18n, + locale?: Locale +) => string /** * The `useLocalePath` composable returns function that resolve the locale path. @@ -237,6 +267,7 @@ export type LocalePathFunction = (route: RouteLocation | RouteLocationRaw, local * @public */ export function useLocalePath(): LocalePathFunction { + // @ts-expect-error - generated types conflict with the generic types we accept return wrapComposable(localePath) } @@ -255,11 +286,18 @@ export function useLocalePath(): LocalePathFunction { * * @public */ -export type LocaleRouteFunction = ( - route: RouteLocationRaw, +export type LocaleRouteFunction = ( + route: + | Name + /** + * Note: disabled route path string autocompletion, this can break depending on `strategy` + * this can be enabled again after route resolve has been improved. + */ + // | RouteLocationAsStringI18n + | RouteLocationAsRelativeI18n + | RouteLocationAsPathI18n, locale?: Locale -) => ReturnType | undefined - +) => RouteLocationResolvedI18n | undefined /** * The `useLocaleRoute` composable returns function that resolve the locale route. * @@ -271,6 +309,7 @@ export type LocaleRouteFunction = ( * @public */ export function useLocaleRoute(): LocaleRouteFunction { + // @ts-expect-error - generated types conflict with the generic types we accept return wrapComposable(localeRoute) } @@ -289,7 +328,18 @@ export function useLocaleRoute(): LocaleRouteFunction { * * @public */ -export type LocaleLocationFunction = (route: RouteLocationRaw, locale?: Locale) => Location | RouteLocation | undefined +export type LocaleLocationFunction = ( + route: + | Name + /** + * Note: disabled route path string autocompletion, this can break depending on `strategy` + * this can be enabled again after route resolve has been improved. + */ + // | RouteLocationAsStringI18n + | RouteLocationAsRelativeI18n + | RouteLocationAsPathI18n, + locale?: Locale +) => RouteLocationResolvedI18n | undefined /** * The `useLocaleLocation` composable returns function that resolve the locale location. @@ -302,6 +352,7 @@ export type LocaleLocationFunction = (route: RouteLocationRaw, locale?: Locale) * @public */ export function useLocaleLocation(): LocaleLocationFunction { + // @ts-expect-error - generated types conflict with the generic types we accept return wrapComposable(localeLocation) } diff --git a/src/types.ts b/src/types.ts index 3e2cc3fc3..c0ad80f2d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,7 @@ import type { ParsedPath } from 'path' import type { PluginOptions } from '@intlify/unplugin-vue-i18n' import type { NuxtPage } from '@nuxt/schema' import type { STRATEGIES } from './constants' +import type { RouteMapGeneric, RouteMapI18n } from 'vue-router' export type RedirectOnOptions = 'all' | 'root' | 'no prefix' @@ -66,13 +67,10 @@ export interface RootRedirectOptions { statusCode: number } -export type CustomRoutePages = { - [key: string]: - | false - | { - [key: string]: false | string - } +type RouteLocationAsStringTypedListI18n = { + [N in keyof T]?: Partial> | false } +export type CustomRoutePages = RouteLocationAsStringTypedListI18n export interface ExperimentalFeatures { localeDetector?: string