@ryupold/vode-i18n (0.5.0)

Published 2026-07-07 01:43:25 +02:00 by michi in ryupold/vode-i18n

Installation

@ryupold:registry=
npm install @ryupold/vode-i18n@0.5.0
"@ryupold/vode-i18n": "0.5.0"

About this package

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

<script src="https://unpkg.com/@ryupold/vode-i18n/dist/vode-i18n.es5.min.js"></script>
<script>
    const { createI18nContext } = V;
</script>

NPM

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}"]],
    },
    fallbackCatalog: (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, _many or 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?: Partial<C> | object | ((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 single Map.get.
  • fallbackCatalog (optional): consulted whenever a key misses the main catalog. Either another catalog object (e.g. your primary language) or a function (key: string) => string to produce a fallback value.

The returned context contains locale and the six functions below.

Translate to text

$T(key, pluralAndFirstArg?, ...restArgs): string

Looks up key and returns the translated string. Returns undefined if the key exists in neither catalog.

Placeholder arguments fill {1}, {2}, … in order:

const { $T } = createI18nContext({
    locale: "en",
    catalog: {
        summary: "Today {1} has accomplished {2} tasks",
        reversed: "{2} before {1}",
    },
});

$T("summary", "John", "2");          // "Today John has accomplished 2 tasks"
$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:

  1. an exact numeric key (0, 2, …) wins if present
  2. otherwise the category chosen by Intl.PluralRules for your locale (_zero, _one, _two, _few, _many)
  3. 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): ChildVode

Same 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} inside class or style stays as-is.
  • The vode is deep-copied before replacement, so the catalog is never mutated.
  • Plain strings work with $V too; it simply returns the translated string.

Raw catalog access

$R(key): any

Returns 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 fallback be provided 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:

  • $T only accepts paths that lead to a string or plural form
  • $V only accepts paths that lead to a vode, string, or vode plural form
  • $R accepts 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

TypeScript

License: MIT

Dependencies

Development dependencies

ID Version
@babel/cli 8.0.1
@babel/core 8.0.1
@babel/preset-env 8.0.2
dts-bundle-generator 9.5.1
esbuild 0.28.1
typescript 6.0.3

Keywords

typescript library vode internationalization i18n localization l10n translation pluralization formatting
Details
npm
2026-07-07 01:43:25 +02:00
3
ryupold
MIT
13 KiB
Assets (1)
Versions (3) View all
0.6.1 2026-07-07
0.6.0 2026-07-07
0.5.0 2026-07-07