pyreon

@pyreon/i18n provides a reactive internationalization system built on @pyreon/reactivity signals. Translations automatically update when the locale changes -- no manual re-renders needed. Supports interpolation, CLDR pluralization, nested keys, namespaced translations, async namespace loading, fallback locale chains, and custom missing key handling.

@pyreon/i18nstable

Installation

npm install @pyreon/i18n
bun add @pyreon/i18n
pnpm add @pyreon/i18n
yarn add @pyreon/i18n

Basic Usage

Create an i18n instance with createI18n and use the t() function to translate keys.

import { createI18n } from '@pyreon/i18n'

const i18n = createI18n({
  locale: 'en',
  fallbackLocale: 'en',
  messages: {
    en: {
      greeting: 'Hello {{name}}!',
      farewell: 'Goodbye!',
    },
    de: {
      greeting: 'Hallo {{name}}!',
      farewell: 'Auf Wiedersehen!',
    },
  },
})

i18n.t('greeting', { name: 'Alice' }) // "Hello Alice!"
i18n.t('farewell') // "Goodbye!"

The t() function is the primary API for translations. It reads the current locale reactively, meaning that any effect, computed, or component render function that calls t() will automatically re-evaluate when the locale changes.

i18n — switch locale, translate

I18nProvider and useI18n

Use I18nProvider to supply an i18n instance via context, and useI18n to access it from any child component. This avoids prop-drilling the i18n instance through your component tree.

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

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

// Root component
function App() {
  return (
    <I18nProvider instance={i18n}>
      <Greeting />
    </I18nProvider>
  )
}

// Child component — access i18n via context
function Greeting() {
  const { t, locale } = useI18n()
  return <h1>{t('greeting', { name: 'World' })}</h1>
}

I18nProvider accepts &#123; instance: I18nInstance, children &#125; props. useI18n() throws if called outside an I18nProvider. The I18nContext is also exported for advanced use cases.

Trans Component

The Trans component enables rich JSX interpolation within translation strings. Use it when your translations contain inline markup like bold text, links, or other components.

import { Trans, useI18n } from '@pyreon/i18n'

// Translation: "You have <bold>{{count}}</bold> unread messages"
function Messages({ count }) {
  const { t } = useI18n()
  return (
    <Trans
      t={t}
      i18nKey="messages.unread"
      values={{ count }}
      components={{
        bold: (children) => <strong>{children}</strong>,
      }}
    />
  )
  // Renders: You have <strong>5</strong> unread messages
}

// Translation: "Read our <terms>terms of service</terms> and <privacy>privacy policy</privacy>"
function Legal() {
  const { t } = useI18n()
  return (
    <Trans
      t={t}
      i18nKey="legal"
      components={{
        terms: (children) => <a href="/terms">{children}</a>,
        privacy: (children) => <a href="/privacy">{children}</a>,
      }}
    />
  )
}

TransProps:

PropertyTypeDescription
i18nKeystringTranslation key (supports namespace/* zero-content: unhandled mdast node "textDirective" */ syntax)
valuesInterpolationValuesInterpolation values for placeholder syntax
componentsRecord<string, (children) => VNode>Component map for rich interpolation
t(key, values?) => stringThe i18n t function

parseRichText

The parseRichText function parses tagged translation strings into an array of strings and &#123; tag, children &#125; objects. It is used internally by Trans but is exported for custom rendering scenarios.

import { parseRichText } from '@pyreon/i18n'

parseRichText('Hello <bold>world</bold>, click <link>here</link>')
// → ["Hello ", { tag: "bold", children: "world" }, ", click ", { tag: "link", children: "here" }]

Parses <tag>content</tag> patterns into an array of strings and &#123; tag, children &#125; objects. Tags that do not have a matching closing tag are left as plain text.

createI18n Configuration

The createI18n function accepts an I18nOptions object with the following properties:

locale (required)

The initial locale string (e.g., "en", "de", "ja"). This becomes the default locale used by t().

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

fallbackLocale

