@ryupold/vode-i18n
v0.6.1
Published
A library for internationalization (i18n) specifically for usage in vode based apps
Maintainers
Readme
vode-i18n
Internationalization (i18n) library for vode based apps.
Support for plurals (utilizing Intl.PluralRules).
Translations can even be vodes.
Usage
ESM
import { createI18nContext } from 'https://unpkg.com/@ryupold/vode-i18n/dist/vode-i18n.min.mjs';IIFE
Binds the library to the global Vi18n object.
<script src="https://unpkg.com/@ryupold/vode-i18n/dist/vode-i18n.es5.min.js"></script>
<script>
const { createI18nContext } = Vi18n;
</script>NPM
import { createI18nContext } from '@ryupold/vode-i18n';Quick start
import { createI18nContext } from '@ryupold/vode-i18n';
const { $T, $V } = createI18nContext({
locale: "en",
catalog: {
greeting: "Hello, {1}!",
menu: {
file: {
open: "Open…",
save: "Save {1}",
},
},
items: {
0: "no items at all",
_one: "{1} item",
_other: "{1} items",
},
banner: ["div", { class: "banner" }, "Welcome ", ["b", "{1}"]],
},
onMissingKey: (key) => key, // return the key as-is if no translation is found
});
$T("greeting", "John"); // "Hello, John!"
$T("menu.file.open"); // "Open…"
$T("items", 1); // "1 item"
$T("items", 0); // "no items at all"
$V("banner", "John"); // ["div", { class: "banner" }, "Welcome ", ["b", "John"]]When using TypeScript, all keys are typed. So for example,
$T("menu.file.foobar")is a compile error because"foobar"is not a valid key for"menu.file".
The catalog
A catalog is a plain nested object. Leaves can be:
- strings: optionally containing
{1},{2}, … placeholders - plural forms: objects with
_other(required) and optional_zero,_one,_two,_few,_manyor exact numeric keys. See plural forms. - vodes:
[TAG, PROPS?, ...CHILDREN]tuples, for translations with markup - anything else: numbers, dates, functions… accessible via
$R
Nested objects become dot-separated key paths: { menu: { file: { open: "Open…" } } } is addressed as "menu.file.open".
For multiple languages, create one context per locale. Type the other languages against your primary one so the compiler tells you when a translation is missing:
const en = { greeting: "Hello, {1}!" };
const de: typeof en = { greeting: "Hallo, {1}!" };
const i18n = createI18nContext({
locale: userLocale,
catalog: userLocale === "de" ? de : en,
fallbackCatalog: en,
});createI18nContext
function createI18nContext<C>(options: {
locale: Intl.UnicodeBCP47LocaleIdentifier;
catalog: C;
fallbackCatalog?: DeepPartial<C | Record<string, unknown>> | undefined;
onMissingKey?: (key: string) => unknown;
}): I18nContext<C>locale: a BCP 47 locale identifier like"en","es-MX","zh-Hant-TW". Used to pick the right plural rules.catalog: your translation object. It is flattened once at creation, so lookups afterwards are a singleMap.get.fallbackCatalog(optional): consulted whenever a key misses the main catalog.onMissingKey(optional): consulted whenever a key misses the main catalog and the fallback catalog is not defined.
The returned context contains locale and the six functions below.
Translate to text
$T(key, pluralAndFirstArg?, ...restArgs): string | undefinedLooks up key and returns the translated string. Returns undefined if the key exists in neither catalog.
Placeholder arguments (strings or numbers) fill {1}, {2}, … in order:
const { $T } = createI18nContext({
locale: "en",
catalog: {
summary: "Today {1} finished {2}",
reversed: "{2} before {1}",
},
});
$T("summary", "John", "his homework"); // "Today John finished his homework"
$T("reversed", "world", "hello"); // "hello before world"Placeholders without a matching argument stay in the text ($T("summary", "John") → "Today John has accomplished {2} tasks"), and extra arguments are silently ignored.
Plurals
If the value at key is a plural form (an object with _other key) and the first argument is a number, it selects the plural category (cardinal) and also fills {1}:
const { $T } = createI18nContext({
locale: "en",
catalog: {
files: {
_one: "one file in {2}",
_other: "{1} files in {2}",
},
},
});
$T("files", 1, "folder"); // "one file in folder"
$T("files", 3, "trash"); // "3 files in trash"Selection order:
- an exact numeric key (
0,2, …) wins if present - otherwise the category chosen by
Intl.PluralRulesfor your locale (_zero,_one,_two,_few,_many) - otherwise
_other
Which of the six CLDR categories apply is decided by the locale, not by hardcoded English logic. French, for example, treats 0 as singular and uses _many for millions:
const { $T } = createI18nContext({
locale: "fr",
catalog: {
jours: {
_one: "{1} jour restant",
_many: "{1} de jours restants",
_other: "{1} jours restants",
},
},
});
$T("jours", 0); // "0 jour restant" (singular!)
$T("jours", 1); // "1 jour restant"
$T("jours", 5); // "5 jours restants"
$T("jours", 1000000); // "1000000 de jours restants"For ordinal plurals (1st, 2nd, 3rd…), pass { type: "ordinal", value: n } as first argument after the key instead of a plain number:
const { $T } = createI18nContext({
locale: "en",
catalog: {
place: {
_one: "{1}st place",
_two: "{1}nd place",
_few: "{1}rd place",
_other: "{1}th place",
},
},
});
$T("place", { type: "ordinal", value: 1 }); // "1st place"
$T("place", { type: "ordinal", value: 22 }); // "22nd place"
$T("place", { type: "ordinal", value: 103 }); // "103rd place"({ type: "cardinal", value: n } is equivalent to passing n directly.)
Translations as vodes
$V(key, pluralAndFirstArg?, ...restArgs): ChildVodeSame lookup, plural, and placeholder rules as $T, but the catalog value may be a vode:
const { $V } = createI18nContext({
locale: "en",
catalog: {
banner: ["div", { class: "banner" }, "Welcome {1}", ["b", "to {2}"]],
items: {
_one: ["span", { class: "single" }, "one item"],
_other: ["span", { class: "multi" }, "{1} items"],
},
},
});
$V("banner", "John", "Wonderland");
// ["div", { class: "banner" }, "Welcome John", ["b", "to Wonderland"]]
$V("items", 4);
// ["span", { class: "multi" }, "4 items"]Drop the result straight into your view:
app(document.body, state, (s) => [DIV,
$V("banner", s.userName, s.appName),
]);Notes:
- Placeholders are replaced in children (recursively), never in props: a
{1}insideclassorstylestays as-is. - The vode is deep-copied before replacement, so the catalog is never mutated.
- Plain strings work with
$Vtoo; it simply returns the translated string.
Raw catalog access
$R(key): anyReturns whatever sits at key. No placeholder replacement, no plural selection. Use it for non-string values or to grab whole subtrees:
const { $R } = createI18nContext({
locale: "en",
catalog: {
meta: { version: 3, released: new Date("2026-07-06") },
a: { b: { c: "leaf" } },
},
});
$R("meta.version"); // 3
$R("a.b"); // { c: "leaf" }
$R("a.b.c"); // "leaf"$TPrefix / $VPrefix / $RPrefix
Each function has a prefix companion that binds it to a subtree of the catalog. Great for components that only care about one corner of your translations:
const { $TPrefix } = createI18nContext({
locale: "en",
catalog: {
menu: {
file: {
open: "Open…",
save: "Save {1}",
recent: { _one: "{1} recent file", _other: "{1} recent files" },
},
},
},
});
const $file = $TPrefix("menu.file");
$file("open"); // "Open…"
$file("save", "a.txt"); // "Save a.txt"
$file("recent", 12); // "12 recent files"The returned function is fully typed against the subtree. $file("nope") won't compile in TS. $VPrefix and $RPrefix work the same way for vodes and raw values.
Fallback catalog
When a key is missing from the main catalog, the fallbackCatalog is consulted before giving up:
const { $T } = createI18nContext({
locale: "de",
catalog: de, // partial German translation
fallbackCatalog: en, // complete English catalog
});- The main catalog always wins when both have the key.
- Fallback values support everything the main catalog does: placeholders, plural forms, vodes.
- A function can also be provided as fallback to handle missing keys. It is good practice to return a default value indicating the key was not found. I usually return the key itself.
createI18nContext({
locale: "en",
catalog: en,
fallbackCatalog: (key) => key,
});If the key is found nowhere, $T/$V/$R return undefined.
Type safety
The key types are derived from your catalog with template literal types:
$Tonly accepts paths that lead to a string or plural form$Vonly accepts paths that lead to a vode, string, or vode plural form$Raccepts any path in the catalog
Development
npm run test # compile & run the test suite
npm run release # build all dist variants (ESM, IIFE, minified, ES5) + type declarations