i18n Module

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:

  • ready fires once, because init() is idempotent and every call returns the same promise.
  • changed does not fire for the language init() detected, and not when setLangcode() sets the language that is already active.
  • missing fires per key from translate() only. lookup() and has() read without the missing strategy and emit nothing.
  • error is emitted in addition to the rejection: init() and setLangcode() reject, they never resolve with the error as a value. Because setLangcode() loads the messages before it applies the language, a changed listener can call translate() 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.

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:

  1. the <template lang> of the active language
  2. the message of the active language or of a fallback, with the variables interpolated
  3. the <template lang="default">
  4. the missing strategy 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.

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.

Riba.js © 2026 ·GitHub · Version 2.0.0-rc.23