When a translation key is missing in the active locale, the system tries this locale before giving up. This is useful for partially translated locales.

const i18n = createI18n({
  locale: 'de',
  fallbackLocale: 'en',
  messages: {
    en: {
      greeting: 'Hello',
      onlyInEnglish: 'This only exists in English',
    },
    de: {
      greeting: 'Hallo',
    },
  },
})

i18n.t('greeting') // "Hallo" (found in 'de')
i18n.t('onlyInEnglish') // "This only exists in English" (falls back to 'en')

messages

Static translation messages keyed by locale. Each locale maps to a TranslationDictionary -- a nested object where leaf values are translation strings.

const i18n = createI18n({
  locale: 'en',
  messages: {
    en: {
      nav: {
        home: 'Home',
        about: 'About',
        contact: 'Contact',
      },
      auth: {
        login: 'Log In',
        logout: 'Log Out',
        errors: {
          invalid: 'Invalid credentials',
          expired: 'Session expired',
        },
      },
    },
    fr: {
      nav: {
        home: 'Accueil',
        about: 'A propos',
        contact: 'Contact',
      },
      auth: {
        login: 'Se connecter',
        logout: 'Se deconnecter',
        errors: {
          invalid: 'Identifiants invalides',
          expired: 'Session expiree',
        },
      },
    },
  },
})

When messages are provided, they are stored under the default namespace (which is "common" unless overridden by defaultNamespace).

loader

An async function for loading translations on demand. Called with (locale, namespace) when loadNamespace() is invoked. The loader should return a TranslationDictionary or undefined if the namespace does not exist.

const i18n = createI18n({
  locale: 'en',
  loader: async (locale, namespace) => {
    try {
      const mod = await import(`./locales/${locale}/${namespace}.json`)
      return mod.default
    } catch {
      return undefined
    }
  },
})

defaultNamespace

The namespace used when t() is called without a namespace prefix. Defaults to "common".

const i18n = createI18n({
  locale: 'en',
  defaultNamespace: 'main',
  messages: {
    en: { title: 'My App' }, // stored under "main" namespace
  },
})

i18n.t('title') // looks up "title" in "main" namespace

pluralRules

Custom plural rules per locale. Each rule is a function that receives a count and returns a CLDR plural category string. If not provided, Intl.PluralRules is used where available.

const i18n = createI18n({
  locale: 'ar',
  pluralRules: {
    ar: (count) => {
      if (count === 0) return 'zero'
      if (count === 1) return 'one'
      if (count === 2) return 'two'
      if (count >= 3 && count <= 10) return 'few'
      if (count >= 11 && count <= 99) return 'many'
      return 'other'
    },
  },
  messages: {
    ar: {
      items_zero: 'No items',
      items_one: 'One item',
      items_two: 'Two items',
      items_few: '{{count}} items (few)',
      items_many: '{{count}} items (many)',
      items_other: '{{count}} items',
    },
  },
})

onMissingKey

A callback invoked when a translation key is not found in either the current locale or the fallback locale. It receives the locale, the full key, and the resolved namespace. It can return a custom fallback string, or return void to use the default behavior (returning the key itself).

const i18n = createI18n({
  locale: 'en',
  messages: { en: {} },
  onMissingKey: (locale, key, namespace) => {
    console.warn(`Missing translation: [${locale}] ${namespace}:${key}`)
    return `[MISSING: ${key}]`
  },
})

i18n.t('unknown.key') // logs warning, returns "[MISSING: unknown.key]"

If onMissingKey returns void (or undefined), the raw key string is returned as a visual fallback:

const i18n = createI18n({
  locale: 'en',
  messages: { en: {} },
  onMissingKey: (locale, key) => {
    // Log but don't provide a custom fallback
    reportMissingKey(locale, key)
  },
})

i18n.t('missing') // returns "missing"

Translation Function (t)

The t() function is the core of the i18n system. It resolves a translation key, applies interpolation, handles pluralization, and returns the translated string.

Key Format

