From 1aa2871a999777aebe889ab30e5585493c27810d Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Mon, 4 May 2020 00:40:46 +0900 Subject: [PATCH 1/2] refactor: removes --- src/i18n.ts | 1 - src/mixin.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/i18n.ts b/src/i18n.ts index 150fc8ac5..443caaa99 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -241,7 +241,6 @@ function setupLifeCycle( const instance = getCurrentInstance() if (instance) { if (instance.proxy && instance.proxy.$el.__intlify__) { - instance.proxy.$el.__intlify__ = undefined delete instance.proxy.$el.__intlify__ } } diff --git a/src/mixin.ts b/src/mixin.ts index 615036216..aceb3f3f6 100644 --- a/src/mixin.ts +++ b/src/mixin.ts @@ -52,7 +52,6 @@ export function defineMixin( }, beforeDestroy() { - this.$el.__intlify__ = undefined delete this.$el.__intlify__ } } From 9eab33ebd3f0d3d59ff14983014f893419df008e Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Tue, 5 May 2020 03:08:01 +0900 Subject: [PATCH 2/2] breaking: locale inheritance --- README.md | 4 + e2e/fallback/component.test.js | 4 +- e2e/scope/inherit-locale.test.js | 63 ++++ e2e/scope/local.test.js | 14 +- examples/composable/scope/inherit-locale.html | 98 ++++++ examples/legacy/scope/inherit-locale.html | 94 ++++++ src/components/DatetimeFormat.ts | 18 +- src/components/NumberFormat.ts | 13 +- src/components/Translation.ts | 14 +- src/composer.ts | 73 ++++- src/i18n.ts | 288 ++++++++++++------ src/index.ts | 10 +- src/legacy.ts | 38 +-- src/mixin.ts | 33 +- src/plugin.ts | 11 +- test/composer.test.ts | 65 +++- test/i18n.test.ts | 25 ++ 17 files changed, 679 insertions(+), 186 deletions(-) create mode 100644 e2e/scope/inherit-locale.test.js create mode 100644 examples/composable/scope/inherit-locale.html create mode 100644 examples/legacy/scope/inherit-locale.html create mode 100644 test/i18n.test.ts diff --git a/README.md b/README.md index b0711d2cd..a01bf9a07 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,8 @@ The examples are offered that use the following two API styles: - Legacy API: `warnHtmlInMessage` property. - For development mode, warning is default. - For production mode, HTML message detect is not check due to performance. +- Legacy API `sync` option: + - default: change to `false` from `true` - `VueI18n.version` -> `import { VERSION } from 'vue-i18n'` - `VueI18n.availabilities` -> `import { availabilities } from 'vue-i18n'` - See the details [here](https://github.com/intlify/vue-i18n-next/blob/master/docs/vue-i18n.md) @@ -158,6 +160,7 @@ yarn add vue-i18n@next - properties - [x] locale - [x] fallbackLocale + - [x] inheritLocale - [x] availableLocales - [x] messages - [x] modifiers @@ -190,6 +193,7 @@ yarn add vue-i18n@next - VueI18n - [x] locale - [x] fallbackLocale + - [x] sync - [x] availableLocales - [x] messages - [x] pluralizationRules diff --git a/e2e/fallback/component.test.js b/e2e/fallback/component.test.js index 6f9dd2157..62fd5d8d0 100644 --- a/e2e/fallback/component.test.js +++ b/e2e/fallback/component.test.js @@ -8,9 +8,7 @@ test('initial rendering', async () => { await expect(page).toMatch('こんにちは、世界') - await expect(page).toMatch( - 'Component1 locale messages: こんにちは、component1' - ) + await expect(page).toMatch('Component1 locale messages: hello component1') await expect(page).toMatch( 'Fallback global locale messages: おはよう、世界!' ) diff --git a/e2e/scope/inherit-locale.test.js b/e2e/scope/inherit-locale.test.js new file mode 100644 index 000000000..b24427196 --- /dev/null +++ b/e2e/scope/inherit-locale.test.js @@ -0,0 +1,63 @@ +;['composable', 'legacy'].forEach(pattern => { + describe(`${pattern}`, () => { + beforeAll(async () => { + await page.goto( + `http://localhost:8080/examples/${pattern}/scope/inherit-locale.html` + ) + }) + + test('initial rendering', async () => { + await expect(page).toMatchElement('#app p', { + text: 'こんにちは、世界!' + }) + await expect(page).toMatchElement('#app div.child p', { + text: 'こんにちは!' + }) + await expect(page).toMatchElement('#app label[for=checkbox]', { + text: 'root から locale を継承する' + }) + }) + + test('change locale', async () => { + // root + await expect(page).toSelect('#app select', 'en') + await expect(page).toMatchElement('#app p', { text: 'hello world!' }) + await expect(page).toMatchElement('#app div.child p', { text: 'Hi !' }) + await expect(page).toMatchElement('#app label[for=checkbox]', { + text: 'Inherit locale from root' + }) + + // Child + await expect(page).toSelect('#app div.child select', 'ja') + await expect(page).toMatchElement('#app p', { text: 'hello world!' }) + await expect(page).toMatchElement('#app div.child p', { + text: 'こんにちは!' + }) + await expect(page).toMatchElement('#app label[for=checkbox]', { + text: 'root から locale を継承する' + }) + + // checkbox off + await expect(page).toClick('#checkbox') + await expect(page).toSelect('#app select', 'ja') + await expect(page).toSelect('#app select', 'en') + await expect(page).toMatchElement('#app p', { text: 'hello world!' }) + await expect(page).toMatchElement('#app div.child p', { + text: 'こんにちは!' + }) + await expect(page).toMatchElement('#app label[for=checkbox]', { + text: 'root から locale を継承する' + }) + + // checkbox on + await expect(page).toClick('#checkbox') + await expect(page).toSelect('#app select', 'ja') + await expect(page).toSelect('#app select', 'en') + await expect(page).toMatchElement('#app p', { text: 'hello world!' }) + await expect(page).toMatchElement('#app div.child p', { text: 'Hi !' }) + await expect(page).toMatchElement('#app label[for=checkbox]', { + text: 'Inherit locale from root' + }) + }) + }) +}) diff --git a/e2e/scope/local.test.js b/e2e/scope/local.test.js index 35de1f5c8..8adbeafb6 100644 --- a/e2e/scope/local.test.js +++ b/e2e/scope/local.test.js @@ -10,23 +10,21 @@ await expect(page).toMatchElement('#app p', { text: 'こんにちは、世界!' }) - await expect(page).toMatchElement('#app div.child p', { - text: 'こんにちは!' - }) + await expect(page).toMatchElement('#app div.child p', { text: 'Hi !' }) }) test('change locale', async () => { // root await expect(page).toSelect('#app select', 'en') await expect(page).toMatchElement('#app p', { text: 'hello world!' }) - await expect(page).toMatchElement('#app div.child p', { - text: 'こんにちは!' - }) + await expect(page).toMatchElement('#app div.child p', { text: 'Hi !' }) // Child - await expect(page).toSelect('#app div.child select', 'en') + await expect(page).toSelect('#app div.child select', 'ja') await expect(page).toMatchElement('#app p', { text: 'hello world!' }) - await expect(page).toMatchElement('#app div.child p', { text: 'Hi !' }) + await expect(page).toMatchElement('#app div.child p', { + text: 'こんにちは!' + }) }) }) }) diff --git a/examples/composable/scope/inherit-locale.html b/examples/composable/scope/inherit-locale.html new file mode 100644 index 000000000..d799ed865 --- /dev/null +++ b/examples/composable/scope/inherit-locale.html @@ -0,0 +1,98 @@ + + + + + Inherit locale example + + + + +
+

Root

+
+ + +
+

{{ t("message.hello") }}

+ +
+ + + diff --git a/examples/legacy/scope/inherit-locale.html b/examples/legacy/scope/inherit-locale.html new file mode 100644 index 000000000..fc17dd331 --- /dev/null +++ b/examples/legacy/scope/inherit-locale.html @@ -0,0 +1,94 @@ + + + + + Inherit locale example + + + + +
+

Root

+
+ + +
+

{{ $t("message.hello") }}

+ +
+ + + diff --git a/src/components/DatetimeFormat.ts b/src/components/DatetimeFormat.ts index 096fa11c5..bc73cfc47 100644 --- a/src/components/DatetimeFormat.ts +++ b/src/components/DatetimeFormat.ts @@ -1,10 +1,5 @@ -import { - getCurrentInstance, - defineComponent, - SetupContext, - PropType -} from 'vue' -import { useI18n, getComposer } from '../i18n' +import { defineComponent, SetupContext, PropType } from 'vue' +import { useI18n } from '../i18n' import { DateTimeOptions } from '../core' import { renderFormatter, FormattableProps } from './formatRenderer' @@ -51,14 +46,7 @@ export const DatetimeFormat = defineComponent({ }, /* eslint-enable */ setup(props, context: SetupContext) { - const instance = getCurrentInstance() - // TODO: should be raise unexpected error, if `instance` is null - const i18n = - instance !== null - ? instance.parent !== null - ? getComposer(instance.parent) - : useI18n() - : useI18n() + const i18n = useI18n({ useScope: 'parent' }) return renderFormatter< FormattableProps, diff --git a/src/components/NumberFormat.ts b/src/components/NumberFormat.ts index 684989e8e..b02c30576 100644 --- a/src/components/NumberFormat.ts +++ b/src/components/NumberFormat.ts @@ -1,5 +1,5 @@ -import { getCurrentInstance, defineComponent, SetupContext } from 'vue' -import { useI18n, getComposer } from '../i18n' +import { defineComponent, SetupContext } from 'vue' +import { useI18n } from '../i18n' import { NumberOptions } from '../core' import { renderFormatter, FormattableProps } from './formatRenderer' @@ -41,14 +41,7 @@ export const NumberFormat = defineComponent({ }, /* eslint-enable */ setup(props, context: SetupContext) { - const instance = getCurrentInstance() - // TODO: should be raise unexpected error, if `instance` is null - const i18n = - instance !== null - ? instance.parent !== null - ? getComposer(instance.parent) - : useI18n() - : useI18n() + const i18n = useI18n({ useScope: 'parent' }) return renderFormatter< FormattableProps, diff --git a/src/components/Translation.ts b/src/components/Translation.ts index 25fc6e993..7d1612004 100644 --- a/src/components/Translation.ts +++ b/src/components/Translation.ts @@ -3,10 +3,9 @@ import { Fragment, defineComponent, SetupContext, - VNodeArrayChildren, - getCurrentInstance + VNodeArrayChildren } from 'vue' -import { useI18n, getComposer } from '../i18n' +import { useI18n } from '../i18n' import { TranslateOptions, Locale } from '../core' import { NamedValue } from '../message/runtime' import { isNumber, isString } from '../utils' @@ -41,14 +40,7 @@ export const Translation = defineComponent({ /* eslint-enable */ setup(props: TranslationProps, context: SetupContext) { const { slots, attrs } = context - const instance = getCurrentInstance() - // TODO: should be raise unexpected error, if `instance` is null - const i18n = - instance !== null - ? instance.parent !== null - ? getComposer(instance.parent) - : useI18n() - : useI18n() + const i18n = useI18n({ useScope: 'parent' }) const keys = Object.keys(slots).filter(key => key !== '_') return () => { diff --git a/src/composer.ts b/src/composer.ts index b1482387f..f1cb37629 100644 --- a/src/composer.ts +++ b/src/composer.ts @@ -9,12 +9,11 @@ import { ref, computed, getCurrentInstance, - App, ComponentInternalInstance, - createTextVNode + createTextVNode, + watch } from 'vue' import { WritableComputedRef, ComputedRef } from '@vue/reactivity' -import { apply } from './plugin' import { Path, parse as parsePath } from './path' import { DateTimeFormats, @@ -83,12 +82,15 @@ export type PreCompileHandler = () => { } export type CustomBlocks = string[] | PreCompileHandler -/*! - * Composer Options +/** + * Composer Options + * + * This is options to create composer. */ export type ComposerOptions = { locale?: Locale fallbackLocale?: FallbackLocale + inheritLocale?: boolean messages?: LocaleMessages datetimeFormats?: DateTimeFormats numberFormats?: NumberFormats @@ -101,12 +103,14 @@ export type ComposerOptions = { fallbackFormat?: boolean postTranslation?: PostTranslationHandler warnHtmlMessage?: boolean - __i18n?: CustomBlocks // for custom blocks, and internal - __root?: Composer // for internal + __i18n?: CustomBlocks + __root?: Composer } -/*! - * Composer Interfaces +/** + * Composer Interfaces + * + * This is the interface for being used for Vue 3 Composition API. */ export type Composer = { /*! @@ -114,12 +118,14 @@ export type Composer = { */ locale: WritableComputedRef fallbackLocale: WritableComputedRef + inheritLocale: boolean readonly availableLocales: Locale[] readonly messages: ComputedRef readonly datetimeFormats: ComputedRef readonly numberFormats: ComputedRef readonly modifiers: LinkedModifiers readonly pluralRules?: PluralizationRules + readonly isGlobal: boolean missingWarn: boolean | RegExp fallbackWarn: boolean | RegExp fallbackRoot: boolean @@ -166,7 +172,6 @@ export type Composer = { setPostTranslationHandler(handler: PostTranslationHandler | null): void getMissingHandler(): MissingHandler | null setMissingHandler(handler: MissingHandler | null): void - install(app: App, ...options: unknown[]): void __transrateVNode(...args: unknown[]): unknown // for internal __numberParts(...args: unknown[]): string | Intl.NumberFormatPart[] // for internal __datetimeParts(...args: unknown[]): string | Intl.DateTimeFormatPart[] // for internal @@ -251,12 +256,19 @@ export function addPreCompileMessages( }) } +/** + * Create composer interface factory + * @internal + */ export function createComposer(options: ComposerOptions = {}): Composer { const { __root } = options + const _isGlobal = __root === undefined + + let _inheritLocale = !!options.inheritLocale const _locale = ref( // prettier-ignore - __root + __root && _inheritLocale ? __root.locale.value : isString(options.locale) ? options.locale @@ -265,7 +277,7 @@ export function createComposer(options: ComposerOptions = {}): Composer { const _fallbackLocale = ref( // prettier-ignore - __root + __root && _inheritLocale ? __root.fallbackLocale.value : isString(options.fallbackLocale) || isArray(options.fallbackLocale) || @@ -439,7 +451,7 @@ export function createComposer(options: ComposerOptions = {}): Composer { // NOTE: // if this composer is global (__root is `undefined`), add dependency trakcing! // by containing this, we can reactively notify components that reference the global composer. - if (!__root) { + if (!_isGlobal) { _locale.value } @@ -626,6 +638,24 @@ export function createComposer(options: ComposerOptions = {}): Composer { // for debug composerID++ + // watch root locale & fallbackLocale + if (__root) { + watch(__root.locale, (val: Locale) => { + if (_inheritLocale) { + _locale.value = val + _context.locale = val + updateFallbackLocale(_context, _locale.value, _fallbackLocale.value) + } + }) + watch(__root.fallbackLocale, (val: FallbackLocale) => { + if (_inheritLocale) { + _fallbackLocale.value = val + _context.fallbackLocale = val + updateFallbackLocale(_context, _locale.value, _fallbackLocale.value) + } + }) + } + // export composable API! const composer = { /*! @@ -633,6 +663,17 @@ export function createComposer(options: ComposerOptions = {}): Composer { */ locale, fallbackLocale, + get inheritLocale(): boolean { + return _inheritLocale + }, + set inheritLocale(val: boolean) { + _inheritLocale = val + if (val && __root) { + _locale.value = __root.locale.value + _fallbackLocale.value = __root.fallbackLocale.value + updateFallbackLocale(_context, _locale.value, _fallbackLocale.value) + } + }, get availableLocales(): Locale[] { return Object.keys(_messages.value).sort() }, @@ -645,6 +686,9 @@ export function createComposer(options: ComposerOptions = {}): Composer { get pluralRules(): PluralizationRules | undefined { return _pluralRules }, + get isGlobal(): boolean { + return _isGlobal + }, get missingWarn(): boolean | RegExp { return _missingWarn }, @@ -699,9 +743,6 @@ export function createComposer(options: ComposerOptions = {}): Composer { setPostTranslationHandler, getMissingHandler, setMissingHandler, - install(app: App, ...options: unknown[]): void { - apply(app, composer, ...options) - }, __transrateVNode, __numberParts, __datetimeParts diff --git a/src/i18n.ts b/src/i18n.ts index 443caaa99..1127c8579 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1,76 +1,89 @@ import { - provide, inject, onMounted, onUnmounted, InjectionKey, getCurrentInstance, ComponentInternalInstance, - ComponentOptions + ComponentOptions, + App } from 'vue' import { Composer, ComposerOptions, createComposer } from './composer' import { createVueI18n, VueI18n, VueI18nOptions } from './legacy' +import { apply } from './plugin' +import { defineMixin } from './mixin' import { isEmptyObject } from './utils' -const generateSymbolID = (): string => - `vue-i18n-${new Date().getUTCMilliseconds().toString()}` - -export const GlobalI18nSymbol: InjectionKey = Symbol.for('vue-i18n') -let globalInstance: VueI18n | Composer | null = null - -const providers: Map< - ComponentInternalInstance, - InjectionKey -> = new Map() - -const getGlobalComposer = (): Composer => { - if (globalInstance === null) throw new Error('TODO') // TODO: - return '__composer' in globalInstance - ? globalInstance.__composer - : globalInstance -} - -// TODO: if we don't need the below, should be removed! -// This code should be removed with using rollup (`/*#__PURE__*/`) -export function enumProviders(): void { - if (__DEV__) { - providers.forEach((sym, instance) => { - console.log('provider:', instance, sym) - }) - } -} - -export function getComposer( - instance: ComponentInternalInstance | null -): Composer { - if (!instance) { - return getGlobalComposer() - } - const symbol = providers.get(instance) - return symbol ? inject(symbol, getGlobalComposer()) : getGlobalComposer() -} - -/*! +/** * I18n Options * - * {@link createI18n} factory option. - * * @remarks - * `I18nOptions` is union type of {@link ComposerOptions} and {@link VueI18nOptions}, so you can specify these options. + * `I18nOptions` is inherited {@link ComposerOptions} and {@link VueI18nOptions}, so you can specify these options. * */ export type I18nOptions = { /** + * Whether vue-i18n legacy API use on your Vue App. * @defaultValue `false` */ legacy?: boolean } & (ComposerOptions | VueI18nOptions) -/*! - * I18n factory +/** + * I18n API mode + */ +export type I18nMode = 'legacy' | 'composable' + +/** + * I18n interface + */ +export type I18n = { + readonly mode: I18nMode + install(app: App, ...options: unknown[]): void +} + +/** + * I18n interface for internal usage + * @internal + */ +export type I18nInternal = { + readonly _global: Composer + _getComposer(instance: ComponentInternalInstance): Composer | null + _setComposer(instance: ComponentInternalInstance, composer: Composer): void + _deleteComposer(instance: ComponentInternalInstance): void + _getLegacy(instance: ComponentInternalInstance): VueI18n | null + _setLegacy(instance: ComponentInternalInstance, legacy: VueI18n): void + _deleteLegacy(instance: ComponentInternalInstance): void +} + +/** + * I18n Scope + */ +export type I18nScope = 'local' | 'parent' | 'global' + +/** + * `useI18n` options + * + * @remarks + * `UseI18nOptions` is inherited {@link ComposerOptions}, so you can specify these options. + */ +export type UseI18nOptions = { + useScope?: I18nScope // default 'global' +} & ComposerOptions + +/** + * I18n instance injectin key + * @internal + */ +export const I18nSymbol: InjectionKey = Symbol.for( + 'vue-i18n' +) + +/** + * I18n factory function * * @param options - see the {@link I18nOptions} - * @returns {@link Composer} object, or {@link VueI18n} object + * @returns {@link I18n} object * * @remarks * When you use Composable API, you need to specify options of {@link ComposerOptions}. @@ -133,26 +146,71 @@ export type I18nOptions = { * app.mount('#app') * ``` */ -export function createI18n(options: I18nOptions = {}): Composer | VueI18n { - if (globalInstance !== null) { - return globalInstance +export function createI18n(options: I18nOptions = {}): I18n { + const __legacyMode = !!options.legacy + const __composers = new Map() + const __legaceis = new Map() + const __global = __legacyMode + ? createVueI18n(options) + : createComposer(options) + + const i18n = { + // mode + get mode(): I18nMode { + return __legacyMode ? 'legacy' : 'composable' + }, + install(app: App, ...options: unknown[]): void { + apply(app, i18n, ...options) + if (__legacyMode) { + app.mixin( + defineMixin( + __global as VueI18n, + (__global as VueI18n).__composer, + i18n + ) + ) + } + }, + get _global(): Composer { + return __legacyMode + ? (__global as VueI18n).__composer + : (__global as Composer) + }, + _getComposer(instance: ComponentInternalInstance): Composer | null { + return __composers.get(instance) || null + }, + _setComposer( + instance: ComponentInternalInstance, + composer: Composer + ): void { + __composers.set(instance, composer) + }, + _deleteComposer(instance: ComponentInternalInstance): void { + __composers.delete(instance) + }, + _getLegacy(instance: ComponentInternalInstance): VueI18n | null { + return __legaceis.get(instance) || null + }, + _setLegacy(instance: ComponentInternalInstance, legacy: VueI18n): void { + __legaceis.set(instance, legacy) + }, + _deleteLegacy(instance: ComponentInternalInstance): void { + __legaceis.delete(instance) + } } - const legacyMode = !!options.legacy - return (globalInstance = legacyMode - ? createVueI18n(options) - : createComposer(options)) + return i18n } -/*! - * Use Composable API +/** + * Use Composable API starting function * - * @param options - See the {@link ComponentOptions} + * @param options - See {@link UseI18nOptions} * @returns {@link Composer} object * * @remarks * This function is mainly used by `setup`. - * If options are specified Composer object is created for each component, and you can be localized on the component. + * If options are specified, Composer object is created for each component and you can be localized on the component. * If options are not specified, you can be localized using the global Composer. * * @example @@ -189,60 +247,114 @@ export function createI18n(options: I18nOptions = {}): Composer | VueI18n { * * ``` */ -export function useI18n(options: ComposerOptions = {}): Composer { - const globalComposer = getGlobalComposer() +export function useI18n(options: UseI18nOptions = {}): Composer { + const i18n = inject(I18nSymbol) + // TODO: should be error + if (!i18n) { + throw new Error('TODO') + } + + const global = i18n._global + let emptyOption = false + // prettier-ignore + const scope: I18nScope = (emptyOption = isEmptyObject(options)) // eslint-disable-line no-cond-assign + ? 'global' + : !options.useScope + ? 'local' + : options.useScope + + if (emptyOption) { + return global + } + + // TODO: should be unexpected error (vue runtime error!) const instance = getCurrentInstance() - if (instance === null || isEmptyObject(options)) { - return globalComposer + if (instance == null) { + throw new Error('TODO') + } + + if (scope === 'parent') { + let composer = getComposer(i18n, instance) + if (composer == null) { + // TODO: warning! + composer = global + } + return composer + } else if (scope === 'global') { + return global } - const symbol = providers.get(instance) - if (!symbol) { + // scope 'local' case + if (i18n.mode === 'legacy') { + // TODO: + throw new Error('TODO') + } + + let composer = i18n._getComposer(instance) + if (composer == null) { const type = instance.type as ComponentOptions if (type.__i18n) { options.__i18n = type.__i18n } - if (globalComposer) { - options.__root = globalComposer + if (global) { + options.__root = global } - const composer = createComposer(options) - setupLifeCycle(instance, composer) + composer = createComposer(options) + setupLifeCycle(i18n, instance, composer) - const sym: InjectionKey = Symbol.for(generateSymbolID()) - providers.set(instance, sym) - provide(sym, composer) + i18n._setComposer(instance, composer) + } - return composer - } else { - const composer = inject(symbol) || globalComposer - if (!composer) throw new Error('TODO') // TODO: - return composer + return composer +} + +function getComposer( + i18n: I18n & I18nInternal, + target: ComponentInternalInstance +): Composer | null { + let composer: Composer | null = null + const root = target.root + let current: ComponentInternalInstance | null = target.parent + while (current != null) { + if (i18n.mode === 'composable') { + composer = i18n._getComposer(current) + } else { + const vueI18n = i18n._getLegacy(current) + if (vueI18n != null) { + composer = vueI18n.__composer + } + } + if (composer != null) { + break + } + if (root === current) { + break + } + current = current.parent } + return composer } function setupLifeCycle( - instance: ComponentInternalInstance | null, + i18n: I18nInternal, + target: ComponentInternalInstance, composer: Composer ): void { onMounted(() => { // inject composer instance to DOM for intlify-devtools - if (instance) { - if (instance.proxy) { - instance.proxy.$el.__intlify__ = composer - } + if (target.proxy) { + target.proxy.$el.__intlify__ = composer } - }) + }, target) onUnmounted(() => { // remove composer instance from DOM for intlify-devtools - const instance = getCurrentInstance() - if (instance) { - if (instance.proxy && instance.proxy.$el.__intlify__) { - delete instance.proxy.$el.__intlify__ - } + if (target.proxy && target.proxy.$el.__intlify__) { + delete target.proxy.$el.__intlify__ } - }) + i18n._deleteComposer(target) + }, target) } diff --git a/src/index.ts b/src/index.ts index 7979f1ccc..2fc14841b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,14 @@ export { VueI18nOptions, VueI18n } from './legacy' -export { createI18n, useI18n, I18nOptions } from './i18n' +export { + createI18n, + useI18n, + I18nOptions, + I18n, + I18nMode, + I18nScope, + UseI18nOptions +} from './i18n' export { I18nPluginOptions } from './plugin' export const VERSION = __VERSION__ diff --git a/src/legacy.ts b/src/legacy.ts index 4f1c50e35..a3a871414 100644 --- a/src/legacy.ts +++ b/src/legacy.ts @@ -4,8 +4,6 @@ * This module is offered legacy vue-i18n API compatibility */ -import { App } from 'vue' -import { defineMixin } from './mixin' import { Path, resolveValue } from './path' import { PluralizationRule, @@ -58,10 +56,10 @@ export interface Formatter { interpolate(message: string, values: any, path: string): Array | null } -/*! +/** * VueI18n Options * - * This option type is compatible with the constructor options of `VueI18n` class (offered with vue-i18n@8.x). + * This option is compatible with the constructor options of `VueI18n` class (offered with vue-i18n@8.x). */ export type VueI18nOptions = { locale?: Locale @@ -83,14 +81,14 @@ export type VueI18nOptions = { pluralizationRules?: PluralizationRules postTranslation?: PostTranslationHandler sync?: boolean - __i18n?: CustomBlocks // for custom blocks, and internal - __root?: Composer // for internal + __i18n?: CustomBlocks + __root?: Composer } -/*! +/** * VueI18n Interfaces * - * This type is compatible with interface of `VueI18n` class (offered with vue-i18n@8.x). + * This interface is compatible with interface of `VueI18n` class (offered with vue-i18n@8.x). */ export type VueI18n = { /*! @@ -156,11 +154,11 @@ export type VueI18n = { setNumberFormat(locale: Locale, format: NumberFormat): void mergeNumberFormat(locale: Locale, format: NumberFormat): void getChoiceIndex: (choice: Choice, choicesLength: number) => number - install(app: App, ...options: unknown[]): void } /** - * Convert to I18n Composer Options from VueI18n Options + * Convert to I18n Composer Options from VueI18n Options + * @internal */ function convertComposerOptions(options: VueI18nOptions): ComposerOptions { const locale = isString(options.locale) ? options.locale : 'en-US' @@ -193,6 +191,7 @@ function convertComposerOptions(options: VueI18nOptions): ComposerOptions { const warnHtmlMessage = isString(options.warnHtmlInMessage) ? options.warnHtmlInMessage !== 'off' : true + const inheritLocale = !!options.sync if (__DEV__ && options.formatter) { warn(`not supportted 'formatter' option`) @@ -227,15 +226,15 @@ function convertComposerOptions(options: VueI18nOptions): ComposerOptions { pluralRules: pluralizationRules, postTranslation, warnHtmlMessage, + inheritLocale, __i18n, __root } } -/*! - * createVueI18n factory - * - * This function is compatible with constructor of `VueI18n` class (offered with vue-i18n@8.x) like `new VueI18n(...)`. +/** + * create VueI18n interface factory + * @internal */ export function createVueI18n(options: VueI18nOptions = {}): VueI18n { const composer = createComposer(convertComposerOptions(options)) @@ -340,11 +339,12 @@ export function createVueI18n(options: VueI18nOptions = {}): VueI18n { composer.setPostTranslationHandler(handler) }, + // sync get sync(): boolean { - return isBoolean(options.sync) ? options.sync : false + return composer.inheritLocale }, set sync(val: boolean) { - options.sync = val + composer.inheritLocale = val }, // warnInHtmlMessage @@ -492,12 +492,6 @@ export function createVueI18n(options: VueI18nOptions = {}): VueI18n { getChoiceIndex(choice: Choice, choicesLength: number): number { __DEV__ && warn(`not supportted 'getChoiceIndex' method.`) return -1 - }, - - // install - install(app: App, ...options: unknown[]): void { - composer.install(app, ...options) - app.mixin(defineMixin(vueI18n, composer)) } } diff --git a/src/mixin.ts b/src/mixin.ts index aceb3f3f6..1f30f9318 100644 --- a/src/mixin.ts +++ b/src/mixin.ts @@ -1,4 +1,4 @@ -import { ComponentOptions } from 'vue' +import { ComponentOptions, getCurrentInstance } from 'vue' import { Path } from './path' import { Locale } from './core/context' import { Composer } from './composer' @@ -10,16 +10,23 @@ import { DateTimeFormatResult, NumberFormatResult } from './legacy' +import { I18nInternal } from './i18n' // supports compatibility for legacy vue-i18n APIs export function defineMixin( legacy: VueI18n, - composer: Composer + composer: Composer, + i18n: I18nInternal ): ComponentOptions { return { beforeCreate() { - const options = this.$options + const instance = getCurrentInstance() + if (!instance) { + // TODO: + throw new Error('TODO') + } + const options = this.$options if (options.i18n) { const optionsI18n = options.i18n as VueI18nOptions if (options.__i18n) { @@ -27,12 +34,17 @@ export function defineMixin( } optionsI18n.__root = composer this.$i18n = createVueI18n(optionsI18n) + + i18n._setLegacy(instance, this.$i18n) } else if (options.__i18n) { this.$i18n = createVueI18n({ __i18n: options.__i18n, __root: composer }) + + i18n._setLegacy(instance, this.$i18n) } else { + // set global this.$i18n = legacy } @@ -52,7 +64,22 @@ export function defineMixin( }, beforeDestroy() { + const instance = getCurrentInstance() + if (!instance) { + // TODO: + throw new Error('TODO') + } + delete this.$el.__intlify__ + + delete this.$t + delete this.$tc + delete this.$te + delete this.$d + delete this.$n + + i18n._deleteLegacy(instance) + delete this.$i18n } } } diff --git a/src/plugin.ts b/src/plugin.ts index 0c2e25e40..2747e7b24 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,6 +1,5 @@ import { App, FunctionDirective } from 'vue' -import { Composer } from './composer' -import { GlobalI18nSymbol } from './i18n' +import { I18nSymbol, I18n } from './i18n' import { Translation, NumberFormat, DatetimeFormat } from './components' import { hook as vT } from './directive' import { isPlainObject, isString, warn } from './utils' @@ -9,11 +8,7 @@ export type I18nPluginOptions = { 'i18n-t'?: string } -export function apply( - app: App, - composer: Composer, - ...options: unknown[] -): void { +export function apply(app: App, i18n: I18n, ...options: unknown[]): void { const pluginOptions = parseOptions(...options) if (__DEV__ && isString(pluginOptions['i18n-t'])) { @@ -31,7 +26,7 @@ export function apply( app.directive('t', vT as FunctionDirective) // TODO: // setup global provider - app.provide(GlobalI18nSymbol, composer) + app.provide(I18nSymbol, i18n) } function parseOptions(...options: unknown[]): I18nPluginOptions { diff --git a/test/composer.test.ts b/test/composer.test.ts index 88ea31214..468a020fb 100644 --- a/test/composer.test.ts +++ b/test/composer.test.ts @@ -13,7 +13,7 @@ import { addPreCompileMessages } from '../src/composer' import { generateFormatCacheKey } from '../src/utils' -import { watch } from 'vue' +import { watch, nextTick } from 'vue' describe('locale', () => { test('default value', () => { @@ -48,6 +48,69 @@ describe('fallbackLocale', () => { }) }) +describe('inheritLocale', () => { + test('default value', () => { + const root = createComposer({ locale: 'en' }) + const { inheritLocale, locale } = createComposer({ + locale: 'ja', + __root: root + }) + expect(inheritLocale).toEqual(false) + expect(locale.value).toEqual('ja') + }) + + test('initialize with composer option', () => { + const root = createComposer({ locale: 'en' }) + const { inheritLocale, locale } = createComposer({ + locale: 'ja', + inheritLocale: true, + __root: root + }) + expect(inheritLocale).toEqual(true) + expect(locale.value).toEqual('en') + }) + + test('sync root locale, fallbackLocale', async () => { + const root = createComposer({ + locale: 'en', + fallbackLocale: ['ja', 'fr'] + }) + const composer = createComposer({ + locale: 'ja', + fallbackLocale: ['zh', 'de'], + inheritLocale: true, + __root: root + }) + await nextTick() + + expect(composer.locale.value).toEqual('en') + expect(composer.fallbackLocale.value).toEqual(['ja', 'fr']) + + root.locale.value = 'ja' + root.fallbackLocale.value = ['zh', 'de'] + await nextTick() + + expect(composer.locale.value).toEqual('ja') + expect(composer.fallbackLocale.value).toEqual(['zh', 'de']) + + composer.inheritLocale = false + await nextTick() + + root.locale.value = 'en' + root.fallbackLocale.value = ['ja', 'fr'] + await nextTick() + + expect(composer.locale.value).toEqual('ja') + expect(composer.fallbackLocale.value).toEqual(['zh', 'de']) + + composer.inheritLocale = true + await nextTick() + + expect(composer.locale.value).toEqual('en') + expect(composer.fallbackLocale.value).toEqual(['ja', 'fr']) + }) +}) + describe('availableLocales', () => { test('not initialize messages at composer creating', () => { const { availableLocales } = createComposer({}) diff --git a/test/i18n.test.ts b/test/i18n.test.ts new file mode 100644 index 000000000..0ff1e3f75 --- /dev/null +++ b/test/i18n.test.ts @@ -0,0 +1,25 @@ +import { createI18n } from '../src/i18n' + +describe('createI18n', () => { + test('legay mode', () => { + const i18n = createI18n({ + legacy: true + }) + + expect(i18n.mode).toEqual('legacy') + }) + + test('composable mode', () => { + const i18n = createI18n({}) + + expect(i18n.mode).toEqual('composable') + }) +}) + +describe('useI18n', () => { + test.todo('basic') + test.todo('global scope') + test.todo('parent scope') + test.todo('not plugin installed') + test.todo('not used in setup function') +})