i18n

Translations, plurals and locale-aware formatting, all from the browser's Intl.

Switch language

Each button calls i18n.locale(code). That fires change, sets <html lang dir> (Arabic turns the page right to left) and re-runs translate() over every data-i18n* element on the page - the card below and the other demos follow. Polish is not added up front: the first click loads it with i18n.load().

Switch language
Current: en, dir ltr

Hello - this page is in English.

<div class="flex items-center gap-3 flex-wrap mb-4">
    <div class="btn-group" role="group" aria-label="Language" id="lang-switch">
        <button class="btn btn-outline btn-sm" type="button" data-lang="en" aria-pressed="false">English</button>
        <button class="btn btn-outline btn-sm" type="button" data-lang="fr" aria-pressed="false">Français</button>
        <button class="btn btn-outline btn-sm" type="button" data-lang="de" aria-pressed="false">Deutsch</button>
        <button class="btn btn-outline btn-sm" type="button" data-lang="ar" aria-pressed="false">العربية</button>
        <button class="btn btn-outline btn-sm" type="button" data-lang="pl" aria-pressed="false">Polski</button>
    </div>
    <span class="text-sm">Current: <code id="lang-current">en</code>, dir <code id="lang-dir">ltr</code></span>
</div>
<p class="m-0" data-i18n="lang.hello">Hello - this page is in English.</p>
/**
 * Switch language: i18n.locale(code) re-translates the page; Polish arrives through load().
 */
export function mount(root) {
    const {i18n} = Domma;
    const $root = $(root);

    i18n.add('en', {lang: {hello: 'Hello - this page is in English.'}});
    i18n.add('fr', {lang: {hello: 'Bonjour - cette page est en français.'}});
    i18n.add('de', {lang: {hello: 'Hallo - diese Seite ist auf Deutsch.'}});
    i18n.add('ar', {lang: {hello: 'مرحبًا - هذه الصفحة بالعربية.'}});

    // Polish is loaded on first use. load() takes a URL ('/lang/{locale}.json') or, as here, a loader function.
    const polish = () => Promise.resolve({lang: {hello: 'Cześć - ta strona jest po polsku.'}});

    // The locale is global: remember it (and <html lang dir>) so teardown can put it back.
    const html = $('html');
    const before = {locale: i18n.locale(), lang: html.attr('lang'), dir: html.attr('dir')};

    const showState = () => {
        $root.find('#lang-current').text(i18n.locale());
        $root.find('#lang-dir').text(i18n.dir());
        $root.find('#lang-switch button').each(function () {
            const on = $(this).data('lang') === i18n.locale();
            $(this).toggleClass('active', on).attr('aria-pressed', on ? 'true' : 'false');
        });
    };
    const stopListening = i18n.on('change', showState);

    $root.find('#lang-switch button').on('click', async function () {
        const code = $(this).data('lang');
        if (code === 'pl') await i18n.load('pl', polish);
        i18n.locale(code);
    });

    i18n.translate(root);
    showState();

    return () => {
        stopListening();
        i18n.locale(before.locale);
        for (const name of ['lang', 'dir']) {
            if (before[name] == null) html.removeAttr(name);
            else html.attr(name, before[name]);
        }
    };
}
$('#lang-switch button').on('click', async function () {
    const code = $(this).data('lang');
    if (code === 'pl') await i18n.load('pl', '/lang/{locale}.json');
    i18n.locale(code);
});

The card is markup only: data-i18n sets text, data-i18n-html sanitised HTML, data-i18n-attr attributes, each with params from data-i18n-params. The last line has no translation except English, so with i18n.fallback('en') it falls back; what the page first said is kept and shown if a key is missing everywhere.

Welcome to the shop

Welcome to the shop

Everything on this card comes from data-i18n attributes.

Signed in as Ann

3 items in your cart

This line is only written in English - it falls back.

<h3 class="text-lg font-semibold mb-2" data-i18n="shop.title">Welcome to the shop</h3>
<p class="mb-3" data-i18n="shop.intro">Everything on this card comes from data-i18n attributes.</p>
<p class="mb-3" data-i18n-html="shop.signedIn" data-i18n-params="{&quot;name&quot;: &quot;Ann&quot;}">Signed in as <strong>Ann</strong></p>
<p class="mb-3" data-i18n="shop.items" data-i18n-params="{&quot;count&quot;: 3}">3 items in your cart</p>
<div class="form-group">
    <label for="shop-search" data-i18n="shop.searchLabel">Search</label>
    <input id="shop-search" type="search" class="form-input" placeholder="Search products" data-i18n-attr="placeholder:shop.searchPlaceholder; title:shop.searchHint">
