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 vue-i18n and messages type generation #3151

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
38 changes: 31 additions & 7 deletions docs/content/docs/5.v9/3.options/10.misc.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,48 @@ description: Miscellaneous options.

## `experimental`

- type: `object`
- default: `{ localeDetector: '', switchLocalePathLinkSSR: false, autoImportTranslationFunctions: false }`
Experimental configuration property is an object with the following properties:

Supported properties:
### `experimental.localeDetector`
- type: `string`
- default: `''`
- Specify the locale detector to be called per request on the server side. You need to specify the filepath where the locale detector is defined.

- `localeDetector` (default: `''`) - Specify the locale detector to be called per request on the server side. You need to specify the filepath where the locale detector is defined.
::callout{icon="i-heroicons-exclamation-triangle" color="amber"}
About how to define the locale detector, see the [`defineI18nLocaleDetector` API](/docs/api#definei18nlocaledetector)
::
- `switchLocalePathLinkSSR` (default: `false`) - Changes the way dynamic route parameters are tracked and updated internally, improving language switcher SSR when using the [`SwitchLocalePathLink`](/docs/api/components#switchlocalepathlink) component.
- `autoImportTranslationFunctions` (default: `false`) - Automatically imports/initializes `$t`, `$rt`, `$d`, `$n`, `$tm` and `$te` functions in `<script setup>` when used.

### `experimental.switchLocalePathLinkSSR`
- type: `boolean`
- default: `false`
- Changes the way dynamic route parameters are tracked and updated internally, improving language switcher SSR when using the [`SwitchLocalePathLink`](/docs/api/components#switchlocalepathlink) component.

### `experimental.autoImportTranslationFunctions`
- type: `boolean`
- 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.
::

### `experimental.typedPages`
- type: `boolean`
- 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.
::

### `experimental.typedOptionsAndMessages`
- type: `false | 'default' | 'all'`
- `false` - disables type generation
- `'default'` - generate types based on configured `defaultLocale`
- `'all'` - generate types based on all configured locales
- default: `false`
- Generate `vue-i18n` and message types used in translation functions and `vue-i18n` configuration. Can be configured to use the `defaultLocale` (better performance) or all locales for type generation.


## `customBlocks`

Expand Down
3 changes: 2 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ export const DEFAULT_OPTIONS = {
localeDetector: '',
switchLocalePathLinkSSR: false,
autoImportTranslationFunctions: false,
typedPages: true
typedPages: true,
typedOptionsAndMessages: false
},
bundle: {
compositionOnly: true,
Expand Down
6 changes: 6 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { prepareStrategy } from './prepare/strategy'
import { prepareLayers } from './prepare/layers'
import { prepareTranspile } from './prepare/transpile'
import { prepareVite } from './prepare/vite'
import { prepareTypeGeneration } from './prepare/type-generation'

export * from './types'

Expand Down Expand Up @@ -75,6 +76,11 @@ export default defineNuxtModule<NuxtI18nOptions>({
*/
prepareRuntime(ctx, nuxt)

/**
* generate vue-i18n and messages types using runtime server endpoint
*/
prepareTypeGeneration(ctx, nuxt)

/**
* disable preloading/prefetching lazy loaded locales
*/
Expand Down
4 changes: 2 additions & 2 deletions src/nitro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ type AdditionalSetupNitroParams = {
}

export async function setupNitro(
{ genTemplate, isSSR, localeInfo, resolver, options: nuxtOptions }: I18nNuxtContext,
{ genTemplate, isSSR, localeInfo, resolver, options: nuxtOptions, isDev }: I18nNuxtContext,
nuxt: Nuxt
) {
const [enableServerIntegration, localeDetectionPath] = await resolveLocaleDetectorPath(nuxt)

nuxt.hook('nitro:config', async nitroConfig => {
if (enableServerIntegration) {
if (enableServerIntegration || (nuxtOptions.experimental.typedOptionsAndMessages && isDev)) {
const additionalParams: AdditionalSetupNitroParams = {
optionsCode: genTemplate(true, true),
localeInfo
Expand Down
129 changes: 129 additions & 0 deletions src/prepare/type-generation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { relative, resolve } from 'pathe'
import { addServerHandler, addTypeTemplate, updateTemplates, useNitro } from '@nuxt/kit'

import type { Nuxt } from '@nuxt/schema'
import type { I18nOptions } from 'vue-i18n'
import type { I18nNuxtContext } from '../context'

/**
* Simplifies messages object to properties of an interface
*/
function generateInterface(obj: Record<string, unknown>, indentLevel = 1) {
const indent = ' '.repeat(indentLevel)
let str = ''

for (const key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue

if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
str += `${indent}${key}: {\n`
str += generateInterface(obj[key] as Record<string, unknown>, indentLevel + 1)
str += `${indent}};\n`
} else {
// str += `${indent}/**\n`
// str += `${indent} * ${JSON.stringify(obj[key])}\n`
// str += `${indent} */\n`
let propertyType = Array.isArray(obj[key]) ? 'unknown[]' : typeof obj[key]
if (propertyType === 'function') {
propertyType = '() => string'
}
str += `${indent}${key}: ${propertyType};\n`
}
}
return str
}

const MERGED_OPTIONS_ENDPOINT = '__nuxt_i18n/merged'

export function prepareTypeGeneration(
{ resolver, options, localeInfo, vueI18nConfigPaths, isDev }: I18nNuxtContext,
nuxt: Nuxt
) {
if (options.experimental.typedOptionsAndMessages === false || !isDev) return

addServerHandler({
route: '/' + MERGED_OPTIONS_ENDPOINT,
// @ts-ignore
handler: resolver.resolve('./runtime/server/api/merged-options.get')
})

let res: Pick<I18nOptions, 'messages' | 'numberFormats' | 'datetimeFormats'>

const fetchMergedOptions = () => fetch(nuxt.options.devServer.url + MERGED_OPTIONS_ENDPOINT, { cache: 'no-cache' })

/**
* We use a runtime server endpoint to retrieve and merge options,
* to reuse existing options/message loading logic
*
* These hooks have been the most reliable way to fetch on startup when the endpoint is ready
*/
nuxt.hooks.hookOnce('vite:serverCreated', () => {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
const afterEachFn = useNitro().hooks.afterEach(async e => {
if (e.name === 'dev:reload') {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
res = await (await fetchMergedOptions()).json()
await updateTemplates({ filter: template => template.filename === 'types/i18n-messages.d.ts' })
afterEachFn()
} catch {
// console.log('fetching merged options endpoint failed')
}
}
})
})

addTypeTemplate({
filename: 'types/i18n-messages.d.ts',
getContents: () => {
// console.log(res)
if (res == null) return ''

return `// generated by @nuxtjs/i18n
import type { DateTimeFormatOptions, NumberFormatOptions, SpecificNumberFormatOptions, CurrencyNumberFormatOptions } from '@intlify/core'

interface GeneratedLocaleMessage {
${generateInterface(res.messages || {}).trim()}
}

interface GeneratedDateTimeFormat {
${Object.keys(res.datetimeFormats || {})
.map(k => `${k}: DateTimeFormatOptions;`)
.join(`\n `)}
}

interface GeneratedNumberFormat {
${Object.entries(res.numberFormats || {})
.map(([k]) => `${k}: NumberFormatOptions;`)
.join(`\n `)}
}

declare module 'vue-i18n' {
export interface DefineLocaleMessage extends GeneratedLocaleMessage {}
export interface DefineDateTimeFormat extends GeneratedDateTimeFormat {}
export interface DefineNumberFormat extends GeneratedNumberFormat {}
}

declare module '@intlify/core' {
export interface DefineCoreLocaleMessage extends GeneratedLocaleMessage {}
}

export {}`
}
})

// watch locale files for changes and update template
// TODO: consider conditionally checking absolute paths for Nuxt 4
const localePaths = localeInfo.flatMap(x => x.files.map(f => relative(nuxt.options.srcDir, f.path)))
nuxt.hook('builder:watch', async (_, path) => {
// compatibility see https://nuxt.com/docs/getting-started/upgrade#absolute-watch-paths-in-builderwatch
// TODO: consider conditionally checking absolute paths for Nuxt 4
path = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, path))

if (!localePaths.includes(path) && !vueI18nConfigPaths.some(x => x.absolute.includes(path))) return
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
res = await (await fetchMergedOptions()).json()
await updateTemplates({ filter: template => template.filename === 'types/i18n-messages.d.ts' })
})
}
54 changes: 54 additions & 0 deletions src/runtime/server/api/merged-options.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { deepCopy } from '@intlify/shared'
// @ts-ignore
import { defineEventHandler } from '#imports'
import { vueI18nConfigs, localeLoaders, nuxtI18nOptions, normalizedLocales } from '#internal/i18n/options.mjs'

import type { Locale, LocaleMessages } from 'vue-i18n'
import { loadLocale, loadVueI18nOptions } from '../../messages'
import { nuxtMock } from '../utils'
import type { DefineLocaleMessage } from '@intlify/h3'

export default defineEventHandler(async () => {
const messages = {}
const datetimeFormats = {}
const numberFormats = {}

const targetLocales: string[] = []
if (nuxtI18nOptions.experimental.typedOptionsAndMessages === 'default' && nuxtI18nOptions.defaultLocale != null) {
targetLocales.push(nuxtI18nOptions.defaultLocale)
} else if (nuxtI18nOptions.experimental.typedOptionsAndMessages === 'all') {
targetLocales.push(...normalizedLocales.map(x => x.code))
}

const vueI18nConfig = await loadVueI18nOptions(vueI18nConfigs, nuxtMock)
for (const locale in vueI18nConfig.messages) {
if (!targetLocales.includes(locale)) continue
deepCopy(vueI18nConfig.messages[locale] || {}, messages)
deepCopy(vueI18nConfig.numberFormats?.[locale] || {}, numberFormats)
deepCopy(vueI18nConfig.datetimeFormats?.[locale] || {}, datetimeFormats)
}

// @ts-ignore
const _defineI18nLocale = globalThis.defineI18nLocale
// @ts-ignore
globalThis.defineI18nLocale = val => val

for (const locale in localeLoaders) {
if (!targetLocales.includes(locale)) continue

const setter = (_: Locale, message: LocaleMessages<DefineLocaleMessage, Locale>) => {
deepCopy(message, messages)
}

await loadLocale(locale, localeLoaders, setter)
}

// @ts-ignore
globalThis.defineI18nLocale = _defineI18nLocale

return {
messages,
numberFormats,
datetimeFormats
}
})
5 changes: 1 addition & 4 deletions src/runtime/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,12 @@ import { defineI18nMiddleware } from '@intlify/h3'
import { localeCodes, vueI18nConfigs, localeLoaders } from '#internal/i18n/options.mjs'
import { defineNitroPlugin } from 'nitropack/dist/runtime/plugin'
import { localeDetector as _localeDetector } from '#internal/i18n/locale.detector.mjs'
import { nuxtMock } from './utils'
import { loadVueI18nOptions, loadInitialMessages, makeFallbackLocaleCodes, loadAndSetLocaleMessages } from '../messages'

import type { H3Event } from 'h3'
import type { Locale, DefineLocaleMessage } from 'vue-i18n'
import type { CoreContext } from '@intlify/h3'
import type { NuxtApp } from 'nuxt/app'

// eslint-disable-next-line @typescript-eslint/no-unsafe-return
const nuxtMock: { runWithContext: NuxtApp['runWithContext'] } = { runWithContext: async fn => await fn() }

// eslint-disable-next-line @typescript-eslint/no-misused-promises
export default defineNitroPlugin(async nitro => {
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/server/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type { NuxtApp } from 'nuxt/app'

// eslint-disable-next-line @typescript-eslint/no-unsafe-return
export const nuxtMock: { runWithContext: NuxtApp['runWithContext'] } = { runWithContext: async fn => await fn() }
10 changes: 10 additions & 0 deletions src/runtime/shared-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ export interface ExperimentalFeatures {
* @defaultValue `true`
*/
typedPages?: boolean

/**
* Generates types for vue-i18n and messages
*
* @defaultValue `false`
*
* @remark `'default'` to generate types based on `defaultLocale`
* @remark `'all'` to generate types based on all locales
*/
typedOptionsAndMessages?: false | 'default' | 'all'
}

export interface BundleOptions
Expand Down