Guide/guides

Internationalization (i18n) Guide

Guren provides a comprehensive internationalization system with translation management, pluralization support for multiple languages, and both file-based and memory-based loading.

Internationalization (i18n) Guide

Guren provides a comprehensive internationalization system with translation management, pluralization support for multiple languages, and both file-based and memory-based loading.

Core Concepts

  • I18nManager – Central hub for managing translations and locales.
  • Translator – Instance for performing translations with context.
  • TranslationLoader – Interface for loading translations (JSON files or memory).
  • Pluralization – Built-in rules for 15+ languages with customizable forms.

Basic Usage

Quick Start

import { createI18n, setI18n, t, tc } from '@guren/core'

const i18n = createI18n({
  locale: 'en',
  fallbackLocale: 'en',
  messages: {
    en: {
      messages: {
        hello: 'Hello',
        welcome: 'Welcome, :name!',
        items: 'One item|:count items',
      },
    },
    ja: {
      messages: {
        hello: 'こんにちは',
        welcome: 'ようこそ、:nameさん!',
      },
    },
  },
})

setI18n(i18n)

// Translate
t('messages.hello')                    // 'Hello'
t('messages.welcome', { name: 'John' }) // 'Welcome, John!'

// Pluralize
tc('messages.items', 1)  // 'One item'
tc('messages.items', 5)  // '5 items'

Replacement Syntax

Two formats are supported for variable replacement:

// Laravel-style :variable
const messages = { greeting: 'Hello, :name!' }
t('greeting', { name: 'World' }) // 'Hello, World!'

// Brace-style {variable}
const messages = { greeting: 'Hello, {name}!' }
t('greeting', { name: 'World' }) // 'Hello, World!'

Nested Keys

Use dot notation to access nested translation keys:

const messages = {
  en: {
    user: {
      profile: {
        title: 'User Profile',
        settings: 'Settings',
      },
    },
  },
}

t('user.profile.title')    // 'User Profile'
t('user.profile.settings') // 'Settings'

Pluralization

Basic Pluralization

Separate plural forms with |:

const messages = {
  en: {
    apple: 'apple|apples',
    item: 'One item|:count items',
  },
}

tc('apple', 1)  // 'apple'
tc('apple', 5)  // 'apples'

tc('item', 1)   // 'One item'
tc('item', 10)  // '10 items'

Supported Languages

Built-in pluralization rules for:

Language Forms Rule
English, German, Spanish, Italian, Portuguese, Dutch 2 1 = singular, else plural
French, Brazilian Portuguese 2 0-1 = singular, else plural
Japanese, Chinese, Korean, Vietnamese, Thai 1 No plural forms
Russian, Ukrainian 3 one, few (2-4), many (5+)
Polish, Czech, Slovak 3 Complex rules
Arabic 6 Most complex pluralization

Complex Pluralization

For languages with multiple plural forms:

const messages = {
  ru: {
    // Russian: one | few | many
    apple: 'яблоко|яблока|яблок',
  },
}

i18n.setLocale('ru')
tc('apple', 1)   // 'яблоко' (one)
tc('apple', 2)   // 'яблока' (few)
tc('apple', 5)   // 'яблок' (many)
tc('apple', 21)  // 'яблоко' (one - special case)

Custom Pluralization Rules

import { createI18n } from '@guren/core'

const i18n = createI18n({
  locale: 'custom',
  messages: { /* ... */ },
})

// Set custom rule
i18n.setPluralizationRule('custom', (count) => {
  if (count === 0) return 0
  if (count === 1) return 1
  return 2
})

Loading Translations

JSON Loader

Load translations from JSON files:

lang/
├── en/
│   ├── messages.json
│   └── validation.json
└── ja/
    ├── messages.json
    └── validation.json
import { I18nManager, JsonLoader } from '@guren/core'

const loader = new JsonLoader('./lang', {
  cache: true, // Enable caching (default: true)
})

const i18n = new I18nManager({
  locale: 'en',
  loader,
})

// Load locale on demand
await i18n.loadLocale('ja')

Memory Loader

For testing or embedded translations:

import { MemoryLoader, I18nManager } from '@guren/core'

const loader = new MemoryLoader({
  en: {
    messages: { hello: 'Hello' },
  },
  ja: {
    messages: { hello: 'こんにちは' },
  },
})

// Dynamically add messages
loader.addMessages('fr', { messages: { hello: 'Bonjour' } })

// Remove locale
loader.removeLocale('fr')

I18nManager

Creating the Manager

import { I18nManager, JsonLoader } from '@guren/core'

const i18n = new I18nManager({
  locale: 'en',           // Current locale
  fallbackLocale: 'en',   // Fallback when translation missing
  loader: new JsonLoader('./lang'),
  messages: {
    // Optional initial messages
    en: { /* ... */ },
  },
})

Locale Management

// Get/set current locale
const locale = i18n.getLocale()
i18n.setLocale('ja')

// Get/set fallback locale
const fallback = i18n.getFallbackLocale()
i18n.setFallbackLocale('en')

// Check loaded locales
i18n.isLocaleLoaded('en')      // true
i18n.getAvailableLocales()     // ['en', 'ja']

// Load locale
await i18n.loadLocale('fr')

Translation Methods

// Simple translation
i18n.t('messages.hello')

// With replacements
i18n.t('messages.welcome', { name: 'John' })

// Pluralization
i18n.tc('messages.items', 5)
i18n.tc('messages.items', 5, { type: 'apple' })

// Check if translation exists
i18n.has('messages.hello')       // true
i18n.has('messages.hello', 'ja') // check specific locale

