@pyreon/i18n
v0.14.0
Published
Reactive internationalization for Pyreon with async namespace loading
Downloads
3,277
Readme
@pyreon/i18n
Reactive internationalization for Pyreon. Async namespace loading, pluralization, interpolation, and rich JSX text.
Install
bun add @pyreon/i18nTwo entry points
| Entry | Use when | Includes |
| --------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| @pyreon/i18n | You're building a Pyreon UI and want the JSX components | createI18n, Trans, I18nProvider, useI18n, plus everything below |
| @pyreon/i18n/core | You're on a backend / non-Pyreon runtime / don't need JSX | createI18n, interpolate, resolvePluralCategory, types only |
The /core entry has zero JSX dependencies — it only depends on @pyreon/reactivity (which is framework-agnostic). Use it for backend translation pipelines, edge workers, non-Pyreon frontends, or any context where you don't need the <Trans> JSX component.
// Backend / non-JSX usage:
import { createI18n } from '@pyreon/i18n/core'
const i18n = createI18n({ locale: 'en', messages: { en: { hello: 'Hi' } } })
i18n.t('hello') // "Hi"The main @pyreon/i18n entry includes the same createI18n plus the JSX components — both entries return identical I18nInstance objects, so you can switch entries without changing your code if you decide later to add UI bindings.
Quick Start
import { createI18n } from '@pyreon/i18n'
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
messages: {
en: {
greeting: 'Hello, {{name}}!',
items_one: '{{count}} item',
items_other: '{{count}} items',
},
de: {
greeting: 'Hallo, {{name}}!',
},
},
})
i18n.t('greeting', { name: 'Alice' }) // "Hello, Alice!"
i18n.t('items', { count: 3 }) // "3 items"
i18n.locale.set('de')
i18n.t('greeting', { name: 'Alice' }) // "Hallo, Alice!"
i18n.t('items', { count: 1 }) // "1 item" (fallback to en)API
createI18n(options)
Create a reactive i18n instance with static messages and/or an async namespace loader.
| Parameter | Type | Description |
| -------------------------- | -------------------------------------------------- | ------------------------------------------------------- |
| options.locale | string | Initial locale (e.g. "en") |
| options.fallbackLocale | string | Locale to try when key is missing in active locale |
| options.messages | Record<string, TranslationDictionary> | Static messages keyed by locale |
| options.loader | NamespaceLoader | (locale, namespace) => Promise<TranslationDictionary> |
| options.defaultNamespace | string | Default namespace for t() (default: "common") |
| options.pluralRules | PluralRules | Custom plural rules per locale |
| options.onMissingKey | (locale, key, namespace?) => string \| undefined | Missing key handler |
Returns: I18nInstance with:
| Property | Type | Description |
| ------------------------------------ | ------------------------------------------------------- | ------------------------------------ |
| t(key, values?) | (key: string, values?: InterpolationValues) => string | Translate a key |
| locale | Signal<string> | Current locale (reactive, writable) |
| loadNamespace(ns, locale?) | (ns: string, locale?: string) => Promise<void> | Load a namespace |
| isLoading | Computed<boolean> | Whether any namespace is loading |
| loadedNamespaces | Computed<Set<string>> | Namespaces loaded for current locale |
| exists(key) | (key: string) => boolean | Check if a key exists |
| addMessages(locale, messages, ns?) | Function | Add messages at runtime |
| availableLocales | Computed<string[]> | All locales with registered messages |
const i18n = createI18n({
locale: 'en',
loader: async (locale, namespace) => {
const mod = await import(`./locales/${locale}/${namespace}.json`)
return mod.default
},
})
await i18n.loadNamespace('auth')
i18n.t('auth:errors.invalid') // namespace:key.path syntaxinterpolate(template, values?)
Replace {{key}} placeholders in a string. Supports whitespace inside braces. Unmatched placeholders are left as-is.
| Parameter | Type | Description |
| ---------- | --------------------- | --------------------------------------- |
| template | string | Template string with {{placeholders}} |
| values | InterpolationValues | Key-value pairs for substitution |
Returns: string
interpolate('Hello, {{ name }}!', { name: 'World' })
// "Hello, World!"resolvePluralCategory(locale, count, customRules?)
Resolve the CLDR plural category for a count. Uses custom rules if provided, then Intl.PluralRules, then a basic one/other fallback.
| Parameter | Type | Description |
| ------------- | ------------- | -------------------------------- |
| locale | string | Locale code |
| count | number | The number to pluralize for |
| customRules | PluralRules | Optional custom rules per locale |
Returns: string — one of "zero", "one", "two", "few", "many", "other"
resolvePluralCategory('en', 1) // "one"
resolvePluralCategory('en', 5) // "other"
resolvePluralCategory('ar', 3) // "few" (via Intl.PluralRules)I18nProvider / useI18n()
Context pattern for providing an i18n instance to the component tree.
// Root:
;<I18nProvider instance={i18n}>
<App />
</I18nProvider>
// Any descendant:
function Greeting() {
const { t, locale } = useI18n()
return () => <h1>{t('greeting', { name: 'World' })}</h1>
}I18nContext is also exported for advanced usage with useContext directly.
Trans
Rich JSX interpolation component. Resolves {{values}} first, then maps <tag>content</tag> patterns to component functions.
| Parameter | Type | Description |
| ------------ | ------------------------------------- | --------------------------------------- |
| t | (key, values?) => string | Translation function (from useI18n()) |
| i18nKey | string | Translation key |
| values | InterpolationValues | Interpolation values |
| components | Record<string, (children) => VNode> | Component map for rich tags |
// Translation: "Read our <terms>terms</terms> and <privacy>policy</privacy>"
<Trans
t={t}
i18nKey="legal"
components={{
terms: (children) => <a href="/terms">{children}</a>,
privacy: (children) => <a href="/privacy">{children}</a>,
}}
/>parseRichText(text)
Parse a string into an array of plain text and { tag, children } segments. Used internally by Trans.
| Parameter | Type | Description |
| --------- | -------- | ----------------------------------------- |
| text | string | String with <tag>content</tag> patterns |
Returns: (string | { tag: string, children: string })[]
parseRichText('Hello <bold>world</bold>!')
// ["Hello ", { tag: "bold", children: "world" }, "!"]Patterns
Namespace-Based Loading
Split translations by feature and load them lazily.
const i18n = createI18n({
locale: 'en',
loader: (locale, ns) => fetch(`/locales/${locale}/${ns}.json`).then((r) => r.json()),
})
// Load on route entry:
await i18n.loadNamespace('dashboard')
i18n.t('dashboard:widgets.chart')Runtime Message Addition
Add messages without async loading (e.g. from server-rendered data).
i18n.addMessages('en', { newFeature: 'Try our new feature!' })
i18n.addMessages('en', { errors: { timeout: 'Request timed out' } }, 'api')Pluralization
Use _one, _other (and _zero, _two, _few, _many for complex locales) suffixes with a count value.
// messages: { items_one: "{{count}} item", items_other: "{{count}} items" }
i18n.t('items', { count: 1 }) // "1 item"
i18n.t('items', { count: 5 }) // "5 items"Types
| Type | Description |
| ----------------------- | ------------------------------------------------------------------------------------ |
| I18nInstance | Public API returned by createI18n() |
| I18nOptions | Options for createI18n() |
| TranslationDictionary | { [key: string]: string \| TranslationDictionary } |
| TranslationMessages | Record<string, TranslationDictionary> |
| NamespaceLoader | (locale: string, namespace: string) => Promise<TranslationDictionary \| undefined> |
| InterpolationValues | Record<string, string \| number> |
| PluralRules | Record<string, (count: number) => string> |
| I18nProviderProps | Props for I18nProvider: { instance: I18nInstance } |
| TransProps | Props for Trans component |
Gotchas
t()readslocalereactively — it re-evaluates inside effects and computeds when the locale changes.- Concurrent loads for the same locale:namespace are deduplicated — calling
loadNamespace("auth")twice returns the same promise. - Missing keys return the key string itself as a visual fallback (e.g.
"auth:missing.key"). - The default namespace is
"common"— keys without anamespace:prefix look up in the"common"namespace. addMessagesdeep-merges into existing translations. It does not replace the entire namespace.
