@vaadin/hilla-react-i18n
Version:
Hilla I18n utils for React
340 lines • 12.6 kB
JavaScript
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