Keys follow the format namespace:dotted.path or just dotted.path (which uses the default namespace):

i18n.t('greeting') // default namespace, key "greeting"
i18n.t('user.profile.title') // default namespace, key "user.profile.title"
i18n.t('auth:login') // "auth" namespace, key "login"
i18n.t('auth:errors.invalid') // "auth" namespace, key "errors.invalid"

Reactivity

The t() function reads the current locale and store version reactively. This means that when called inside an effect(), computed(), or component render function, it automatically re-evaluates when:

  • The locale changes via i18n.locale.set(...)

  • New messages are added via i18n.addMessages(...)

  • A namespace finishes loading via i18n.loadNamespace(...)

import { effect } from '@pyreon/reactivity'

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

effect(() => {
  console.log(i18n.t('greeting'))
})
// Logs: "Hello"

i18n.locale.set('de')
// Logs: "Hallo"

Interpolation

Use &#123;&#123;key&#125;&#125; placeholders in translation strings. Values can be strings or numbers. Whitespace inside the braces is allowed.

const i18n = createI18n({
  locale: 'en',
  messages: {
    en: {
      welcome: 'Welcome, {{user}}! You have {{count}} messages.',
      status: 'Logged in as {{ username }}', // spaces inside braces work
    },
  },
})

i18n.t('welcome', { user: 'Alice', count: 5 })
// "Welcome, Alice! You have 5 messages."

i18n.t('status', { username: 'bob' })
// "Logged in as bob"

Unmatched placeholders are left as-is, which makes it easy to spot missing interpolation values during development:

i18n.t('welcome', { user: 'Alice' })
// "Welcome, Alice! You have {{count}} messages."

Standalone Interpolation

The interpolate function is exported for standalone use outside the i18n system:

import { interpolate } from '@pyreon/i18n'

interpolate('Hello {{name}}!', { name: 'World' })
// "Hello World!"

interpolate('No placeholders here', { name: 'ignored' })
// "No placeholders here"

interpolate('Hello {{name}}!')
// "Hello {{name}}!" (no values = template unchanged)

Nested Keys

Translation dictionaries can be deeply nested. Use dot-separated paths to access nested values:

const i18n = createI18n({
  locale: 'en',
  messages: {
    en: {
      user: {
        greeting: 'Hello {{name}}!',
        profile: {
          title: 'Your Profile',
          settings: {
            theme: 'Theme',
            language: 'Language',
            notifications: 'Notifications',
          },
        },
      },
    },
  },
})

i18n.t('user.greeting', { name: 'Alice' })
// "Hello Alice!"

i18n.t('user.profile.title')
// "Your Profile"

i18n.t('user.profile.settings.theme')
// "Theme"

Nested keys work with namespaces too:

i18n.addMessages(
  'en',
  {
    errors: {
      auth: {
        invalid: 'Invalid credentials',
        expired: 'Session expired',
      },
      network: 'Network error',
    },
  },
  'auth',
)

i18n.t('auth:errors.auth.invalid') // "Invalid credentials"
i18n.t('auth:errors.network') // "Network error"

Pluralization

When interpolation values include a count key, the system automatically resolves CLDR plural categories and looks for suffixed keys. The CLDR categories are: zero, one, two, few, many, other.

Basic Pluralization

const i18n = createI18n({
  locale: 'en',
  messages: {
    en: {
      items_one: '{{count}} item',
      items_other: '{{count}} items',
    },
  },
})

i18n.t('items', { count: 0 }) // "0 items"
i18n.t('items', { count: 1 }) // "1 item"
i18n.t('items', { count: 5 }) // "5 items"
i18n.t('items', { count: 42 }) // "42 items"

The naming convention is baseKey_category. The system resolves the plural category for the current locale and count, then looks up baseKey_category. If the specific plural form is not found, it falls back to the base key itself.

Fallback to Base Key

If no plural-suffixed key matches, the base key is used:

