diff --git a/packages/unplugin-vue-i18n/README.md b/packages/unplugin-vue-i18n/README.md index 77aa44e7..d0b820b4 100644 --- a/packages/unplugin-vue-i18n/README.md +++ b/packages/unplugin-vue-i18n/README.md @@ -607,6 +607,22 @@ If do you will use this option, you need to enable `jitCompilation` option. By using it you can exclude from the bundle those localizations that are not specified in this option. +### `hmr` + +- **Type:** `boolean` +- **Default:** `true` + + HMR without page reload for bundled virtual messages `@intlify/unplugin-vue-i18n/messages`. + + > [!IMPORTANT] > Vite support only + +### `appRootContainer` + +- **Type:** `string` +- **Default:** `'#app'` + + The selector used find the Vue app root container during HMR. + ### `useVueI18nImportName` (Experimental) - **Type:** `boolean` diff --git a/packages/unplugin-vue-i18n/src/core/options.ts b/packages/unplugin-vue-i18n/src/core/options.ts index 1a76615a..467376f8 100644 --- a/packages/unplugin-vue-i18n/src/core/options.ts +++ b/packages/unplugin-vue-i18n/src/core/options.ts @@ -57,6 +57,8 @@ export function resolveOptions(options: PluginOptions) { const ssrBuild = !!options.ssr const allowDynamic = !!options.allowDynamic + const hmr = options.hmr ?? true + const appRootContainer = options.appRootContainer ?? '#app' const strictMessage = isBoolean(options.strictMessage) ? options.strictMessage @@ -83,6 +85,8 @@ export function resolveOptions(options: PluginOptions) { return { include, exclude, + hmr, + appRootContainer, module: moduleType, onlyLocales, forceStringify, diff --git a/packages/unplugin-vue-i18n/src/core/resource.ts b/packages/unplugin-vue-i18n/src/core/resource.ts index 94426d62..36c8aeec 100644 --- a/packages/unplugin-vue-i18n/src/core/resource.ts +++ b/packages/unplugin-vue-i18n/src/core/resource.ts @@ -51,6 +51,7 @@ export function resourcePlugin( exclude, module, forceStringify, + appRootContainer, defaultSFCLang, globalSFCScope, runtimeOnly, @@ -61,6 +62,7 @@ export function resourcePlugin( strictMessage, allowDynamic, escapeHtml, + hmr, transformI18nBlock }: ResolvedOptions, meta: UnpluginContextMeta @@ -230,6 +232,16 @@ export function resourcePlugin( } } }, + configureServer(server) { + // client could not find vue app root during HMR + server.ws.on('unplugin-vue-i18n:app-root-missing', _ => { + console.error( + `[unplugin-vue-i18n] Unable to find vue-i18n instance on vue app root container with selector "${appRootContainer}" - skipping HMR and reloading page.\n` + + `The selector used can be configured using the \`appRootcontainer\` option.` + ) + server.ws.send({ type: 'full-reload' }) + }) + }, async handleHotUpdate({ file, server }) { if (/\.(json5?|ya?ml)$/.test(file)) { @@ -326,7 +338,9 @@ export function resourcePlugin( { forceStringify, strictMessage, - escapeHtml + escapeHtml, + hmr, + appRootContainer } ) // TODO: support virtual import identifier @@ -526,6 +540,8 @@ async function generateBundleResources( strictMessage = true, escapeHtml = false, jit = true, + hmr = false, + appRootContainer, transformI18nBlock = undefined }: { forceStringify?: boolean @@ -534,6 +550,8 @@ async function generateBundleResources( strictMessage?: boolean escapeHtml?: boolean jit?: boolean + hmr?: boolean + appRootContainer: string transformI18nBlock?: PluginOptions['transformI18nBlock'] } ) { @@ -562,6 +580,38 @@ async function generateBundleResources( } } + const hmrCode = ` +if(import.meta.hot) { + function uniqueKeys(...objects) { + const keySet = new Set() + + for (const obj of objects) { + for (const key of Object.keys(obj)) { + keySet.add(key) + } + } + + return Array.from(keySet) + } + + import.meta.hot.accept(mod => { + // retrieve global i18n instance + const i18n = document.querySelector('${appRootContainer}')?.__vue_app__?.__VUE_I18N__?.global + if(i18n == null) { + import.meta.hot.send('unplugin-vue-i18n:app-root-missing', {}) + return + } + + // locale keys of both original and updated merged messages + const localeKeys = uniqueKeys(merged, mod.default) + + // set locale messages for each locale key or overwrite with empty object if deleted + for(const locale of localeKeys){ + i18n.setLocaleMessage(locale, mod.default[locale] || {}) + } + }) +}` + return `const isObject = (item) => item && typeof item === 'object' && !Array.isArray(item); const mergeDeep = (target, ...sources) => { @@ -582,9 +632,13 @@ const mergeDeep = (target, ...sources) => { return mergeDeep(target, ...sources); } -export default mergeDeep({}, +const merged = mergeDeep({}, ${codes.map(code => `{${code}}`).join(',\n')} -);` +); +export default merged + +${isProduction || !hmr ? '' : hmrCode} +` } async function getCode( diff --git a/packages/unplugin-vue-i18n/src/index.ts b/packages/unplugin-vue-i18n/src/index.ts index 6c8d8ed4..3a1b0fba 100644 --- a/packages/unplugin-vue-i18n/src/index.ts +++ b/packages/unplugin-vue-i18n/src/index.ts @@ -1,6 +1,11 @@ import { createUnplugin } from 'unplugin' import createDebug from 'debug' -import { raiseError, resolveNamespace } from './utils' +import { + getWebpackNotSupportedMessage, + raiseError, + resolveNamespace, + warn +} from './utils' import { resolveOptions, resourcePlugin, directivePlugin } from './core' import type { UnpluginFactory, UnpluginInstance } from 'unplugin' @@ -24,13 +29,14 @@ export const unpluginFactory: UnpluginFactory = ( const resolvedOptions = resolveOptions(options) debug('plugin options (resolved):', resolvedOptions) + if (options.hmr && meta.framework === 'webpack') { + warn(getWebpackNotSupportedMessage('hmr')) + } + const plugins = [resourcePlugin(resolvedOptions, meta)] if (resolvedOptions.optimizeTranslationDirective) { if (meta.framework === 'webpack') { - raiseError( - `The 'optimizeTranslationDirective' option still is not supported for webpack.\n` + - `We are waiting for your Pull Request 🙂.` - ) + raiseError(getWebpackNotSupportedMessage('optimizeTranslationDirective')) } plugins.push(directivePlugin(resolvedOptions)) } diff --git a/packages/unplugin-vue-i18n/src/types.ts b/packages/unplugin-vue-i18n/src/types.ts index 5b8276d5..eb8cba60 100644 --- a/packages/unplugin-vue-i18n/src/types.ts +++ b/packages/unplugin-vue-i18n/src/types.ts @@ -10,6 +10,8 @@ export interface PluginOptions { runtimeOnly?: boolean compositionOnly?: boolean ssr?: boolean + hmr?: boolean + appRootContainer?: string fullInstall?: boolean forceStringify?: boolean defaultSFCLang?: SFCLangFormat diff --git a/packages/unplugin-vue-i18n/src/utils/log.ts b/packages/unplugin-vue-i18n/src/utils/log.ts index e079e247..4f5b54ff 100644 --- a/packages/unplugin-vue-i18n/src/utils/log.ts +++ b/packages/unplugin-vue-i18n/src/utils/log.ts @@ -12,3 +12,11 @@ export function error(...args: unknown[]) { export function raiseError(message: string) { throw new Error(`[${PKG_NAME}] ${message}`) } + +// TODO: extract warn/error messages similar to vue-i18n structure +export function getWebpackNotSupportedMessage(optionName: string) { + return ( + `The '${optionName}' option still is not supported for webpack.\n` + + `We are waiting for your Pull Request 🙂.` + ) +}