Internationalization
@inglorious/i18n translates Inglorious Web apps. i18next does the translating (interpolation, plurals, fallbacks, formatting through Intl); the package binds it to the store, the way react-i18next binds it to React.
The active locale lives in an entity. Changing language is an ordinary event, and the render that follows reads every string in the new language.
Setup
pnpm add @inglorious/i18nPut one JSON catalogue per language in a folder, named after it (en.json, it.json, …):
// src/i18n.js
import { createI18n } from "@inglorious/i18n"
export const { I18n, t, trans, ready } = createI18n({
catalogues: import.meta.glob("./locales/*.json"),
defaultLocale: "en",
storageKey: "my-app-locale",
})Each catalogue is its own chunk, loaded when it is needed. Adding a language is adding its file.
Register the type and an entity for it:
import { createStore } from "@inglorious/store"
import { I18n } from "./i18n.js"
export const store = createStore({
types: { I18n },
entities: { i18n: { type: "I18n" } },
})Mount once ready resolves: the user's language and the fallback are loaded by then, so the first render is already translated.
ready.then(() => mount(store, renderApp, document.getElementById("root")))The app starts in the language the user chose last time, else the first of the device's languages it supports (it-CH picks it), else the default. <html lang> follows it, and so does <html dir>: rtl for Arabic, Hebrew and the other right-to-left languages, so a layout written with logical properties mirrors on its own. Inglorious UI's components do.
Translating
{
"events": {
"title": "Events",
"count_one": "{{count}} event",
"count_other": "{{count}} events",
"closes": "Closes on {{date, datetime}}"
}
}import { html } from "@inglorious/web"
import { t } from "../i18n.js"
export const EventList = {
render(entity) {
return html`<h1>${t("events.title")}</h1>
<p>${t("events.count", { count: entity.events.length })}</p>`
},
}Plurals use the language's own rules through Intl.PluralRules: French counts 0 as singular, and Italian and French also have a many form for large numbers, so give those languages _many keys too.
Switching language
api.notify("#i18n:localeChange", "it")A locale not loaded yet is loaded first: entity.loadingLocale names it until it arrives. Only the latest choice switches, and a failed load keeps the current language.
For a language picker, entity.languages lists the supported locales as options, each named in itself ({ value: "it", label: "Italiano" }) so whoever needs it finds it whatever language is showing. localeChange takes a picker's { value } as well as a plain locale.
The user's language
storageKey remembers the choice on the device. To keep it with the user's account, so it follows them everywhere, pass saveUserLocale and tell the entity who signed in:
createI18n({
catalogues: import.meta.glob("./locales/*.json"),
saveUserLocale: (locale, api) => api.notify("#profile:localeSave", locale),
})
// When the user's data arrives:
api.notify("#i18n:userLocaleLoad", { userId: user.id, locale: user.locale })
// When they sign out:
api.notify("#i18n:userLocaleClear")A change while signed in is saved at once. One made before signing in, on the login screen say, is saved when the user arrives, being the more recent; otherwise their saved locale is applied. Once per user, so reloading their data does not undo a change still being saved.
Markup in translations
When a sentence has bold text or a link in it, keep it one sentence so each language can order it its own way, and render it with trans:
{ "loggedInAs": "Logging in as <strong>{{email}}</strong>." }trans("loggedInAs", {
values: { email },
components: { strong: (children) => html`<strong>${children}</strong>` },
})The tags are placeholders, not HTML. Each one is rendered by the component of the same name; a tag without a component shows as written. Values are inserted as text after the tags are parsed, so user data can never turn into markup.
Recipes
Labels in data
Lists of options defined at module level would freeze in the language they were created in. Keep the key as the label, and translate it where it is rendered; a label that is not a key, such as a place name, comes back as it is:
import { t } from "../i18n.js"
export const periods = [{ id: "week", label: "periods.week" }]
t(periods[0].label) // "This week", or "Questa settimana"Text kept in state
State that ends up on screen, such as a combobox's label or an error, should hold the key and be translated when it renders. Text stored in the store stays in the language of the moment it was stored.
Dates and numbers
Intl formats them in the active locale, which currentLocale() returns:
import { currentLocale } from "../i18n.js"
new Intl.DateTimeFormat(currentLocale(), {
month: "short",
day: "numeric",
}).format(date)With SSX
SSX picks the locale from the route and keeps it in entity.locale. To translate the strings on the page, switch @inglorious/i18n to it: api.notify("#i18n:localeChange", entity.locale).
