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
hreflangstrategy. - 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: isolateor<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.
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