</div>
<p class="m-0" data-i18n="shop.onlyInEnglish">This line is only written in English - it falls back.</p>
/**
 * Welcome to the shop: a card translated entirely by data-i18n attributes. Switch language above.
 */
export function mount(root) {
    const {i18n} = Domma;

    i18n.add('en', {shop: {
        title: 'Welcome to the shop',
        intro: 'Everything on this card comes from data-i18n attributes.',
        signedIn: 'Signed in as <strong>{name}</strong>',
        items: {'=0': 'Your cart is empty', one: '{count} item in your cart', other: '{count} items in your cart'},
        searchLabel: 'Search',
        searchPlaceholder: 'Search products',
        searchHint: 'Type a product name',
        onlyInEnglish: 'This line is only written in English - it falls back.'
    }});
    i18n.add('fr', {shop: {
        title: 'Bienvenue dans la boutique',
        intro: 'Tout le contenu de cette carte vient des attributs data-i18n.',
        signedIn: 'Connecté en tant que <strong>{name}</strong>',
        items: {'=0': 'Votre panier est vide', one: '{count} article dans votre panier', other: '{count} articles dans votre panier'},
        searchLabel: 'Rechercher',
        searchPlaceholder: 'Rechercher des produits',
        searchHint: 'Saisissez un nom de produit'
    }});
    i18n.add('de', {shop: {
        title: 'Willkommen im Shop',
        intro: 'Alles auf dieser Karte stammt aus data-i18n-Attributen.',
        signedIn: 'Angemeldet als <strong>{name}</strong>',
        items: {'=0': 'Ihr Warenkorb ist leer', one: '{count} Artikel im Warenkorb', other: '{count} Artikel im Warenkorb'},
        searchLabel: 'Suche',
        searchPlaceholder: 'Produkte suchen',
        searchHint: 'Produktnamen eingeben'
    }});
    i18n.add('ar', {shop: {
        title: 'مرحبًا بك في المتجر',
        intro: 'كل ما في هذه البطاقة يأتي من سمات data-i18n.',
        signedIn: 'تم تسجيل الدخول باسم <strong>{name}</strong>',
        items: {zero: 'سلتك فارغة', one: 'عنصر واحد في سلتك', two: 'عنصران في سلتك', few: '{count} عناصر في سلتك', many: '{count} عنصرًا في سلتك', other: '{count} عنصر في سلتك'},
        searchLabel: 'بحث',
        searchPlaceholder: 'ابحث عن المنتجات',
        searchHint: 'اكتب اسم المنتج'
    }});
    i18n.add('pl', {shop: {
        title: 'Witamy w sklepie',
        intro: 'Wszystko na tej karcie pochodzi z atrybutów data-i18n.',
        signedIn: 'Zalogowano jako <strong>{name}</strong>',
        items: {'=0': 'Koszyk jest pusty', one: '{count} przedmiot w koszyku', few: '{count} przedmioty w koszyku', many: '{count} przedmiotów w koszyku', other: '{count} przedmiotu w koszyku'},
        searchLabel: 'Szukaj',
        searchPlaceholder: 'Szukaj produktów',
        searchHint: 'Wpisz nazwę produktu'
    }});

    // Keys a locale lacks (shop.onlyInEnglish) come from English. fallback() is global, so teardown restores it.
    const fallbacks = i18n.fallback();
    if (!fallbacks.includes('en')) i18n.fallback([...fallbacks, 'en']);

    // i18n.locale(code) re-translates the whole document; this fills the card for the locale already set.
    i18n.translate(root);

    return () => i18n.fallback(fallbacks);
}
<h3 data-i18n="shop.title">Welcome to the shop</h3>
<p data-i18n-html="shop.signedIn" data-i18n-params='{"name": "Ann"}'></p>
<input data-i18n-attr="placeholder:shop.searchPlaceholder; title:shop.searchHint">

Plurals and ordinals

A plural is an object of forms chosen by params.count through Intl.PluralRules. Polish uses one / few / many, Arabic all six, and =0 matches exactly. Ordinals use the same object with ordinal: true.

Plurals and ordinals
locale en

t('cart.items', {count})

t('race.place', {count})

<div class="flex items-center gap-3 flex-wrap mb-4">
    <label for="plural-count" class="font-semibold">count</label>
    <input id="plural-count" type="number" class="form-input" value="3" min="0" max="1000">
    <span class="text-sm">locale <code id="plural-locale">en</code></span>
</div>
<p class="mb-2"><span class="text-sm">t('cart.items', {count})</span><br><span class="sx-i18n-out" id="out-items"></span></p>
<p class="m-0"><span class="text-sm">t('race.place', {count})</span><br><span class="sx-i18n-out" id="out-place"></span></p>
/**
 * Plurals and ordinals: params.count picks the form through Intl.PluralRules, in the current locale.
 */
