i18n
A framework-agnostic internationalization engine. It mirrors the router
design: a small core (I18nCore) with an optional global singleton
(createI18n / useI18n) and no DOM coupling, so you bind it to the UI however you like.
Use when you need runtime locale switching in a ranui app. Call
createI18nonce, then read strings withuseI18n().t(key, params)and switch languages withsetLocale. It has no framework or DOM dependency, so it works in plain JS, any framework, and SSR.
The engine ships as its own ranui/i18n entry: importing it registers no custom
elements, so a page that only needs translation never pulls in the component library. The
same exports are also available from the top-level ranui barrel.
Quick start
Create the i18n singleton once at startup, then translate anywhere:
import { createI18n, useI18n } from 'ranui/i18n';
createI18n({
// Each locale is a FLAT dictionary — keys are looked up verbatim, not nested.
messages: {
en: { 'hero.title': 'Hi {name}', 'nav.home': 'Home' },
zh: { 'hero.title': '你好 {name}', 'nav.home': '首页' },
},
fallbackLocale: 'en', // used when a key is missing in the active locale
persist: true, // remember the choice under localStorage key 'ran-locale'
detectNavigator: true, // seed the initial locale from the browser's language preferences
});
const i18n = useI18n();
i18n.t('hero.title', { name: 'Ada' }); // → "Hi Ada"
i18n.setLocale('zh'); // persists and notifies subscribers
i18n.t('hero.title', { name: 'Ada' }); // → "你好 Ada"t(key) looks up messages[activeLocale][key], then messages[fallbackLocale][key], and
finally returns the key itself if neither exists. {param} placeholders in the string are
interpolated from the second argument. Because lookup is a flat map access, keys are literal
strings: write 'hero.title' as one key, not a nested { hero: { title } } object.
Parameters (interpolation)
Yes, messages take runtime parameters. Put {name}-style placeholders in the string and pass
the values as the second argument to t(); each {param} is replaced by the matching value:
createI18n({
messages: {
en: {
'cart.summary': '{count} items · ${total}',
greeting: 'Welcome back, {user}!',
},
zh: {
'cart.summary': '{count} 件商品 · ¥{total}',
greeting: '欢迎回来,{user}!',
},
},
});
const i18n = useI18n();
i18n.t('cart.summary', { count: 3, total: 59.9 }); // → "3 items · $59.9"
i18n.t('greeting', { user: 'Ada' }); // → "Welcome back, Ada!"Details:
- Placeholder syntax is
{word}(letters, digits,_). Values may be strings or numbers: numbers are stringified. - A placeholder with no matching key is left as-is (
{oops}stays literally in the output), which makes missing params easy to spot rather than silently blank. - Interpolation runs after locale fallback, so the same params work no matter which locale actually resolved the string.
- There is no built-in pluralization or number/date formatting; compose those with
Intl.NumberFormat/Intl.PluralRulesand pass the formatted string in as a param.
Escaping literal braces
A lone { or }, or a spaced group like { color: red }, is not a placeholder and passes
through untouched, so CSS, JSON, and code fragments inside a message are safe by default. The
only ambiguous case is a literal {word} you want to show verbatim. To escape it, double the
braces (the same convention as Rust format!, Python str.format, and .NET String.Format):
const i18n = useI18n(); // messages below assumed registered
i18n.t('use {{ and }} for literal braces'); // → "use { and } for literal braces"
i18n.t('the {{count}} token'); // → "the {count} token" (not interpolated)
i18n.t('{{{name}}}', { name: 'Ada' }); // → "{Ada}" (value wrapped in literal braces)| In the message | Output |
|---|---|
{{ |
{ |
}} |
} |
{name} |
the name param, or {name} if absent |
{ name } |
{ name } (spaces → not a placeholder) |
{ |
{ (lone brace) |
Escaping is applied in the same left-to-right pass as interpolation and works whether or not
you pass params, so {{/}} always mean literal braces.
Doubling is the same convention used by Rust
format!, Pythonstr.format, and .NETString.Format, so it needs no new escape character. If you need real plural/gender/number grammar, format withIntl.*and pass the result in as a param.
Reacting to locale changes
onChange fires after every setLocale; use it to re-render the strings you've painted:
const i18n = useI18n();
const unsubscribe = i18n.onChange((locale) => {
document.documentElement.lang = locale;
repaintStrings(); // re-run your t() calls
});
// later, when the view unmounts
unsubscribe();Adding messages lazily
Load a locale's dictionary on demand (e.g. code-split per language) and merge it in:
const i18n = useI18n();
const { default: fr } = await import('./locales/fr.js');
i18n.addMessages('fr', fr); // merges into any existing 'fr' dictionary
i18n.setLocale('fr');Localizing component text
The components do not read from this engine themselves. That is deliberate: a component
that read directly from a global singleton would tie every consumer to one instance and one
key-naming scheme, and would make a page that imports one button pull in the translation
layer too. Instead
every user-visible string is an input: an attribute, a property, an option, or slotted
content, so localizing ranui means passing t() output in where the string already goes:
const i18n = useI18n(); // messages below assumed registered
modal.setAttribute('title', i18n.t('dialog.deleteProject.title'));
themeSwitch.setAttribute('label-dark', i18n.t('theme.dark'));Most components have no text of their own at all: it arrives through slots and attributes you already write. A handful ship an English default for a string that has nowhere else to come from, mostly accessible names:
| Component | Built-in English | Override with |
|---|---|---|
Modal.confirm / Modal.open |
title Confirm, buttons OK / Cancel |
the title, okText, cancelText options |
Modal.info / .success / .warning / .error |
titles Info / Success / Warning / Error |
the title option |
<r-theme-switch> |
aria-labels Theme, System theme, Light theme, Dark theme |
label, label-system, label-light, label-dark |
<r-voice-button> |
aria-labels Start voice input / Stop voice input; hints Release to keep · slide up to cancel, Release to cancel |
label, active-label, hold-hint, cancel-hint |
<r-reasoning> |
header label Reasoning |
label |
<r-token-meter> |
label Context |
label |
<r-colorpicker> |
aria-labels Choose color, Hue, Alpha opacity |
label, hue-label, alpha-label |
A practical pattern is to re-apply them from one place on every locale change, so the same code runs at startup and after a switch:
const i18n = useI18n();
const applyLabels = () => {
document.querySelectorAll('r-voice-button').forEach((el) => {
el.setAttribute('label', i18n.t('voice.start'));
el.setAttribute('active-label', i18n.t('voice.stop'));
});
};
applyLabels();
i18n.onChange(applyLabels);Remember to keep document.documentElement.lang in step too: it is what the browser, screen
readers and :lang() selectors go by.
API
createI18n(config) creates and registers the global singleton (call once);
useI18n() returns it, or null if createI18n hasn't run yet.
I18nConfig
| Field | Type | Default | Description |
|---|---|---|---|
messages |
LocaleMessages |
{} |
locale → { key → string }. Each dictionary is flat. |
locale |
string |
fallback | Initial locale (overridden by a persisted choice when on). |
fallbackLocale |
string |
'en' |
Locale consulted when a key is missing in the active locale. |
persist |
boolean |
false |
Persist the active locale to localStorage. |
storageKey |
string |
'ran-locale' |
localStorage key used when persist is on. |
detectNavigator |
boolean |
false |
Seed the initial locale from the browser's language preferences. Reads the whole ordered navigator.languages list, so a reader whose first choice has no dictionary still gets their second rather than the fallback. |
I18nCore methods
| Method | Returns | Description |
|---|---|---|
t(key, params?) |
string |
Translate; falls back to fallback locale, then the key. |
setLocale(locale) |
void |
Switch locale; persists (if on) and notifies subscribers. |
getLocale() |
string |
The active locale. |
onChange(handler) |
() => void |
Subscribe to locale changes; returns an unsubscribe function. |
addMessages(locale, dict) |
void |
Merge more messages into a locale. |
getMessages(locale?) |
MessageDict |
Read a locale's dictionary (defaults to the active locale). |
availableLocales |
string[] |
Locales that have a dictionary registered. |
destroy() |
void |
Remove all subscribers. |
Types
type MessageDict = Record<string, string>; // flat: 'hero.title' → 'Hi {name}'
type LocaleMessages = Record<string, MessageDict>; // locale → MessageDict
type TranslateParams = Record<string, string | number>;SSR
The core is SSR-safe: localStorage and navigator access is guarded, so createI18n /
t run without throwing during server rendering. Persistence and navigator detection simply
no-op on the server and take effect once the code runs in the browser.