UNPKG

@lion/ui

Version:

A package of extendable web components

671 lines (602 loc) 21.2 kB
// @ts-expect-error import MessageFormat from '@bundled-es-modules/message-format/MessageFormat.js'; import { isServer } from 'lit'; import isLocalizeESModule from './isLocalizeESModule.js'; /** * @typedef {import('../types/LocalizeMixinTypes.js').NumberPostProcessor} NumberPostProcessor * @typedef {import('../types/LocalizeMixinTypes.js').DatePostProcessor} DatePostProcessor * @typedef {import('../types/LocalizeMixinTypes.js').NamespaceObject} NamespaceObject */ /** * We can't access `window.document.documentElement` on the server, * so we write to and read from this object on the server. * N.B.: for now, the goal is to make LocalizeManager not crash on the server, and let localization happen on the client. * In the future, we might want to look into more advanced SSR of localized messages */ const documentElement = isServer ? { getAttribute: () => null, lang: '' } : globalThis.document?.documentElement; /** * `LocalizeManager` manages your translations (includes loading) */ export class LocalizeManager extends EventTarget { /** * The localize system uses (normalized) Intl for formatting numbers. * It's possible to customize this output per locale */ formatNumberOptions = { returnIfNaN: '', /** @type {Map<string, NumberPostProcessor>} */ postProcessors: new Map(), }; /** * The localize system uses (normalized) Intl for formatting dates. * It's possible to customize this output per locale */ formatDateOptions = { /** @type {Map<string, DatePostProcessor>} */ postProcessors: new Map(), }; /** * Although it's common to configure the language via the html[lang] attribute, * the lang attribute can be changed by 3rd party translation tools like Google Translate. * * ## Why is this a potential problem? * The localize system reads from html[lang] for its original configuration. Let's say it's * configured as "en-US" by the developer. This means all translation data are fetched for locale "en-US". * * Now the Google Translate plugin kicks in. It will automatically translate all English texts found into * Chinese texts. Everything looks fine... But Google Translate also sets html[lang] to "zh-CN": * our localize system responds by trying to fetch Chinese translation data. * Two problems can occur here: * - the Chinese translations don't exist * - Google Translate expects to translate from English to Chinese... and now we suddenly serve Chinese text * ourselves... not what we intended. * * ## How can we solve this? * Via `html[data-localize-lang]`, developers are allowed to set the initial locale, without * having to worry about whether locale is initialized before 3rd parties like Google Translate. * When this value differs from html[lang], we assume the 3rd party took * control over the page language and we set this.#localeSetByTranslationTool to html[lang] */ #shouldHandleTranslationTools = false; /** * The locale that is configured on html[data-localize-lang] */ #localeProvidedViaDataLangAttr = ''; /** * The locale that is set on html[lang] by a 3rd party translation tool like Googl Translate * @type {string|null} */ #localeSetByTranslationTool = null; /** * @type {Object<string, Object<string, Object>>} * @private */ __storage = {}; /** * @type {Map<RegExp|string, function>} * @private */ __namespacePatternsMap = new Map(); /** * @type {Object<string, function|null>} * @private */ __namespaceLoadersCache = {}; /** * @type {Object<string, Object<string, Promise<Object|void>>>} * @private */ __namespaceLoaderPromisesCache = {}; /** * @returns {string} */ get locale() { if (!this.#shouldHandleTranslationTools) { return documentElement.lang || ''; } return this.#localeProvidedViaDataLangAttr || ''; } /** * @param {string} newLocale */ set locale(newLocale) { this.#assertCorrectLocale(newLocale); if (!this.#shouldHandleTranslationTools) { const oldLocale = documentElement.lang; this._setHtmlLangAttribute(newLocale); this._onLocaleChanged(newLocale, oldLocale); return; } const oldLocale = /** @type {string} */ (this.#localeProvidedViaDataLangAttr); this.#localeProvidedViaDataLangAttr = newLocale; const isLangAutoOrTranslationToolNotProcessed = this.#localeSetByTranslationTool === null; if (isLangAutoOrTranslationToolNotProcessed) { this._setHtmlLangAttribute(newLocale); } this._onLocaleChanged(newLocale, oldLocale); } /** * @readonly * @returns {Promise<Object|void>} */ get loadingComplete() { const hasPendingCacheForLocale = typeof this.__namespaceLoaderPromisesCache[this.locale] === 'object'; return !hasPendingCacheForLocale ? Promise.resolve() : Promise.all(Object.values(this.__namespaceLoaderPromisesCache[this.locale])); } constructor({ allowOverridesForExistingNamespaces = false, autoLoadOnLocaleChange = false, showKeyAsFallback = false, fallbackLocale = '', } = {}) { super(); /** @private */ this.__allowOverridesForExistingNamespaces = allowOverridesForExistingNamespaces; /** @protected */ this._autoLoadOnLocaleChange = !!autoLoadOnLocaleChange; /** @protected */ this._showKeyAsFallback = showKeyAsFallback; /** @protected */ this._fallbackLocale = fallbackLocale; const localeProvidedViaDataLangAttr = documentElement.getAttribute('data-localize-lang'); this.#shouldHandleTranslationTools = Boolean(localeProvidedViaDataLangAttr); if (this.#shouldHandleTranslationTools) { this.locale = /** @type {string} */ (localeProvidedViaDataLangAttr); this._setupTranslationToolSupport(); } if (!documentElement.lang) { documentElement.lang = this.locale || 'en-GB'; } this._setupHtmlLangAttributeObserver(); } /** * @param {string} locale * @param {string} namespace * @param {object} data * @throws {Error} Namespace can be added only once, for a given locale unless allowOverridesForExistingNamespaces * is set to `true` */ addData(locale, namespace, data) { if ( !this.__allowOverridesForExistingNamespaces && this._isNamespaceInCache(locale, namespace) ) { throw new Error( `Namespace "${namespace}" has been already added for the locale "${locale}".`, ); } this.__storage[locale] = this.__storage[locale] || {}; if (this.__allowOverridesForExistingNamespaces) { this.__storage[locale][namespace] = { ...this.__storage[locale][namespace], ...data, }; } else { this.__storage[locale][namespace] = data; } } /** * @param {RegExp|string} pattern * @param {function} loader */ setupNamespaceLoader(pattern, loader) { this.__namespacePatternsMap.set(pattern, loader); } /** * @param {NamespaceObject[]} namespaces * @param {Object} options * @param {string} [options.locale] * @returns {Promise<Object>} */ loadNamespaces(namespaces, { locale } = {}) { return Promise.all( namespaces.map( /** @param {NamespaceObject} namespace */ namespace => this.loadNamespace(namespace, { locale }), ), ); } /** * @param {NamespaceObject} namespaceObj * @param {Object} options * @param {string} [options.locale] * @returns {Promise<Object|void>} */ loadNamespace(namespaceObj, { locale = this.locale } = { locale: this.locale }) { const isDynamicImport = typeof namespaceObj === 'object'; const namespace = /** @type {string} */ ( isDynamicImport ? Object.keys(namespaceObj)[0] : namespaceObj ); if (this._isNamespaceInCache(locale, namespace)) { return Promise.resolve(); } const existingLoaderPromise = this._getCachedNamespaceLoaderPromise(locale, namespace); if (existingLoaderPromise) { return existingLoaderPromise; } return this._loadNamespaceData(locale, namespaceObj, isDynamicImport, namespace); } /** * @param {string | string[]} keys * @param {Object<string,?>} [vars] * @param {Object} [opts] * @param {string} [opts.locale] * @returns {string} */ msg(keys, vars, opts = {}) { const locale = opts.locale ? opts.locale : this.locale; const message = this._getMessageForKeys(keys, locale); if (!message) { return ''; } const formatter = new MessageFormat(message, locale); return formatter.format(vars); } teardown() { this._teardownHtmlLangAttributeObserver(); } reset() { this.__storage = {}; this.__namespacePatternsMap = new Map(); this.__namespaceLoadersCache = {}; this.__namespaceLoaderPromisesCache = {}; } /** * @param {{locale:string, postProcessor:DatePostProcessor}} options */ setDatePostProcessorForLocale({ locale, postProcessor }) { this.formatDateOptions?.postProcessors.set(locale, postProcessor); } /** * @param {{locale:string, postProcessor:NumberPostProcessor}} options */ setNumberPostProcessorForLocale({ locale, postProcessor }) { this.formatNumberOptions?.postProcessors.set(locale, postProcessor); } /** * This value allows for support for Google Translate (or other 3rd parties taking control * of the html[lang] attribute). * * Have the following scenario in mind: * 1. locale is initialized by developer via html[data-localize-lang="en-US"] and * html[lang="en-US"]. When localize is loaded (note that this also can be after step 2 below), * it will sync its initial state from html[data-localize-lang] * 2. Google Translate kicks in for the French language. It will set html[lang="fr"]. * This new language is not one known by us, so we most likely don't have translations for * this file. Therefore, we do NOT sync this value to LocalizeManager. The manager should * still ask for known resources (in this case for locale 'en-US') * 3. locale is changed (think of a language dropdown) * It's a bit of a weird case, because we would not expect an end user to do this. If he/she * does, make sure that we do not go against Google Translate, so we maintain accessibility * (by not altering html[lang]). We detect this by reading #localeSetByTranslationTool: * when its value is null, we consider Google translate 'not active'. * * When Google Translate is turned off by the user (html[lang=auto]), * `localize.locale` will be synced to html[lang] again * * Keep in mind that all of the above also works with other tools than Google Translate, * but this is the most widely used tool and therefore used as an example. * @protected */ _setupTranslationToolSupport() { this.#localeSetByTranslationTool = documentElement.lang || null; } /** * @param {string} locale * @protected */ _setHtmlLangAttribute(locale) { this._teardownHtmlLangAttributeObserver(); documentElement.lang = locale; this._setupHtmlLangAttributeObserver(); } /** @protected */ _setupHtmlLangAttributeObserver() { if (isServer) return; if (!this._htmlLangAttributeObserver) { this._htmlLangAttributeObserver = new MutationObserver(mutations => { mutations.forEach(mutation => { if (this.#shouldHandleTranslationTools) { if (documentElement.lang === 'auto') { // Google Translate is switched off this.#localeSetByTranslationTool = null; this._setHtmlLangAttribute(this.locale); } else { this.#localeSetByTranslationTool = document.documentElement.lang; } } else { this._onLocaleChanged(document.documentElement.lang, mutation.oldValue || ''); } }); }); } this._htmlLangAttributeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'], attributeOldValue: true, }); } /** @protected */ _teardownHtmlLangAttributeObserver() { if (this._htmlLangAttributeObserver) { this._htmlLangAttributeObserver.disconnect(); } } /** * @param {string} locale * @param {string} namespace * @protected */ _isNamespaceInCache(locale, namespace) { return !!(this.__storage[locale] && this.__storage[locale][namespace]); } /** * @param {string} locale * @param {string} namespace * @protected */ _getCachedNamespaceLoaderPromise(locale, namespace) { if (this.__namespaceLoaderPromisesCache[locale]) { return this.__namespaceLoaderPromisesCache[locale][namespace]; } return null; } /** * @param {string} locale * @param {NamespaceObject} namespaceObj * @param {boolean} isDynamicImport * @param {string} namespace * @returns {Promise<Object|void>} * @protected */ _loadNamespaceData(locale, namespaceObj, isDynamicImport, namespace) { const loader = this._getNamespaceLoader(namespaceObj, isDynamicImport, namespace); const loaderPromise = this._getNamespaceLoaderPromise(loader, locale, namespace); this._cacheNamespaceLoaderPromise(locale, namespace, loaderPromise); return loaderPromise.then( /** * @param {Object} obj * @param {Object} obj.default */ obj => { // add data only if we have the promise in cache if ( this.__namespaceLoaderPromisesCache[locale] && this.__namespaceLoaderPromisesCache[locale][namespace] === loaderPromise ) { const data = isLocalizeESModule(obj) ? obj.default : obj; this.addData(locale, namespace, data); } }, ); } /** * @param {NamespaceObject} namespaceObj * @param {boolean} isDynamicImport * @param {string} namespace * @throws {Error} Namespace shall setup properly. Check loader! * @protected */ _getNamespaceLoader(namespaceObj, isDynamicImport, namespace) { let loader = this.__namespaceLoadersCache[namespace]; if (!loader) { if (isDynamicImport) { const _namespaceObj = /** @type {Object<string,function>} */ (namespaceObj); loader = _namespaceObj[namespace]; this.__namespaceLoadersCache[namespace] = loader; } else { loader = this._lookupNamespaceLoader(namespace); this.__namespaceLoadersCache[namespace] = loader; } } if (!loader) { throw new Error(`Namespace "${namespace}" was not properly setup.`); } this.__namespaceLoadersCache[namespace] = loader; return loader; } /** * @param {function} loader * @param {string} locale * @param {string} namespace * @param {string} [fallbackLocale] * @returns {Promise<any>} * @throws {Error} Data for namespace and (locale or fallback locale) could not be loaded. * @protected */ _getNamespaceLoaderPromise(loader, locale, namespace, fallbackLocale = this._fallbackLocale) { return loader(locale, namespace).catch(() => { const lang = this._getLangFromLocale(locale); return loader(lang, namespace).catch(() => { if (fallbackLocale) { return this._getNamespaceLoaderPromise(loader, fallbackLocale, namespace, '').catch( () => { const fallbackLang = this._getLangFromLocale(fallbackLocale); throw new Error( `Data for namespace "${namespace}" and current locale "${locale}" or fallback locale "${fallbackLocale}" could not be loaded. ` + `Make sure you have data either for locale "${locale}" (and/or generic language "${lang}") or for fallback "${fallbackLocale}" (and/or "${fallbackLang}").`, ); }, ); } throw new Error( `Data for namespace "${namespace}" and locale "${locale}" could not be loaded. ` + `Make sure you have data for locale "${locale}" (and/or generic language "${lang}").`, ); }); }); } /** * @param {string} locale * @param {string} namespace * @param {Promise<Object|void>} promise * @protected */ _cacheNamespaceLoaderPromise(locale, namespace, promise) { if (!this.__namespaceLoaderPromisesCache[locale]) { this.__namespaceLoaderPromisesCache[locale] = {}; } this.__namespaceLoaderPromisesCache[locale][namespace] = promise; } /** * @param {string} namespace * @returns {function|null} * @protected */ _lookupNamespaceLoader(namespace) { /* eslint-disable no-restricted-syntax */ for (const [key, value] of this.__namespacePatternsMap) { const isMatchingString = typeof key === 'string' && key === namespace; const isMatchingRegexp = typeof key === 'object' && key.constructor.name === 'RegExp' && key.test(namespace); if (isMatchingString || isMatchingRegexp) { return value; } } return null; /* eslint-enable no-restricted-syntax */ } /** * @param {string} locale * @returns {string} * @protected */ // eslint-disable-next-line class-methods-use-this _getLangFromLocale(locale) { return locale.substring(0, 2); } /** * @param {string} newLocale * @param {string} oldLocale * @returns {undefined} * @protected */ _onLocaleChanged(newLocale, oldLocale) { // Event firing immediately, does not wait for loading the translations this.dispatchEvent(new CustomEvent('__localeChanging')); if (newLocale === oldLocale) return; if (this._autoLoadOnLocaleChange) { this._loadAllMissing(newLocale, oldLocale); this.loadingComplete.then(() => { this.dispatchEvent(new CustomEvent('localeChanged', { detail: { newLocale, oldLocale } })); }); } else { this.dispatchEvent(new CustomEvent('localeChanged', { detail: { newLocale, oldLocale } })); } } /** * @param {string} newLocale * @param {string} oldLocale * @protected */ _loadAllMissing(newLocale, oldLocale) { const oldLocaleNamespaces = this.__storage[oldLocale] || {}; const newLocaleNamespaces = this.__storage[newLocale] || {}; Object.keys(oldLocaleNamespaces).forEach(namespace => { const newNamespaceData = newLocaleNamespaces[namespace]; if (!newNamespaceData) { this.loadNamespace(namespace, { locale: newLocale, }); } }); } /** * @param {string | string[]} keys * @param {string} locale * @returns {string | undefined} * @protected */ _getMessageForKeys(keys, locale) { if (typeof keys === 'string') { return this._getMessageForKey(keys, locale); } const reversedKeys = Array.from(keys).reverse(); // Array.from prevents mutation of argument let key; let message; while (reversedKeys.length) { key = reversedKeys.pop(); message = this._getMessageForKey(key, locale); if (message) { return message; } } return undefined; } /** * @param {string | undefined} key * @param {string} locale * @returns {string} * @throws {Error} `key`is missing namespace. The format for `key` is "namespace:name" * @protected * */ _getMessageForKey(key, locale) { if (!key || key.indexOf(':') === -1) { throw new Error( `Namespace is missing in the key "${key}". The format for keys is "namespace:name".`, ); } const [ns, namesString] = key.split(':'); const namespaces = this.__storage[locale]; const messages = namespaces ? namespaces[ns] : {}; const names = namesString.split('.'); const result = names.reduce( /** * @param {Object<string, any> | string} message * @param {string} name * @returns {string} */ (message, name) => (typeof message === 'object' ? message[name] : message), messages, ); return String(result || (this._showKeyAsFallback ? key : '')); } /** * @param {string} value * @throws {Error} Language only locales are not allowed(Use 'en-GB' instead of 'en') */ // eslint-disable-next-line class-methods-use-this #assertCorrectLocale(value) { if (value.includes('-')) return; throw new Error(` Locale was set to ${value}. Language only locales are not allowed, please use the full language locale e.g. 'en-GB' instead of 'en'. See https://github.com/ing-bank/lion/issues/187 for more information. `); } // === TODO: delete below in a next breaking release --- /** * @deprecated * @protected */ get _supportExternalTranslationTools() { return this.#shouldHandleTranslationTools; } /** * @deprecated * @protected */ set _supportExternalTranslationTools(supportsThem) { this.#shouldHandleTranslationTools = supportsThem; } /** * @deprecated * @protected */ get _langAttrSetByTranslationTool() { return this.#localeProvidedViaDataLangAttr; } /** * @deprecated * @protected */ set _langAttrSetByTranslationTool(newValue) { this.#localeProvidedViaDataLangAttr = newValue; } }