i18n
A framework-agnostic internationalisation engine: a small reactive core (I18nCore) with an optional global singleton (createI18n / useI18n). Nothing here touches the DOM — bind it to the UI however you like.
import { createI18n, useI18n } from 'ranuts/i18n';It is also re-exported from ranuts/utils. Import from ranuts/i18n when i18n is all you need — that entry carries only the engine and its two helpers, instead of whatever else the broad utils barrel happens to pull in.
Usage
import { createI18n, useI18n } from 'ranuts/i18n';
createI18n({
messages: {
en: { 'hero.title': 'Hello, {name}', 'nav.docs': 'Docs' },
zh: { 'hero.title': '你好,{name}', 'nav.docs': '文档' },
},
fallbackLocale: 'en',
persist: true,
detectNavigator: true,
});
const i18n = useI18n()!;
i18n.t('hero.title', { name: 'Ada' }); // "Hello, Ada"
i18n.setLocale('zh');
i18n.t('hero.title', { name: 'Ada' }); // "你好,Ada"Dictionaries are flat — t() does a direct messages[locale][key] lookup, so keys are literal strings like 'hero.title', not nested objects.
Initial locale
Resolved once in the constructor, in this order:
- The persisted choice in
localStorage(only whenpersistis on, and only if that locale has a dictionary) config.locale- The browser's languages (only when
detectNavigatoris on) fallbackLocale
Step 3 goes through resolveLocale, which reads the whole ordered navigator.languages list rather than just navigator.language — a reader whose first choice isn't among your dictionaries still gets their second, instead of dropping straight to the fallback.
Interpolation
t(key, params) substitutes {param} placeholders in a single left-to-right pass, following the format-string convention of Rust format!, Python str.format and .NET String.Format:
| Input | Output |
|---|---|
{{ | literal { |
}} | literal } |
{name} | params.name, stringified |
{name} with no such param | left untouched, so a stray placeholder is visible rather than silently blank |
A lone { / } or a spaced group like { x } is not a placeholder and is emitted verbatim, so CSS, JSON or code fragments inside a message pass through unharmed. To wrap a value in literal braces, double the outer pair — {{{name}}}.
Typed dictionaries
Pass your dictionary shape as a type argument and every t() call is checked at compile time. Without it, a renamed or mistyped key degrades silently into "render the key itself" — the user sees agentModelFirstDownlaod where a sentence should be, and nothing fails until then.
interface Messages {
save: string;
cancel: string;
}
const i18n = createI18n<Messages>({
messages: {
en: { save: 'Save', cancel: 'Cancel' },
'zh-CN': { save: '保存' }, // still being translated — that is fine
},
fallbackLocale: 'en',
});
i18n.t('save'); // ok
i18n.t('saev'); // compile error
useI18n<Messages>()?.t('cancel'); // pass the same type back to keep the checkThree details make this usable rather than merely available:
- Each locale is
Partial. A translation in progress is the normal state; the fallback locale covers what a locale has not filled in yet. - The type comes from the type argument, never from the data.
messagesis wrapped inNoInfer, so locales with different key sets cannot make TypeScript infer their intersection — otherwise a key only the fallback defines would be rejected at every call site, and an incomplete translation would break the build instead of falling back at runtime. - An
interfaceworks, not just atype. The constraint isStringValues<T>({ [K in keyof T]: string }) rather thanRecord<string, string>, because TypeScript only gives implicit index signatures to type aliases — constraining the obvious way would have forced every consumer to rewrite their dictionary as atype.
Omitting the type argument keeps the untyped behaviour exactly: the default MessageDict is Record<string, string>, whose keyof is string.
Config
| Field | Description | Type | Default |
|---|---|---|---|
locale | Initial locale. Overridden by a persisted choice when persist is on | string | - |
fallbackLocale | Locale used when a key is missing in the active locale | string | 'en' |
messages | Locale → key → string | LocaleMessages | {} |
persist | Persist the active locale to localStorage | boolean | false |
storageKey | localStorage key used when persist is on | string | 'ran-locale' |
detectNavigator | Seed the initial locale from the browser's language preferences | boolean | false |
API
createI18n
Create and register the global singleton.
Parameters
| Parameter | Description | Type | Default |
|---|---|---|---|
config | See Config | I18nConfig | {} |
Return
| Argument | Description | Type |
|---|---|---|
i18n | The new instance | I18nCore |
useI18n
Return the active global instance, or null when none was created.
Return
| Argument | Description | Type |
|---|---|---|
i18n | The active instance or null | I18nCore | null |
I18nCore
| Member | Description |
|---|---|
t(key, params?) | Translate; falls back to the fallback locale, then the key itself |
locale / getLocale() | The active locale |
setLocale(locale) | Switch locale, persist (when enabled) and notify. No-op if unchanged |
addMessages(locale, dict) | Merge a dictionary into a locale, creating it if needed |
getMessages(locale?) | The dictionary for a locale, or {} |
availableLocales | Locales that have a dictionary registered |
onChange(fn) | Subscribe to locale changes; returns an unsubscribe function |
destroy() | Remove all subscribers |
SSR
Safe. All localStorage and navigator access is guarded, so constructing an instance during server rendering falls through to config.locale or fallbackLocale.