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
Recommended Organization
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
Use namespaced keys: Organize translations by feature (
user.profile.title).Set fallback locale: Always configure a fallback for missing translations.
Include count in pluralization: Use
:countplaceholder for dynamic numbers.Cache in production: Enable caching for JSON loader in production.
Keep translations organized: Use separate files for different concerns.
Handle missing keys gracefully: Use
onMissingKeycallback for logging.Test translations: Write tests to ensure all keys exist in all locales.
Load locales on demand: Use
loadLocale()for better performance.