@burojs/i18n-core
v0.9.0
Published
Buro: hierarchical i18n with fallback chain over i18next
Readme
@burojs/i18n-core
Hierarchical i18n for buro, with a per-resource fallback chain over i18next.
Translation happens at render time, so switching language updates the whole UI live — no reload, no refetch.
Install
pnpm add @burojs/i18n-coreUsage
import { createI18n, createI18nextAdapter } from '@burojs/i18n-core';
const i18n = createI18n({
adapter: createI18nextAdapter({
defaultLocale: 'ru',
locales: ['ru', 'en'],
resources: { ru: ruBundle, en: enBundle },
fallbackLocale: 'en',
}),
});Key resolution
A key is resolved against the most specific namespace first and falls back outward, so a resource can override a shared string without copying the rest:
resources.<resource>.fields.<field>.label → fields.<field>.label → <the key itself>resolveKey implements that chain and is exported for callers that need to know which key won.
Counts
Text that shows a number is a count family: one key per CLDR plural
category of the locale, <key>_one, <key>_few, <key>_many, <key>_other.
Ask for it with count — the facade picks the form with the locale's own
rules and interpolates {{count}}:
// ru: count_one «{{count}} документ», count_few «{{count}} документа», count_many «{{count}} документов»
i18n.t('resources.document.count', { count: 2 }); // «2 документа»
i18n.t('resources.document.count', { count: 5 }); // «5 документов»
i18n.has('resources.document.count', { count: 5 }); // true
i18n.has('resources.document.count'); // false — a family is only present for a countA count family is for counted text only. A collection title is not a count
form — Russian «Документы» is none of one, few or many — so a resource's
title is its own key, labelPlural.
An empty-string translation counts as missing: the adapter walks on to the
fallback locale, then the caller's default in code. Which key each element of
an app reads is in one table in docs/i18n-guide.md.
Locale registry
Most apps assemble their resources from more than one place: the framework
ships a base dictionary, the app adds its own chrome strings, a domain module
adds its enum labels. Printing that as one literal object means adding a
language is a code change. createLocaleRegistry instead lets each source
register a contribution — the registry merges them:
import {
createLocaleRegistry,
localeContributionsFromGlob,
createI18nFromRegistry,
} from '@burojs/i18n-core';
import { en as frameworkEn } from '@burojs/react';
// `primary` is the locale whose text is the default in code. It is required, and it is the only
// place the app names it: everything else reads `registry.primary()`.
const registry = createLocaleRegistry({ primary: 'en' });
// `layer: 'kit'` marks the framework's own strings: they sit beneath the app's texts.
registry.register({
locale: 'en',
priority: 0,
source: '@burojs/react',
layer: 'kit',
bundle: frameworkEn,
});
// One file per locale, globbed by the bundler — dropping in `es.ts` is the
// entire mechanism for adding Spanish. Nothing here lists locale codes.
const glob = import.meta.glob('./locales/*.ts', { eager: true });
for (const c of localeContributionsFromGlob(glob, { source: 'app', priority: 10 })) {
registry.register(c);
}
// Starts in, and falls back to, registry.primary(). Override either when needed:
// `defaultLocale` for a stored choice, `fallbackLocale` for a per-locale chain
// such as { fr: ['es'], default: ['en'] }.
const i18n = await createI18nFromRegistry(registry);buildResources() — and so createI18nFromRegistry — throws when no
contribution is registered for the primary locale: the fallback chain would
end at a locale with no bundle, and a language switcher built from
registry.locales() would not offer it. That is almost always a typo or a
glob that missed its file; an app with genuinely no strings of its own for the
primary locale registers an empty bundle for it.
A key collision within one (locale, namespace) resolves by the higher
DECLARED priority, never by which contribution happened to register last.
priority orders contributions within a layer; a kit contribution (layer: 'kit')
always sits beneath the app's, whatever the priorities, and under
createI18nFromRegistry also beneath the texts declared in code.
localeContributionsFromGlob is bundler-agnostic (same reasoning as
@burojs/core's docsFromGlob): it accepts whatever flat path → module
map import.meta.glob or an equivalent walk produces, and the caller — not
this package — is the one that actually calls the bundler API.
Main exports
createI18n/I18nFacade— the facade the React layer consumes.createI18nextAdapter— the default backend; swap it to use another i18n runtime.createResourceI18n/ResourceI18n— a resource-scoped view of the facade.resolveKey— the fallback-chain resolver, usable standalone.createLocaleRegistry({ primary })/LocaleRegistry— merges per-source locale contributions by declared priority, and declares the primary locale (registry.primary()).localeContributionsFromGlob— turns a bundler glob of per-locale files into contributions.createI18nFromRegistry— wires aLocaleRegistrystraight intocreateI18nextAdapter+createI18n.
License
MIT