const i18n = createI18n({
  locale: 'en',
  messages: {
    en: {
      items: 'some items', // no _one or _other suffixes
    },
  },
})

i18n.t('items', { count: 1 }) // "some items"
i18n.t('items', { count: 5 }) // "some items"

Multi-Category Languages (Arabic Example)

Languages with complex pluralization rules can use all six CLDR categories:

const i18n = createI18n({
  locale: 'ar',
  pluralRules: {
    ar: (count) => {
      if (count === 0) return 'zero'
      if (count === 1) return 'one'
      if (count === 2) return 'two'
      if (count >= 3 && count <= 10) return 'few'
      return 'other'
    },
  },
  messages: {
    ar: {
      items_zero: 'No items',
      items_one: 'One item',
      items_two: 'Two items',
      items_few: '{{count}} items (few)',
      items_other: '{{count}} items',
    },
  },
})

i18n.t('items', { count: 0 }) // "No items"
i18n.t('items', { count: 1 }) // "One item"
i18n.t('items', { count: 2 }) // "Two items"
i18n.t('items', { count: 5 }) // "5 items (few)"
i18n.t('items', { count: 100 }) // "100 items"

Plural Fallback Locale

Pluralization also respects the fallback locale. If a plural form is missing in the current locale, the fallback locale is checked:

const i18n = createI18n({
  locale: 'de',
  fallbackLocale: 'en',
  messages: {
    en: {
      items_one: '{{count}} item',
      items_other: '{{count}} items',
    },
    de: {}, // no items translation in German
  },
})

i18n.t('items', { count: 1 }) // "1 item" (falls back to English)
i18n.t('items', { count: 5 }) // "5 items" (falls back to English)

Standalone Plural Resolution

The resolvePluralCategory function is exported for use outside the i18n system:

import { resolvePluralCategory } from '@pyreon/i18n'

resolvePluralCategory('en', 1) // "one"
resolvePluralCategory('en', 0) // "other"
resolvePluralCategory('en', 5) // "other"
resolvePluralCategory('ar', 2) // "two" (via Intl.PluralRules)

It accepts optional custom rules as the third argument:

const customRules = {
  custom: (count: number) => (count === 0 ? 'zero' : count === 1 ? 'one' : 'other'),
}
resolvePluralCategory('custom', 0, customRules) // "zero"

Namespaces

Namespaces partition translations into logical groups. This is useful for code splitting, loading translations on demand, and organizing large translation files.

Namespace Syntax

Use the namespace:key format in t():

i18n.t('auth:errors.invalid')
// Looks up "errors.invalid" in the "auth" namespace

i18n.t('admin:dashboard.title')
// Looks up "dashboard.title" in the "admin" namespace

Keys without a namespace prefix use the default namespace (defaults to "common"):

i18n.t('greeting')
// Equivalent to i18n.t('common:greeting')

Adding Namespaced Messages

Use the third argument of addMessages to target a specific namespace:

i18n.addMessages('en', { title: 'Dashboard' }, 'admin')
i18n.addMessages('en', { login: 'Log In' }, 'auth')

i18n.t('admin:title') // "Dashboard"
i18n.t('auth:login') // "Log In"

Checking Namespaced Key Existence

The exists() method supports the namespace syntax:

i18n.exists('admin:title') // true
i18n.exists('admin:nonexistent') // false
i18n.exists('greeting') // true (checks default namespace)

Async Namespace Loading

For large applications, load translations on demand with the loader option. This allows you to code-split translations and load them only when needed.

Setting Up a Loader

const i18n = createI18n({
  locale: 'en',
  loader: async (locale, namespace) => {
    const mod = await import(`./locales/${locale}/${namespace}.json`)
    return mod.default
  },
})

Loading a Namespace

// Load translations before using them
await i18n.loadNamespace('auth')

// Now the translations are available
i18n.t('auth:login') // "Log In"
i18n.t('auth:errors.invalid') // "Invalid credentials"

Before a namespace is loaded, t() returns the raw key as a fallback:

i18n.t('auth:login') // "auth:login" (not loaded yet)

