npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pyreon/i18n

v0.34.0

Published

Reactive internationalization for Pyreon with async namespace loading

Downloads

4,536

Readme

@pyreon/i18n

Reactive internationalization — interpolation, CLDR pluralization, async namespace loading, rich JSX text.

@pyreon/i18n ships a createI18n({ locale, messages, loader, fallbackLocale, pluralRules, onMissingKey }) factory that returns a signal-aware I18nInstancet(key, values) reads the locale signal reactively so calls inside effects / computeds re-evaluate on locale change. Namespace lazy loading with promise deduplication, addMessages for runtime additions, and a <Trans> component for JSX-aware interpolation where simple string interpolation would lose markup. Two entry points: full (@pyreon/i18n, includes <Trans> + provider + hook) and framework-agnostic (@pyreon/i18n/core — only createI18n / interpolate / resolvePluralCategory, no @pyreon/core dep, safe for backend / edge workers / non-JSX consumers).

Install

bun add @pyreon/i18n @pyreon/core @pyreon/reactivity

Two entry points

| Entry | Use when | Includes | | ------------------ | ---------------------------------------------------- | ----------------------------------------------------------------------- | | @pyreon/i18n | Pyreon UI app — you want the JSX components | createI18n, Trans, I18nProvider, useI18n, parseRichText, types | | @pyreon/i18n/core | Backend / edge / non-JSX runtime | createI18n, interpolate, resolvePluralCategory, types only |

The /core entry transitively depends ONLY on @pyreon/reactivity — zero JSX, zero @pyreon/core. Use it for backend translation pipelines, edge workers, non-Pyreon frontends, or any context where you don't need the <Trans> JSX component. Both entries return identical I18nInstance objects, so switching later is non-breaking.

import { createI18n } from '@pyreon/i18n/core'

const i18n = createI18n({ locale: 'en', messages: { en: { hello: 'Hi' } } })
i18n.t('hello') // 'Hi'

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)

createI18n(options)

| Option | Type | Description | | ------------------- | --------------------------------------------------------- | -------------------------------------------------------- | | locale | string | Initial locale (e.g. 'en') | | fallbackLocale | string | Locale to try when key is missing in active locale | | messages | Record<string, TranslationDictionary> | Static messages, keyed by locale | | loader | (locale, namespace) => Promise<TranslationDictionary?> | Async namespace loader | | defaultNamespace | string | Default namespace for t() (default: 'common') | | pluralRules | Record<string, (count: number) => string> | Custom plural rules; defaults to Intl.PluralRules | | onMissingKey | (locale, key, namespace?) => string \| undefined | Missing-key handler — log, report, or supply a fallback |

Returns I18nInstance:

| Property | Type | Description | | ------------------------------------- | ------------------------------------------------- | ------------------------------------ | | t(key, values?) | (string, InterpolationValues?) => string | Translate; reads locale reactively | | locale | Signal<string> | Current locale, writable | | loadNamespace(ns, locale?) | (string, string?) => Promise<void> | Load a namespace; deduped per-request | | isLoading | Computed<boolean> | True while any namespace is loading | | loadedNamespaces | Computed<Set<string>> | Namespaces loaded for current locale | | exists(key) | (string) => boolean | Check key existence | | addMessages(locale, messages, ns?) | Function | Add messages at runtime (deep-merge) | | availableLocales | Computed<string[]> | Locales with any registered messages |

Namespaces + lazy loading

Split translations by feature and load on route entry. Concurrent loads for the same locale:namespace dedupe — calling loadNamespace('auth') twice returns the same promise.

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' syntax

Pluralization

Use CLDR-style _zero / _one / _two / _few / _many / _other suffixes with a count value. Resolution is via Intl.PluralRules by default; override per-locale with pluralRules.

// messages: { items_one: '{{count}} item', items_other: '{{count}} items' }
i18n.t('items', { count: 1 }) // '1 item'
i18n.t('items', { count: 5 }) // '5 items'

Interpolation

import { interpolate } from '@pyreon/i18n'
interpolate('Hello, {{ name }}!', { name: 'World' }) // 'Hello, World!'

Supports whitespace inside braces. Unmatched placeholders are left as-is.

I18nProvider / useI18n()

import { createI18n, I18nProvider, useI18n } from '@pyreon/i18n'

const i18n = createI18n({ locale: 'en', messages: { en: { greeting: 'Hello {{name}}' } } })

;<I18nProvider instance={i18n}>
  <App />
</I18nProvider>

function Greeting() {
  const { t, locale } = useI18n()
  return () => <h1>{t('greeting', { name: 'World' })}</h1>
}

I18nProvider is marked nativeCompat so it works correctly under @pyreon/{react,preact,vue,solid}-compat apps. useI18n throws [@pyreon/i18n] useI18n() must be used within an <I18nProvider>. at dev time if no provider is mounted above.

I18nContext is also exported for advanced usage with useContext directly.

<Trans> — rich JSX interpolation

When a translated string sits next to JSX elements (<a>, <strong>, etc.), plain {t('cta')} can't carry the markup. <Trans> resolves {{values}} first, then maps <tag>content</tag> patterns to component functions.

// 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>,
  }}
/>

Caught by the opt-in lint rule pyreon/i18n-prefer-trans-for-rich-jsx when interleaving {t('…')} with element siblings.

parseRichText(text)

Internal utility used by <Trans> — parses a string into (string | { tag, children })[]. Useful for custom rich-text renderers.

parseRichText('Hello <bold>world</bold>!')
// ['Hello ', { tag: 'bold', children: 'world' }, '!']

Runtime message addition

Deep-merge new messages without async loading (e.g. server-rendered translation strings):

i18n.addMessages('en', { newFeature: 'Try our new feature!' })
i18n.addMessages('en', { errors: { timeout: 'Request timed out' } }, 'api')

Devtools

import { i18nRegistry } from '@pyreon/i18n/devtools'
// WeakRef registry of live i18n instances — tree-shakeable.

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; children?: VNodeChild } | | TransProps | Props for Trans component |

Gotchas

  • t() reads locale reactively — call it inside effects / computeds / JSX accessors so re-evaluation happens on locale change. Reading once at setup captures the initial value.
  • Concurrent loadNamespace calls dedupe — calling loadNamespace('auth') twice in parallel returns the same in-flight promise.
  • Missing keys return the key string itself as a visual fallback ('auth:missing.key'). Override via onMissingKey.
  • Default namespace is 'common' — keys without a namespace: prefix look up in common.
  • addMessages deep-merges — it does not replace the entire namespace.
  • /core entry only depends on @pyreon/reactivity — picking the right entry per consumer keeps backend bundles JSX-free.

Documentation

Full docs: pyreon.dev/docs/i18n (or docs/src/content/docs/i18n.md in this repo).

License

MIT