UNPKG

@vaadin/hilla-react-i18n

Version:

Hilla I18n utils for React

340 lines 12.6 kB
import { batch, computed, signal } from "@vaadin/hilla-react-signals"; import { DefaultBackend } from "./backend.js"; import { FormatCache } from "./FormatCache.js"; import { getLanguageSettings, updateLanguageSettings } from "./settings.js"; function determineInitialLanguage(options) { if (options?.language) { return options.language; } const settings = getLanguageSettings(); if (settings?.language) { return settings.language; } return navigator.language; } const keyLiteralMarker = Symbol("keyMarker"); export class I18n { #backend = new DefaultBackend(); #initialized = signal(false); #language = signal(undefined); #translations = signal({}); #resolvedLanguage = signal(undefined); #chunks = new Set(); #alreadyRequestedKeys = signal(new Set()); #batchedKeys = new Set(); #batchedKeysPromise = undefined; #formatCache = new FormatCache(navigator.language); #translationSignalCache = new Map(); constructor() { if (import.meta.hot) { import.meta.hot.on("translations-update", () => { this.reloadTranslations(); }); } } /** * Returns a signal indicating whether the I18n instance has been initialized. * The instance is considered initialized after `configure` has been called * and translations for the initial language have been loaded. Can be used to * show a placeholder or loading indicator until the translations are ready. * * Subscribers to this signal will be notified when initialization is complete * and translations are ready to be used. */ get initialized() { return this.#initialized; } /** * Returns a signal with the currently configured language. * * Subscribers to this signal will be notified when the language has changed * and new translations have been loaded. */ get language() { return this.#language; } /** * Returns a signal with the resolved language. The resolved language is the * language that was actually used to load translations. It may differ from * the configured language if there are no translations available for the * configured language. For example, when setting the language to "de-DE" but * translations are only available for "de", the resolved language will be * "de". */ get resolvedLanguage() { return this.#resolvedLanguage; } /** * Initializes the I18n instance. This method should be called once to load * translations for the initial language. The `translate` API will not return * properly translated strings until the initializations has completed. * * The initialization runs asynchronously as translations are loaded from the * backend. The method returns a promise that resolves when the translations * have been loaded, after which the `translate` API can safely be used. * * The initial language is determined as follows: * 1. If a user opens the app for the first time, the browser language is used. * 2. If the language has been changed in a previous usage of the app using * `setLanguage`, the last used language is used. The last used language is * automatically stored in local storage. * * Alternatively, the initial language can be explicitly configured using the * `language` option. The language should be a valid IETF BCP 47 language tag, * such as `en` or `en-US`. * * @param options - Optional options object to specify the initial language. */ async configure(options) { const language = determineInitialLanguage(options); await this.updateLanguage({ language }); } /** * Changes the current language and loads translations for the new language. * Components using the `translate` API will automatically re-render, and * subscribers to the `language` signal will be notified, when the new * translations have been loaded. * * The language should be a valid IETF BCP 47 language tag, such as `en` or * `en-US`. * * If there is no translation file for that specific language tag, the backend * will try to load the translation file for the parent language tag. For * example, if there is no translation file for `en-US`, the backend will try * to load the translation file for `en`. Otherwise, it will fall back to the * default translation file `translations.properties`. * * Changing the language is an asynchronous operation. The method returns a * promise that resolves when the translations for the new language have been * loaded. * * @param newLanguage - a valid IETF BCP 47 language tag, such as `en` or `en-US` */ async setLanguage(newLanguage) { await this.updateLanguage({ language: newLanguage, updateSettings: true }); } /** * Registers the chunk name for loading translations, and loads the * translations for the specified chunk. * * @internal only for automatic internal calls from production JS bundles * * @param chunkName - the production JS bundle chunk name */ async registerChunk(chunkName) { if (this.#chunks.has(chunkName)) { return; } this.#chunks.add(chunkName); if (this.#language.value) { await this.updateLanguage({ chunkName }); } } async requestKeys(keys) { if (this.#batchedKeysPromise) { for (const key of keys) { this.#batchedKeys.add(key); } return; } const nonBatchedKeys = keys.filter((key) => !this.#batchedKeys.has(key)); if (nonBatchedKeys.length === 0) { return; } this.#batchedKeys.clear(); for (const key of nonBatchedKeys) { this.#batchedKeys.add(key); } this.#batchedKeysPromise = Promise.resolve(this.#batchedKeys); await this.#batchedKeysPromise; this.#batchedKeysPromise = undefined; const batchedKeys = [...this.#batchedKeys]; return this.updateLanguage({ keys: batchedKeys }).then(() => { console.warn(["A server call was made to translate keys those were not loaded:", ...batchedKeys].join("\n - ")); }); } async updateLanguage(options) { const { language, updateSettings, chunkName, keys } = { language: this.#language.value, updateSettings: false, chunkName: undefined, keys: undefined, ...options }; const partialLoad = !!chunkName || !!keys?.length; if (language === undefined || language === this.#language.value && !partialLoad) { return; } const chunks = chunkName ? [chunkName] : this.#chunks.size && !keys?.length ? [...this.#chunks.values()] : undefined; let translationsResult; try { translationsResult = await this.#backend.loadTranslations(language, chunks, keys); } catch (e) { console.error(`Failed to load translations for language: ${language}`, e); return; } batch(() => { this.#translations.value = partialLoad ? { ...this.#translations.value, ...translationsResult.translations } : translationsResult.translations; this.#resolvedLanguage.value = translationsResult.resolvedLanguage; if (language !== this.#language.value) { this.#language.value = language; this.#formatCache = new FormatCache(language); } this.#initialized.value = !!language; if (!partialLoad) { this.#alreadyRequestedKeys.value = new Set(); } else if (keys?.length) { this.#alreadyRequestedKeys.value = new Set([...this.#alreadyRequestedKeys.value, ...keys]); } if (updateSettings) { updateLanguageSettings({ language }); } }); } /** * Reloads all translations for the current language. This method should only * be used for HMR in development mode. */ async reloadTranslations() { const currentLanguage = this.#language.value; if (!currentLanguage) { return; } let translationsResult; try { translationsResult = await this.#backend.loadTranslations(currentLanguage); } catch (e) { console.error(`Failed to reload translations for language: ${currentLanguage}`, e); return; } batch(() => { this.#translations.value = translationsResult.translations; this.#resolvedLanguage.value = translationsResult.resolvedLanguage; this.#formatCache = new FormatCache(currentLanguage); }); } /** * Returns a translated string for the given translation key. The key should * match a key in the loaded translations. If no translation is found for the * key, the key itself is returned. * * Translations may contain placeholders, following the ICU MessageFormat * syntax. They can be replaced by passing a `params` object with placeholder * values, where keys correspond to the placeholder names and values are the * replacement value. Values should be basic types such as strings, numbers, * or dates that match the placeholder format configured in the translation * string. For example, when using a placeholder `{count, number}`, the value * should be a number, when using `{date, date}`, the value should be a Date * object, and so on. * * This method internally accesses a signal, meaning that React components * that use it will automatically re-render when translations change. * Likewise, signal effects automatically subscribe to translation changes * when calling this method. * * @param key - The key to translate * @param params - Optional object with placeholder values */ translate(key, params) { const translation = this.#translations.value[key]; if (!translation) { return this.handleMissingTranslation(key); } const format = this.#formatCache.getFormat(translation); return format.format(params); } /** * Creates a computed signal with translated string for to the given key. * This method uses dynamic loading and does not guarantee immediate * availability of the translation. * * If the translation for the given key has not been loaded yet at the time * of the call, loads the translation for the key and updates the returned * signal value. * * When given an `undefined` key, returns empty string signal value. * * @param key - The translation key to translate * @param params - Optional object with placeholder values */ translateDynamic(key, params) { if (this.#translationSignalCache.has(key ?? "")) { return this.#translationSignalCache.get(key ?? ""); } if (!key) { const translationSignal = computed(() => ""); this.#translationSignalCache.set("", translationSignal); return translationSignal; } const translationSignal = computed(() => { const translation = this.#translations.value[key]; if (!translation) { if (this.#alreadyRequestedKeys.value.has(key)) { return this.handleMissingTranslation(key); } if (this.#language.value) { void this.requestKeys([key]); } return ""; } const format = this.#formatCache.getFormat(translation); return format.format(params); }); this.#translationSignalCache.set(key, translationSignal); return translationSignal; } handleMissingTranslation(key) { const lang = this.#language.value ? `${this.#language.value.split(/[_-]/u)[0]}: ` : ""; return `!${lang}${key}`; } } /** * The global I18n instance that is used to initialize translations, change the * current language, and translate strings. */ const i18n = new I18n(); /** * A tagged template literal function to create translation keys. * The {@link translate} function requires using this tag. * E.g.: * translate(key`my.translation.key`) */ function keyTag(strings, ..._values) { return Object.assign(strings[0], { [keyLiteralMarker]: undefined }); } /** * Returns a translated string for the given translation key. The key should * match a key in the loaded translations. If no translation is found for the * key, a modified version of the key is returned to indicate that the translation * is missing. * * Translations may contain placeholders, following the ICU MessageFormat * syntax. They can be replaced by passing a `params` object with placeholder * values, where keys correspond to the placeholder names and values are the * replacement value. Values should be basic types such as strings, numbers, * or dates that match the placeholder format configured in the translation * string. For example, when using a placeholder `{count, number}`, the value * should be a number, when using `{date, date}`, the value should be a Date * object, and so on. * * This method internally accesses a signal, meaning that React components * that use it will automatically re-render when translations change. * Likewise, signal effects automatically subscribe to translation changes * when calling this method. * * This function is a shorthand for `i18n.translate` of the global I18n instance. * * @param key - The translation key to translate * @param params - Optional object with placeholder values */ export function translate(key, params) { return i18n.translate(key, params); } export { i18n, keyTag as key }; //# sourceMappingURL=./index.js.map