Localization (i18n)
Strav provides a full localization module with per-request locale detection, translation key resolution with fallback chains, interpolation, pluralization, and integration with views and validation.
Setup
1. Configuration
Createconfig/i18n.ts:
export default {
default: 'en',
fallback: 'en',
supported: ['en', 'fr', 'es'],
directory: 'lang',
detect: ['query', 'cookie', 'header'],
}
| Option | Description |
|---|---|
default |
Default locale when no detection matches |
fallback |
Fallback locale when a key is missing in the current locale |
supported |
List of supported locale codes |
directory |
Path to the lang/ directory (relative to cwd) |
detect |
Detection strategies, tried in order: query, cookie, header |
2. Bootstrap
Using a service provider (recommended)
import { I18nProvider } from '@strav/kernel'
app.use(new I18nProvider())
The I18nProvider registers I18nManager as a singleton and calls load() to read translation files. It depends on the config provider.
Then add the middleware and template globals:
import { t, choice, locale } from '@strav/kernel'
import { i18n } from '@strav/http'
import { ViewEngine } from '@strav/view'
ViewEngine.setGlobal('t', t)
ViewEngine.setGlobal('choice', choice)
ViewEngine.setGlobal('locale', locale)
router.use(i18n())
Manual setup
import { I18nManager, t, choice, locale } from '@strav/kernel'
import { i18n } from '@strav/http'
import { ViewEngine } from '@strav/view'
app.singleton(I18nManager)
app.resolve(I18nManager)
await I18nManager.load()
ViewEngine.setGlobal('t', t)
ViewEngine.setGlobal('choice', choice)
ViewEngine.setGlobal('locale', locale)
router.use(i18n())
3. Translation Files
Create JSON files organized by locale and namespace:
lang/
├── en/
│ ├── auth.json
│ ├── messages.json
│ └── validation.json
└── fr/
├── auth.json
├── messages.json
└── validation.json
Each JSON file becomes a namespace. For example, lang/en/auth.json:
{
"failed": "Invalid credentials.",
"throttle": "Too many login attempts. Please try again in :seconds seconds."
}
Access these keys with dot notation: t('auth.failed'), t('auth.throttle', { seconds: 60 }).
Nested keys are supported:
{
"min": {
"number": "Must be at least :min",
"string": "Must be at least :min characters"
}
}
Access: t('validation.min.number', { min: 5 }).
Usage
t() — Translate a Key
import { t } from '@strav/kernel'
t('auth.failed') // "Invalid credentials."
t('messages.welcome', { name: 'Alice' }) // "Welcome, Alice!"
Works everywhere — controllers, services, middleware, validation — because it reads the locale from AsyncLocalStorage.
choice() — Pluralization
Translation strings use | to separate plural forms:
{
"apple": "one apple|:count apples"
}
import { choice } from '@strav/kernel'
choice('messages.apple', 1) // "one apple"
choice('messages.apple', 5) // "5 apples"
choice('messages.apple', 0) // "0 apples"
Uses Intl.PluralRules for locale-aware pluralization. Languages with more than 2 forms (e.g., Arabic) can use up to 6 segments mapped to: zero|one|two|few|many|other.
The :count replacement is injected automatically.
locale() — Current Locale
import { locale } from '@strav/kernel'
locale() // 'en' (or the detected request locale)
Locale Detection
Thei18n() middleware detects the locale per-request using the strategies configured in detect, tried in order:
| Strategy | Source |
|---|---|
query |
?lang=fr or ?locale=fr query parameter |
cookie |
locale cookie |
header |
Accept-Language header (parsed, sorted by q-value, matched against supported) |
config.default.
View Templates
t(), choice(), and locale() are automatically available as globals in .strav templates:
<html lang="{{ locale() }}">
<body>
<h1>{{ t('messages.welcome', { name: user.name }) }}</h1>
<p>{{ choice('messages.items', items.length) }}</p>
</body>
</html>
Vue Islands
Pass translated strings as props:
<vue:counter :label="{{ t('components.counter_label') }}" />
For components that need many translations, pass a translations object:
<vue:form :translations="{{ JSON.stringify({
submit: t('form.submit'),
cancel: t('form.cancel'),
}) }}" />
Validation Integration
All validation rules uset() internally. When i18n is configured, validation messages are translated to the current locale. Without i18n, they return hardcoded English defaults — no configuration required for English-only apps.
To customize validation messages, create lang/en/validation.json (or any locale):
{
"required": "This field is required",
"string": "Must be a string",
"integer": "Must be an integer",
"number": "Must be a number",
"boolean": "Must be a boolean",
"min": {
"number": "Must be at least :min",
"string": "Must be at least :min characters"
},
"max": {
"number": "Must be at most :max",
"string": "Must be at most :max characters"
},
"email": "Must be a valid email address",
"url": "Must be a valid URL",
"regex": "Invalid format",
"enum": "Must be one of: :values",
"array": "Must be an array"
}
Interpolation
Replacements use:name syntax:
{ "welcome": "Welcome, :name! You have :count new messages." }
t('messages.welcome', { name: 'Alice', count: 5 })
// "Welcome, Alice! You have 5 new messages."
Fallback Chain
When a key is missing in the current locale, the system tries thefallback locale (typically en). If still missing, it returns the raw key.
1. Try requested locale (e.g., 'fr')
2. Try fallback locale (e.g., 'en')
3. Return the key itself (e.g., 'auth.failed')
Runtime Registration
For testing or dynamic loading:
import { I18nManager } from '@strav/kernel'
I18nManager.register('en', {
custom: { greeting: 'Hello from runtime!' },
})
t('custom.greeting') // "Hello from runtime!"
Testing
Thet() helper works without any setup — it falls back to English validation defaults for validation.* keys and returns the raw key for everything else. No need to configure i18n for tests unless you're testing localization itself.
To test locale-specific behavior:
import { localeStorage, t, I18nManager } from '@strav/kernel'
// Register translations
I18nManager.register('fr', {
messages: { hello: 'Bonjour' },
})
// Run in a locale context
await localeStorage.run('fr', async () => {
expect(t('messages.hello')).toBe('Bonjour')
})