@samveys/i18n

Version
1.0.2Nov 5, 2026
Install
pnpm add @samveys/i18n

From packages/i18n/README.md in the framework repository.

Formatting with Intl for the request's locale, and translations written in MessageFormat 2. The only runtime dependency is messageformat 4.1.0 (Apache-2.0, no dependencies of its own).

Set up

ts
import { i18n } from '@samveys/i18n'

export const plugin = i18n({
  defaultLocale: 'en',
  fallbackLocale: 'en', // used when the requested locale lacks a key
  langDir: 'lang', // lang/en.json, lang/id.json, …
  strict: true, // a missing key throws SVS_E111 instead of returning the key
})

The plugin binds 'i18n' and adds two fields to every request: ctx.locale (negotiated from Accept-Language) and ctx.i18n (a translator for it).

ts
import type { HttpContext } from '@samveys/core'
import '@samveys/i18n'

export function invoice(ctx: HttpContext) {
  return {
    locale: ctx.locale,
    total: ctx.i18n.money(1250001, 'IDR'), // "Rp 1.250.001" for id-ID, "IDR 1,250,001.00" for en-US
    when: ctx.i18n.date(new Date(), 'long'),
    tags: ctx.i18n.list(['a', 'b', 'c']), // "a, b, dan c" for id, "a, b, and c" for en
    ago: ctx.i18n.relative(Date.now() - 3 * 86_400_000), // "3 hari yang lalu"
  }
}

Formatting comes from Intl, so it needs no data files: money(amount, currency), number(value, options?), date(value, style) and dateTime(value, style) (short, medium, long, full), list(items, type?), relative(value).

Translations

lang/<locale>.json holds one catalog per locale. Nested keys are dotted (cart.items). Messages are MessageFormat 2.

json
{
  "greeting": "Halo {$name}!",
  "cart": {
    "items": ".input {$count :number}\n.match $count\none {{{$count} item}}\n* {{{$count} items}}"
  }
}

Plural categories come from the language's CLDR rules, so the same message works everywhere: English picks one or other, Indonesian has only other, Russian also has few and many.

ts
import { createI18nManager } from '@samveys/i18n'

const plural = '.input {$count :number}\n.match $count\none {{{$count} item}}\n* {{{$count} items}}'
const manager = createI18nManager({ defaultLocale: 'en', messages: { en: { items: plural }, id: { items: plural } } })

export const english = manager.for('en').t('items', { count: 1 }) // "1 item"
export const indonesian = manager.for('id').t('items', { count: 1 }) // "1 items": id has no "one"
  • Fallback: a key missing from the locale is looked up in fallbackLocale.
  • Missing everywhere: the key itself is returned, or SVS_E111 in strict mode.
  • Messages in code: messages takes catalogs directly and wins over files.

Locale negotiation

manager.negotiate(acceptLanguage) returns the best supported locale: tags are tried in quality order; for each an exact match, then the bare language (id-ID → id), then any locale of the same language (pt-PT → pt-BR); otherwise the default.