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>
      <a href="/">{{ t('messages.nav.home') }}</a>
      <a href="/about">{{ t('messages.nav.about') }}</a>
      <a href="/contact">{{ t('messages.nav.contact') }}</a>
    </nav>
  </div>
</template>

Directive Syntax

<template>
  <!-- Using v-t directive -->
  <h1 v-t="'messages.welcome'"></h1>

  <!-- With parameters -->
  <p v-t="{ path: 'messages.greeting', args: { name: user.name } }"></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>
  <div class="locale-switcher">
    <select @change="switchLocale" :value="currentLocale">
      <option v-for="locale in availableLocales" :value="locale.code">
        {{ 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)
    <link rel="stylesheet" href="/css/rtl.css" />
  @endif
</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
    }
  }
})