await i18n.loadNamespace('auth')

i18n.t('auth:login') // "Log In"

Loading for a Specific Locale

You can preload namespaces for a locale other than the current one:

// Preload German translations while English is still active
await i18n.loadNamespace('common', 'de')

Loading State

The isLoading computed signal tracks whether any namespace is currently being loaded:

// Show a loading indicator while translations are loading
effect(() => {
  if (i18n.isLoading()) {
    showSpinner()
  } else {
    hideSpinner()
  }
})

Loaded Namespaces

The loadedNamespaces computed returns the set of namespaces loaded for the current locale:

await i18n.loadNamespace('auth')
await i18n.loadNamespace('admin')

i18n.loadedNamespaces()
// Set { "auth", "admin" }

Deduplication

Concurrent calls to loadNamespace for the same locale and namespace are automatically deduplicated. The loader is only called once:

// These two calls share the same loader invocation
await Promise.all([i18n.loadNamespace('auth'), i18n.loadNamespace('auth')])
// loader('en', 'auth') was only called once

Already-loaded namespaces are not re-fetched:

await i18n.loadNamespace('auth')
await i18n.loadNamespace('auth') // no-op, already loaded

Error Handling

If the loader throws, the error propagates to the caller and the loading state is properly cleaned up:

try {
  await i18n.loadNamespace('broken')
} catch (err) {
  console.error('Failed to load translations:', err)
}

i18n.isLoading() // false (cleaned up)

If the loader returns undefined, the namespace is silently skipped (not added to loaded namespaces):

const i18n = createI18n({
  locale: 'en',
  loader: async () => undefined,
})

await i18n.loadNamespace('missing')
i18n.loadedNamespaces().has('missing') // false

Mixed Static and Async Messages

You can combine static messages with an async loader. Static messages are available immediately while additional namespaces load on demand:

const i18n = createI18n({
  locale: 'en',
  messages: {
    en: { welcome: 'Welcome!' },
  },
  loader: async (locale, ns) => {
    const mod = await import(`./locales/${locale}/${ns}.json`)
    return mod.default
  },
})

i18n.t('welcome') // "Welcome!" (available immediately)
i18n.t('dashboard:title') // "dashboard:title" (not loaded yet)

await i18n.loadNamespace('dashboard')
i18n.t('dashboard:title') // "Dashboard" (now loaded)

Reactive Locale Switching

The locale property is a reactive signal. Changing it causes all reactive contexts that read translations to re-evaluate.

Basic Locale Switching

import { effect } from '@pyreon/reactivity'

const i18n = createI18n({
  locale: 'en',
  messages: {
    en: { greeting: 'Hello' },
    de: { greeting: 'Hallo' },
    fr: { greeting: 'Bonjour' },
  },
})

effect(() => {
  console.log(i18n.t('greeting'))
})
// Logs: "Hello"

i18n.locale.set('de')
// Logs: "Hallo"

i18n.locale.set('fr')
// Logs: "Bonjour"

Reading the Current Locale

// Reactive read (triggers re-evaluation in effects)
const currentLocale = i18n.locale()

// Non-reactive peek (does not trigger re-evaluation)
const currentLocale = i18n.locale.peek()

Component with Language Switcher

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

const i18n = createI18n({
  locale: 'en',
  messages: {
    en: {
      greeting: 'Hello, {{name}}!',
      switchLang: 'Switch Language',
    },
    de: {
      greeting: 'Hallo, {{name}}!',
      switchLang: 'Sprache wechseln',
    },
    ja: {
      greeting: '{{name}}!',
      switchLang: '',
    },
  },
})

const LanguageSwitcher = defineComponent(() => {
  return () => (
    <div>
      <p>{i18n.t('greeting', { name: 'World' })}</p>

      <select value={i18n.locale()} onChange={(e) => i18n.locale.set(e.target.value)}>
        {i18n.availableLocales().map((loc) => (
          <option value={loc}>{loc.toUpperCase()}</option>
        ))}
      </select>
    </div>
  )
})

