Version: 2.0 Date: January 8, 2026 Status: Active Development Phase 2
This guide provides comprehensive instructions for developers working with Ampel's internationalization (i18n) system.
- Quick Start
- Adding Translatable Strings
- Frontend: Using
t()Hook - Backend: Using
t!()Macro - Adding New Languages
- Updating Existing Translations
- Testing Translations Locally
- Common Pitfalls & Solutions
- Translation Tool CLI Reference
- Debugging & Troubleshooting
# Ensure you have the development environment set up
make install # Install all dependencies
make dev-api # Start backend server
make dev-frontend # Start frontend dev server- Add English string to appropriate namespace JSON/YAML file
- Frontend: Import
useTranslation()and uset('key') - Backend: Use
t!("key")macro in Rust code - Translate: Run
cargo i18n translate en/common.json --all-languages - Test: Switch language in UI or use browser console
| File | Purpose |
|---|---|
frontend/public/locales/en/*.json |
Frontend English strings (5 namespaces) |
crates/ampel-api/locales/en/*.yml |
Backend English strings (4 namespaces) |
frontend/src/i18n/config.ts |
i18next configuration |
crates/ampel-api/src/middleware/locale.rs |
Locale detection middleware |
crates/ampel-i18n-builder/ |
Translation automation tool |
Location: frontend/public/locales/en/
Supported namespaces:
common.json- App-wide strings (auth, UI labels, etc.)dashboard.json- PR dashboard specific stringssettings.json- Settings page stringserrors.json- Error messagesvalidation.json- Form validation messages
Step 1: Identify the right namespace
// PRCard component - use 'dashboard' namespace
// Header component - use 'common' namespace
// Form validation - use 'validation' namespaceStep 2: Add to English JSON file
Use dot notation for nested keys:
{
"auth": {
"form": {
"username": "Username",
"password": "Password",
"rememberMe": "Remember me"
},
"error": {
"invalidCredentials": "Invalid username or password",
"sessionExpired": "Your session has expired"
}
}
}Step 3: Use dot notation in code
// ✅ CORRECT - Matches JSON structure
t('auth.form.username');
t('auth.error.invalidCredentials');
// ❌ WRONG - Won't find the key
t('username');
t('invalidCredentials');Guidelines for Frontend Strings:
| Type | Example Key | Location |
|---|---|---|
| Page/Component Titles | dashboard.prDashboard |
dashboard.json |
| Form Labels | auth.form.username |
common.json |
| Button Text | common.button.submit |
common.json |
| Error Messages | errors.auth.invalidEmail |
errors.json |
| Validation Messages | validation.email.required |
validation.json |
| Status Badges | common.status.open |
common.json |
| Menu Items | common.menu.settings |
common.json |
Location: crates/ampel-api/locales/en/
Supported namespaces:
common.yml- General backend stringserrors.yml- API error messagesvalidation.yml- Field validation messagesproviders.yml- Git provider specific messages
Step 1: Edit the English YAML file
errors:
auth:
invalid_credentials: 'Invalid username or password'
token_expired: 'Authentication token has expired'
unauthorized: 'You are not authorized to perform this action'
validation:
email:
required: 'Email address is required'
invalid: 'Email address is invalid'
already_exists: 'Email address is already registered'Step 2: Use in Rust code
// Enable rust-i18n macro
use rust_i18n::t;
// Basic usage
return Err(AppError::Unauthorized(t!("errors.auth.unauthorized")));
// With interpolation
return Err(AppError::Validation(t!("validation.email.required")));
// With namespace
let message = t!("errors.auth.invalid_credentials", locale = "es");Guidelines for Backend Strings:
| Type | Example Key | Location |
|---|---|---|
| API Errors | errors.auth.invalid_credentials |
errors.yml |
| Validation Errors | validation.email.required |
validation.yml |
| Provider Errors | providers.github.auth_failed |
providers.yml |
| Success Messages | common.success.password_updated |
common.yml |
| Log Messages | common.logs.user_login |
common.yml |
import { useTranslation } from 'react-i18next';
export function MyComponent() {
// Hook can specify one or multiple namespaces
const { t } = useTranslation(['common', 'dashboard']);
return (
<div>
<h1>{t('dashboard:prDashboard')}</h1>
<p>{t('common:app.loading')}</p>
</div>
);
}// Specify single namespace (uses default if not specified)
const { t } = useTranslation();
// Specify multiple namespaces
const { t } = useTranslation(['common', 'errors']);
// Get i18n instance for advanced usage
const { t, i18n } = useTranslation();const { t } = useTranslation('common');
// Direct translation
<button>{t('button.submit')}</button>
<p>{t('auth.form.username')}</p>
<span>{t('status.open')}</span>Use {{variable}} placeholder syntax:
{
"messages": {
"welcome": "Welcome, {{name}}!",
"prCount": "You have {{count}} pull requests",
"lastUpdated": "Last updated: {{date}}"
}
}Usage in component:
const { t } = useTranslation('common');
<h1>{t('messages.welcome', { name: 'John' })}</h1>
<p>{t('messages.prCount', { count: 5 })}</p>
<span>{t('messages.lastUpdated', { date: new Date().toLocaleDateString() })}</span>Define plural forms in JSON:
{
"pullRequests_one": "{{count}} pull request",
"pullRequests_other": "{{count}} pull requests",
"reviewsNeeded_zero": "No reviews needed",
"reviewsNeeded_one": "{{count}} review needed",
"reviewsNeeded_other": "{{count}} reviews needed"
}Usage:
const { t } = useTranslation('dashboard');
// i18next automatically selects correct plural form
<p>{t('pullRequests', { count: prList.length })}</p>
<p>{t('reviewsNeeded', { count: reviewCount })}</p>// If key not found, returns the key itself
<p>{t('unknown.key')}</p> // Renders: "unknown.key"
// Provide custom fallback
<p>{t('unknown.key', { defaultValue: 'No data available' })}</p>const { t, i18n } = useTranslation();
// Get current language
console.log(i18n.language); // 'en', 'fr', 'de', etc.
// Change language programmatically
await i18n.changeLanguage('fr');
// Check if language is RTL
const isRTL = i18n.language === 'ar' || i18n.language === 'he';Components are automatically wrapped with RTLProvider:
// No special code needed - RTLProvider handles it
// For languages 'ar' (Arabic) or 'he' (Hebrew):
// - document.dir === 'rtl'
// - document.lang updated
// - CSS classes applied
// Use logical CSS properties (not affected by RTL)
<div className="ps-4 me-2"> // padding-inline-start, margin-inline-end
{t('common:app.title')}
</div>Enable rust-i18n in your crate:
// At top of main.rs or lib.rs
rust_i18n::i18n!("locales");Directory structure:
crates/ampel-api/
├── locales/
│ ├── en/
│ │ ├── common.yml
│ │ ├── errors.yml
│ │ ├── validation.yml
│ │ └── providers.yml
│ ├── fr/
│ ├── de/
│ └── ... (25 more languages)
└── src/
use rust_i18n::t;
// Simple string
let msg = t!("errors.auth.unauthorized");
// With interpolation
let msg = t!("validation.email.already_exists", email = "user@example.com");
// With context locale override
let msg = t!("errors.auth.invalid_credentials", locale = "es");
// Construct error with translated message
return Err(AppError::Unauthorized(t!("errors.auth.unauthorized")));Locale is automatically detected from requests via middleware:
// In handler function
use crate::middleware::locale::DetectedLocale;
use axum::extract::Extension;
async fn login(
Extension(detected_locale): Extension<DetectedLocale>,
// ... other parameters
) -> Result<impl IntoResponse> {
// detected_locale.code contains detected language (e.g., "en", "fr", "de")
// Translate error to detected locale
if invalid_email(&email) {
let error_msg = t!("validation.email.invalid", locale = detected_locale.code);
return Err(AppError::Validation(error_msg));
}
Ok(Json(response))
}# errors.yml
errors:
provider:
rate_limit: 'Rate limit exceeded. Please try again in {{minutes}} minutes'
auth_failed: 'Authentication failed for {{provider}}: {{reason}}'Usage:
let msg = t!("errors.provider.rate_limit", minutes = 5);
let msg = t!("errors.provider.auth_failed", provider = "GitHub", reason = "Invalid token");# common.yml
pull_requests:
one: '{{count}} pull request'
other: '{{count}} pull requests'// rust-i18n handles pluralization based on count
let msg = t!("pull_requests.one", count = 1); // "1 pull request"
let msg = t!("pull_requests.other", count = 5); // "5 pull requests"// ❌ Before: Hardcoded error messages
return Err(AppError::Unauthorized("Invalid credentials".into()));
// ✅ After: Localized error messages
return Err(AppError::Unauthorized(t!("errors.auth.invalid_credentials")));
// ✅ Best: With context
if !user_exists {
return Err(AppError::NotFound(
t!("errors.auth.user_not_found", locale = locale.code)
));
}#[tokio::test]
async fn test_error_message_in_finnish() {
let response = client
.post("/api/v1/auth/login?lang=fi")
.json(&invalid_login)
.send()
.await;
// Response will include Finnish error message
assert!(response_body.contains("Virheellinen"));
}Ampel supports 27 languages:
Simple Codes (21 languages):
en (English), fr (French), de (German), it (Italian), ru (Russian),
ja (Japanese), ko (Korean), ar (Arabic), he (Hebrew), hi (Hindi),
nl (Dutch), pl (Polish), sr (Serbian), th (Thai), tr (Turkish),
sv (Swedish), da (Danish), fi (Finnish), vi (Vietnamese),
no (Norwegian), cs (Czech)
Regional Variants (6 languages):
en-GB (English UK), pt-BR (Portuguese Brazil), zh-CN (Chinese Simplified),
zh-TW (Chinese Traditional), es-ES (Spanish Spain), es-MX (Spanish Mexico)
Step 1: Create locale directories
# Frontend
mkdir -p frontend/public/locales/{language-code}
# Backend
mkdir -p crates/ampel-api/locales/{language-code}Step 2: Copy English template files
# Frontend
cp frontend/public/locales/en/*.json frontend/public/locales/{language-code}/
# Backend
cp crates/ampel-api/locales/en/*.yml crates/ampel-api/locales/{language-code}/Step 3: Verify in configuration
Check if language is in SUPPORTED_LANGUAGES array:
// frontend/src/i18n/config.ts
export const SUPPORTED_LANGUAGES: LanguageInfo[] = [
{ code: 'xx', name: 'Language Name', nativeName: 'Native Name', dir: 'ltr', isoCode: 'xx-XX' },
];
// For RTL languages (Arabic, Hebrew)
dir: 'rtl';Backend middleware already includes all 27 languages automatically.
Step 4: Run translation CLI
# Translate frontend files
cargo i18n translate frontend/public/locales/en/*.json \
--target {language-code} \
--all-languages
# Translate backend files (YAML to JSON → translate → back to YAML)
for namespace in common errors validation providers; do
yq eval -o=json "crates/ampel-api/locales/en/${namespace}.yml" > "/tmp/${namespace}.json"
cargo i18n translate "/tmp/${namespace}.json" --target {language-code}
yq eval -P "/tmp/${namespace}.json" > "crates/ampel-api/locales/{language-code}/${namespace}.yml"
doneStep 5: Validate translations
# Check coverage
node validate-translations.js {language-code}
# Should show >90% coverageStep 6: Test locally
# Frontend
make dev-frontend
# Change language to new language in browser
# Backend
make dev-api
curl http://localhost:8080/api/v1/auth/login?lang={language-code} \
-H "Content-Type: application/json" \
-d '{"email":"test","password":"test"}'
# Should return error in target languageFrontend:
- Add key to
frontend/public/locales/en/{namespace}.json - Translate to target languages:
cargo i18n translate frontend/public/locales/en/{namespace}.json \ --target {language-code} - Run validation:
node validate-translations.js {language-code}
Backend:
- Add key to
crates/ampel-api/locales/en/{namespace}.yml - Convert, translate, and convert back:
yq eval -o=json "crates/ampel-api/locales/en/{namespace}.yml" > "/tmp/{namespace}.json" cargo i18n translate "/tmp/{namespace}.json" --target {language-code} yq eval -P "/tmp/{namespace}.json" > "crates/ampel-api/locales/{language-code}/{namespace}.yml"
Option 1: Use alternative translation provider
# Try different provider (Systran, DeepL, Google, OpenAI)
cargo i18n translate frontend/public/locales/en/common.json \
--target fr \
--provider deeplOption 2: Manual correction
Edit the JSON/YAML file directly:
{
"messages": {
"welcome": "Bienvenue, {{name}}!" // Manually corrected
}
}Option 3: Get context for better translation
Include context comments in source:
{
"status": {
"merged": "Merged", // "Merged" as in PR merged, not "combined"
"_comment_merged": "Context: Pull Request status, past tense"
}
}# Translate all untranslated keys in all languages
cargo i18n translate frontend/public/locales/en/*.json \
--all-languages \
--parallel \
--max-concurrent 3
# Check coverage report
node validate-translations.js --allManual Testing:
-
Start dev server:
make dev-frontend
-
Open browser console:
// Change language localStorage.setItem('ampel-i18n-lng', 'fr'); location.reload(); // Check current language localStorage.getItem('ampel-i18n-lng'); // Check favorites JSON.parse(localStorage.getItem('ampel-language-favorites'));
-
Verify UI changes to selected language
Automated Testing:
# Run frontend tests
make test-frontend
# Run specific language tests
npm test -- --testNamePattern="French"RTL Testing (Arabic/Hebrew):
// Browser console
localStorage.setItem('ampel-i18n-lng', 'ar');
location.reload();
// Verify RTL
console.log(document.dir); // Should be 'rtl'
console.log(document.lang); // Should be 'ar'
document.documentElement.classList.contains('rtl'); // Should be trueTest with query parameter:
# Start backend
make dev-api
# Finnish
curl http://localhost:8080/api/v1/auth/login?lang=fi \
-H "Content-Type: application/json" \
-d '{"email":"invalid","password":"wrong"}'
# German
curl http://localhost:8080/api/v1/auth/login?lang=de \
-H "Content-Type: application/json" \
-d '{"email":"invalid","password":"wrong"}'Test with Accept-Language header:
curl http://localhost:8080/api/v1/auth/login \
-H "Accept-Language: fr,en;q=0.9" \
-H "Content-Type: application/json" \
-d '{"email":"invalid","password":"wrong"}'Test with cookie:
curl http://localhost:8080/api/v1/auth/login \
-H "Cookie: lang=es-ES" \
-H "Content-Type: application/json" \
-d '{"email":"invalid","password":"wrong"}'Unit tests:
# Run all locale middleware tests
cargo test --package ampel-api locale_detection
# Run specific test
cargo test --package ampel-api test_normalize_locale# Validate single language
node validate-translations.js pt-BR
# Validate all languages
node validate-translations.js --all
# Example output:
# ✓ fr ████████░░░░░░░░░░░░ 50.5% (164/325)
# ✗ de ███░░░░░░░░░░░░░░░░░░ 16.6% (54/325)Problem: Component shows raw key instead of translated text
// Shows "common:app.unknownKey" in UI
{
t('common:app.unknownKey');
}Solution:
-
Check key exists in JSON:
grep -r "unknownKey" frontend/public/locales/en/ -
Verify namespace and key path match:
// ✅ CORRECT const { t } = useTranslation('common'); { t('app.unknownKey'); } // ❌ WRONG (namespaced syntax) { t('common:app.unknownKey'); } // Should omit namespace here
-
Add missing key to English file first:
{ "app": { "unknownKey": "Some translation" } }
Problem: Translation not loading, wrong namespace specified
// ❌ WRONG
const { t } = useTranslation('common');
{
t('dashboard:prDashboard');
} // Can't access dashboard namespace
// ✅ CORRECT (Option 1: Load multiple namespaces)
const { t } = useTranslation(['common', 'dashboard']);
{
t('dashboard:prDashboard');
}
// ✅ CORRECT (Option 2: Single namespace, proper usage)
const { t } = useTranslation('dashboard');
{
t('prDashboard');
}Problem: Interpolation shows {{variable}} literally
// English JSON
"messages": {
"welcome": "Welcome, {{name}}!"
}
// ❌ WRONG - Missing variable parameter
{t('messages.welcome')} // Shows: "Welcome, {{name}}!"
// ✅ CORRECT
{t('messages.welcome', { name: 'Alice' })} // Shows: "Welcome, Alice!"Solution: Always pass variables as second parameter in object format.
Problem: Plural forms not switching correctly
// ❌ WRONG - Missing _one and _other suffixes
{
"items": "Item|Items"
}
// ✅ CORRECT - Use i18next suffix convention
{
"items_one": "{{count}} item",
"items_other": "{{count}} items"
}Usage:
// Always use `count` parameter for pluralization
{
t('items', { count: 1 });
} // "1 item"
{
t('items', { count: 5 });
} // "5 items"Problem: Layout doesn't flip for Arabic/Hebrew
Solution: Use logical CSS properties instead of physical:
/* ❌ WRONG - Physical properties */
.sidebar {
margin-left: 10px;
padding-right: 15px;
border-left: 1px solid #ccc;
}
/* ✅ CORRECT - Logical properties */
.sidebar {
margin-inline-start: 10px; /* Flips with text direction */
padding-inline-end: 15px;
border-inline-start: 1px solid #ccc;
}
/* Or use Tailwind logical utilities */
<div className="ps-4 me-2">...</div>Problem: Backend error messages always in English
Causes and solutions:
// ❌ WRONG - Not using detected locale
return Err(AppError::Unauthorized("Invalid credentials".into()));
// ✅ CORRECT - Use detected locale
use crate::middleware::locale::DetectedLocale;
async fn login(
Extension(detected_locale): Extension<DetectedLocale>
) -> Result<()> {
if invalid {
let msg = t!("errors.auth.invalid", locale = detected_locale.code);
return Err(AppError::Unauthorized(msg));
}
}Priority order for backend locale detection:
- Query parameter:
?lang=fi - Cookie:
lang=de - Accept-Language header:
Accept-Language: pt-BR,pt;q=0.9 - Default:
en
Problem: Some languages missing translations others have
Solution:
# Validate all languages
node validate-translations.js --all
# Re-translate all untranslated keys
cargo i18n translate frontend/public/locales/en/*.json \
--all-languages \
--force # Overwrite existing
# Check specific language coverage
node validate-translations.js de
# Output shows exactly what's missingProblem: Key lookups fail unexpectedly
// ❌ WRONG - camelCase in JSON
{ "userName": "Username" }
t('userName')
// BUT in YAML, use snake_case
// errors.yml
errors:
invalid_credentials: "Invalid credentials"
t!("errors.invalid_credentials")
// ✅ RULE: Follow source file conventions
// JSON files: camelCase
// YAML files: snake_case# Build ampel-i18n-builder crate
cd crates/ampel-i18n-builder
cargo build --release
# Or use via cargo
cargo i18n --help# Translate single file
cargo i18n translate frontend/public/locales/en/common.json --target fr
# Translate all namespaces to all languages
cargo i18n translate frontend/public/locales/en/*.json \
--all-languages \
--parallel \
--max-concurrent 3
# Translate with specific provider
cargo i18n translate common.json --target de --provider deepl
# Translate with timeout override
cargo i18n translate settings.json --target ja --timeout 60
# Validate coverage
cargo i18n validate frontend/public/locales
# Generate coverage report
node validate-translations.js --all > coverage-report.txtAPI keys in .env:
# Tier 1 - Primary provider
SYSTRAN_API_KEY="your_systran_key"
# Tier 2 - EU languages
DEEPL_API_KEY="your_deepl_key"
# Tier 3 - All languages
GOOGLE_API_KEY="your_google_key"
# Tier 4 - Fallback
OPENAI_API_KEY="your_openai_key"Frontend:
import i18n from 'i18next';
// Enable debug mode
i18n.on('missingKey', (lng, ns, key) => {
console.warn(`Missing translation: [${lng}][${ns}] ${key}`);
});
// Monitor language changes
i18n.on('languageChanged', (lng) => {
console.log(`Language changed to: ${lng}`);
console.log(`Direction: ${document.dir}`);
});Browser DevTools:
// Check i18next state
i18next.language;
i18next.languages;
i18next.ns;
i18next.backend;
i18next.t('key'); // Manually test translationsIssue: Locale detection priority wrong
# Test detection order
curl "http://localhost:8080/api/test?lang=fi" \
-H "Cookie: lang=de" \
-H "Accept-Language: fr" \
# Priority: fi (query) > de (cookie) > fr (header)Issue: Translation file syntax error
# Validate JSON syntax
node -e "console.log(JSON.parse(require('fs').readFileSync('frontend/public/locales/en/common.json')))"
# Validate YAML syntax
yamllint crates/ampel-api/locales/en/errors.ymlIssue: Missing Backend Translation
# Check if translation macro is using correct key path
grep -r "t!(\"errors.auth" crates/ampel-api/src/
# Verify YAML file has the key
grep "invalid_credentials" crates/ampel-api/locales/en/errors.yml// Measure language switch time
console.time('language-switch');
await i18n.changeLanguage('fr');
console.timeEnd('language-switch'); // Should be <100ms# Measure backend response time with translation
time curl http://localhost:8080/api/v1/auth/login?lang=de \
-H "Content-Type: application/json" \
-d '{"email":"test","password":"test"}'- Add English strings to frontend JSON (common/dashboard/settings/errors/validation)
- Add English strings to backend YAML (common/errors/validation/providers)
- Import
useTranslation()in frontend component - Use
t()hook with correct namespace and key path - Use
t!()macro in backend error handlers - Verify frontend builds without errors:
make build-frontend - Verify backend builds without errors:
make build-backend - Test in English:
make dev-frontend && make dev-api - Run translation tool:
cargo i18n translate en/*.json --all-languages - Validate coverage:
node validate-translations.js --all - Test in at least 3 languages (e.g., French, German, Arabic)
- Test RTL language if applicable (Arabic/Hebrew)
- Run test suite:
make test - Create PR with
[i18n]prefix
- react-i18next Documentation - Frontend hook usage
- rust-i18n Documentation - Backend macro usage
- i18next Pluralization - Complex plural rules
- ICU MessageFormat - Advanced syntax
- CLDR Plural Rules - Language-specific rules
- CSS Logical Properties - RTL support
Last Updated: January 8, 2026 Maintained By: Ampel Development Team Status: Active Development