Internationalization and Localization

Help agents handle copy with ICU MessageFormat, RTL layout, and locale-aware formatting; define key names and fallback chains—avoid concat-based translation and hard-coded locales.

Key naming and default-locale file layout belong in the SKILL; do not scatter user-visible strings in business logic—use named placeholders so translators can reorder sentences.

Plurals, ordinals, and relative time differ by language: use runtime plural categories; format numbers and currency with BCP 47 locales and consistent server/client timezone policy.

Pseudo-localization and truncation checks can run in CI to surface long strings and missed extractions early; before release, verify missing keys and fallback chains.

  • Document language persistence separately from SEO hreflang strategy.
  • List exceptions for untranslatable images or legal copy with owners.
  • Keep translation memory / glossary versions aligned with code branches.

Overview and principles

For agents: read the repo’s resource layout (e.g. locales/, messages/) and default locale before changing copy or adding keys; do not write visible strings back into components outside the resource layer.

  • Stable, semantic keys aligned with routes or feature areas—avoid using English sentences as keys.
  • Reuse one key per meaning across surfaces to reduce TM fragmentation and inconsistency.
  • CI should fail on missing keys, placeholder mismatches, or invalid BCP 47 tags when configured.

i18next/vue-i18n Configuration and Plural Rules

Complete i18next configuration:

// i18n/index.ts
import i18next from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import HttpBackend from 'i18next-http-backend'

i18next
  .use(HttpBackend)
  .use(LanguageDetector)
  .init({
    fallbackLng: 'en',
    supportedLngs: ['en', 'zh', 'ar'],
    ns: ['common', 'errors', 'dashboard'],
    defaultNS: 'common',
    backend: { loadPath: '/locales/{{lng}}/{{ns}}.json' },
    detection: { order: ['localStorage', 'navigator'], caches: ['localStorage'] },
    interpolation: { escapeValue: false }, // React/Vue handles its own escaping
  })

Plural rule handling (different rules for English, Chinese, and Arabic):

// locales/en/common.json — English: one / other
{
  "message_count": "{{count}} message",
  "message_count_plural": "{{count}} messages"
}

// locales/zh/common.json — Chinese: no plural form (only other)
{
  "message_count": "{{count}} messages",
  "message_count_plural": "{{count}} messages"
}

// locales/ar/common.json — Arabic: zero/one/two/few/many/other
{
  "message_count_zero": "لا رسائل",
  "message_count_one": "رسالة واحدة",
  "message_count_two": "رسالتان",
  "message_count_few": "{{count}} رسائل",
  "message_count_many": "{{count}} رسالة",
  "message_count_other": "{{count}} رسالة"
}

// Usage (i18next automatically selects the branch based on locale)
t('message_count', { count: 5 })  // → "5 messages"

Intl API for date / number / currency formatting:

// utils/formatters.ts
export function formatDate(date: Date, locale: string): string {
  return new Intl.DateTimeFormat(locale, {
    year: 'numeric', month: 'long', day: 'numeric'
  }).format(date)
  // zh-CN: "2026年4月11日"  | en-US: "April 11, 2026"
}

export function formatCurrency(amount: number, locale: string, currency: string): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency', currency,
    minimumFractionDigits: 2,
  }).format(amount)
  // zh-CN + CNY: "¥1,234.56"  | en-US + USD: "$1,234.56"
}

export function formatRelativeTime(diffMs: number, locale: string): string {
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
  const diffSec = Math.round(diffMs / 1000)
  if (Math.abs(diffSec) < 60) return rtf.format(diffSec, 'second')
  const diffMin = Math.round(diffSec / 60)
  if (Math.abs(diffMin) < 60) return rtf.format(diffMin, 'minute')
  return rtf.format(Math.round(diffMin / 60), 'hour')
}

i18next-scanner configuration (automatically extract translation keys):

// i18next-scanner.config.js
module.exports = {
  input: ['src/**/*.{ts,tsx,vue}'],
  output: './',
  options: {
    func: { list: ['t', 'i18next.t', '$t'], extensions: ['.ts', '.tsx', '.vue'] },
    lngs: ['en', 'zh', 'ar'],
    ns: ['common', 'errors', 'dashboard'],
    defaultLng: 'en',
    defaultNS: 'common',
    resource: {
      loadPath: 'public/locales/{{lng}}/{{ns}}.json',
      savePath: 'public/locales/{{lng}}/{{ns}}.json',
    },
    defaultValue: '__STRING_NOT_TRANSLATED__',
    keySeparator: '.', nsSeparator: ':',
  },
}
// Run: npx i18next-scanner

RTL Support: CSS Logical Properties vs Physical Properties

CSS logical properties (recommended) vs physical properties (avoid):

