About
The localisation module allows you to translate content on client side. This is especially useful if the used (CMS) system is not able to translate the content server side or if no hard reload should happen if the language was changed.
Install
npm install --save @ribajs/i18n
Register
Import i18nModule and a LocalesService implementation (for example LocalesStaticService), then register the module. The option localesService is required; i18nModule.init() calls localesService.init() for you, which detects the language, loads its messages and emits the ready event.
import { coreModule, Riba } from "@ribajs/core";
import { ready } from "@ribajs/utils/src/dom.js";
import { i18nModule, LocalesStaticService } from "@ribajs/i18n";
const locales = {
de: {
examples: {
newsletter: { title: "Melde dich für den Newsletter an" },
},
},
en: {
examples: {
newsletter: { title: "Sign up for the newsletter" },
},
},
};
const riba = new Riba();
const localesService = new LocalesStaticService(locales);
const model = {};
riba.module.register(coreModule.init());
riba.module.register(i18nModule.init({ localesService }));
ready(() => {
riba.bind(document.body, model);
});
The root keys of the locales object are the language codes (de, en, …) and become the selectable languages. See LocalesStaticService.
Every binder, formatter and component of the module reads from that one service, so only one instance can be registered. Registering the same service again is a no-op, a second different service throws; I18nService.reset() forgets it again, mainly for tests.
A default export exists only in the browser bundle (browser.ts); from the main package entry use named imports: import { i18nModule } from '@ribajs/i18n'.
Source language
The markup is written in the source language. It defaults to the lang attribute of <html>, and you can set it explicitly with the sourceLangcode option:
<!doctype html>
<html lang="en">
<head>
...
</head>
<body>
...
</body>
</html>
With sourceLangcode: "en" the service reads <html lang="de"> as a request for de and still treats en as the language of your markup.
A key that neither the active language nor the fallback chain translates keeps the markup the element had, so you can translate a page step by step. See rv-i18n-[type] and the missing option in LocalesService.
Detect and remember the language
init() looks for the initial language in the order of the detect option (["html"] by default), the first available match wins:
| Source | Reads |
|---|---|
query |
the query parameter lang, configurable with queryParam |
storage |
localStorage, key storageKey (riba:i18n:langcode by default) |
html |
the lang attribute of <html> |
navigator |
navigator.languages, then navigator.language |
A candidate is matched against the available languages without case and region, so de-AT picks de. If nothing matches, the source language is active.
With persist: true the language the user picks through setLangcode() is written to localStorage; a detected language is not written. Every access to document, location, navigator and localStorage is guarded, so the services run without a DOM.
Events
Every event is emitted on the LocalesService instance you pass to i18nModule.init({ localesService }). Each service owns its own dispatcher (localesService.event), so two services never hear each other.
Subscribe with on(), which is typed by LocalesEvents and returns the function that unsubscribes:
const unsubscribe = localesService.on("changed", (langcode, previousLangcode) => {
console.debug("The language changed", previousLangcode, "->", langcode);
});
unsubscribe();
localesService.event is the plain EventDispatcher of @ribajs/events, useful when you need once(), off() or a shared callback:
localesService.event.on("ready", (langcode: string) => {
console.debug("Locales are initialized; safe to translate", langcode);
});
| Name | Arguments | Emitted when |
|---|---|---|
ready |
langcode |
init() finished, the first language is active and its messages are loaded |
changed |
langcode, previousLangcode |
setLangcode() activated another language than before |
missing |
path, langcode |
translate() found no message for path in the active language or in a fallback |
error |
error |
init() or loading the messages of a language failed |
Details worth knowing:
readyfires once, becauseinit()is idempotent and every call returns the same promise.changeddoes not fire for the languageinit()detected, and not whensetLangcode()sets the language that is already active.missingfires per key fromtranslate()only.lookup()andhas()read without themissingstrategy and emit nothing.erroris emitted in addition to the rejection:init()andsetLangcode()reject, they never resolve with the error as a value. BecausesetLangcode()loads the messages before it applies the language, achangedlistener can calltranslate()synchronously.
An rv-i18n-* binder catches an error while it renders, logs it to the console and falls back to the source markup; the i18n-switcher component does the same for a failed switch.
dispose() removes all listeners and drops the cached messages; the service is unusable afterwards.
Services
LocalesService
The abstract class LocalesService (packages/i18n/src/services/locales.service.ts) is the base of every locale source. It owns the language state — source language, active language, detection, persistence, fallback — and delegates the key lookup to the Translator. A subclass only says how the messages of one language are loaded.
| Class | Purpose |
|---|---|
LocalesStaticService |
messages of all languages are already in memory |
LocalesRestService |
messages are fetched over HTTP, one request per language |
Methods
| Member | Description |
|---|---|
init(): Promise<void> |
Detect the initial language and load its messages. Idempotent, every call returns the same promise, rejects on error. i18nModule.init() calls it for you. |
whenReady(): Promise<void> |
Same as init(), for code that awaits the readiness of a service someone else started. |
ready: boolean |
true after the first language is loaded. translate() and lookup() throw before that. |
setLangcode(langcode): Promise<void> |
Switch the language. Loads the messages first, rejects for an unknown language, emits error and keeps the active language when the load fails. Two quick switches resolve to the last one. |
preload(langcode): Promise<void> |
Load the messages of a language ahead of a switch. |
getLangcode(): string | undefined |
The active language. |
getSourceLangcode(): string |
The language the markup is written in. |
getAvailableLangcodes(): Langcode[] |
The selectable languages with the active one marked. Reads the configured langcodes, never the loaded messages, so it does not wait for a request. |
translate(path, vars?, langcode?): string | undefined |
Read a message and apply the missing strategy. Throws while the service is not ready. |
lookup(path, vars?, langcode?): string | undefined |
Read a message without the missing strategy and without the missing event. undefined means no language of the fallback chain has the key. |
has(path, langcode?): boolean |
Does the active language or a fallback have the key. |
on(name, callback): () => void |
Subscribe to ready, changed, missing or error, returns the unsubscribe function. |
event: EventDispatcher |
The raw dispatcher, for once() and off(). |
dispose() |
Remove all listeners and cached messages. The service is unusable afterwards. |
path is a dot separated key path or an array of segments; vars fills the {{ placeholders }} of the message. Both translate() and lookup() use the active language unless you pass a langcode yourself.
Keys and fallback
The fallback is per key, not per language: de-AT is looked up as de-AT, then de, then every entry of the fallbackLangcodes option. The first language that has the key wins, the others are not consulted for that key.
await localesService.setLangcode("de-AT");
// de-AT -> de -> en -> it (the variants of fallbackLangcodes)
localesService.translate("form.newsletter.title");
A message is either a string or a set of plural forms selected by the count variable:
const locales = {
de: {
cart: {
items: { one: "{{ count }} Artikel", other: "{{ count }} Artikel" },
},
},
};
zero, one and two win when count is exactly that number, the rest follows Intl.PluralRules for the active language, and other is the last resort. An empty string is a valid message, so you can blank a text without a fallback.
Options
LocalesOptions is the second constructor argument of both services:
| Option | Type | Default | Description |
|---|---|---|---|
sourceLangcode |
string |
lang attribute of <html>, otherwise en |
The language the markup is written in |
langcodes |
string[] |
the keys of the catalog (static service only) | The selectable languages |
fallbackLangcodes |
string[] |
[] |
Tried per key after the active language and its base language |
detect |
Array<"query" | "storage" | "html" | "navigator"> |
["html"] |
Where to look for the initial language, first match wins |
queryParam |
string |
lang |
The query parameter read by detect: ["query"] |
storageKey |
string |
riba:i18n:langcode |
The localStorage key used by persist and detect: ["storage"] |
persist |
boolean |
false |
Remember the language the user picks in localStorage |
syncHtmlLang |
boolean |
true |
Write the active language to the lang attribute of <html> |
missing |
"keep" | "marker" | "throw" |
"keep" |
What translate() does with a key no language has: return undefined (the binder keeps the source markup), return translation missing: "key", or throw |
Your own service
Extend LocalesService and implement load(), which resolves the messages of one language or undefined if it has none:
import {
LocalesService,
type LocalesOptions,
type MessageTree,
} from "@ribajs/i18n";
class MyLocalesService extends LocalesService {
constructor(protected readonly endpoint: string, options: LocalesOptions) {
super(options);
}
protected async load(langcode: string): Promise<MessageTree | undefined> {
const response = await fetch(`${this.endpoint}/${langcode}.json`);
return response.ok ? await response.json() : undefined;
}
}
load() is called once per language, concurrent callers share the same promise, and the resolved messages are cached; a load that rejected is retried by the next switch.
Override resolveLangcodes(): Promise<string[]> when the selectable languages are not known upfront; it is called once by init(), before the first load():
protected async resolveLangcodes(): Promise<string[]> {
const response = await fetch(`${this.endpoint}/index.json`);
return response.ok ? await response.json() : [];
}
Inside both you may use this.langcodes and this.sourceLangcode, and in a subclass of LocalesRestService also this.urlFor(langcode) and this.fetchJSON(url). Without an override of resolveLangcodes(), init() rejects when the langcodes option is missing.
LocalesStaticService
The LocalesStaticService holds the messages of all languages in memory, which suits a bundled app, a demo or a small set of UI strings. Any language can be read without loading anything, so the service never emits a request and works without a DOM.
import { LocalesStaticService } from "@ribajs/i18n";
const locales = {
de: {
examples: {
newsletter: {
description_html:
"Abonnieren Sie unseren Newsletter und erhalten Sie <strong>10% Rabatt</strong> auf Ihren nächsten Einkauf.",
input_value: "Unbekannt",
placeholder_last_name: "Nachname",
title: "Melde dich für den Newsletter an",
},
},
},
en: {
examples: {
newsletter: {
description_html:
"Subscribe to our newsletter and get <strong>10% off</strong> your next purchase.",
input_value: "Unknown",
placeholder_last_name: "Surname",
title: "Sign up for the newsletter",
},
},
},
};
const localesService = new LocalesStaticService(locales);
The root keys of locales are the language codes (de, en, …). Do not wrap the messages in an extra property such as locales: a lookup of examples.newsletter.title would resolve against en.locales.examples… and find nothing. The nested keys are the key paths the binders and the t formatter use.
new LocalesStaticService(catalog: Catalog, options?: LocalesOptions)
Without the langcodes option the catalog keys are the selectable languages. Set the option to offer a subset of them, or to add a language whose messages are not in the catalog:
const localesService = new LocalesStaticService(locales, {
sourceLangcode: "en",
langcodes: ["en", "de", "fr"],
detect: ["query", "storage", "html"],
persist: true,
});
Since the source language is the language of your markup, you can leave its entry out of the catalog: its messages are not needed, and a key without a message keeps the markup the element had. Keep in mind that a catalog entry of the source language does win over the markup for the keys it contains.
The service only implements load(), which reads the catalog; detection, persistence and the fallback chain come from LocalesService. See LocalesService for the options and the fallback rules, LocalesRestService for messages that are not bundled.
import { coreModule, Riba } from "@ribajs/core";
import { ready } from "@ribajs/utils/src/dom.js";
import { i18nModule, LocalesStaticService } from "@ribajs/i18n";
import I18nStaticModule from "./i18n-static.module.js";
const locales = {
de: {
examples: {
i18n: {
switch_language: "Klicke auf eine Sprache um sie zu ändern",
},
newsletter: {
description_html:
"Abonnieren Sie unseren Newsletter und erhalten Sie <strong>10% Rabatt</strong> auf Ihren nächsten Einkauf.",
input_value: "Unbekannt",
placeholder_last_name: "Nachname",
title: "Melde dich für den Newsletter an",
},
},
},
en: {
examples: {
i18n: {
switch_language: "Click on a language to change it",
},
newsletter: {
description_html:
"Subscribe to our newsletter and get <strong>10% off</strong> your next purchase.",
input_value: "Unknown",
placeholder_last_name: "Surname",
title: "Sign up for the newsletter",
},
},
},
};
const riba = new Riba();
const localesService = new LocalesStaticService(locales);
const model = {};
riba.module.register(coreModule.init());
riba.module.register(i18nModule.init({ localesService }));
riba.module.register(I18nStaticModule.init());
ready(() => {
riba.bind(document.body, model);
});
<i18n-switcher>
<p><strong rv-i18n-text="'examples.i18n.switch_language'"></strong></p>
<div class="btn-group" role="group">
<button
type="button"
class="btn btn-secondary text-uppercase"
rv-each-langcode="langcodes"
rv-text="langcode.code"
rv-on-click="switch | args langcode"
rv-class-active="langcode.active"
>Left</button>
</div>
</i18n-switcher>
<div class="form-row my-5">
<div class="col-12">
<h1 rv-i18n-text="'examples.newsletter.title'"></h1>
<p rv-i18n-html="'examples.newsletter.description_html'"></p>
</div>
<div class="col">
<input type="text" class="form-control" rv-i18n-value="'examples.newsletter.input_value'" />
</div>
<div class="col">
<input type="text" class="form-control" rv-i18n-placeholder="'examples.newsletter.placeholder_last_name'" />
</div>
</div>
import { Component } from "@ribajs/core";
export class I18nStaticExampleComponent extends Component {
public static tagName = "rv-i18n-static-example";
protected autobind = true;
static get observedAttributes(): string[] {
return [];
}
public scope = {};
constructor() {
super();
}
protected connectedCallback() {
super.connectedCallback();
this.init(I18nStaticExampleComponent.observedAttributes);
}
protected async init(observedAttributes: string[]) {
return super.init(observedAttributes).then((view) => {
return view;
});
}
protected requiredAttributes(): string[] {
return [];
}
protected async template() {
const { default: template } =
await import("./i18n-static-example.component.html?raw");
return template;
}
}
LocalesRestService
The LocalesRestService fetches the messages over HTTP with HttpService.getJSON(). It requests one file per language the first time that language is needed, caches the response and requests nothing else, which keeps the initial payload small.
import { LocalesRestService } from "@ribajs/i18n";
const localesService = new LocalesRestService((langcode) => `/locales/${langcode}.json`, {
langcodes: ["en", "de", "fr"],
sourceLangcode: "en",
});
new LocalesRestService(url: string | ((langcode: string) => string), options?: LocalesRestOptions)
Each file contains the message tree of that one language, without the langcode as a wrapper:
{
"form": {
"newsletter": {
"title": "Newsletter abonnieren"
}
}
}
A missing url throws in the constructor. langcodes is required in the options, because the service cannot know which languages the server offers: without it, and without an override of resolveLangcodes(), init() rejects.
Lazy per language
init() loads the detected language and the languages of its fallback chain, nothing else. setLangcode() loads the language it is asked for before it applies it. Every response is cached, so switching back and forth requests a file once; a request that failed is not cached and is retried by the next switch.
The catalog of the source language is never requested, because the markup is the source. Set loadSourceCatalog: true to fetch it as well.
await localesService.setLangcode("de");
// one request for /locales/de.json, then `translate()` reads synchronously
localesService.translate("form.newsletter.title");
A failed request rejects the setLangcode() promise, emits error and leaves the active language in place.
One file for all languages
Pass a fixed url and pick the messages of the requested language out of the response with parse(). Requests to the same url are shared, so one file serves every switch:
import { LocalesRestService, type LocalesRestOptions, type MessageTree } from "@ribajs/i18n";
class ThemeLocalesService extends LocalesRestService {
constructor(baseUrl: string, options: LocalesRestOptions = {}) {
super(`${baseUrl}/locales`, options);
}
protected parse(body: unknown, langcode: string): MessageTree | undefined {
return (body as Record<string, MessageTree>)[langcode];
}
protected async resolveLangcodes(): Promise<string[]> {
const body = (await this.fetchJSON(this.urlFor(this.sourceLangcode))) as object;
return Object.keys(body);
}
}
/locales then returns { "de": { … }, "fr": { … } } and init() reads the languages from its keys.
Hooks
| Member | Description |
|---|---|
protected buildUrl(url, langcode) |
Change the url right before it is requested, e.g. to add a query |
protected parse(body, langcode) |
Pick the messages of one language out of the response body |
protected resolveLangcodes() |
The selectable languages, called once by init() instead of the langcodes option |
protected urlFor(langcode) |
The url of one language, buildUrl() applied |
protected fetchJSON(url) |
HttpService.getJSON() with the response cache, returns the parsed body |
packages/shopify-tda/src/services/locales.service.ts is a real implementation of these hooks: the ?shop=… query of the Shopify backend lives in its buildUrl(), the messages of all languages arrive in one file, which resolveLangcodes() and parse() read.
See LocalesService for the shared options, the fallback rules and the events.
Binders
i18n-[type]
Sets the translation of a key path as text, html, value or any attribute: rv-i18n-<target>="'<key path>'". The value of the binder is the key path as a quoted string, the target comes from the attribute name.
| Binder | Sets |
|---|---|
rv-i18n-text |
textContent |
rv-i18n-html |
innerHTML, for a message that contains markup |
rv-i18n-value |
value of an input, innerHTML on a contenteditable element |
rv-i18n-<attributeName> |
the attribute of that name, e.g. rv-i18n-placeholder |
The binder reads the messages from the registered LocalesService and re-renders on its ready and changed events; the listeners are removed when the view is unbound.
i18n-text
i18n-html
i18n-value
i18n-[attributeName]
Source restore
The content an element has when the binder binds is its source: the language the markup is written in (option sourceLangcode). The source is restored when the active language has no message for the key, so a page that is only partly translated stays readable, and switching back to the source language gives your markup back unchanged:
<h1 rv-i18n-text="'examples.newsletter.title'">Sign up for the newsletter</h1>
<!-- source language active: "Sign up for the newsletter"
de active: "Melde dich für den Newsletter an"
fr active: no message in fr or its fallbacks, the markup again -->
For an attribute target the source is the attribute as it was; an attribute that did not exist is removed again instead of being set to an empty string. Empty messages are applied, so a translation can blank a text.
What happens with a key that no language has is the missing option of the service: keep (default) keeps the source markup, marker writes translation missing: "<key>", and throw logs the error to the console and keeps the markup, because the binder catches it.
Inline templates
Instead of a catalog you can put the translations of one element into <template lang="…"> children. The {{ placeholders }} of a message are filled from the data-* attributes of the element and from <template name="…"> children:
<p rv-i18n-html="'form.greeting'" data-name="Pascal">
Hello
<template lang="de">Hallo {{ name }}</template>
<template lang="default">Welcome</template>
</p>
<p rv-i18n-html="'form.greeting'">
Hello
<template lang="de">Hallo {{ name }}</template>
<template name="name">Pascal</template>
</p>
const locales = {
de: { form: { greeting: "Hallo {{ name }}" } },
};
The variable count provided by data-count="3" selects the plural form of a message, see LocalesService.
A <template lang> is matched like a language code, so de-AT uses the <template lang="de">. Templates and variables are read once when the binder binds, and only the lang attribute makes a template a translation — a <template name> is a variable, never a translation.
The element picks the first of these that exists:
- the
<template lang>of the active language - the message of the active language or of a fallback, with the variables interpolated
- the
<template lang="default"> - the
missingstrategy of the service, which keeps the source markup by default
Without a key in the binder expression (rv-i18n-html="") only the inline templates apply, which is the shortest way to translate one element:
<p rv-i18n-html="">Hello<template lang="de">Hallo</template></p>
For the content of a mustache text node, use the t formatter instead.
Formatters
t
This formatter resolves a translation key path in a mustache text node and re-renders when the language changes. For element content and attributes, the i18n-* binders (rv-i18n-text, rv-i18n-html, …) are more explicit and support the source markup and inline templates, see rv-i18n-[type].
In mustache text nodes, Riba’s default delimiters are a single { and } (see configuration: templateDelimiters). That differs from Liquid/Shopify, which use {{ and }} — do not copy that syntax here. Double braces are the placeholders inside a translation, not Riba’s syntax.
{ 'examples.newsletter.title' | t }
Arguments
{ 'form.newsletter.title' | t }
{ 'form.newsletter.title' | t 'de' }
{ 'cart.items' | t vars }
{ 'cart.items' | t 'de' vars }
| Argument | Type | Description |
|---|---|---|
| langcode | string |
Read the message of that language instead of the active one, without switching |
| variables | object |
Values for the {{ placeholders }} of the message and the count that selects the plural form |
Both are optional and may be given in any order: a string argument is a langcode, an object is the variables. A langcode can be a literal in the markup, the variables are a keypath read from the view model, so a text node with variables has to be bound:
const model = {
vars: { count: 3, name: "Pascal" },
};
<p>{ 'cart.items' | t vars }</p>
<!-- with de: { cart: { items: { one: "{{ count }} Artikel", other: "{{ count }} Artikel" } } } -->
<!-- renders "3 Artikel" in the active language -->
A langcode argument reads a language that may not be loaded yet: with a LocalesRestService and en active, a t 'de' renders the key until de has been loaded once, for example by setLangcode("de") or preload("de"). Translate the active language instead when the text must always be complete.
Missing keys
The formatter renders the key itself when neither the active language nor a fallback chain has the message:
{ 'form.unknown' | t } <!-- renders: form.unknown -->
The missing option of the service still applies: with missing: "marker" the text becomes translation missing: "form.unknown", and with missing: "throw" the error is thrown while the text node is rendered. Before the service is ready the text is empty and re-renders with the message in the ready event.
{ 'examples.newsletter.title' | t }
The formatter subscribes to changed (and to ready while the service is not ready yet) through the view model, and the subscriptions are removed when the view is unbound.
Components
i18n-switcher
Use this component to switch to another available language. It does not ship with a template: add your own markup as children, for example buttons bound to langcodes.
The languages come from the configuration of the LocalesService, never from the component: the scope langcodes is localesService.getAvailableLangcodes(), which reads the langcodes option (or the keys of the catalog of a LocalesStaticService). Add a language there and it shows up in the switcher, remove it and it disappears. The component re-reads the list on ready and changed, which only updates the active flag of an entry.
Types
export interface Langcode {
code: string;
active: boolean;
}
Template methods
| Name | Arguments | Description |
|---|---|---|
| switch | langcode: Langcode, event?: Event |
Activates langcode.code if that language is not active. Calls preventDefault() and stopPropagation() when an event is passed, so it also works inside a link. |
| toggle | event?: Event |
Switches to the first language that is not active; useful with exactly two languages |
Both methods catch a failed switch and log it to the console; setLangcode() is asynchronous because it loads the messages first.
Template properties
| Name | Type | Description |
|---|---|---|
| langcodes | Langcode[] |
The languages of the service configuration, with active set for the current one |
| ready | boolean |
true once the service has loaded the first language |
With persist: true on the service the language a visitor picks is remembered in localStorage, with detect: ["storage", …] the switcher opens in that language on the next visit.