Skip to content

Commit

Permalink
feat: experimental vue-i18n and messages type generation (#3151)
Browse files Browse the repository at this point in the history
  • Loading branch information
BobbieGoede authored Oct 7, 2024
1 parent 893f924 commit 4f73469
Show file tree
Hide file tree
Showing 9 changed files with 239 additions and 14 deletions.
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

0 comments on commit 4f73469

Please sign in to comment.