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().
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
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="{"name": "Ann"}">Signed in as <strong>Ann</strong></p>
<p class="mb-3" data-i18n="shop.items" data-i18n-params="{"count": 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.
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.
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 <img src=x onerror=alert(1)>">
<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.
locale en
| Call | Result |
|---|---|
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.