/* ❌ Physical properties: direction is wrong in RTL; requires extra overrides */
.card {
  margin-left: 1rem;
  padding-right: 0.5rem;
  border-left: 2px solid blue;
  text-align: left;
}
[dir="rtl"] .card {          /* requires additional RTL override */
  margin-left: 0;
  margin-right: 1rem;
  padding-right: 0;
  padding-left: 0.5rem;
  border-left: none;
  border-right: 2px solid blue;
  text-align: right;
}

/* ✅ Logical properties: automatically adapt to dir="rtl" — no override needed */
.card {
  margin-inline-start: 1rem;  /* LTR = left, RTL = right */
  padding-inline-end: 0.5rem; /* LTR = right, RTL = left */
  border-inline-start: 2px solid blue;
  text-align: start;          /* follows writing direction automatically */
}

/* RTL full mapping reference */
/* margin-left        → margin-inline-start  */
/* margin-right       → margin-inline-end    */
/* padding-left       → padding-inline-start */
/* padding-right      → padding-inline-end   */
/* left / right       → inset-inline-start / inset-inline-end */
/* border-left        → border-inline-start  */

HTML dir attribute and dynamic switching:

// Dynamically set document direction
const RTL_LANGS = ['ar', 'he', 'fa', 'ur']

function applyLocale(locale: string) {
  const isRtl = RTL_LANGS.some(l => locale.startsWith(l))
  document.documentElement.setAttribute('dir', isRtl ? 'rtl' : 'ltr')
  document.documentElement.setAttribute('lang', locale)
}

// Exceptions: icons that must NOT be mirrored (e.g. playback controls, progress bars)
.playback-icon { unicode-bidi: normal; }
  • Wrap mixed-direction strings (LTR digits + RTL copy) with unicode-bidi: isolate or <bdi>.
  • Forms, timelines, and chart axes need separate RTL QA.

Fallback chains and missing keys

Define an explicit order, e.g. en-GB → en or pt-BR → pt → en; dev may warn, prod may log telemetry—avoid silently showing raw keys.

  • Missing keys: List key paths at build or runtime; do not hide English defaults inside components as invisible fallbacks.
  • Partial updates: Land new keys in the default locale before shipping translation bundles so empty locale files are not mistaken for “done”.
  • SSR: First-paint locale must match client hydration to prevent language flash or wrong-language SEO.
SKILL hint: State which fallback locale applies, whether missing keys show the key path, and whether events are reported—align names with framework settings like fallbackLocale.

Extraction and validation flow

Encode “read conventions → edit resources → placeholder/key checks → pseudo-locale or CI” in the SKILL for ordered execution.

  [ Read SKILL: key naming, dirs, default locale, fallback chain ]
                    │
                    ▼
            [ Scan: hard-coded visible strings / bypassing i18n APIs ]
                    │
                    ▼
            [ Extract or add keys; ICU for plurals and formats ]
                    │
           ┌────────┴────────┐
           ▼                 ▼
    [ RTL: dir + logical props ]   [ Locale: dates, currency, timezone alignment ]
           │                 │
           └────────┬──────────┘
                    ▼
            [ Placeholder sets: base vs translation alignment ]
                    │
                    ▼
         [ CI / local: missing keys, pseudo-locale, truncation ]
                    │
           ┌────────┴────────┐
           ▼                 ▼
        [ Pass ]         [ Fix keys or strings, rerun ]

Key and placeholder helper

Lightweight checks below: keys should be dot-separated lowercase segments (digits allowed); placeholders compare simple ICU-style {name} tokens (full plural blocks are not parsed). Supplement only—trust project linters for authority.


            

SKILL snippet

Copy as a skeleton, then substitute real paths and scripts from your repo.

---
name: i18n-l10n
description: Extract copy, ICU plurals, and locale formats; validate keys, placeholders, and fallback order
---
# Rules
- No hardcoded user-visible strings; all text goes through t(key) / $t(key)
- Keys are dot-separated lowercase, organized by feature namespace (common/errors/dashboard)
- Plurals use library plural categories; no if (n === 1) branches in code
- Dates, currencies, and numbers use the Intl API or library built-in formatters
- RTL layout uses CSS logical properties (margin-inline-start, etc.); do not write dir overrides
- Fallback chain: zh-TW → zh → en; missing keys warn in dev, report telemetry in production

# Steps
1. Read SKILL: key naming conventions, namespace directories, default language, fallback chain config
2. Run i18next-scanner to scan for untranslated strings
3. Extract / add keys to locales/{lng}/{ns}.json
4. Plurals: write _zero/_one/_two/_few/_many/_other variants following CLDR plural rules
5. Dates / currencies: replace hardcoded formats with Intl.DateTimeFormat / Intl.NumberFormat
6. RTL: convert margin-left/right to margin-inline-start/end; verify dir=rtl layout
7. CI: run placeholder consistency check; block merging on missing keys

Back to skills More skills