Internationalization architecture: locale detection strategy, translation key organization (flat vs. namespaced), pluralization rules (CLDR), gender agreement, RTL layout (CSS logical properties), date/time/number/currency formatting (Intl API), and locale-aware sorting. Language-agnostic patterns applicable to any framework.
From clarcnpx claudepluginhub marvinrichter/clarc --plugin clarcThis skill uses the workspace's default tool permissions.
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Enables AI agents to execute x402 payments with per-task budgets, spending controls, and non-custodial wallets via MCP tools. Use when agents pay for APIs, services, or other agents.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
vs
i18n-frameworks: This skill covers language-agnostic architecture decisions (locale detection strategy, key organization, RTL support, Intl API). Usei18n-frameworkswhen you need framework-specific setup code (react-i18next, next-intl, Django i18n, Rails I18n, SwiftUI, Flutter ARB).
Apply locale sources in this priority order (highest wins):
1. User preference (stored in DB or localStorage)
2. URL parameter (?lang=de, /de/about)
3. Subdomain (de.example.com)
4. Accept-Language header (server-side)
5. Browser (navigator.language)
6. Default (en)
function detectLocale(req?: Request): string {
// 1. User preference (e.g., from JWT or cookie)
const userPref = getUserPreferenceLocale(); // from auth session
if (userPref) return userPref;
// 2. URL param
const urlParam = new URLSearchParams(window.location.search).get('lang');
if (urlParam && SUPPORTED_LOCALES.includes(urlParam)) return urlParam;
// 3. Browser
const browserLang = navigator.language.split('-')[0]; // 'en-US' → 'en'
if (SUPPORTED_LOCALES.includes(browserLang)) return browserLang;
// 4. Default
return 'en';
}
const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'ar', 'zh'];
# Flat (simple apps, <200 keys)
"submit_button" = "Submit"
"error_required" = "This field is required"
# Namespaced (recommended for larger apps)
"checkout:payment.submit" = "Pay now"
"checkout:payment.error.card_declined" = "Your card was declined"
"auth:login.error.invalid_credentials" = "Invalid email or password"
# Format: namespace:context.element[.state]
"dashboard:sidebar.nav.overview" = "Overview"
"dashboard:sidebar.nav.settings" = "Settings"
"forms:validation.required" = "Required"
"forms:validation.min_length" = "Minimum {min} characters"
# CORRECT — descriptive key, not the string itself
"auth:submit_button" = "Sign in"
# WRONG — using the English string as the key (breaks on reuse and German has cases)
"Sign in" = "Anmelden"
snake_case or camelCase — be consistentDifferent languages use different plural forms. Never assume two forms (singular/plural).
| Language | Categories | Example |
|---|---|---|
| English | one, other | 1 item / 2 items |
| German | one, other | 1 Element / 2 Elemente |
| Russian | one, few, many, other | 1 файл / 2 файла / 5 файлов / 1.5 файла |
| Arabic | zero, one, two, few, many, other | 0 ملفات / 1 ملف / 2 ملفان / 5 ملفات |
| Japanese | other | (no plural forms) |
Most modern i18n libraries support ICU MessageFormat:
# English
"items_selected": "{count, plural, one {# item selected} other {# items selected}}"
# German
"items_selected": "{count, plural, one {# Element ausgewählt} other {# Elemente ausgewählt}}"
# Russian
"items_selected": "{count, plural, one {# файл выбран} few {# файла выбрано} many {# файлов выбрано} other {# файла выбрано}}"
// i18next with ICU
t('items_selected', { count: selectedItems.length })
// Intl.PluralRules (vanilla JS)
const pr = new Intl.PluralRules('ru');
const rule = pr.select(count); // 'one' | 'few' | 'many' | 'other'
const message = ruTranslations[`items_${rule}`];
Replace directional properties with logical equivalents that auto-flip in RTL:
/* WRONG — directional, breaks in RTL */
.card {
margin-left: 16px;
padding-right: 12px;
border-left: 2px solid blue;
text-align: left;
}
/* CORRECT — logical, works in both LTR and RTL */
.card {
margin-inline-start: 16px; /* left in LTR, right in RTL */
padding-inline-end: 12px; /* right in LTR, left in RTL */
border-inline-start: 2px solid blue;
text-align: start; /* left in LTR, right in RTL */
}
| Directional | Logical equivalent |
|---|---|
margin-left / margin-right | margin-inline-start / margin-inline-end |
padding-left / padding-right | padding-inline-start / padding-inline-end |
border-left / border-right | border-inline-start / border-inline-end |
left / right (position) | inset-inline-start / inset-inline-end |
text-align: left | text-align: start |
float: left | float: inline-start |
<!-- Set on root element — CSS logical properties respond automatically -->
<html lang="ar" dir="rtl">
<!-- Or per-element for mixed content -->
<p dir="rtl">نص عربي</p>
Some icons need to be mirrored (arrows, chevrons, navigation icons — not symmetric icons):
[dir="rtl"] .icon-arrow-right,
[dir="rtl"] .icon-chevron-right,
[dir="rtl"] .icon-back {
transform: scaleX(-1);
}
/* Do NOT mirror: close, settings, search, user icons */
// Always pass locale and options — never rely on default locale
const formatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'long', // '15. März 2024'
timeStyle: 'short', // '14:30'
});
console.log(formatter.format(new Date()));
// Relative time
const relFormatter = new Intl.RelativeTimeFormat('de', { numeric: 'auto' });
console.log(relFormatter.format(-1, 'day')); // 'gestern'
console.log(relFormatter.format(-3, 'day')); // 'vor 3 Tagen'
// Number with locale-aware thousands separator and decimal
new Intl.NumberFormat('de-DE').format(1234567.89); // '1.234.567,89'
new Intl.NumberFormat('en-US').format(1234567.89); // '1,234,567.89'
// Currency
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(1234.5);
// '1.234,50 €'
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1234.5);
// '$1,234.50'
// WRONG — default sort is bytewise, incorrect for non-ASCII
const sorted = names.sort();
// CORRECT — locale-aware collation
const collator = new Intl.Collator('de', { sensitivity: 'base' });
const sorted = names.sort((a, b) => collator.compare(a, b));
// Correctly sorts: Ärger, Apfel, Über → Apfel, Ärger, Über
// i18next fallback configuration
i18n.init({
fallbackLng: ['en'], // fall back to English
// Or a chain: de-AT → de → en
fallbackLng: {
'de-AT': ['de', 'en'],
'de-CH': ['de', 'en'],
default: ['en'],
},
});
Replace characters to verify layout handles expansion (German is ~30% longer than English):
function pseudoLocalize(str: string): string {
return str
.replace(/a/g, 'à').replace(/e/g, 'é').replace(/i/g, 'î')
.replace(/o/g, 'ô').replace(/u/g, 'û').replace(/c/g, 'ç')
+ ' !!!'; // force string expansion
}
// "Submit" → "Ŝûbmît !!!"
Enable in dev:
if (process.env.NODE_ENV === 'development' && process.env.PSEUDO_LOCALE) {
i18n.addResourceBundle('pseudo', 'translation', pseudoLocalize);
i18n.changeLanguage('pseudo');
}
i18n.init({
missingKeyHandler: (lngs, ns, key) => {
console.warn(`[i18n] Missing key: ${ns}:${key} for ${lngs.join(', ')}`);
// In production: report to Sentry or similar
if (process.env.NODE_ENV === 'production') {
Sentry.captureMessage(`Missing i18n key: ${ns}:${key}`);
}
},
});
margin-inline-start not margin-left)dir="rtl" set on <html> for RTL localesIntl.DateTimeFormat (locale + options always explicit)Intl.NumberFormatIntl.Collator