Scoped Translators

// Create translator for specific locale
const jaTranslator = i18n.forLocale('ja')
jaTranslator.t('messages.hello') // 'こんにちは'

// Original manager unchanged
i18n.getLocale() // still 'en'

Global Functions

Setup Global Manager

import { createI18n, setI18n, getI18n, t, tc } from '@guren/core'

// In bootstrap file
const i18n = createI18n({
  locale: 'en',
  messages: { /* ... */ },
})

setI18n(i18n)

// Anywhere in application
t('messages.hello')
tc('messages.items', 5)

// Access manager
const i18n = getI18n()

Request Middleware

Per-Request Locale

Use the built-in detectLocaleMiddleware to resolve each request's locale from the ?locale= query parameter, a locale cookie, or the Accept-Language header (in that order), restricted to your supported locales:

import { detectLocaleMiddleware } from '@guren/core'

app.use('*', detectLocaleMiddleware({ supported: ['en', 'ja'] }))

The middleware sets the locale context variable — which Inertia responses use for the root <html lang> attribute — and, when an i18n manager is available (the global one from setI18n(), or one passed via the i18n option), binds request-scoped t/tc translator helpers:

export class PostController extends Controller {
  async index() {
    const t = this.ctx.get('t')
    return this.json({ message: t('messages.hello') })
  }
}

Options: fallback (defaults to the first supported locale), sources to change the detection order (e.g. ['header']), queryParam, cookieName, and i18n to pass a manager explicitly (or false to skip the translator binding). Accept-Language matching understands region subtags (ja-JP matches ja) and q-values. Keep supported in sync with the locales your translation files actually provide — a locale missing from the list is never detected.

Without the middleware, Inertia responses fall back to the app-wide i18n locale, then en. A per-response lang option always wins:

return this.inertia(pages.posts.Show, { post }, { lang: 'ja' })

Using in Controllers

import { Controller } from '@guren/core'

export class UserController extends Controller {
  async index() {
    const t = this.ctx.get('t')

    return this.json({
      message: t('messages.hello'),
    })
  }
}

Configuration

Full Configuration Example

import { I18nManager, JsonLoader } from '@guren/core'

const i18n = new I18nManager({
  locale: 'en',
  fallbackLocale: 'en',
  loader: new JsonLoader('./lang', { cache: true }),
  messages: {
    en: {
      messages: {
        hello: 'Hello',
        goodbye: 'Goodbye',
      },
      validation: {
        required: 'The :field field is required',
        email: 'Please enter a valid email address',
        min: 'Must be at least :min characters',
      },
      errors: {
        not_found: 'Resource not found',
        unauthorized: 'Please log in to continue',
      },
    },
    ja: {
      messages: {
        hello: 'こんにちは',
        goodbye: 'さようなら',
      },
      validation: {
        required: ':fieldは必須です',
        email: '有効なメールアドレスを入力してください',
        min: ':min文字以上で入力してください',
      },
    },
  },
})

Testing

import { describe, test, expect, beforeEach } from 'bun:test'
import { createI18n, MemoryLoader } from '@guren/core'

describe('Translations', () => {
  let i18n

  beforeEach(() => {
    i18n = createI18n({
      locale: 'en',
      fallbackLocale: 'en',
      messages: {
        en: {
          hello: 'Hello',
          welcome: 'Welcome, :name!',
          items: 'One item|:count items',
        },
        ja: {
          hello: 'こんにちは',
        },
      },
    })
  })

  test('translates simple key', () => {
    expect(i18n.t('hello')).toBe('Hello')
  })

  test('translates with replacements', () => {
    expect(i18n.t('welcome', { name: 'John' })).toBe('Welcome, John!')
  })

  test('handles pluralization', () => {
    expect(i18n.tc('items', 1)).toBe('One item')
    expect(i18n.tc('items', 5)).toBe('5 items')
  })

  test('switches locale', () => {
    i18n.setLocale('ja')
    expect(i18n.t('hello')).toBe('こんにちは')
  })

  test('falls back for missing translation', () => {
    i18n.setLocale('ja')
    expect(i18n.t('welcome', { name: 'John' })).toBe('Welcome, John!')
  })

  test('returns key for missing translation', () => {
    expect(i18n.t('missing.key')).toBe('missing.key')
  })
})

Translation File Structure

lang/
├── en/
│   ├── messages.json     # General messages
│   ├── validation.json   # Validation messages
│   ├── errors.json       # Error messages
│   └── emails.json       # Email templates
└── ja/
    ├── messages.json
    ├── validation.json
    ├── errors.json
    └── emails.json

Example JSON Files

// lang/en/messages.json
{
  "hello": "Hello",
  "welcome": "Welcome, :name!",
  "items": "One item|:count items",
  "user": {
    "profile": "Profile",
    "settings": "Settings"
  }
}

// lang/en/validation.json
{
  "required": "The :field field is required",
  "email": "Please enter a valid email",
  "min": {
    "string": "Must be at least :min characters",
    "numeric": "Must be at least :min"
  }
}

Best Practices

  1. Use namespaced keys: Organize translations by feature (user.profile.title).

  2. Set fallback locale: Always configure a fallback for missing translations.

  3. Include count in pluralization: Use :count placeholder for dynamic numbers.

  4. Cache in production: Enable caching for JSON loader in production.

  5. Keep translations organized: Use separate files for different concerns.

  6. Handle missing keys gracefully: Use onMissingKey callback for logging.

  7. Test translations: Write tests to ensure all keys exist in all locales.

  8. Load locales on demand: Use loadLocale() for better performance.