From 7f54852d40bc5d3700e27b6df451499af0cc64bb Mon Sep 17 00:00:00 2001 From: Tal Hadad Date: Thu, 28 Apr 2022 21:26:12 +0300 Subject: [PATCH 1/2] Allow to initialize a non-singleton i18n client. --- src/runtime/client.ts | 132 +++++++++ src/runtime/configs.ts | 26 +- src/runtime/includes/formatters.ts | 93 ++++--- src/runtime/index.ts | 9 + src/runtime/stores/formatters.ts | 61 ++++- src/runtime/stores/loading.ts | 8 +- src/runtime/stores/locale.ts | 106 ++++---- src/runtime/types/index.ts | 10 +- test/runtime/configs.test.ts | 11 +- test/runtime/stores/formatters.test.ts | 358 +++++++++++++++++-------- 10 files changed, 596 insertions(+), 218 deletions(-) create mode 100644 src/runtime/client.ts diff --git a/src/runtime/client.ts b/src/runtime/client.ts new file mode 100644 index 0000000..454b421 --- /dev/null +++ b/src/runtime/client.ts @@ -0,0 +1,132 @@ +import { setContext, getContext, hasContext, onDestroy } from 'svelte'; +import { Writable, Readable } from "svelte/store"; + +import type { + MessageFormatter, + TimeFormatter, + DateFormatter, + NumberFormatter, + JSONGetter, + ConfigureOptionsInit, +} from './types'; + +import { applyOptions, getOptions } from './configs'; + +import { $isLoading, createLoadingStore } from "./stores/loading"; +import { $locale, createLocaleStore } from "./stores/locale"; +import { $format, $formatDate, $formatNumber, $formatTime, $getJSON, createFormattingStores } from "./stores/formatters"; + +export type I18nClient = { + locale: Writable, + isLoading: Readable, + format: Readable, + t: Readable, + _: Readable, + time: Readable, + date: Readable, + number: Readable, + json: Readable, +} + +export function createI18nClient(opts?: ConfigureOptionsInit): I18nClient { + const isLoading = createLoadingStore(); + + const options = { ...getOptions() }; + const initialLocale = applyOptions(opts, options); + + const { localeStore } = createLocaleStore(isLoading, options.loadingDelay); + localeStore.set(initialLocale); + + const { format, formatTime, formatDate, formatNumber, getJSON } = createFormattingStores( + localeStore, () => options); + + return { + locale: localeStore, + isLoading, + format, + t: format, + _: format, + time: formatTime, + date: formatDate, + number: formatNumber, + json: getJSON, + }; +} + +const globalClient: I18nClient = { + locale: $locale, + isLoading: $isLoading, + format: $format, + t: $format, + _: $format, + time: $formatTime, + date: $formatDate, + number: $formatNumber, + json: $getJSON, +} + +const key = {}; + +const lifecycleFuncsStyle = { hasContext, setContext, getContext, onDestroy }; +let lifecycleFuncs: typeof lifecycleFuncsStyle | null = null; + +// Need the user to init it once, since we can't get the relevant functions by ourself by the way svelte compiling works. +// That is due to the fact that svelte is not a runtime dependency, rather just a code generator. +// It means for example that the svelte function "hasContext" is different between what this library sees, +// and what the user uses. +export function initLifecycleFuncs(funcs: typeof lifecycleFuncsStyle) { + lifecycleFuncs = { ...funcs }; +} + +function verifyLifecycleFuncsInit() { + if (!lifecycleFuncs) { + throw "Error: Lifecycle functions aren't initialized! Use initLifecycleFuncs() before."; + } +} + +type ClientContainer = { client: I18nClient | null }; + +// All the functions below can be called only in Svelte component initialization. + +export function setI18nClientInContext(i18nClient: I18nClient) : ClientContainer { + verifyLifecycleFuncsInit(); + + const clientContainer = { client: i18nClient }; + lifecycleFuncs!.setContext(key, clientContainer); + return clientContainer; +} + +export function clearI18nClientInContext(clientContainer: ClientContainer) { + clientContainer.client = null; +} + +// A shortcut function that initializes i18n client in context on component initialization +// and cleans it on component destruction. +export function setupI18nClientInComponentInit(opts?: ConfigureOptionsInit): I18nClient { + verifyLifecycleFuncsInit(); + + const client = createI18nClient(opts); + const container = setI18nClientInContext(client); + + // We clean the client from the context for robustness. + // Should svelte clean it by itself? + // Anyway it seems safer, because of the ability of the user to give custom lifecycle funcs. + lifecycleFuncs!.onDestroy(() => clearI18nClientInContext(container)); + + return client; +} + +export function getI18nClientInComponentInit(): I18nClient { + // Notice that unlike previous functions, calling this one without initializing lifecycle function is fine. + // In this case, the global client will be returned. + + if (lifecycleFuncs?.hasContext(key)) { + const { client } = lifecycleFuncs!.getContext(key); + if (client !== null) { + return client; + } + } + // otherwise + + return globalClient; +} \ No newline at end of file diff --git a/src/runtime/configs.ts b/src/runtime/configs.ts index d7cef18..9d416ce 100644 --- a/src/runtime/configs.ts +++ b/src/runtime/configs.ts @@ -68,13 +68,19 @@ export const defaultOptions: ConfigureOptions = { ignoreTag: true, }; -const options: ConfigureOptions = defaultOptions as any; +// Deep copy to options +const options: ConfigureOptions = JSON.parse(JSON.stringify(defaultOptions)) as any; export function getOptions() { return options; } -export function init(opts: ConfigureOptionsInit) { +export function applyOptions(opts: ConfigureOptionsInit | undefined, target: ConfigureOptions) { + if (opts === undefined) { + return undefined; + } + // otherwise + const { formats, ...rest } = opts; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const initialLocale = opts.initialLocale || opts.fallbackLocale; @@ -91,21 +97,27 @@ export function init(opts: ConfigureOptionsInit) { } } - Object.assign(options, rest, { initialLocale }); + Object.assign(target, rest, { initialLocale }); if (formats) { if ('number' in formats) { - Object.assign(options.formats.number, formats.number); + Object.assign(target.formats.number, formats.number); } if ('date' in formats) { - Object.assign(options.formats.date, formats.date); + Object.assign(target.formats.date, formats.date); } if ('time' in formats) { - Object.assign(options.formats.time, formats.time); + Object.assign(target.formats.time, formats.time); } } - return $locale.set(initialLocale); + return initialLocale; } + +export function init(opts: ConfigureOptionsInit) { + const initialLocale = applyOptions(opts, getOptions()); + + return $locale.set(initialLocale); +} \ No newline at end of file diff --git a/src/runtime/includes/formatters.ts b/src/runtime/includes/formatters.ts index eafc40e..2a7309c 100644 --- a/src/runtime/includes/formatters.ts +++ b/src/runtime/includes/formatters.ts @@ -1,4 +1,4 @@ -import IntlMessageFormat from 'intl-messageformat'; +import IntlMessageFormat, { Formats } from 'intl-messageformat'; import type { MemoizedIntlFormatter, @@ -31,9 +31,8 @@ type MemoizedDateTimeFormatterFactoryOptional = MemoizedIntlFormatterOptional< const getIntlFormatterOptions = ( type: 'time' | 'number' | 'date', name: string, + formats: Formats, ): any => { - const { formats } = getOptions(); - if (type in formats && name in formats[type]) { return formats[type][name]; } @@ -42,72 +41,88 @@ const getIntlFormatterOptions = ( }; const createNumberFormatter: MemoizedNumberFormatterFactory = monadicMemoize( - ({ locale, format, ...options }) => { - if (locale == null) { + ({ locale, ...options }) => { + if (!locale) { throw new Error('[svelte-i18n] A "locale" must be set to format numbers'); } - if (format) { - options = getIntlFormatterOptions('number', format); - } - return new Intl.NumberFormat(locale, options); }, ); const createDateFormatter: MemoizedDateTimeFormatterFactory = monadicMemoize( - ({ locale, format, ...options }) => { - if (locale == null) { + ({ locale, ...options }) => { + if (!locale) { throw new Error('[svelte-i18n] A "locale" must be set to format dates'); } - if (format) { - options = getIntlFormatterOptions('date', format); - } else if (Object.keys(options).length === 0) { - options = getIntlFormatterOptions('date', 'short'); - } - return new Intl.DateTimeFormat(locale, options); }, ); const createTimeFormatter: MemoizedDateTimeFormatterFactory = monadicMemoize( - ({ locale, format, ...options }) => { - if (locale == null) { + ({ locale, ...options }) => { + if (!locale) { throw new Error( '[svelte-i18n] A "locale" must be set to format time values', ); } - if (format) { - options = getIntlFormatterOptions('time', format); - } else if (Object.keys(options).length === 0) { - options = getIntlFormatterOptions('time', 'short'); - } - return new Intl.DateTimeFormat(locale, options); }, ); +const createMessageFormatter = monadicMemoize( + (message: string, locale = getCurrentLocale(), formats = getOptions().formats, + ignoreTag = getOptions().ignoreTag) => { + return new IntlMessageFormat(message, locale, formats, { + ignoreTag, + }) + }, +); + export const getNumberFormatter: MemoizedNumberFormatterFactoryOptional = ({ locale = getCurrentLocale(), - ...args -} = {}) => createNumberFormatter({ locale, ...args }); + format = undefined as (string | undefined), + formats = getOptions().formats, + ...options +} = {}) => { + if (format) { + options = getIntlFormatterOptions('number', format, formats); + } + + return createNumberFormatter({ locale, ...options }); +} export const getDateFormatter: MemoizedDateTimeFormatterFactoryOptional = ({ locale = getCurrentLocale(), - ...args -} = {}) => createDateFormatter({ locale, ...args }); + format = undefined as (string | undefined), + formats = getOptions().formats, + ...options +} = {}) => { + if (format) { + options = getIntlFormatterOptions('date', format, formats); + } else if (Object.keys(options).length === 0) { + options = getIntlFormatterOptions('date', 'short', formats); + } + + return createDateFormatter({ locale, ...options }); +} export const getTimeFormatter: MemoizedDateTimeFormatterFactoryOptional = ({ locale = getCurrentLocale(), - ...args -} = {}) => createTimeFormatter({ locale, ...args }); - -export const getMessageFormatter = monadicMemoize( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (message: string, locale: string = getCurrentLocale()!) => - new IntlMessageFormat(message, locale, getOptions().formats, { - ignoreTag: getOptions().ignoreTag, - }), -); + format = undefined as (string | undefined), + formats = getOptions().formats, + ...options +} = {}) => { + if (format) { + options = getIntlFormatterOptions('time', format, formats); + } else if (Object.keys(options).length === 0) { + options = getIntlFormatterOptions('time', 'short', formats); + } + + return createTimeFormatter({ locale, ...options }); +} + +export const getMessageFormatter = (message: string, locale: string = getCurrentLocale()!, options = getOptions()) => + createMessageFormatter(message, locale, options.formats, options.ignoreTag); diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 9e71a2a..f1db001 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -24,6 +24,8 @@ import { getTimeFormatter, getMessageFormatter, } from './includes/formatters'; +import { createI18nClient, initLifecycleFuncs, setI18nClientInContext, clearI18nClientInContext, + setupI18nClientInComponentInit, getI18nClientInComponentInit } from './client'; // defineMessages allow us to define and extract dynamic message ids export function defineMessages(i: Record) { @@ -64,4 +66,11 @@ export { getLocaleFromNavigator, getLocaleFromQueryString, getLocaleFromHash, + // Client + createI18nClient, + initLifecycleFuncs, + setI18nClientInContext, + clearI18nClientInContext, + setupI18nClientInComponentInit, + getI18nClientInComponentInit, }; diff --git a/src/runtime/stores/formatters.ts b/src/runtime/stores/formatters.ts index 989f35b..94ff8a4 100644 --- a/src/runtime/stores/formatters.ts +++ b/src/runtime/stores/formatters.ts @@ -1,12 +1,14 @@ -import { derived } from 'svelte/store'; +import { derived, Readable } from 'svelte/store'; import type { MessageFormatter, + MessageFormatterExtended, MessageObject, TimeFormatter, DateFormatter, NumberFormatter, JSONGetter, + ConfigureOptions, } from '../types'; import { lookup } from '../includes/lookup'; import { @@ -18,8 +20,9 @@ import { import { getOptions } from '../configs'; import { $dictionary } from './dictionary'; import { getCurrentLocale, $locale } from './locale'; +import type { Formats } from 'intl-messageformat'; -const formatMessage: MessageFormatter = (id, options = {}) => { +const formatMessage: MessageFormatterExtended = (id, options, fallbackLocale, _options) => { let messageObj = options as MessageObject; if (typeof id === 'object') { @@ -29,11 +32,11 @@ const formatMessage: MessageFormatter = (id, options = {}) => { const { values, - locale = getCurrentLocale(), + locale = fallbackLocale, default: defaultValue, } = messageObj; - if (locale == null) { + if (!locale) { throw new Error( '[svelte-i18n] Cannot format a message without first setting the initial locale.', ); @@ -43,7 +46,7 @@ const formatMessage: MessageFormatter = (id, options = {}) => { if (!message) { message = - getOptions().handleMissingMessage?.({ locale, id, defaultValue }) ?? + _options.handleMissingMessage?.({ locale, id, defaultValue }) ?? defaultValue ?? id; } else if (typeof message !== 'string') { @@ -61,7 +64,7 @@ const formatMessage: MessageFormatter = (id, options = {}) => { let result = message; try { - result = getMessageFormatter(message, locale).format(values) as string; + result = getMessageFormatter(message, locale, _options).format(values) as string; } catch (e) { console.warn(`[svelte-i18n] Message "${id}" has syntax error:`, e.message); } @@ -88,8 +91,44 @@ const getJSON: JSONGetter = ( return lookup(id, locale) as T; }; -export const $format = derived([$locale, $dictionary], () => formatMessage); -export const $formatTime = derived([$locale], () => formatTime); -export const $formatDate = derived([$locale], () => formatDate); -export const $formatNumber = derived([$locale], () => formatNumber); -export const $getJSON = derived([$locale, $dictionary], () => getJSON); +function putInOptions( + func: (first: T1, options?: T2) => S, _locale: string | undefined, _options: ConfigureOptions) : + (first: T1, options?: T2) => S { + return function (first, options) { + options = { ...options } as T2; + + if (!options?.locale) { + options.locale = _locale; + } + + if (!options?.formats) { + options.formats = _options.formats; + } + + return func(first, options); + }; +} + +const normalizeLocale = (locale: string | null | undefined) => locale ?? undefined; + +export function createFormattingStores(localeStore: Readable, + getOptions: () => ConfigureOptions) { + + return { + format: derived([localeStore, $dictionary], ([$locale, ]) => (id: any, options = {}) => + formatMessage(id, options, normalizeLocale($locale), getOptions())), + formatTime: derived([localeStore], ([$locale, ]) => putInOptions(formatTime, normalizeLocale($locale), getOptions())), + formatDate: derived([localeStore], ([$locale, ]) => putInOptions(formatDate, normalizeLocale($locale), getOptions())), + formatNumber: derived([localeStore], ([$locale, ]) => putInOptions(formatNumber, normalizeLocale($locale), getOptions())), + getJSON: derived([localeStore, $dictionary], ([$locale, ]) => (id: string, locale: string | undefined) => + getJSON(id, locale || normalizeLocale($locale))), + }; +} + +const singletonStores = createFormattingStores($locale, getOptions); + +export const $format: Readable = singletonStores.format; +export const $formatTime: Readable = singletonStores.formatTime; +export const $formatDate: Readable = singletonStores.formatDate; +export const $formatNumber: Readable = singletonStores.formatNumber; +export const $getJSON: Readable = singletonStores.getJSON; \ No newline at end of file diff --git a/src/runtime/stores/loading.ts b/src/runtime/stores/loading.ts index 6e1d0aa..f2417ba 100644 --- a/src/runtime/stores/loading.ts +++ b/src/runtime/stores/loading.ts @@ -1,3 +1,7 @@ -import { writable } from 'svelte/store'; +import { writable, Writable } from 'svelte/store'; -export const $isLoading = writable(false); +export function createLoadingStore() : Writable { + return writable(false); +} + +export const $isLoading = createLoadingStore(); diff --git a/src/runtime/stores/locale.ts b/src/runtime/stores/locale.ts index d843aa7..25809bb 100644 --- a/src/runtime/stores/locale.ts +++ b/src/runtime/stores/locale.ts @@ -1,13 +1,10 @@ -import { writable } from 'svelte/store'; +import { writable, Writable } from 'svelte/store'; import { flush, hasLocaleQueue } from '../includes/loaderQueue'; import { getOptions } from '../configs'; import { getClosestAvailableLocale } from './dictionary'; import { $isLoading } from './loading'; -let current: string | null | undefined; -const internalLocale = writable(null); - function getSubLocales(refLocale: string) { return refLocale .split('-') @@ -28,59 +25,72 @@ export function getPossibleLocales( return locales; } -export function getCurrentLocale() { - return current ?? undefined; -} - -internalLocale.subscribe((newLocale: string | null | undefined) => { - current = newLocale ?? undefined; +export function createLocaleStore(isLoading: Writable, loadingDelayInit?: number) : { + localeStore: Writable, + getCurrentLocale: () => string | undefined +} { + let current : string | null | undefined; + const internalLocale = writable(null); - if (typeof window !== 'undefined' && newLocale != null) { - document.documentElement.setAttribute('lang', newLocale); + function getCurrentLocale() { + return current ?? undefined; } -}); -const set = (newLocale: string | null | undefined): void | Promise => { - if ( - newLocale && - getClosestAvailableLocale(newLocale) && - hasLocaleQueue(newLocale) - ) { - const { loadingDelay } = getOptions(); + internalLocale.subscribe((newLocale: string | null | undefined) => { + current = newLocale ?? undefined; - let loadingTimer: number; + if (typeof window !== 'undefined' && newLocale != null) { + document.documentElement.setAttribute('lang', newLocale); + } + }); - // if there's no current locale, we don't wait to set isLoading to true - // because it would break pages when loading the initial locale + const set = (newLocale: string | null | undefined): void | Promise => { if ( - typeof window !== 'undefined' && - getCurrentLocale() != null && - loadingDelay + newLocale && + getClosestAvailableLocale(newLocale) && + hasLocaleQueue(newLocale) ) { - loadingTimer = window.setTimeout( - () => $isLoading.set(true), - loadingDelay, - ); - } else { - $isLoading.set(true); + const loadingDelay = loadingDelayInit ?? getOptions().loadingDelay; + + let loadingTimer: number; + + // if there's no current locale, we don't wait to set isLoading to true + // because it would break pages when loading the initial locale + if ( + typeof window !== 'undefined' && + getCurrentLocale() != null && + loadingDelay + ) { + loadingTimer = window.setTimeout( + () => isLoading.set(true), + loadingDelay, + ); + } else { + isLoading.set(true); + } + + return flush(newLocale as string) + .then(() => { + internalLocale.set(newLocale); + }) + .finally(() => { + clearTimeout(loadingTimer); + isLoading.set(false); + }); } - return flush(newLocale as string) - .then(() => { - internalLocale.set(newLocale); - }) - .finally(() => { - clearTimeout(loadingTimer); - $isLoading.set(false); - }); - } + return internalLocale.set(newLocale); + }; + + const store = { + ...internalLocale, + set, + }; - return internalLocale.set(newLocale); -}; + return { localeStore: store, getCurrentLocale }; +} -const $locale = { - ...internalLocale, - set, -}; +const { getCurrentLocale, localeStore } = createLocaleStore($isLoading); -export { $locale }; +export { getCurrentLocale }; +export const $locale = localeStore; \ No newline at end of file diff --git a/src/runtime/types/index.ts b/src/runtime/types/index.ts index d579a27..f67fa6e 100644 --- a/src/runtime/types/index.ts +++ b/src/runtime/types/index.ts @@ -38,6 +38,13 @@ export type MessageFormatter = ( options?: Omit, ) => string; +export type MessageFormatterExtended = ( + id: string | MessageObject, + options: Omit, + fallbackLocale: string | undefined, + _options: ConfigureOptions, +) => string; + export type TimeFormatter = ( d: Date | number, options?: IntlFormatterOptions, @@ -53,10 +60,11 @@ export type NumberFormatter = ( options?: IntlFormatterOptions, ) => string; -export type JSONGetter = (id: string, locale?: string | null) => T; +export type JSONGetter = (id: string, locale?: string | undefined) => T; type IntlFormatterOptions = T & { format?: string; + formats?: Formats; locale?: string; }; diff --git a/test/runtime/configs.test.ts b/test/runtime/configs.test.ts index 07a6484..63432d0 100644 --- a/test/runtime/configs.test.ts +++ b/test/runtime/configs.test.ts @@ -1,7 +1,7 @@ /* eslint-disable node/global-require */ import { get } from 'svelte/store'; -import { init, getOptions, defaultFormats } from '../../src/runtime/configs'; +import { init, getOptions, defaultFormats, applyOptions } from '../../src/runtime/configs'; import { $locale } from '../../src/runtime/stores/locale'; const warnSpy = jest.spyOn(global.console, 'warn').mockImplementation(); @@ -10,6 +10,15 @@ beforeEach(() => { warnSpy.mockReset(); }); +test('check that empty source configuration do nothing', () => { + const originalOptions = { ...getOptions() }; + const options = { ...originalOptions }; + const locale = applyOptions(undefined, options); + + expect(locale).toBe(undefined); + expect(options).toEqual(originalOptions); +}); + test('inits the fallback locale', () => { expect(getOptions().fallbackLocale).toBeNull(); diff --git a/test/runtime/stores/formatters.test.ts b/test/runtime/stores/formatters.test.ts index c3bbdd0..758f74b 100644 --- a/test/runtime/stores/formatters.test.ts +++ b/test/runtime/stores/formatters.test.ts @@ -7,6 +7,8 @@ import type { TimeFormatter, DateFormatter, NumberFormatter, + ConfigureOptions, + ConfigureOptionsInit, } from '../../../src/runtime/types'; import { $format, @@ -15,9 +17,11 @@ import { $formatNumber, $getJSON, } from '../../../src/runtime/stores/formatters'; -import { init } from '../../../src/runtime/configs'; +import { applyOptions, defaultOptions, getOptions, init } from '../../../src/runtime/configs'; import { addMessages } from '../../../src/runtime/stores/dictionary'; import { $locale } from '../../../src/runtime/stores/locale'; +import { createI18nClient, getI18nClientInComponentInit, I18nClient, initLifecycleFuncs, + setI18nClientInContext, setupI18nClientInComponentInit } from '../../../src/runtime/client'; let formatMessage: MessageFormatter; let formatTime: TimeFormatter; @@ -25,14 +29,6 @@ let formatDate: DateFormatter; let formatNumber: NumberFormatter; let getJSON: JSONGetter; -$locale.subscribe(() => { - formatMessage = get($format); - formatTime = get($formatTime); - formatDate = get($formatDate); - formatNumber = get($formatNumber); - getJSON = get($getJSON) as JSONGetter; -}); - addMessages('en', require('../../fixtures/en.json')); addMessages('en-GB', require('../../fixtures/en-GB.json')); addMessages('pt', require('../../fixtures/pt.json')); @@ -40,141 +36,285 @@ addMessages('pt-BR', require('../../fixtures/pt-BR.json')); addMessages('pt-PT', require('../../fixtures/pt-PT.json')); describe('format message', () => { - it('formats a message by its id and the current locale', () => { - init({ fallbackLocale: 'en' }); + function performTest(init: (opts: ConfigureOptionsInit) => void, setLocale: (locale: string | null) => void) { + it('formats a message by its id and the current locale', () => { + init({ fallbackLocale: 'en' }); - expect(formatMessage({ id: 'form.field_1_name' })).toBe('Name'); - }); + expect(formatMessage({ id: 'form.field_1_name' })).toBe('Name'); + }); - it('formats a message by its id and the a passed locale', () => { - expect(formatMessage({ id: 'form.field_1_name', locale: 'pt' })).toBe( - 'Nome', - ); - }); + it('formats a message by its id and the a passed locale', () => { + expect(formatMessage({ id: 'form.field_1_name', locale: 'pt' })).toBe( + 'Nome', + ); + }); - it('formats a message with interpolated values', () => { - init({ fallbackLocale: 'en' }); - - expect(formatMessage({ id: 'photos', values: { n: 0 } })).toBe( - 'You have no photos.', - ); - expect(formatMessage({ id: 'photos', values: { n: 1 } })).toBe( - 'You have one photo.', - ); - expect(formatMessage({ id: 'photos', values: { n: 21 } })).toBe( - 'You have 21 photos.', - ); - }); + it('formats a message with interpolated values', () => { + init({ fallbackLocale: 'en' }); + + expect(formatMessage({ id: 'photos', values: { n: 0 } })).toBe( + 'You have no photos.', + ); + expect(formatMessage({ id: 'photos', values: { n: 1 } })).toBe( + 'You have one photo.', + ); + expect(formatMessage({ id: 'photos', values: { n: 21 } })).toBe( + 'You have 21 photos.', + ); + }); - it('formats the default value with interpolated values', () => { - init({ fallbackLocale: 'en' }); + it('formats the default value with interpolated values', () => { + init({ fallbackLocale: 'en' }); - expect( - formatMessage({ - id: 'non-existent', - default: '{food}', - values: { food: 'potato' }, - }), - ).toBe('potato'); - }); + expect( + formatMessage({ + id: 'non-existent', + default: '{food}', + values: { food: 'potato' }, + }), + ).toBe('potato'); + }); - it('formats the key with interpolated values', () => { - init({ fallbackLocale: 'en' }); + it('formats the key with interpolated values', () => { + init({ fallbackLocale: 'en' }); - expect( - formatMessage({ - id: '{food}', - values: { food: 'potato' }, - }), - ).toBe('potato'); - }); + expect( + formatMessage({ + id: '{food}', + values: { food: 'potato' }, + }), + ).toBe('potato'); + }); - it('accepts a message id as first argument', () => { - init({ fallbackLocale: 'en' }); + it('accepts a message id as first argument', () => { + init({ fallbackLocale: 'en' }); - expect(formatMessage('form.field_1_name')).toBe('Name'); - }); + expect(formatMessage('form.field_1_name')).toBe('Name'); + }); - it('accepts a message id as first argument and formatting options as second', () => { - init({ fallbackLocale: 'en' }); + it('accepts a message id as first argument and formatting options as second', () => { + init({ fallbackLocale: 'en' }); - expect(formatMessage('form.field_1_name', { locale: 'pt' })).toBe('Nome'); - }); + expect(formatMessage('form.field_1_name', { locale: 'pt' })).toBe('Nome'); + }); - it('throws if no locale is set', () => { - init({ fallbackLocale: 'en' }); + it('throws if no locale is set', () => { + init({ fallbackLocale: 'en' }); - $locale.set(null); + setLocale(null); - expect(() => formatMessage('form.field_1_name')).toThrow( - '[svelte-i18n] Cannot format a message without first setting the initial locale.', - ); - }); + expect(() => formatMessage('form.field_1_name')).toThrow( + '[svelte-i18n] Cannot format a message without first setting the initial locale.', + ); + }); - it('uses a missing message default value', () => { - init({ fallbackLocale: 'en' }); + it('uses a missing message default value', () => { + init({ fallbackLocale: 'en' }); - expect(formatMessage('missing', { default: 'Missing Default' })).toBe( - 'Missing Default', - ); - }); + expect(formatMessage('missing', { default: 'Missing Default' })).toBe( + 'Missing Default', + ); + }); - it('errors out when value found is not string', () => { - init({ fallbackLocale: 'en' }); + it('errors out when value found is not string', () => { + init({ fallbackLocale: 'en' }); - const spy = jest.spyOn(global.console, 'warn').mockImplementation(); + const spy = jest.spyOn(global.console, 'warn').mockImplementation(); - expect(typeof formatMessage('form')).toBe('object'); - expect(spy).toBeCalledWith( - `[svelte-i18n] Message with id "form" must be of type "string", found: "object". Gettin its value through the "$format" method is deprecated; use the "json" method instead.`, - ); + expect(typeof formatMessage('form')).toBe('object'); + expect(spy).toBeCalledWith( + `[svelte-i18n] Message with id "form" must be of type "string", found: "object". Gettin its value through the "$format" method is deprecated; use the "json" method instead.`, + ); - spy.mockRestore(); - }); + spy.mockRestore(); + }); + + it('warn on missing messages if "warnOnMissingMessages" is true', () => { + init({ + fallbackLocale: 'en', + warnOnMissingMessages: true, + }); + + const spy = jest.spyOn(global.console, 'warn').mockImplementation(); + + formatMessage('missing'); - it('warn on missing messages if "warnOnMissingMessages" is true', () => { - init({ - fallbackLocale: 'en', - warnOnMissingMessages: true, + expect(spy).toBeCalledWith( + `[svelte-i18n] The message "missing" was not found in "en".`, + ); + + spy.mockRestore(); }); - const spy = jest.spyOn(global.console, 'warn').mockImplementation(); + it('uses result of handleMissingMessage handler', () => { + init({ + fallbackLocale: 'en', + handleMissingMessage: () => 'from handler', + }); - formatMessage('missing'); + expect(formatMessage('should-default')).toBe('from handler'); + }); - expect(spy).toBeCalledWith( - `[svelte-i18n] The message "missing" was not found in "en".`, - ); + it('does not throw with invalid syntax', () => { + init({ fallbackLocale: 'en' }); - spy.mockRestore(); - }); + setLocale('en'); + const spy = jest.spyOn(global.console, 'warn').mockImplementation(); + + // eslint-disable-next-line line-comment-position + formatMessage('with-syntax-error', { values: { name: 'John' } }); + + expect(spy).toHaveBeenCalledWith( + expect.stringContaining( + `[svelte-i18n] Message "with-syntax-error" has syntax error:`, + ), + expect.anything(), + ); - it('uses result of handleMissingMessage handler', () => { - init({ - fallbackLocale: 'en', - handleMissingMessage: () => 'from handler', + spy.mockRestore(); }); + } + + // Running the tests + + describe('Test by the global singleton initialization', () => { + function cleanInit() { + const initialConf: ConfigureOptions = JSON.parse(JSON.stringify(defaultOptions)); + delete initialConf.warnOnMissingMessages; + applyOptions(initialConf, getOptions()); + getOptions().handleMissingMessage = undefined; + } + beforeEach(cleanInit); + afterAll(cleanInit); + + function setLocale(locale: string | null, override: boolean = true) { + if (override) { + $locale.set(locale); + } + + formatMessage = get($format); + formatTime = get($formatTime); + formatDate = get($formatDate); + formatNumber = get($formatNumber); + getJSON = get($getJSON) as JSONGetter; + } + + performTest((opts) => { init(opts); setLocale(null, false) }, setLocale); + }); - expect(formatMessage('should-default')).toBe('from handler'); + function generateSetLocaleFunc(getClient: () => I18nClient | null) { + //Initial global functions + + formatMessage = get($format); + formatTime = get($formatTime); + formatDate = get($formatDate); + formatNumber = get($formatNumber); + getJSON = get($getJSON) as JSONGetter; + + function setLocale(locale: string | null, override: boolean = true) { + const client: I18nClient | null = getClient(); + + if (override) { + (client === null ? $locale : client.locale).set(locale); + } + + formatMessage = get(client === null ? $format : client.format); + formatTime = get(client === null ? $formatTime : client.time); + formatDate = get(client === null ? $formatDate : client.date); + formatNumber = get(client === null ? $formatNumber : client.number); + getJSON = get(client === null ? $getJSON : client.json) as JSONGetter; + } + + return setLocale; + } + + describe('Test by initializing i18n clients', () => { + let client: I18nClient | null = null; + + const setLocale = generateSetLocaleFunc(() => client); + + function init(opts: ConfigureOptionsInit) { + client = createI18nClient(opts); + setLocale(get(client.locale) ?? null, false); + } + + performTest(init, setLocale); }); - it('does not throw with invalid syntax', () => { - init({ fallbackLocale: 'en' }); + describe('Test by initializing i18n by setuping "svelte" contexts', () => { + let client: I18nClient | null = null; + + const setLocale = generateSetLocaleFunc(() => client); + + const globalClient = getI18nClientInComponentInit(); + + // Testing that lifecycle behaving correctly + + const errorMessage = "Error: Lifecycle functions aren't initialized! Use initLifecycleFuncs() before."; + expect(setI18nClientInContext).toThrowError(errorMessage); + expect(setupI18nClientInComponentInit).toThrowError(errorMessage); + + let active: boolean; + let initCalled: boolean; + let keyTrack: any; + let valueTrack: any; + let onDestroyDo: null | (() => void); + initLifecycleFuncs({ + hasContext(key: any) { + return key === keyTrack; + }, + setContext(key: any, context: T) { + keyTrack = key; + valueTrack = context; + active = true; + }, + getContext(key: any) { + expect(active).toBe(true); + expect(key).toBe(keyTrack); + return valueTrack; + }, + onDestroy(fn) { + expect(onDestroyDo).toBe(null); + onDestroyDo = fn; + } + }); + + expect(getI18nClientInComponentInit()).toBe(globalClient); + + function init(opts: ConfigureOptionsInit) { + expect(active).toBe(false); + expect(initCalled).toBe(false); + + client = setupI18nClientInComponentInit(opts); + expect(getI18nClientInComponentInit()).toBe(client); - $locale.set('en'); - const spy = jest.spyOn(global.console, 'warn').mockImplementation(); + setLocale(get(client.locale) ?? null, false); - // eslint-disable-next-line line-comment-position - formatMessage('with-syntax-error', { values: { name: 'John' } }); + expect(active).toBe(true); - expect(spy).toHaveBeenCalledWith( - expect.stringContaining( - `[svelte-i18n] Message "with-syntax-error" has syntax error:`, - ), - expect.anything(), - ); + initCalled = true; + } + + beforeEach(() => { + active = false; + initCalled = false; + keyTrack = undefined; + valueTrack = undefined; + onDestroyDo = null; + }); + + afterEach(() => { + if (initCalled) { + expect(active).toBe(true); + + expect(onDestroyDo).toBeTruthy(); + onDestroyDo!(); + } + + expect(getI18nClientInComponentInit()).toBe(globalClient); + }); - spy.mockRestore(); + performTest(init, setLocale); }); }); From 842c55815fb11814c82c8d21c055289735365b6b Mon Sep 17 00:00:00 2001 From: Tal Hadad Date: Sun, 29 May 2022 22:38:40 +0300 Subject: [PATCH 2/2] Add the config option autoLangAttribute. This option tells whether the client document language should be changed to the locale, every time it is set. --- src/runtime/client.ts | 66 ++++++++++++++++++++++-------------- src/runtime/configs.ts | 17 +++++++--- src/runtime/stores/locale.ts | 26 +++++++++----- src/runtime/types/index.ts | 9 +++++ 4 files changed, 79 insertions(+), 39 deletions(-) diff --git a/src/runtime/client.ts b/src/runtime/client.ts index 454b421..afff668 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -1,6 +1,18 @@ import { setContext, getContext, hasContext, onDestroy } from 'svelte'; -import { Writable, Readable } from "svelte/store"; +import { applyOptions, getOptions } from './configs'; +import { $isLoading, createLoadingStore } from './stores/loading'; +import { $locale, createLocaleStore } from './stores/locale'; +import { + $format, + $formatDate, + $formatNumber, + $formatTime, + $getJSON, + createFormattingStores, +} from './stores/formatters'; + +import type { Writable, Readable } from 'svelte/store'; import type { MessageFormatter, TimeFormatter, @@ -10,23 +22,17 @@ import type { ConfigureOptionsInit, } from './types'; -import { applyOptions, getOptions } from './configs'; - -import { $isLoading, createLoadingStore } from "./stores/loading"; -import { $locale, createLocaleStore } from "./stores/locale"; -import { $format, $formatDate, $formatNumber, $formatTime, $getJSON, createFormattingStores } from "./stores/formatters"; - export type I18nClient = { - locale: Writable, - isLoading: Readable, - format: Readable, - t: Readable, - _: Readable, - time: Readable, - date: Readable, - number: Readable, - json: Readable, -} + locale: Writable; + isLoading: Readable; + format: Readable; + t: Readable; + _: Readable; + time: Readable; + date: Readable; + number: Readable; + json: Readable; +}; export function createI18nClient(opts?: ConfigureOptionsInit): I18nClient { const isLoading = createLoadingStore(); @@ -34,11 +40,12 @@ export function createI18nClient(opts?: ConfigureOptionsInit): I18nClient { const options = { ...getOptions() }; const initialLocale = applyOptions(opts, options); - const { localeStore } = createLocaleStore(isLoading, options.loadingDelay); + const { localeStore } = createLocaleStore(isLoading, options); + localeStore.set(initialLocale); - const { format, formatTime, formatDate, formatNumber, getJSON } = createFormattingStores( - localeStore, () => options); + const { format, formatTime, formatDate, formatNumber, getJSON } = + createFormattingStores(localeStore, () => options); return { locale: localeStore, @@ -63,7 +70,7 @@ const globalClient: I18nClient = { date: $formatDate, number: $formatNumber, json: $getJSON, -} +}; const key = {}; @@ -88,11 +95,15 @@ type ClientContainer = { client: I18nClient | null }; // All the functions below can be called only in Svelte component initialization. -export function setI18nClientInContext(i18nClient: I18nClient) : ClientContainer { +export function setI18nClientInContext( + i18nClient: I18nClient, +): ClientContainer { verifyLifecycleFuncsInit(); const clientContainer = { client: i18nClient }; + lifecycleFuncs!.setContext(key, clientContainer); + return clientContainer; } @@ -102,7 +113,9 @@ export function clearI18nClientInContext(clientContainer: ClientContainer) { // A shortcut function that initializes i18n client in context on component initialization // and cleans it on component destruction. -export function setupI18nClientInComponentInit(opts?: ConfigureOptionsInit): I18nClient { +export function setupI18nClientInComponentInit( + opts?: ConfigureOptionsInit, +): I18nClient { verifyLifecycleFuncsInit(); const client = createI18nClient(opts); @@ -116,17 +129,18 @@ export function setupI18nClientInComponentInit(opts?: ConfigureOptionsInit): I18 return client; } -export function getI18nClientInComponentInit(): I18nClient { +export function getI18nClientInComponentInit(): I18nClient { // Notice that unlike previous functions, calling this one without initializing lifecycle function is fine. // In this case, the global client will be returned. if (lifecycleFuncs?.hasContext(key)) { const { client } = lifecycleFuncs!.getContext(key); + if (client !== null) { return client; } } // otherwise - + return globalClient; -} \ No newline at end of file +} diff --git a/src/runtime/configs.ts b/src/runtime/configs.ts index 9d416ce..af2da25 100644 --- a/src/runtime/configs.ts +++ b/src/runtime/configs.ts @@ -1,10 +1,11 @@ +import { $locale, getCurrentLocale, getPossibleLocales } from './stores/locale'; +import { hasLocaleQueue } from './includes/loaderQueue'; + import type { ConfigureOptions, ConfigureOptionsInit, MissingKeyHandlerInput, } from './types'; -import { $locale, getCurrentLocale, getPossibleLocales } from './stores/locale'; -import { hasLocaleQueue } from './includes/loaderQueue'; interface Formats { number: Record; @@ -66,16 +67,22 @@ export const defaultOptions: ConfigureOptions = { warnOnMissingMessages: true, handleMissingMessage: undefined, ignoreTag: true, + autoLangAttribute: true, }; // Deep copy to options -const options: ConfigureOptions = JSON.parse(JSON.stringify(defaultOptions)) as any; +const options: ConfigureOptions = JSON.parse( + JSON.stringify(defaultOptions), +) as any; export function getOptions() { return options; } -export function applyOptions(opts: ConfigureOptionsInit | undefined, target: ConfigureOptions) { +export function applyOptions( + opts: ConfigureOptionsInit | undefined, + target: ConfigureOptions, +) { if (opts === undefined) { return undefined; } @@ -120,4 +127,4 @@ export function init(opts: ConfigureOptionsInit) { const initialLocale = applyOptions(opts, getOptions()); return $locale.set(initialLocale); -} \ No newline at end of file +} diff --git a/src/runtime/stores/locale.ts b/src/runtime/stores/locale.ts index 25809bb..eac9a94 100644 --- a/src/runtime/stores/locale.ts +++ b/src/runtime/stores/locale.ts @@ -1,10 +1,13 @@ -import { writable, Writable } from 'svelte/store'; +import { writable } from 'svelte/store'; import { flush, hasLocaleQueue } from '../includes/loaderQueue'; import { getOptions } from '../configs'; import { getClosestAvailableLocale } from './dictionary'; import { $isLoading } from './loading'; +import type { Writable } from 'svelte/store'; +import type { ConfigureOptions } from '../types'; + function getSubLocales(refLocale: string) { return refLocale .split('-') @@ -25,11 +28,14 @@ export function getPossibleLocales( return locales; } -export function createLocaleStore(isLoading: Writable, loadingDelayInit?: number) : { - localeStore: Writable, - getCurrentLocale: () => string | undefined +export function createLocaleStore( + isLoading: Writable, + options?: ConfigureOptions, +): { + localeStore: Writable; + getCurrentLocale: () => string | undefined; } { - let current : string | null | undefined; + let current: string | null | undefined; const internalLocale = writable(null); function getCurrentLocale() { @@ -39,7 +45,11 @@ export function createLocaleStore(isLoading: Writable, loadingDelayInit internalLocale.subscribe((newLocale: string | null | undefined) => { current = newLocale ?? undefined; - if (typeof window !== 'undefined' && newLocale != null) { + if ( + typeof window !== 'undefined' && + newLocale != null && + (options ?? getOptions()).autoLangAttribute + ) { document.documentElement.setAttribute('lang', newLocale); } }); @@ -50,7 +60,7 @@ export function createLocaleStore(isLoading: Writable, loadingDelayInit getClosestAvailableLocale(newLocale) && hasLocaleQueue(newLocale) ) { - const loadingDelay = loadingDelayInit ?? getOptions().loadingDelay; + const { loadingDelay } = options ?? getOptions(); let loadingTimer: number; @@ -93,4 +103,4 @@ export function createLocaleStore(isLoading: Writable, loadingDelayInit const { getCurrentLocale, localeStore } = createLocaleStore($isLoading); export { getCurrentLocale }; -export const $locale = localeStore; \ No newline at end of file +export const $locale = localeStore; diff --git a/src/runtime/types/index.ts b/src/runtime/types/index.ts index f67fa6e..de7d19a 100644 --- a/src/runtime/types/index.ts +++ b/src/runtime/types/index.ts @@ -115,6 +115,15 @@ export interface ConfigureOptions { * When this is false we only allow simple tags without any attributes * */ ignoreTag: boolean; + /** + * Whether to automatically set the document lang attribute to the locale value, + * every time the locale value is set (on the client side). + * Notice that this doesn't set this attribute in server side rendering(SSR). + * A useful example for setting this option to false is when you use a nested i18n client + * inside a component which uses another i18n client. + * Default: true + */ + autoLangAttribute: boolean; } export type ConfigureOptionsInit = Pick &