export function mount(root) {
    const {i18n} = Domma;
    const $root = $(root);

    i18n.add('en', {
        cart: {items: {'=0': 'Your cart is empty', one: '{count} item', other: '{count} items'}},
        race: {place: {ordinal: true, one: '{count}st', two: '{count}nd', few: '{count}rd', other: '{count}th'}}
    });
    i18n.add('fr', {
        cart: {items: {'=0': 'Votre panier est vide', one: '{count} article', other: '{count} articles'}},
        race: {place: {ordinal: true, one: '{count}er', other: '{count}e'}}
    });
    i18n.add('de', {
        cart: {items: {'=0': 'Ihr Warenkorb ist leer', one: '{count} Artikel', other: '{count} Artikel'}},
        race: {place: {ordinal: true, other: '{count}.'}}
    });
    i18n.add('ar', {
        cart: {items: {zero: 'لا توجد عناصر', one: 'عنصر واحد', two: 'عنصران', few: '{count} عناصر', many: '{count} عنصرًا', other: '{count} عنصر'}},
        race: {place: {ordinal: true, other: 'المركز {count}'}}
    });
    i18n.add('pl', {
        cart: {items: {'=0': 'Koszyk jest pusty', one: '{count} przedmiot', few: '{count} przedmioty', many: '{count} przedmiotów', other: '{count} przedmiotu'}},
        race: {place: {ordinal: true, other: '{count}.'}}
    });

    const render = () => {
        const count = Math.max(0, parseInt($root.find('#plural-count').val(), 10) || 0);
        $root.find('#plural-locale').text(i18n.locale());
        $root.find('#out-items').text(i18n.t('cart.items', {count}));
        $root.find('#out-place').text(i18n.t('race.place', {count: Math.max(1, count)}));
    };

    $root.find('#plural-count').on('input', render);
    const stopListening = i18n.on('change', render);
    render();

    return stopListening;
}
i18n.add('pl', {cart: {items: {
    '=0': 'Koszyk jest pusty',
    one: '{count} przedmiot',
    few: '{count} przedmioty',
    many: '{count} przedmiotów',
    other: '{count} przedmiotu'
}}});

i18n.add('en', {race: {place: {ordinal: true, one: '{count}st', two: '{count}nd', few: '{count}rd', other: '{count}th'}}});

i18n.t('cart.items', {count: 22}, {locale: 'pl'});   // "22 przedmioty"
i18n.t('race.place', {count: 22});                   // "22nd"

Placeholders and escaping

t() is plain text - put it in with .text(). html() keeps the message's own markup and escapes every param, so a name like <img onerror> arrives as text.

Placeholders and escaping

t('greet.hello', {name})

html('greet.signedIn', {name})

rendered

<label for="escape-name">Name</label>
<input id="escape-name" type="text" class="form-input mb-3" value="Ann &lt;img src=x onerror=alert(1)&gt;">
<p class="mb-2"><span class="text-sm">t('greet.hello', {name})</span><br><span class="sx-i18n-out" id="out-t"></span></p>
<p class="mb-2"><span class="text-sm">html('greet.signedIn', {name})</span><br><span class="sx-i18n-out" id="out-html-src"></span></p>
<p class="m-0"><span class="text-sm">rendered</span><br><span id="out-html"></span></p>
/**
 * Placeholders and escaping: t() returns text, html() keeps the message's markup and escapes the params.
 */
export function mount(root) {
    const {i18n} = Domma;
    const $root = $(root);

    i18n.add('en', {greet: {hello: 'Hello, {name}!', signedIn: 'Signed in as <strong>{name}</strong>'}});
    i18n.add('fr', {greet: {hello: 'Bonjour, {name} !', signedIn: 'Connecté en tant que <strong>{name}</strong>'}});
    i18n.add('de', {greet: {hello: 'Hallo, {name}!', signedIn: 'Angemeldet als <strong>{name}</strong>'}});
    i18n.add('ar', {greet: {hello: 'مرحبًا، {name}!', signedIn: 'تم تسجيل الدخول باسم <strong>{name}</strong>'}});
    i18n.add('pl', {greet: {hello: 'Cześć, {name}!', signedIn: 'Zalogowano jako <strong>{name}</strong>'}});

    const render = () => {
        const name = $root.find('#escape-name').val();
        // Plain text: put it in with .text(), never .html().
        $root.find('#out-t').text(i18n.t('greet.hello', {name}));
        // HTML with the name escaped: show the source, then render it.
        const html = i18n.html('greet.signedIn', {name});
        $root.find('#out-html-src').text(html);
        $root.find('#out-html').html(html);
    };

    $root.find('#escape-name').on('input', render);
    const stopListening = i18n.on('change', render);
    render();

    return stopListening;
}
i18n.add('en', {greet: {hello: 'Hello, {name}!', signedIn: 'Signed in as <strong>{name}</strong>'}});

