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

Create config/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

The i18n() 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)
If no strategy matches, falls back to 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 use t() 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 the fallback 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

The t() 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')
})