Locale Switching with Namespace Reloading

When using async loading, you may need to reload namespaces after switching locales:

async function switchLocale(newLocale: string) {
  i18n.locale.set(newLocale)

  // Reload all currently loaded namespaces for the new locale
  const namespaces = i18n.loadedNamespaces()
  await Promise.all([...namespaces].map((ns) => i18n.loadNamespace(ns)))
}

Adding Messages at Runtime

Use addMessages to merge translations into the store without async loading. This is useful for plugin systems, feature flags, or server-provided translations.

// Add to the default namespace
i18n.addMessages('en', {
  dashboard: {
    title: 'Dashboard',
    welcome: 'Welcome back, {{name}}!',
  },
})

// Add to a specific namespace
i18n.addMessages('en', { greeting: 'Hi!' }, 'notifications')
i18n.t('notifications:greeting') // "Hi!"

Deep Merging

Messages are deep-merged with existing translations. Existing keys are preserved:

const i18n = createI18n({
  locale: 'en',
  messages: {
    en: {
      errors: {
        auth: 'Auth error',
      },
    },
  },
})

i18n.addMessages('en', {
  errors: {
    network: 'Network error',
  },
})

i18n.t('errors.auth') // "Auth error" (preserved)
i18n.t('errors.network') // "Network error" (added)

Creating New Locales

addMessages creates the locale if it does not exist:

i18n.addMessages('fr', { greeting: 'Bonjour' })
i18n.locale.set('fr')
i18n.t('greeting') // "Bonjour"

i18n.availableLocales() // includes "fr"

Immutability

The store clones the provided messages, so mutating the source object after calling addMessages has no effect:

const source = { greeting: 'Hello' }
i18n.addMessages('en', source)

source.greeting = 'MUTATED'
i18n.t('greeting') // "Hello" (not affected)

Reactive Updates

addMessages triggers a reactive update, so any effect or computed that reads the added keys will re-evaluate:

effect(() => {
  console.log(i18n.t('newKey'))
})
// Logs: "newKey" (missing key fallback)

i18n.addMessages('en', { newKey: 'New value!' })
// Logs: "New value!" (reactive update)

Checking Key Existence

The exists() method checks whether a translation key exists in the current locale or the fallback locale:

i18n.exists('greeting') // true
i18n.exists('nonexistent.key') // false
i18n.exists('auth:errors.invalid') // true (if namespace is loaded)
i18n.exists('auth:nonexistent') // false

It also checks the fallback locale:

const i18n = createI18n({
  locale: 'de',
  fallbackLocale: 'en',
  messages: {
    en: { onlyEn: 'English only' },
    de: {},
  },
})

i18n.exists('onlyEn') // true (found in fallback)

Note that exists() uses .peek() internally and is not reactive. Use it for imperative checks, not inside render functions or effects where you need reactivity.

Missing Key Handling

The resolution order for a translation key is:

  1. Look up the key in the current locale and resolved namespace

  2. Look up the key in the fallback locale (if configured) and resolved namespace

  3. Call the onMissingKey handler (if configured)

  4. Return the raw key string as a visual fallback

This means you always get a string back from t() -- it never throws or returns undefined.

Logging Missing Keys in Development

const i18n = createI18n({
  locale: 'en',
  messages: { en: {} },
  onMissingKey: (locale, key, namespace) => {
    if (import.meta.env.DEV) {
      console.warn(`[i18n] Missing key "${key}" in namespace "${namespace}" for locale "${locale}"`)
    }
    // Return undefined to use default behavior (return the key itself)
  },
})

Collecting Missing Keys for Translation Tools

const missingKeys = new Set<string>()

const i18n = createI18n({
  locale: 'en',
  messages: { en: {} },
  onMissingKey: (locale, key, namespace) => {
    missingKeys.add(`${locale}:${namespace}:${key}`)
    return `[${key}]` // Custom visual indicator
  },
})