$('#out-t').text(i18n.t('greet.hello', {name}));
$('#out-html').html(i18n.html('greet.signedIn', {name}));   // <strong> kept, name escaped

Formatting

Each helper is a cached Intl formatter in the current locale; pass {locale} in the options for another one. Switch language above to see them change.

Formatting

locale en

CallResult
i18n.number(1234567.891)
i18n.number(0.256, {style: 'percent'})
i18n.currency(1499.5, 'EUR')
i18n.date(D(), {dateStyle: 'full'})
i18n.date(D().subtract(3, 'day'), {dateStyle: 'medium', timeStyle: 'short'})
i18n.relative(-1, 'day', {numeric: 'auto'})
i18n.relative(3, 'hour')
i18n.list(['Ann', 'Bo', 'Cy'])
<p class="text-sm mb-2">locale <code id="fmt-locale">en</code></p>
<table class="table mb-4">
    <thead><tr><th>Call</th><th>Result</th></tr></thead>
    <tbody>
        <tr><td><code>i18n.number(1234567.891)</code></td><td data-fmt="number"></td></tr>
        <tr><td><code>i18n.number(0.256, {style: 'percent'})</code></td><td data-fmt="percent"></td></tr>
        <tr><td><code>i18n.currency(1499.5, 'EUR')</code></td><td data-fmt="currency"></td></tr>
        <tr><td><code>i18n.date(D(), {dateStyle: 'full'})</code></td><td data-fmt="date"></td></tr>
        <tr><td><code>i18n.date(D().subtract(3, 'day'), {dateStyle: 'medium', timeStyle: 'short'})</code></td><td data-fmt="dateTime"></td></tr>
        <tr><td><code>i18n.relative(-1, 'day', {numeric: 'auto'})</code></td><td data-fmt="yesterday"></td></tr>
        <tr><td><code>i18n.relative(3, 'hour')</code></td><td data-fmt="inHours"></td></tr>
        <tr><td><code>i18n.list(['Ann', 'Bo', 'Cy'])</code></td><td data-fmt="list"></td></tr>
    </tbody>
</table>
/**
 * Formatting: cached Intl formatters in the current locale. Switch language above to see them change.
 */
export function mount(root) {
    const {i18n} = Domma;
    const $root = $(root);

    const formats = {
        number: () => i18n.number(1234567.891),
        percent: () => i18n.number(0.256, {style: 'percent'}),
        currency: () => i18n.currency(1499.5, 'EUR'),
        date: () => i18n.date(D(), {dateStyle: 'full'}),
        dateTime: () => i18n.date(D().subtract(3, 'day'), {dateStyle: 'medium', timeStyle: 'short'}),
        yesterday: () => i18n.relative(-1, 'day', {numeric: 'auto'}),
        inHours: () => i18n.relative(3, 'hour'),
        list: () => i18n.list(['Ann', 'Bo', 'Cy'])
    };

    const render = () => {
        $root.find('#fmt-locale').text(i18n.locale());
        $root.find('[data-fmt]').each(function () {
            $(this).text(formats[$(this).data('fmt')]());
        });
    };

    const stopListening = i18n.on('change', render);
    render();

    return stopListening;
}
i18n.currency(1499.5, 'EUR');                      // "€1,499.50"
i18n.currency(1499.5, 'EUR', {locale: 'de'});      // "1.499,50 €"
i18n.on('change', () => $('#price').text(i18n.currency(1499.5, 'EUR')));

Usage

const {i18n} = Domma;

// Messages: nested objects become dot keys, later add() calls merge
i18n.add('en', {demo: {greeting: 'Hello, {name}!'}});
await i18n.load('fr', '/lang/{locale}.json');   // H.get, cached, concurrent calls shared
i18n.fallback('en');

// Pick the reader's language, remember it with S
i18n.detect({storageKey: 'lang', supported: ['en', 'fr', 'de', 'ar', 'pl']});

Domma.t('demo.greeting', {name: 'Ann'});
i18n.on('change', ({locale, previous}) => console.log(previous, '->', locale));
i18n.onMissing((key, locale) => `[${key}]`);
<h1 data-i18n="demo.title">Welcome</h1>
<p data-i18n="cart.items" data-i18n-params='{"count": 3}'></p>
<p data-i18n-html="demo.signedIn" data-i18n-params='{"name": "Ann"}'></p>
<input data-i18n-attr="placeholder:demo.searchPlaceholder; title:demo.searchHint">

Full guide: docs/I18n.md.