288 lines
9.2 KiB
JavaScript
288 lines
9.2 KiB
JavaScript
import MessageFormat from '@bundled-es-modules/message-format/MessageFormat.js';
|
|
import { LionSingleton } from '@lion/core';
|
|
import isLocalizeESModule from './isLocalizeESModule.js';
|
|
|
|
/**
|
|
* `LocalizeManager` manages your translations (includes loading)
|
|
*/
|
|
export class LocalizeManager extends LionSingleton {
|
|
// eslint-disable-line no-unused-vars
|
|
constructor(params = {}) {
|
|
super(params);
|
|
this._fakeExtendsEventTarget();
|
|
|
|
if (!document.documentElement.lang) {
|
|
document.documentElement.lang = 'en-GB';
|
|
}
|
|
|
|
this._autoLoadOnLocaleChange = !!params.autoLoadOnLocaleChange;
|
|
this._fallbackLocale = params.fallbackLocale;
|
|
this.__storage = {};
|
|
this.__namespacePatternsMap = new Map();
|
|
this.__namespaceLoadersCache = {};
|
|
this.__namespaceLoaderPromisesCache = {};
|
|
this.formatNumberOptions = { returnIfNaN: '' };
|
|
|
|
this._setupHtmlLangAttributeObserver();
|
|
}
|
|
|
|
teardown() {
|
|
this._teardownHtmlLangAttributeObserver();
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
get locale() {
|
|
return document.documentElement.lang;
|
|
}
|
|
|
|
set locale(value) {
|
|
const oldLocale = document.documentElement.lang;
|
|
|
|
this._teardownHtmlLangAttributeObserver();
|
|
document.documentElement.lang = value;
|
|
this._setupHtmlLangAttributeObserver();
|
|
|
|
if (!value.includes('-')) {
|
|
this.__handleLanguageOnly(value);
|
|
}
|
|
|
|
this._onLocaleChanged(value, oldLocale);
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
__handleLanguageOnly(value) {
|
|
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.
|
|
`);
|
|
}
|
|
|
|
get loadingComplete() {
|
|
return Promise.all(Object.values(this.__namespaceLoaderPromisesCache[this.locale]));
|
|
}
|
|
|
|
reset() {
|
|
this.__storage = {};
|
|
this.__namespacePatternsMap = new Map();
|
|
this.__namespaceLoadersCache = {};
|
|
this.__namespaceLoaderPromisesCache = {};
|
|
}
|
|
|
|
addData(locale, namespace, data) {
|
|
if (this._isNamespaceInCache(locale, namespace)) {
|
|
throw new Error(
|
|
`Namespace "${namespace}" has been already added for the locale "${locale}".`,
|
|
);
|
|
}
|
|
|
|
this.__storage[locale] = this.__storage[locale] || {};
|
|
this.__storage[locale][namespace] = data;
|
|
}
|
|
|
|
setupNamespaceLoader(pattern, loader) {
|
|
this.__namespacePatternsMap.set(pattern, loader);
|
|
}
|
|
|
|
loadNamespaces(namespaces, { locale } = {}) {
|
|
return Promise.all(namespaces.map(namespace => this.loadNamespace(namespace, { locale })));
|
|
}
|
|
|
|
loadNamespace(namespaceObj, { locale = this.locale } = { locale: this.locale }) {
|
|
const isDynamicImport = typeof namespaceObj === 'object';
|
|
|
|
const namespace = 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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
_setupHtmlLangAttributeObserver() {
|
|
if (!this._htmlLangAttributeObserver) {
|
|
this._htmlLangAttributeObserver = new MutationObserver(mutations => {
|
|
mutations.forEach(mutation => {
|
|
this._onLocaleChanged(document.documentElement.lang, mutation.oldValue);
|
|
});
|
|
});
|
|
}
|
|
this._htmlLangAttributeObserver.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ['lang'],
|
|
attributeOldValue: true,
|
|
});
|
|
}
|
|
|
|
_teardownHtmlLangAttributeObserver() {
|
|
this._htmlLangAttributeObserver.disconnect();
|
|
}
|
|
|
|
_isNamespaceInCache(locale, namespace) {
|
|
return !!(this.__storage[locale] && this.__storage[locale][namespace]);
|
|
}
|
|
|
|
_getCachedNamespaceLoaderPromise(locale, namespace) {
|
|
if (this.__namespaceLoaderPromisesCache[locale]) {
|
|
return this.__namespaceLoaderPromisesCache[locale][namespace];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
_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(obj => {
|
|
const data = isLocalizeESModule(obj) ? obj.default : obj;
|
|
this.addData(locale, namespace, data);
|
|
});
|
|
}
|
|
|
|
_getNamespaceLoader(namespaceObj, isDynamicImport, namespace) {
|
|
let loader = this.__namespaceLoadersCache[namespace];
|
|
|
|
if (!loader) {
|
|
if (isDynamicImport) {
|
|
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;
|
|
}
|
|
|
|
_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, false).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}").`,
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
_cacheNamespaceLoaderPromise(locale, namespace, promise) {
|
|
if (!this.__namespaceLoaderPromisesCache[locale]) {
|
|
this.__namespaceLoaderPromisesCache[locale] = {};
|
|
}
|
|
this.__namespaceLoaderPromisesCache[locale][namespace] = promise;
|
|
}
|
|
|
|
_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 */
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
_getLangFromLocale(locale) {
|
|
return locale.substring(0, 2);
|
|
}
|
|
|
|
_fakeExtendsEventTarget() {
|
|
const delegate = document.createDocumentFragment();
|
|
['addEventListener', 'dispatchEvent', 'removeEventListener'].forEach(funcName => {
|
|
this[funcName] = (...args) => delegate[funcName](...args);
|
|
});
|
|
}
|
|
|
|
_onLocaleChanged(newLocale, oldLocale) {
|
|
if (newLocale === oldLocale) {
|
|
return;
|
|
}
|
|
if (this._autoLoadOnLocaleChange) {
|
|
this._loadAllMissing(newLocale, oldLocale);
|
|
}
|
|
this.dispatchEvent(new CustomEvent('localeChanged', { detail: { newLocale, oldLocale } }));
|
|
}
|
|
|
|
_loadAllMissing(newLocale, oldLocale) {
|
|
const oldLocaleNamespaces = this.__storage[oldLocale] || {};
|
|
const newLocaleNamespaces = this.__storage[newLocale] || {};
|
|
const promises = [];
|
|
Object.keys(oldLocaleNamespaces).forEach(namespace => {
|
|
const newNamespaceData = newLocaleNamespaces[namespace];
|
|
if (!newNamespaceData) {
|
|
promises.push(this.loadNamespace(namespace));
|
|
}
|
|
});
|
|
return Promise.all(promises);
|
|
}
|
|
|
|
_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;
|
|
}
|
|
|
|
_getMessageForKey(key, locale) {
|
|
if (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] : null;
|
|
const names = namesString.split('.');
|
|
return names.reduce((message, n) => (message ? message[n] : null), messages);
|
|
}
|
|
}
|