// Later, export missing keys for your translation management system
exportForTranslation([...missingKeys])

Available Locales

The availableLocales computed returns all locales that have any registered messages:

const i18n = createI18n({
  locale: 'en',
  messages: {
    en: { hello: 'Hello' },
    de: { hello: 'Hallo' },
  },
})

i18n.availableLocales() // ["en", "de"]

i18n.addMessages('fr', { hello: 'Bonjour' })
i18n.availableLocales() // ["en", "de", "fr"]

This is reactive and can be used in render functions to build language selectors.

Date and Number Formatting

@pyreon/i18n focuses on string translation. For date and number formatting, use the built-in Intl APIs with the current locale:

function formatDate(date: Date): string {
  return new Intl.DateTimeFormat(i18n.locale(), {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  }).format(date)
}

function formatCurrency(amount: number, currency = 'USD'): string {
  return new Intl.NumberFormat(i18n.locale(), {
    style: 'currency',
    currency,
  }).format(amount)
}

function formatNumber(value: number): string {
  return new Intl.NumberFormat(i18n.locale()).format(value)
}

Since these functions read i18n.locale(), they are reactive when used inside effects or render functions:

const FormattedPrice = defineComponent<{ amount: number }>((props) => {
  return () => <span>{formatCurrency(props.amount)}</span>
})

RTL Support Considerations

For right-to-left languages, combine @pyreon/i18n with @pyreon/head to set the dir attribute:

import { useHead } from '@pyreon/head'

const rtlLocales = new Set(['ar', 'he', 'fa', 'ur'])

function RootLayout() {
  useHead(() => ({
    htmlAttrs: {
      lang: i18n.locale(),
      dir: rtlLocales.has(i18n.locale()) ? 'rtl' : 'ltr',
    },
  }))

  return <App />
}

Integration with @pyreon/router

For locale-based routing with URL prefixes like /en/about or /de/about:

import { createRouter } from '@pyreon/router'
import { createI18n } from '@pyreon/i18n'

const i18n = createI18n({
  locale: 'en',
  fallbackLocale: 'en',
  loader: async (locale, namespace) => {
    const mod = await import(`./locales/${locale}/${namespace}.json`)
    return mod.default
  },
})

const router = createRouter({
  routes: [
    {
      path: '/:locale',
      beforeEnter: async (to) => {
        const locale = to.params.locale as string
        i18n.locale.set(locale)
        await i18n.loadNamespace('common')
      },
      children: [
        { path: '/', component: HomePage },
        { path: '/about', component: AboutPage },
      ],
    },
  ],
})

SSR Considerations

On the server, effects do not run. The t() function evaluates synchronously using the current locale at render time.

For SSR, set the locale before rendering:

import { createI18n } from '@pyreon/i18n'
import { renderWithHead } from '@pyreon/head'

async function handleRequest(req: Request) {
  const locale = detectLocale(req)

  const i18n = createI18n({
    locale,
    fallbackLocale: 'en',
    messages: allMessages,
  })

  // Load any namespaces needed for the page
  await i18n.loadNamespace('common')

  const { html, head } = await renderWithHead(<App i18n={i18n} />)
  // ...
}

Create a new createI18n instance per request to avoid shared state between requests.

Full Application Example

// i18n.ts
import { createI18n } from '@pyreon/i18n'

// Inline critical translations, lazy-load the rest
export const i18n = createI18n({
  locale: navigator.language.split('-')[0] || 'en',
  fallbackLocale: 'en',
  messages: {
    en: {
      app: { title: 'My App', loading: 'Loading...' },
    },
    de: {
      app: { title: 'Meine App', loading: 'Laden...' },
    },
  },
  loader: async (locale, namespace) => {
    try {
      const mod = await import(`./locales/${locale}/${namespace}.json`)
      return mod.default
    } catch {
      return undefined
    }
  },
  onMissingKey: (locale, key, namespace) => {
    if (import.meta.env.DEV) {
      console.warn(`[i18n] Missing: ${locale}/${namespace}/${key}`)
    }
  },
})
// App.tsx
import { defineComponent } from '@pyreon/core'
import { i18n } from './i18n'

