Internationalization
Stacks.js includes comprehensive internationalization (i18n) support through ts-i18n, enabling multi-language applications with ease.
Configuration
i18n Configuration
// config/i18n.ts
export default {
// Default locale
locale: 'en',
// Fallback locale when translation is missing
fallback: 'en',
// Available locales
locales: ['en', 'es', 'fr', 'de', 'ja', 'zh'],
// Locale detection strategy
detection: {
order: ['query', 'cookie', 'header', 'session'],
queryParameter: 'lang',
cookieName: 'locale',
headerName: 'Accept-Language',
},
// Translation files location
path: 'lang',
// Pluralization rules (optional)
pluralization: {
// Custom rules for specific locales
},
}
Translation Files Structure
lang/
├── en/
│ ├── messages.json
│ ├── validation.json
│ ├── auth.json
│ └── pagination.json
├── es/
│ ├── messages.json
│ ├── validation.json
│ ├── auth.json
│ └── pagination.json
└── fr/
├── messages.json
├── validation.json
├── auth.json
└── pagination.json
Translation File Format
// lang/en/messages.json
{
"welcome": "Welcome to our application",
"greeting": "Hello, {name}!",
"items": {
"one": "You have one item",
"other": "You have {count} items"
},
"nav": {
"home": "Home",
"about": "About",
"contact": "Contact"
}
}
// lang/es/messages.json
{
"welcome": "Bienvenido a nuestra aplicación",
"greeting": "¡Hola, {name}!",
"items": {
"one": "Tienes un artículo",
"other": "Tienes {count} artículos"
},
"nav": {
"home": "Inicio",
"about": "Acerca de",
"contact": "Contacto"
}
}
Basic Usage
Translating Strings
import { t, trans } from '@stacksjs/i18n'
// Simple translation
t('messages.welcome')
// "Welcome to our application"
// With parameters
t('messages.greeting', { name: 'John' })
// "Hello, John!"
// Nested keys
t('messages.nav.home')
// "Home"
// Alternative syntax
trans('messages.welcome')
In STX Templates
<template>
<div
<h1{{ t('messages.welcome') }}</h1
<p{{ t('messages.greeting', { name: user.name }) }}</p
<nav
<ahref="{{ t('messages.nav.home') }}</a
<ahref="{{ t('messages.nav.about') }}</a
<ahref="{{ t('messages.nav.contact') }}</a
</nav
</div
</template
Directive Syntax
<template>
<!-- Using v-t directive -->
<h1v-t="</h1
<!-- With parameters -->
<pv-t="</p
</template
Pluralization
Basic Pluralization
// lang/en/messages.json
{
"items": {
"zero": "You have no items",
"one": "You have one item",
"other": "You have {count} items"
},
"apples": "{count} apple | {count} apples"
}
// Using pluralization
t('messages.items', { count: 0 }) // "You have no items"
t('messages.items', { count: 1 }) // "You have one item"
t('messages.items', { count: 5 }) // "You have 5 items"
// Shorthand syntax
t('messages.apples', 1) // "1 apple"
t('messages.apples', 5) // "5 apples"
Complex Pluralization
// lang/ru/messages.json (Russian has complex plural rules)
{
"items": {
"one": "{count} предмет",
"few": "{count} предмета",
"many": "{count} предметов",
"other": "{count} предмета"
}
}
// Automatically handles Russian plural rules
t('messages.items', { count: 1 }) // "1 предмет"
t('messages.items', { count: 2 }) // "2 предмета"
t('messages.items', { count: 5 }) // "5 предметов"
t('messages.items', { count: 21 }) // "21 предмет"
Locale Management
Setting Locale
import { setLocale, getLocale } from '@stacksjs/i18n'
// Get current locale
const currentLocale = getLocale() // 'en'
// Set locale
setLocale('es')
// Set locale with persistence
setLocale('es', { persist: true }) // Saves to cookie
Middleware for Locale Detection
// app/Middleware/DetectLocale.ts
export async function detectLocale(request: Request, next: Next) {
// Check query parameter
const queryLocale = request.query.lang
// Check cookie
const cookieLocale = request.cookies.get('locale')
// Check Accept-Language header
const headerLocale = parseAcceptLanguage(request.headers.get('Accept-Language'))
// Check user preference
const userLocale = request.user?.preferred_locale
// Set locale (first match wins)
const locale = queryLocale || cookieLocale || userLocale || headerLocale || 'en'
setLocale(locale)
return next()
}
Localized Routes
// routes/web.ts
import { localizedRoutes } from '@stacksjs/router'
// Automatically prefix routes with locale
localizedRoutes(['en', 'es', 'fr'], (router) => {
router.get('/', HomeController.index) // /en/, /es/, /fr/
router.get('/about', AboutController.index) // /en/about, /es/about, /fr/about
router.get('/contact', ContactController.index)
})
// Or manual locale segments
router.get('/{locale}/', HomeController.index)
router.get('/{locale}/about', AboutController.index)
Locale Switcher Component
<!-- components/LocaleSwitcher.stx -->
<template>
<divclass="
<select@change="switchLocale:value="currentLocale
<optionv-for="localeinavailableLocales:value="locale
{{ locale.name }}
</option
</select
</div
</template
<script>
export default {
computed: {
currentLocale() {
return getLocale()
},
availableLocales() {
return [
{ code: 'en', name: 'English' },
{ code: 'es', name: 'Español' },
{ code: 'fr', name: 'Français' },
]
}
},
methods: {
switchLocale(event) {
const locale = event.target.value
setLocale(locale, { persist: true })
window.location.reload()
}
}
}
</script
Date and Number Formatting
Date Formatting
import { formatDate, formatRelativeTime } from '@stacksjs/i18n'
// Format dates in current locale
formatDate(new Date(), 'long')
// English: "December 24, 2024"
// Spanish: "24 de diciembre de 2024"
// Format options
formatDate(new Date(), {
dateStyle: 'full',
timeStyle: 'short',
})
// English: "Tuesday, December 24, 2024 at 2:30 PM"
// Relative time
formatRelativeTime(-1, 'day') // "yesterday"
formatRelativeTime(3, 'hour') // "in 3 hours"
formatRelativeTime(-5, 'minute') // "5 minutes ago"
Number Formatting
import { formatNumber, formatCurrency, formatPercent } from '@stacksjs/i18n'
// Format numbers
formatNumber(1234567.89)
// English: "1,234,567.89"
// German: "1.234.567,89"
// Format currency
formatCurrency(99.99, 'USD')
// English: "$99.99"
// German: "99,99 quot;
formatCurrency(99.99, 'EUR')
// English: "€99.99"
// German: "99,99 €"
// Format percentage
formatPercent(0.25)
// "25%"
In Templates
<template>
<div
<p{{ formatDate(post.published_at, 'long') }}</p
<p{{ formatRelativeTime(post.published_at) }}</p
<p{{ formatCurrency(product.price, 'USD') }}</p
<p{{ formatNumber(stats.views) }} views</p
</div
</template
Model Translations
Translatable Models
// app/Models/Product.ts
import { Model, Translatable } from '@stacksjs/orm'
export default class Product extends Model {
use = [Translatable]
// Fields that can be translated
static translatable = ['name', 'description']
static fields = {
name: { type: 'string' },
description: { type: 'text' },
price: { type: 'decimal' },
sku: { type: 'string' },
}
}
Translation Storage
// Option 1: JSON column
// Stores translations in a JSON column
// Option 2: Separate translations table
// product_translations table:
// id, product_id, locale, name, description
// Database migration
export default {
async up() {
await DB.schema.create('product_translations', (table) => {
table.id()
table.foreignId('product_id').references('products.id').onDelete('cascade')
table.string('locale', 5)
table.string('name')
table.text('description').nullable()
table.timestamps()
table.unique(['product_id', 'locale'])
})
}
}
Using Translations
// Create with translations
const product = await Product.create({
sku: 'SKU-123',
price: 99.99,
translations: {
en: { name: 'Widget', description: 'A great widget' },
es: { name: 'Widget', description: 'Un gran widget' },
},
})
// Get translated value (uses current locale)
product.name // "Widget" (if locale is 'en')
// Get specific translation
product.translate('name', 'es') // "Widget"
// Get all translations
product.translations // { en: {...}, es: {...} }
// Update translation
await product.updateTranslation('es', {
name: 'Nuevo nombre',
})
Validation Messages
Translated Validation
// lang/en/validation.json
{
"required": "The {field} field is required.",
"email": "The {field} must be a valid email address.",
"min": {
"string": "The {field} must be at least {min} characters.",
"numeric": "The {field} must be at least {min}."
},
"max": {
"string": "The {field} may not be greater than {max} characters.",
"numeric": "The {field} may not be greater than {max}."
},
"fields": {
"email": "email address",
"password": "password",
"name": "name"
}
}
// lang/es/validation.json
{
"required": "El campo {field} es obligatorio.",
"email": "El campo {field} debe ser un correo electrónico válido.",
"min": {
"string": "El campo {field} debe tener al menos {min} caracteres.",
"numeric": "El campo {field} debe ser al menos {min}."
},
"fields": {
"email": "correo electrónico",
"password": "contraseña",
"name": "nombre"
}
}
Using Translated Validation
// Validation automatically uses current locale
const errors = await request.validate({
email: 'required|email',
password: 'required|min:8',
})
// Errors are translated
// { email: "The email address field is required." }
// or in Spanish:
// { email: "El campo correo electrónico es obligatorio." }
RTL Support
RTL Configuration
// config/i18n.ts
export default {
locales: ['en', 'es', 'ar', 'he'],
rtl: ['ar', 'he'], // RTL languages
}
RTL in Templates
<html:dir="isRtl?'rtl':'ltr':lang="locale
<head
<!-- RTL stylesheet -->
@if(isRtl
<linkrel="href="
</head
<body
<x-layout
</body
</html
RTL Helper
import { isRtl, getDirection } from '@stacksjs/i18n'
// Check if current locale is RTL
isRtl() // true for Arabic, Hebrew
// Get text direction
getDirection() // 'rtl' or 'ltr'
Translation Management
CLI Commands
# Extract translatable strings from code
buddy i18n:extract
# Check for missing translations
buddy i18n:check
# Sync translation files
buddy i18n:sync
# Export translations for external tools
buddy i18n:export --format=json
# Import translations
buddy i18n:import translations.json
Missing Translation Handling
// config/i18n.ts
export default {
// Handler for missing translations
missing: (locale, key) => {
// Log missing translations in development
if (process.env.NODE_ENV === 'development') {
console.warn(`Missing translation: ${key} for locale ${locale}`)
}
// Return the key or a placeholder
return key
},
}
Translation API for External Tools
// routes/api.ts
router.get('/api/translations/:locale', async (request) => {
const { locale } = request.params
const translations = await loadTranslations(locale)
return translations
})
router.put('/api/translations/:locale', async (request) => {
const { locale } = request.params
const translations = request.body
await saveTranslations(locale, translations)
return { success: true }
})
Best Practices
Translation Key Naming
// Good: Organized by feature
{
"auth": {
"login": {
"title": "Sign In",
"button": "Log In",
"forgot_password": "Forgot your password?"
}
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome back, {name}!"
}
}
// Avoid: Flat, hard to maintain
{
"login_title": "Sign In",
"login_button": "Log In",
"dashboard_title": "Dashboard"
}
Parameter Naming
// Good: Descriptive parameter names
{
"greeting": "Hello, {userName}!",
"items_count": "You have {itemCount} items in your cart"
}
// Avoid: Generic names
{
"greeting": "Hello, {0}!",
"items_count": "You have {n} items in your cart"
}
Context for Translators
{
"save": {
"_context": "Button to save form data",
"value": "Save"
},
"save_document": {
"_context": "Button to save a document file",
"value": "Save Document"
}
}
Testing Translations
// tests/i18n.test.ts
import { test, expect } from 'bun:test'
import { setLocale, t } from '@stacksjs/i18n'
test('translations work correctly', () => {
setLocale('en')
expect(t('messages.welcome')).toBe('Welcome to our application')
setLocale('es')
expect(t('messages.welcome')).toBe('Bienvenido a nuestra aplicación')
})
test('pluralization works', () => {
setLocale('en')
expect(t('messages.items', { count: 0 })).toBe('You have no items')
expect(t('messages.items', { count: 1 })).toBe('You have one item')
expect(t('messages.items', { count: 5 })).toBe('You have 5 items')
})
test('all locales have required keys', async () => {
const locales = ['en', 'es', 'fr']
const requiredKeys = ['messages.welcome', 'messages.greeting']
for (const locale of locales) {
setLocale(locale)
for (const key of requiredKeys) {
expect(t(key)).not.toBe(key) // Should not return the key itself
}
}
})