@clustersoft/i18n
v1.0.0
Published
Universal, zero-dependency internationalisation for JavaScript and TypeScript — nested keys, namespaces, CLDR plural rules, Intl formatting and fallback chains, with optional ClusterSoft MGR bot integration.
Maintainers
Readme
@clustersoft/i18n
Universal internationalisation for JavaScript and TypeScript — zero dependencies,
Intl-powered, and correct where hand-rolled helpers are not.
Use it anywhere you need translated text: an HTTP service, a CLI, a worker, a
scheduled job, a test — or a ClusterSoft MGR bot, through an optional
/bot entry point that the rest of the package
knows nothing about.
- 🟢 Zero runtime dependencies — nothing but the built-in
Intl - 🌍 Runtime-agnostic core — no bot, no framework, no file system required
- 🧠 Real pluralisation —
Intl.PluralRules, so Russianone/few/manyis right - 🧩 Two placeholder styles —
{name}and{{name}}, mixed freely - 🗂 Nested, flat and namespaced keys —
menu.main.title,menu_title,common:greeting - 📦 Files optional — pass message objects, or load a directory
- 🔤
Intlformatting — numbers, currency, dates, relative time, inline in messages - 🧾 TypeScript-first — typed keys from your own message tree; plain JS works identically
- 🤖 Optional bot layer — middleware,
ctx.t, and a ready-made language picker
Features at a glance
| Area | What you get |
|------|--------------|
| Lookup | nested paths · flat keys · namespaces (common:greeting) · per-key fallback chain |
| Plurals | CLDR categories via Intl.PluralRules · exact-count overrides ("0") · graceful degradation |
| Interpolation | {name} · {{name}} · {{{escaped}}} · inline {amount, number} / {when, date} |
| Formatting | number · currency · percent · date · time · dateTime · relativeTime · list |
| Loading | plain objects · one file per locale · one folder per locale (namespaces) · runtime addLocale |
| Storage | in-memory · Redis with your client · anything with get/set |
| Bot layer | locale resolution per update · ctx.t / ctx.i18n · language keyboard + callback |
| Packaging | ESM + CommonJS + .d.ts · dependencies: {} · Node ≥ 18 |
Table of contents
- Installation
- Quick start
- Message files
- Keys, namespaces and fallbacks
- Interpolation
- Pluralisation
- Formatting with Intl
- Missing keys and variables
- Storing a user's locale
- TypeScript
- Bot integration (optional)
- Language codes:
uzvsoz - Migrating from a hand-rolled helper
- API reference
- Examples
- License
Installation
npm install @clustersoft/i18nRequires Node.js ≥ 18. There are no runtime dependencies.
Two entry points:
| Import | Contents |
|--------|----------|
| @clustersoft/i18n | the engine — usable in any JS/TS project |
| @clustersoft/i18n/bot | optional integration with @clustersoft/mgr-bot-sdk |
The SDK is an optional peer dependency: install it only if you use the second entry point.
Both module formats ship, so TypeScript, ESM and CommonJS behave identically:
// TypeScript / ES modules
import { I18n } from '@clustersoft/i18n'// Plain JavaScript (CommonJS)
const { I18n } = require('@clustersoft/i18n')Quick start
Create one engine for your process, then a translator wherever you render text.
TypeScript
import { I18n } from '@clustersoft/i18n'
const i18n = new I18n({
defaultLocale: 'uz',
fallbackLocale: ['uz', 'en'],
locales: {
uz: {
welcome: 'Salom, {name}!',
orders: { one: '{count} ta buyurtma', other: '{count} ta buyurtma' },
},
ru: {
welcome: 'Привет, {name}!',
orders: {
one: '{count} заказ',
few: '{count} заказа',
many: '{count} заказов',
other: '{count} заказа',
},
},
},
})
const t = i18n.translator('ru')
t('welcome', { name: 'Eldor' }) // "Привет, Eldor!"
t('orders', { count: 1 }) // "1 заказ"
t('orders', { count: 3 }) // "3 заказа"
t('orders', { count: 5 }) // "5 заказов"JavaScript
const path = require('node:path')
const { I18n } = require('@clustersoft/i18n')
const i18n = new I18n({
defaultLocale: 'uz',
fallbackLocale: ['uz', 'en'],
directory: path.join(__dirname, 'locales'), // reads locales/*.json
})
// One bound translator per request keeps call sites short.
function handler(req, res) {
const t = i18n.translator(req.query.lang)
res.end(t('welcome', { name: 'Eldor' }))
}Message files
Three ways to supply messages; combine them freely.
Plain objects — no file system involved, which is what you want when bundling or running serverless:
new I18n({ locales: { uz: { welcome: 'Salom!' }, ru: { welcome: 'Привет!' } } })One file per locale — flat or nested keys:
locales/
├── uz.json
├── oz.json
├── ru.json
└── en.json{
"welcome": "Salom, {name}!",
"menu": { "main": { "title": "Bosh menyu" } },
"cart": { "items": { "one": "{count} ta mahsulot", "other": "{count} ta mahsulot" } }
}A folder per locale — each file name becomes a namespace:
locales/
├── uz/
│ ├── common.json → t('common:greeting')
│ └── menu.json → t('menu:title')
└── ru/
└── common.jsonnew I18n({ directory: './locales' }) // the layout is detected automaticallydirectory and locales can be used together — files load first, objects are
deep-merged on top. addLocale() does the same at runtime, for lazily loaded or
remotely fetched translations:
i18n.addLocale('en', await fetchTranslations('en'))
i18n.addLocale('en', invoiceMessages, 'invoice') // under a namespaceKeys, namespaces and fallbacks
i18n.t('uz', 'menu.main.title') // nested path
i18n.t('uz', 'menu_title') // flat key
i18n.t('uz', 'common:greeting') // namespaced keyA key is resolved in this order:
- literally — a key written verbatim in the tree always wins, whichever
separator it contains (
{"user:name": "Ism"}resolves fort('user:name'),{"order.from": "Qayerdan"}fort('order.from')); - as a namespace —
ns:restlooksrestup inside thensbranch; - as a nested path —
menu.main.titlewalks the tree; - in the default namespace (
common, configurable withdefaultNamespace) for keys with nons:prefix.
Both separators are configurable (keySeparator, namespaceSeparator), and
either can be set to false to switch that step off — see
migrating from a hand-rolled helper.
Lookups walk a fallback chain: the requested locale, then every
fallbackLocale in order, then defaultLocale.
const i18n = new I18n({ defaultLocale: 'uz', fallbackLocale: ['oz', 'en'] })
i18n.localeChain('ru') // ['ru', 'oz', 'en', 'uz']The chain is applied per key, not per locale — a locale that is only 40 % translated still renders fully, falling back key by key.
Interpolation
Both styles work, in the same message if you like:
{
"hi": "Salom, {name}!",
"bye": "Xayr, {{name}}!",
"literal": "Use {{{name}}} for a placeholder"
}i18n.t('uz', 'hi', { name: 'Eldor' }) // "Salom, Eldor!"
i18n.t('uz', 'literal') // "Use {name} for a placeholder"Messages can also format values inline:
{
"balance": "Balansingiz: {amount, currency}",
"total": "Jami {value, number} ta",
"joined": "Sana: {when, date}",
"price": "Narx: {value, currency, USD}"
}Supported inline formats: number, currency (optional currency code),
percent, date, time, datetime, relative, list. This is a deliberate
"ICU-lite" subset — a readable shorthand for the common cases, not a full ICU
message parser.
Pluralisation
Plural forms live under the key, using CLDR category names:
{
"orders": {
"0": "Нет заказов",
"one": "{count} заказ",
"few": "{count} заказа",
"many": "{count} заказов",
"other": "{count} заказа"
}
}i18n.t('ru', 'orders', { count: 1 }) // "1 заказ"
i18n.t('ru', 'orders', { count: 2 }) // "2 заказа"
i18n.t('ru', 'orders', { count: 5 }) // "5 заказов"
i18n.t('ru', 'orders', { count: 21 }) // "21 заказ"
i18n.t('ru', 'orders', { count: 0 }) // "Нет заказов" (exact-count key)Rules of the road:
- the form is chosen by
Intl.PluralRulesfor the locale of the message; - an exact-count key (
"0","1", …) beats the CLDR category; countis interpolated as{count}automatically;- a partially translated set degrades to
other, thenmany, then any form present — never to an empty string; - without a
count, the neutralotherform is used; no form is ever guessed.
Uzbek has a single plural form, so { "one": …, "other": … } with identical
text is normal and correct — keeping both keys means the message stays valid if
the wording ever diverges.
Formatting with Intl
const f = i18n.formatter('ru')
f.number(1234567) // "1 234 567"
f.currency(45000) // "45 000 UZS" (defaultCurrency)
f.currency(1500, 'USD') // "1500 $"
f.percent(0.075) // "8 %"
f.date(new Date()) // "15 янв. 2026 г."
f.time(new Date()) // "09:30"
f.dateTime(new Date()) // "15 янв. 2026 г., 09:30"
f.relativeTime(Date.now() - 3_600_000) // "1 час назад"
f.relativeTime(-2, 'day') // "2 дня назад"
f.list(['a', 'b', 'c']) // "a, b и c"i18n.format is the same object bound to the default locale. Every helper
accepts the matching native Intl options as a last argument, and falls back to
String(value) instead of throwing when a runtime lacks data for a locale.
Missing keys and variables
Nothing renders blank. A key missing from every locale in the chain is returned as-is, and reported:
const i18n = new I18n({
onMissing: ({ key, locale, chain }) => {
if (process.env.NODE_ENV !== 'production') {
console.warn(`[i18n] missing ${locale}:${key} (tried ${chain.join(' → ')})`)
}
// return a string here to override what t() gives back
},
})A placeholder whose parameter was not supplied is left in place — visible rather than silently empty — and reported the same way:
new I18n({ onMissingVar: ({ name }) => `«${name}»` })Storing a user's locale
The engine itself is stateless. When you need to remember a choice, use a store — two methods, and the package imports no client of its own:
interface LocaleStore {
get(key: string, context?: unknown): string | null | undefined | Promise<…>
set(key: string, locale: string, context?: unknown): void | Promise<void>
delete?(key: string, context?: unknown): void | Promise<void>
}| Store | Behaviour |
|-------|-----------|
| MemoryLocaleStore | per-process map; fine for a single instance |
| RedisLocaleStore | pass your ioredis-compatible client; optional TTL |
| SessionLocaleStore | from /bot — keeps the locale in ctx.session |
| yours | anything implementing the two methods |
import Redis from 'ioredis'
import { RedisLocaleStore } from '@clustersoft/i18n'
const store = new RedisLocaleStore(new Redis(process.env.REDIS_URL), {
ttlSeconds: 60 * 60 * 24 * 365,
})
await store.set('user:42', 'ru')
const t = i18n.translator((await store.get('user:42')) ?? 'uz')TypeScript
Typed keys come from your own message tree — no code generation step:
import { I18n, defineLocale } from '@clustersoft/i18n'
const uz = defineLocale({
welcome: 'Salom, {name}!',
menu: { main: { title: 'Bosh menyu' } },
})
const i18n = new I18n<typeof uz>({ locales: { uz }, defaultLocale: 'uz' })
i18n.t('uz', 'menu.main.title') // autocompleted
i18n.t('uz', someDynamicKey) // still allowed — no cast requireddefineLocale is an identity function; it exists only to preserve the literal
shape of the object. An imported JSON file works as the type source too
(import uz from './locales/uz.json' with resolveJsonModule).
Bot integration (optional)
Everything above is bot-free. If you are building on
@clustersoft/mgr-bot-sdk, the /bot entry point adds per-update locale
resolution, ctx.t, and the language picker.
Why a language picker is mandatory on MGR
MGR updates identify the sender but carry no language field — a bot cannot detect a user's language automatically. The practical consequence: ask once, then remember. This package treats that flow as a first-class feature rather than an afterthought. If the platform ever starts sending a user language, wire it in through the
localeFromUpdatehook without touching the rest of your bot.
TypeScript
import { Bot } from '@clustersoft/mgr-bot-sdk'
import { I18n } from '@clustersoft/i18n'
import { BotI18n } from '@clustersoft/i18n/bot'
import '@clustersoft/i18n/bot/augment' // types for ctx.t / ctx.i18n
const i18n = new I18n({ defaultLocale: 'uz', directory: './locales' })
const botI18n = new BotI18n(i18n)
const bot = new Bot(process.env.BOT_TOKEN!, {
apiRoot: 'https://api.clustersoft.uz/api/v1/botapi',
})
bot.session() // register first: the locale rides in the session
bot.use(botI18n.middleware()) // then: ctx.t and ctx.i18n become available
bot.start((ctx) => ctx.reply(ctx.t('welcome', { name: 'Eldor' })))
bot.command('lang', (ctx) =>
ctx.reply(ctx.t('choose_language'), ctx.i18n.languageKeyboard({ columns: 2 })),
)
botI18n.handleLanguageCallback(bot) // handles every `locale:<code>` button
bot.launch()JavaScript
const { Bot } = require('@clustersoft/mgr-bot-sdk')
const { I18n } = require('@clustersoft/i18n')
const { BotI18n } = require('@clustersoft/i18n/bot')
const i18n = new I18n({ defaultLocale: 'uz', directory: './locales' })
const botI18n = new BotI18n(i18n)
const bot = new Bot(process.env.BOT_TOKEN, { apiRoot: '…/api/v1/botapi' })
bot.session()
bot.use(botI18n.middleware())
bot.start((ctx) => ctx.reply(ctx.t('welcome', { name: 'Eldor' })))
botI18n.handleLanguageCallback(bot)
bot.launch()Locale resolution per update
First match wins:
| # | Source | Typical use |
|---|--------|-------------|
| 1 | resolveLocale(ctx) | your own database or account settings |
| 2 | store.get(key, ctx) | the persisted choice (session, Redis, …) |
| 3 | ctx.session[sessionKey] | a session written by another middleware |
| 4 | localeFromUpdate(ctx) | reserved for a future platform-supplied language |
| 5 | i18n.defaultLocale | the guaranteed fallback |
const botI18n = new BotI18n(i18n, {
resolveLocale: async (ctx) => db.getUserLang(ctx.from?.id),
getLocaleKey: (ctx) => `${ctx.from?.id}`, // default: "<chatId>:<fromId>"
store: new RedisLocaleStore(redis), // default: SessionLocaleStore
})ctx.i18n.setLocale('ru') switches the locale for the rest of the update and
persists the choice.
The language picker
bot.command('lang', (ctx) =>
ctx.reply(ctx.t('choose_language'), ctx.i18n.languageKeyboard()),
)
botI18n.handleLanguageCallback(bot, {
onChanged: (ctx) => ctx.editMessageText(ctx.t('welcome', { name: 'Eldor' })),
})languageKeyboard() renders one button per loaded locale, each label written in
its own language (🇺🇿 O'zbekcha, 🇺🇿 Ўзбекча, 🇷🇺 Русский, 🇬🇧 English),
and emits locale:<code> when pressed; ctx.i18n.languageKeyboard() marks the
active locale. The handler stores the choice, acknowledges the press with your
language_changed message, then calls onChanged.
Not using the SDK's router? botI18n.languageMiddleware() does the same job as a
middleware.
The language registry (uz, oz, ru, en) lives in the core entry point,
because native language names are just as useful in a web settings page. Extend
or override it:
new I18n({
languages: {
kk: { nativeName: 'Қазақша', flag: '🇰🇿', intlLocale: 'kk-KZ' },
en: { flag: '🇺🇸' }, // override a single field
},
})Language codes: uz vs oz
Across the ClusterSoft ecosystem (and the MGR bots that follow it):
| Code | Script | Native name | Intl tag |
|------|--------|-------------|-----------|
| uz | Latin | O'zbekcha | uz-Latn-UZ |
| oz | Cyrillic | Ўзбекча | uz-Cyrl-UZ |
oz is not a valid BCP 47 tag, so the registry maps it to uz-Cyrl-UZ before
handing it to Intl — plural rules and formatting stay correct. Any custom code
you add can carry its own intlLocale the same way.
Migrating from a hand-rolled helper
Most projects start with something like this:
// before — src/core/i18n.js
const uz = require('./locales/uz.json')
const ru = require('./locales/ru.json')
const dict = { uz, ru }
function t(lang, key, params) {
let s = dict[lang]?.[key] ?? dict.uz[key] ?? key
for (const [k, v] of Object.entries(params ?? {})) {
s = s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
}
return s
}The replacement keeps the same call shape, so call sites do not change:
// after
const { I18n } = require('@clustersoft/i18n')
const uz = require('./locales/uz.json')
const ru = require('./locales/ru.json')
const i18n = new I18n({ defaultLocale: 'uz', locales: { uz, ru } })
const t = (lang, key, params) => i18n.t(lang, key, params)What you gain immediately, with the JSON files untouched:
| Concern | Before | After |
|---------|--------|-------|
| Plural forms | "5 заказа" | Intl.PluralRules per locale |
| Nested keys | flat only | menu.main.title (flat keys still work) |
| Fallbacks | one hard-coded locale | ordered chain, applied per key |
| Missing keys | silent | onMissing hook |
| Numbers, dates, money | manual | Intl helpers and inline formats |
| Adding a locale | edit the dictionary map | drop a file into locales/ |
Existing keys keep working as written: a flat key is always matched literally
first, so "order.from" and "user:name" resolve verbatim even though . and
: are the nested-path and namespace separators.
If your catalogue is entirely flat and you want to rule out any splitting at all, turn both separators off — the strictest, most predictable migration mode:
const i18n = new I18n({
defaultLocale: 'uz',
locales: { uz, ru },
keySeparator: false, // no `menu.main.title` walking
namespaceSeparator: false, // no `common:greeting` splitting
})Every key is then looked up exactly as written, which makes the migration a pure drop-in. Turn the separators back on whenever you decide to nest.
Then migrate at your own pace: replace t(lang, …) with a bound
i18n.translator(lang), convert plural strings into form objects, and — in a
bot — swap the manual language menu for languageKeyboard().
API reference
new I18n(options?)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| defaultLocale | string | 'uz' | Locale used when nothing else resolves |
| fallbackLocale | string \| string[] | defaultLocale | Locales searched, in order, for a missing key |
| locales | Record<string, Messages> | — | Message trees supplied directly (no file system) |
| directory | string | — | Directory of *.json message files |
| namespaces | boolean | auto | Force namespaced lookups (auto-detected from the layout) |
| defaultNamespace | string | 'common' | Namespace used for unprefixed keys |
| namespaceSeparator | string \| false | ':' | Separator between namespace and key; false disables namespace splitting |
| keySeparator | string \| false | '.' | Separator between nested key segments; false disables nesting |
| onMissing | (info) => string \| void | — | Missing-key hook; may supply the return value |
| onMissingVar | (info) => string \| void | — | Missing-parameter hook |
| languages | Record<string, Partial<LanguageInfo>> | — | Extends/overrides the language registry |
| defaultCurrency | string | 'UZS' | Currency used by format.currency(v) |
I18n members
| Member | Returns | Description |
|--------|---------|-------------|
| t(locale, key, params?) | string | Translate a key |
| translator(locale) | (key, params?) => string | Translate function bound to a locale |
| has(locale, key) | boolean | Key exists in that locale (fallbacks excluded) |
| locales | string[] | Every loaded locale code |
| localeChain(locale) | string[] | The fallback chain for a locale |
| addLocale(locale, messages, namespace?) | this | Add/deep-merge messages at runtime |
| getMessages(locale) | Messages \| undefined | The raw message tree |
| format | Formatter | Intl helpers for the default locale |
| formatter(locale) | Formatter | Intl helpers for a locale |
| intlLocale(locale) | string | The BCP 47 tag a code maps to |
| defaultLocale · fallbackLocales · languages · options | — | The resolved configuration |
Formatter
| Method | Example output (ru) |
|--------|----------------------|
| number(v, opts?) | 1 234 567 |
| currency(v, code?, opts?) | 45 000 UZS |
| percent(v, opts?) | 8 % |
| date(v, opts?) | 15 янв. 2026 г. |
| time(v, opts?) | 09:30 |
| dateTime(v, opts?) | 15 янв. 2026 г., 09:30 |
| relativeTime(v, unit?, opts?) | 3 часа назад |
| list(values, opts?) | a, b и c |
| intlLocale | the BCP 47 tag these helpers are bound to |
Core exports
| Export | Description |
|--------|-------------|
| I18n | The translation engine |
| defineLocale(messages) | Identity helper preserving the literal message shape |
| MemoryLocaleStore · RedisLocaleStore | Ready-made locale stores |
| LANGUAGES · buildLanguageRegistry · intlLocaleOf · languageLabel | Language registry |
| interpolate · pluralCategory · selectPluralForm · isPluralForms | Low-level building blocks |
| createFormatter(intlLocale, currency?) | Intl helpers for any BCP 47 tag |
| loadLocalesFromDirectory · mergeMessages · lookupPath | Loader internals |
| types | Messages · PluralForms · LocaleStore · LanguageInfo · I18nOptions · Translator · … |
new BotI18n(i18n, options?) — from @clustersoft/i18n/bot
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| store | LocaleStore | SessionLocaleStore | Where the chosen locale is persisted |
| getLocaleKey | (ctx) => string \| undefined | "<chatId>:<fromId>" | Store key for an update |
| resolveLocale | (ctx) => string \| undefined \| Promise<…> | — | Highest-priority locale source |
| localeFromUpdate | (ctx) => string \| undefined | — | Locale carried by the update itself |
| sessionKey | string | 'locale' | Property used on ctx.session |
| languagePrefix | string | 'locale:' | Callback-data prefix of the language keyboard |
| Member | Returns | Description |
|--------|---------|-------------|
| middleware() | Middleware | Resolves the locale, attaches ctx.t / ctx.i18n |
| languageKeyboard(opts?) | LanguageKeyboard | Inline keyboard of language buttons |
| handleLanguageCallback(bot, opts?) | this | Registers the locale:<code> handler |
| languageMiddleware(opts?) | Middleware | Middleware form of that handler |
| resolveLocale(ctx) | Promise<string> | The resolution chain, callable directly |
| persistLocale(ctx, locale) | Promise<void> | Writes the choice to the store |
| i18n · store · options | — | The bound engine and configuration |
ctx.i18n (per update)
| Member | Description |
|--------|-------------|
| locale | The locale resolved for this update |
| locales | Every loaded locale code |
| t(key, params?) | Translate in the current locale (same as ctx.t) |
| translate(locale, key, params?) | Translate in an explicit locale |
| setLocale(locale) | Switch for this update and persist |
| format | Intl helpers bound to the current locale |
| languageKeyboard(opts?) | Language keyboard with the current locale marked |
| engine | The I18n instance |
Bot exports
| Export | Description |
|--------|-------------|
| BotI18n · botI18n(i18n, opts?) | The binding class and its factory |
| SessionLocaleStore · defaultLocaleKey | Session-backed store and the default key builder |
| languageKeyboard · parseLanguageCallback | Standalone picker helpers |
| types | ContextI18n · BotI18nOptions · BotContextLike · LanguageKeyboard · … |
Examples
| File | Shows |
|------|-------|
| examples/standalone.js | The engine alone, plain CommonJS, every locale |
| examples/service.js | A Node HTTP service: one engine, a translator per request |
| examples/plural.ts | Russian plural forms and Intl formatting |
| examples/typed-keys.ts | Typed keys and inline formats |
| examples/bot-echo.ts · bot-echo.js | A translated bot, in TypeScript and JavaScript |
| examples/bot-language-picker.ts | Asking for a language and remembering it |
npm run build
node examples/standalone.js
PORT=3457 node examples/service.jsLicense
MIT © ClusterSoft
