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.