fix(localize): make LocalizeManager run with lit-ssr

This commit is contained in:
Thijs Louisse 2024-10-29 21:17:41 +01:00 committed by Thijs Louisse
parent 1626dbd460
commit da5ae6743a
2 changed files with 108 additions and 53 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ui': patch
---
[localize] make sure LocalizeManager does not crash with lit-ssr

View file

@ -1,96 +1,144 @@
// @ts-ignore // @ts-expect-error
import MessageFormat from '@bundled-es-modules/message-format/MessageFormat.js'; import MessageFormat from '@bundled-es-modules/message-format/MessageFormat.js';
import { isServer } from 'lit';
import isLocalizeESModule from './isLocalizeESModule.js'; 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 * @typedef {import('../types/LocalizeMixinTypes.js').NamespaceObject} NamespaceObject
*/ */
/** @typedef {import('../types/LocalizeMixinTypes.js').DatePostProcessor} DatePostProcessor */ /**
/** @typedef {import('../types/LocalizeMixinTypes.js').NumberPostProcessor} NumberPostProcessor */ * 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, this is a way to make LocalizeManager not crash on the server.
* Look for better solutions.
*/
const documentElement = isServer
? { getAttribute: () => null, lang: '' }
: globalThis.document?.documentElement;
/** /**
* `LocalizeManager` manages your translations (includes loading) * `LocalizeManager` manages your translations (includes loading)
*/ */
export class LocalizeManager extends EventTarget { export class LocalizeManager extends EventTarget {
// eslint-disable-line no-unused-vars /**
* 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._langAttrSetByTranslationTool to html[lang]
*/
#shouldHandleTranslationTools = false;
constructor({ constructor({
autoLoadOnLocaleChange = false,
fallbackLocale = '',
showKeyAsFallback = false,
allowOverridesForExistingNamespaces = false, allowOverridesForExistingNamespaces = false,
autoLoadOnLocaleChange = false,
showKeyAsFallback = false,
fallbackLocale = '',
} = {}) { } = {}) {
super(); super();
/** @private */
this.__allowOverridesForExistingNamespaces = allowOverridesForExistingNamespaces;
/** @protected */ /** @protected */
this._autoLoadOnLocaleChange = !!autoLoadOnLocaleChange; this._autoLoadOnLocaleChange = !!autoLoadOnLocaleChange;
/** @protected */ /** @protected */
this._fallbackLocale = fallbackLocale;
/** @protected */
this._showKeyAsFallback = showKeyAsFallback; this._showKeyAsFallback = showKeyAsFallback;
/** @protected */
/** @private */ this._fallbackLocale = fallbackLocale;
this.__allowOverridesForExistingNamespaces = allowOverridesForExistingNamespaces;
/** /**
* @type {Object.<string, Object.<string, Object>>} * @type {Object<string, Object<string, Object>>}
* @private * @private
*/ */
this.__storage = {}; this.__storage = {};
/** /**
* @type {Map.<RegExp|string, function>} * @type {Map<RegExp|string, function>}
* @private * @private
*/ */
this.__namespacePatternsMap = new Map(); this.__namespacePatternsMap = new Map();
/** /**
* @type {Object.<string, function|null>} * @type {Object<string, function|null>}
* @private * @private
*/ */
this.__namespaceLoadersCache = {}; this.__namespaceLoadersCache = {};
/** /**
* @type {Object.<string, Object.<string, Promise.<Object|void>>>} * @type {Object<string, Object<string, Promise<Object|void>>>}
* @private * @private
*/ */
this.__namespaceLoaderPromisesCache = {}; this.__namespaceLoaderPromisesCache = {};
/**
* The localize system uses (normalized) Intl for formatting numbers.
* It's possible to customize this output per locale
*/
this.formatNumberOptions = { this.formatNumberOptions = {
returnIfNaN: '', returnIfNaN: '',
/** @type {Map<string,DatePostProcessor>} */ /** @type {Map<string, NumberPostProcessor>} */
postProcessors: new Map(),
};
this.formatDateOptions = {
/** @type {Map<string,DatePostProcessor>} */
postProcessors: new Map(), postProcessors: new Map(),
}; };
/** /**
* Via html[data-localize-lang], developers are allowed to set the initial locale, without * The localize system uses (normalized) Intl for formatting dates.
* having to worry about whether locale is initialized before 3rd parties like Google Translate. * It's possible to customize this output per locale
* When this value differs from html[lang], we assume the 3rd party took
* control over the page language and we set this._langAttrSetByTranslationTool to html[lang]
*/ */
const initialLocale = document.documentElement.getAttribute('data-localize-lang'); this.formatDateOptions = {
/** @type {Map<string, DatePostProcessor>} */
postProcessors: new Map(),
};
/** @protected */ const initialLocale = documentElement.getAttribute('data-localize-lang');
this._supportExternalTranslationTools = Boolean(initialLocale); this.#shouldHandleTranslationTools = Boolean(initialLocale);
if (this._supportExternalTranslationTools) { if (this.#shouldHandleTranslationTools) {
this.locale = initialLocale || 'en-GB'; this.locale = /** @type {string} */ (initialLocale);
this._setupTranslationToolSupport(); this._setupTranslationToolSupport();
} }
if (!document.documentElement.lang) { if (!documentElement.lang) {
document.documentElement.lang = this.locale || 'en-GB'; documentElement.lang = this.locale || 'en-GB';
} }
/** @protected */
this._setupHtmlLangAttributeObserver(); this._setupHtmlLangAttributeObserver();
} }
/**
* @deprecated
* @protected
*/
get _supportExternalTranslationTools() {
return this.#shouldHandleTranslationTools;
}
/**
* @deprecated
* @protected
*/
set _supportExternalTranslationTools(supportsThem) {
this.#shouldHandleTranslationTools = supportsThem;
}
/** @protected */ /** @protected */
_setupTranslationToolSupport() { _setupTranslationToolSupport() {
/** /**
@ -117,7 +165,7 @@ export class LocalizeManager extends EventTarget {
* Keep in mind that all of the above also works with other tools than Google Translate, * 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. * but this is the most widely used tool and therefore used as an example.
*/ */
this._langAttrSetByTranslationTool = document.documentElement.lang || null; this._langAttrSetByTranslationTool = documentElement.lang || null;
} }
teardown() { teardown() {
@ -128,10 +176,10 @@ export class LocalizeManager extends EventTarget {
* @returns {string} * @returns {string}
*/ */
get locale() { get locale() {
if (this._supportExternalTranslationTools) { if (this.#shouldHandleTranslationTools) {
return this.__locale || ''; return this.__locale || '';
} }
return document.documentElement.lang; return documentElement.lang || '';
} }
/** /**
@ -140,14 +188,14 @@ export class LocalizeManager extends EventTarget {
set locale(value) { set locale(value) {
/** @type {string} */ /** @type {string} */
let oldLocale; let oldLocale;
if (this._supportExternalTranslationTools) { if (this.#shouldHandleTranslationTools) {
oldLocale = /** @type {string} */ (this.__locale); oldLocale = /** @type {string} */ (this.__locale);
this.__locale = value; this.__locale = value;
if (this._langAttrSetByTranslationTool === null) { if (this._langAttrSetByTranslationTool === null) {
this._setHtmlLangAttribute(value); this._setHtmlLangAttribute(value);
} }
} else { } else {
oldLocale = document.documentElement.lang; oldLocale = documentElement.lang;
this._setHtmlLangAttribute(value); this._setHtmlLangAttribute(value);
} }
@ -164,7 +212,7 @@ export class LocalizeManager extends EventTarget {
*/ */
_setHtmlLangAttribute(locale) { _setHtmlLangAttribute(locale) {
this._teardownHtmlLangAttributeObserver(); this._teardownHtmlLangAttributeObserver();
document.documentElement.lang = locale; documentElement.lang = locale;
this._setupHtmlLangAttributeObserver(); this._setupHtmlLangAttributeObserver();
} }
@ -183,7 +231,7 @@ export class LocalizeManager extends EventTarget {
} }
/** /**
* @returns {Promise.<Object|void>} * @returns {Promise<Object|void>}
*/ */
get loadingComplete() { get loadingComplete() {
if (typeof this.__namespaceLoaderPromisesCache[this.locale] === 'object') { if (typeof this.__namespaceLoaderPromisesCache[this.locale] === 'object') {
@ -240,7 +288,7 @@ export class LocalizeManager extends EventTarget {
* @param {NamespaceObject[]} namespaces * @param {NamespaceObject[]} namespaces
* @param {Object} options * @param {Object} options
* @param {string} [options.locale] * @param {string} [options.locale]
* @returns {Promise.<Object>} * @returns {Promise<Object>}
*/ */
loadNamespaces(namespaces, { locale } = {}) { loadNamespaces(namespaces, { locale } = {}) {
return Promise.all( return Promise.all(
@ -255,7 +303,7 @@ export class LocalizeManager extends EventTarget {
* @param {NamespaceObject} namespaceObj * @param {NamespaceObject} namespaceObj
* @param {Object} options * @param {Object} options
* @param {string} [options.locale] * @param {string} [options.locale]
* @returns {Promise.<Object|void>} * @returns {Promise<Object|void>}
*/ */
loadNamespace(namespaceObj, { locale = this.locale } = { locale: this.locale }) { loadNamespace(namespaceObj, { locale = this.locale } = { locale: this.locale }) {
const isDynamicImport = typeof namespaceObj === 'object'; const isDynamicImport = typeof namespaceObj === 'object';
@ -278,7 +326,7 @@ export class LocalizeManager extends EventTarget {
/** /**
* @param {string | string[]} keys * @param {string | string[]} keys
* @param {Object.<string,?>} [vars] * @param {Object<string,?>} [vars]
* @param {Object} [opts] * @param {Object} [opts]
* @param {string} [opts.locale] * @param {string} [opts.locale]
* @returns {string} * @returns {string}
@ -295,11 +343,13 @@ export class LocalizeManager extends EventTarget {
/** @protected */ /** @protected */
_setupHtmlLangAttributeObserver() { _setupHtmlLangAttributeObserver() {
if (isServer) return;
if (!this._htmlLangAttributeObserver) { if (!this._htmlLangAttributeObserver) {
this._htmlLangAttributeObserver = new MutationObserver(mutations => { this._htmlLangAttributeObserver = new MutationObserver(mutations => {
mutations.forEach(mutation => { mutations.forEach(mutation => {
if (this._supportExternalTranslationTools) { if (this.#shouldHandleTranslationTools) {
if (document.documentElement.lang === 'auto') { if (documentElement.lang === 'auto') {
// Google Translate is switched off // Google Translate is switched off
this._langAttrSetByTranslationTool = null; this._langAttrSetByTranslationTool = null;
this._setHtmlLangAttribute(this.locale); this._setHtmlLangAttribute(this.locale);
@ -352,7 +402,7 @@ export class LocalizeManager extends EventTarget {
* @param {NamespaceObject} namespaceObj * @param {NamespaceObject} namespaceObj
* @param {boolean} isDynamicImport * @param {boolean} isDynamicImport
* @param {string} namespace * @param {string} namespace
* @returns {Promise.<Object|void>} * @returns {Promise<Object|void>}
* @protected * @protected
*/ */
_loadNamespaceData(locale, namespaceObj, isDynamicImport, namespace) { _loadNamespaceData(locale, namespaceObj, isDynamicImport, namespace) {
@ -388,7 +438,7 @@ export class LocalizeManager extends EventTarget {
let loader = this.__namespaceLoadersCache[namespace]; let loader = this.__namespaceLoadersCache[namespace];
if (!loader) { if (!loader) {
if (isDynamicImport) { if (isDynamicImport) {
const _namespaceObj = /** @type {Object.<string,function>} */ (namespaceObj); const _namespaceObj = /** @type {Object<string,function>} */ (namespaceObj);
loader = _namespaceObj[namespace]; loader = _namespaceObj[namespace];
this.__namespaceLoadersCache[namespace] = loader; this.__namespaceLoadersCache[namespace] = loader;
} else { } else {
@ -411,7 +461,7 @@ export class LocalizeManager extends EventTarget {
* @param {string} locale * @param {string} locale
* @param {string} namespace * @param {string} namespace
* @param {string} [fallbackLocale] * @param {string} [fallbackLocale]
* @returns {Promise.<any>} * @returns {Promise<any>}
* @throws {Error} Data for namespace and (locale or fallback locale) could not be loaded. * @throws {Error} Data for namespace and (locale or fallback locale) could not be loaded.
* @protected * @protected
*/ */
@ -441,7 +491,7 @@ export class LocalizeManager extends EventTarget {
/** /**
* @param {string} locale * @param {string} locale
* @param {string} namespace * @param {string} namespace
* @param {Promise.<Object|void>} promise * @param {Promise<Object|void>} promise
* @protected * @protected
*/ */
_cacheNamespaceLoaderPromise(locale, namespace, promise) { _cacheNamespaceLoaderPromise(locale, namespace, promise) {
@ -563,7 +613,7 @@ export class LocalizeManager extends EventTarget {
const names = namesString.split('.'); const names = namesString.split('.');
const result = names.reduce( const result = names.reduce(
/** /**
* @param {Object.<string, any> | string} message * @param {Object<string, any> | string} message
* @param {string} name * @param {string} name
* @returns {string} * @returns {string}
*/ */
@ -578,13 +628,13 @@ export class LocalizeManager extends EventTarget {
* @param {{locale:string, postProcessor:DatePostProcessor}} options * @param {{locale:string, postProcessor:DatePostProcessor}} options
*/ */
setDatePostProcessorForLocale({ locale, postProcessor }) { setDatePostProcessorForLocale({ locale, postProcessor }) {
this.formatDateOptions.postProcessors.set(locale, postProcessor); this.formatDateOptions?.postProcessors.set(locale, postProcessor);
} }
/** /**
* @param {{locale:string, postProcessor:NumberPostProcessor}} options * @param {{locale:string, postProcessor:NumberPostProcessor}} options
*/ */
setNumberPostProcessorForLocale({ locale, postProcessor }) { setNumberPostProcessorForLocale({ locale, postProcessor }) {
this.formatNumberOptions.postProcessors.set(locale, postProcessor); this.formatNumberOptions?.postProcessors.set(locale, postProcessor);
} }
} }