const App = defineComponent(() => {
  // Load page-specific translations
  i18n.loadNamespace('home')

  return () => {
    if (i18n.isLoading()) {
      return <p>{i18n.t('app.loading')}</p>
    }

    return (
      <main>
        <h1>{i18n.t('app.title')}</h1>
        <p>{i18n.t('home:welcome', { name: 'World' })}</p>

        <footer>
          <select value={i18n.locale()} onChange={(e) => i18n.locale.set(e.target.value)}>
            {i18n.availableLocales().map((loc) => (
              <option value={loc}>{loc}</option>
            ))}
          </select>
        </footer>
      </main>
    )
  }
})

API Reference

createI18n(options)

Create a reactive i18n instance.

Options (I18nOptions):

OptionTypeDefaultDescription
localestring(required)Initial locale
fallbackLocalestringundefinedFallback when a key is missing in the active locale
messagesRecord<string, TranslationDictionary>undefinedStatic messages keyed by locale
loaderNamespaceLoaderundefinedAsync namespace loader function
defaultNamespacestring"common"Default namespace for keys without a prefix
pluralRulesPluralRulesundefinedCustom plural rules per locale
onMissingKey(locale, key, namespace?) => string | voidundefinedMissing key handler

Returns I18nInstance:

Property / MethodTypeDescription
t(key, values?)(key: string, values?: InterpolationValues) => stringTranslate a key (reactive)
localeSignal<string>Current locale signal (read/write)
loadNamespace(ns, locale?)(ns: string, locale?: string) => Promise<void>Load a namespace asynchronously
isLoadingComputed<boolean>Whether any namespace is currently loading
loadedNamespacesComputed<Set<string>>Set of loaded namespaces for the current locale
exists(key)(key: string) => booleanCheck if a key exists (non-reactive)
addMessages(locale, messages, ns?)(locale, messages, ns?) => voidAdd/merge translations at runtime
availableLocalesComputed<string[]>All locales with registered messages

I18nProvider

Provides an i18n instance to the component tree via context.

function I18nProvider(props: { instance: I18nInstance; children: VNode }): VNode

useI18n()

Retrieves the i18n instance from the nearest I18nProvider. Throws if no provider is found.

function useI18n(): I18nInstance

I18nContext

The raw context object used by I18nProvider and useI18n. Exported for advanced use cases where you need direct context access.

Trans

Rich JSX interpolation component for translations containing inline markup.

function Trans(props: TransProps): VNode

TransProps:

PropertyTypeDescription
i18nKeystringTranslation key (supports namespace/* zero-content: unhandled mdast node "textDirective" */ syntax)
valuesInterpolationValuesInterpolation values for placeholder syntax
componentsRecord<string, (children) => VNode>Component map for rich interpolation
t(key, values?) => stringThe i18n t function

parseRichText(text)

Parse a tagged translation string into an array of strings and &#123; tag, children &#125; objects.

function parseRichText(text: string): Array<string | { tag: string; children: string }>

interpolate(template, values?)

Replace &#123;&#123;key&#125;&#125; placeholders in a string with values from the given record.

function interpolate(template: string, values?: InterpolationValues): string

resolvePluralCategory(locale, count, customRules?)

Resolve the CLDR plural category for a given count and locale.

function resolvePluralCategory(locale: string, count: number, customRules?: PluralRules): string

Type Exports

TypeDescription
I18nInstanceThe public i18n instance returned by createI18n
I18nOptionsOptions for createI18n
TranslationDictionaryNested Record<string, string | TranslationDictionary>
TranslationMessagesRecord<string, TranslationDictionary>
NamespaceLoader(locale: string, namespace: string) => Promise<TranslationDictionary | undefined>
InterpolationValuesRecord<string, string | number>
PluralRulesRecord<string, (count: number) => string>
TransPropsProps for the Trans component
i18n