i18n - Custom Internationalization Library
Custom-built internationalization system for matcha email client. Zero external i18n dependencies - everything implemented from scratch.
Table of Contents
- Architecture Overview
- Directory Structure
- Core Components
- Data Flow
- Translation File Format
- Plural Forms
- Caching Strategy
- Language Detection
- Adding/Editing Languages
Architecture Overview
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Application Layer β
β (TUI components call t(), tn(), tpl()) β
ββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββ
β Global Manager β
β β’ Singleton instance β
β β’ Current language state β
β β’ Localizer instances β
ββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββΌββββββββββββββββββ
β β β
ββββββββββΌβββββββββ βββββββΌβββββββ βββββββββΌβββββββββ
β Bundle β β Localizer β β Cache β
β All messages β β Per-lang β β Translations β
β All locales β β instance β β Rendered text β
ββββββββββ¬βββββββββ βββββββ¬βββββββ ββββββββββββββββββ
β β
β ββββββββΌβββββββββββ
β β Pluralizer β
β β Plural rules β
β βββββββββββββββββββ
β
ββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Translation Files β
β locales/en.json, locales/uk.json, ... β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Directory Structure
i18n/
βββ manager.go # Global singleton manager
βββ bundle.go # Translation bundle storage
βββ locale.go # Locale representation
βββ message.go # Message structure (with plural forms)
βββ localizer.go # Per-language translation engine
βββ loader.go # Load & embed translation files
βββ parser.go # JSON parser for translation files
βββ plural_rules.go # Plural rule functions for all languages
βββ pluralizer.go # Plural form selection logic
βββ template.go # Simple {var} template engine
βββ interpolator.go # Variable interpolation in strings
βββ cache.go # In-memory translation cache
βββ fallback.go # Fallback chain (uk β en)
βββ detector.go # Auto-detect user language
βββ registry.go # Available languages registry
βββ formatter.go # Number formatting per locale
βββ date_formatter.go # Date/time formatting per locale
βββ context.go # Context keys for locale passing
βββ errors.go # Error types
βββ init.go # Package initialization
βββ embed.go # Embed translation files in binary
βββ languages/ # Language-specific implementations
β βββ base.go # Base language interface
β βββ en.go # English locale registration
β βββ uk.go # Ukrainian locale registration
β βββ es.go # Spanish locale registration
β βββ ... # Other languages
βββ locales/ # Translation JSON files
βββ en.json # English translations (base)
βββ uk.json # Ukrainian translations
βββ ... # Other language files
Core Components
Translation Manager
File: manager.go
Global singleton that coordinates all i18n operations. Provides three main translation functions:
T(key)- Simple translation lookupTn(key, count, data)- Translation with pluralizationTpl(key, data)- Translation with template variables
// Usage in TUI components
import "github.com/floatpane/matcha/i18n"
func render() string {
title := i18n.GetManager().T("composer.title")
count := i18n.GetManager().Tn("inbox.unread", 5, map[string]interface{}{
"count": 5,
})
return title + " - " + count
}
Maintains:
- Current active language
- Bundle with all loaded translations
- Localizer instances per language
- Translation cache
Bundle System
File: bundle.go
Central storage for all translation messages across all languages. Thread-safe with RWMutex.
type Bundle struct {
defaultLang string
messages map[string]MessageMap // lang β message ID β Message
locales map[string]*Locale // lang β Locale info
mu sync.RWMutex
}
Responsibilities:
- Store translations for all languages
- Retrieve messages by language + key
- Register locale definitions
- List available languages
Localizer
File: localizer.go
Per-language translation engine. Each active language gets one localizer instance.
type Localizer struct {
lang string
bundle *Bundle
locale *Locale
cache *Cache
}
Core methods:
Localize(messageID)- Basic lookupLocalizePlural(messageID, count, data)- With plural rulesLocalizeTemplate(messageID, data)- With variable substitution
Handles:
- Message lookup with fallback
- Plural form selection
- Template interpolation
- Result caching
Plural Rules Engine
Files: plural_rules.go, pluralizer.go
Implements CLDR plural rules for all supported languages.
type PluralForm int
const (
Zero PluralForm = iota // 0 items
One // 1 item
Two // 2 items (Arabic, Welsh)
Few // 2-4 items (Slavic languages)
Many // 5+ items (Slavic languages)
Other // Default/fallback
)
Each language has specific rules:
English/Spanish: Simple (one/other)
func EnglishPlural(n int) PluralForm {
if n == 1 {
return One
}
return Other
}
Ukrainian/Russian: Complex (one/few/many)
func UkrainianPlural(n int) PluralForm {
mod10 := n % 10
mod100 := n % 100
if mod10 == 1 && mod100 != 11 {
return One // 1, 21, 31, 41...
}
if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) {
return Few // 2-4, 22-24, 32-34...
}
return Many // 0, 5-20, 25-30...
}
Arabic: Very complex (zero/one/two/few/many/other)
Template System
Files: template.go, interpolator.go
Simple variable substitution using {variable} syntax.
template := "Update available: {latest} (current: {current})"
data := map[string]interface{}{
"latest": "1.5.0",
"current": "1.4.0",
}
result := Interpolate(template, data)
// β "Update available: 1.5.0 (current: 1.4.0)"
Design philosophy: Keep simple - no complex logic, just variable replacement. Complex formatting done in Go code before passing to templates.
Language Registry
File: registry.go
Global registry of all available languages. Languages self-register on init.
// In languages/uk.go
func init() {
i18n.RegisterLanguage(&i18n.Locale{
Code: "uk",
Name: "Ukrainian",
NativeName: "Π£ΠΊΡΠ°ΡΠ½ΡΡΠΊΠ°",
Direction: "ltr",
PluralFunc: i18n.UkrainianPlural,
})
}
Registry provides:
GetLanguage(code)- Lookup locale by codeAvailableLanguages()- List all registered localesLanguageCodes()- List all language codes
Data Flow
Translation Lookup Flow
1. TUI calls t("composer.title")
β
2. Manager.T() β GetLocalizer(currentLang)
β
3. Localizer checks cache
β’ Cache hit? β return cached value
β’ Cache miss? β continue
β
4. Bundle.GetMessage(lang, "composer.title")
β’ Found? β return Message
β’ Not found? β try fallback chain (uk β en)
β
5. Return message.One (singular form)
β
6. Cache result for future lookups
β
7. Return translated string to TUI
Pluralization Flow
1. TUI calls tn("inbox.unread", 5, {"count": 5})
β
2. Manager.Tn() β GetLocalizer(currentLang)
β
3. Get Message from bundle
β
4. Call locale.PluralFunc(5)
β’ English: 5 β Other
β’ Ukrainian: 5 β Many
β’ Arabic: 5 β Few
β
5. Select appropriate form from Message:
β’ message.One (n=1)
β’ message.Few (n=2-4 in Ukrainian)
β’ message.Many (n=5+ in Ukrainian)
β’ message.Other (fallback)
β
6. Interpolate {count} β "5"
β
7. Return "5 Π»ΠΈΡΡΡΠ²" (Ukrainian) or "5 emails" (English)
Translation File Format
Location: locales/*.json
Structure:
{
"language": "uk",
"messages": {
"common": {
"yes": "Π’Π°ΠΊ",
"no": "ΠΡ",
"save": "ΠΠ±Π΅ΡΠ΅Π³ΡΠΈ"
},
"composer": {
"title": "ΠΠ°ΠΏΠΈΡΠ°ΡΠΈ Π½ΠΎΠ²ΠΈΠΉ Π»ΠΈΡΡ",
"send": "ΠΠ°Π΄ΡΡΠ»Π°ΡΠΈ",
"from": "ΠΡΠ΄"
},
"inbox": {
"unread": {
"one": "{count} Π½Π΅ΠΏΡΠΎΡΠΈΡΠ°Π½ΠΈΠΉ Π»ΠΈΡΡ",
"few": "{count} Π½Π΅ΠΏΡΠΎΡΠΈΡΠ°Π½Ρ Π»ΠΈΡΡΠΈ",
"other": "{count} Π½Π΅ΠΏΡΠΎΡΠΈΡΠ°Π½ΠΈΡ
Π»ΠΈΡΡΡΠ²"
}
}
}
}
Nested structure: Dot notation for keys (composer.title, inbox.unread)
Plural objects: When value is object with one/few/many/other, it's treated as plural form.
Plural Forms
Languages use different plural categories:
| Language | Forms Used | Example (emails) |
|---|---|---|
| English | one, other | 1 email, 2 emails |
| Spanish | one, other | 1 correo, 2 correos |
| German | one, other | 1 E-Mail, 2 E-Mails |
| French | one, other | 0 courriel, 1 courriel, 2 courriels |
| Ukrainian | one, few, other | 1 Π»ΠΈΡΡ, 2 Π»ΠΈΡΡΠΈ, 5 Π»ΠΈΡΡΡΠ² |
| Russian | one, few, other | 1 ΠΏΠΈΡΡΠΌΠΎ, 2 ΠΏΠΈΡΡΠΌΠ°, 5 ΠΏΠΈΡΠ΅ΠΌ |
| Polish | one, few, many, other | 1 e-mail, 2 e-maile, 5 e-maili |
| Arabic | zero, one, two, few, many, other | Complex rules |
| Japanese | other | All numbers use same form |
| Chinese | other | All numbers use same form |
Caching Strategy
File: cache.go
Simple in-memory string cache with RWMutex for thread safety.
Cache key format: {lang}:{messageID}:{count}:{hash(data)}
Why cache?
- Translation lookup involves: map lookups, plural calculation, interpolation
- Typical UI has ~100 translated strings
- Redrawing UI on every keypress = wasted CPU
- Cache hit = instant string return
Cache invalidation: Cache cleared when language changes via SetLanguage().
No size limit: Translation cache is small (~KB for entire UI) and only grows to unique message combinations actually used.
Language Detection
File: detector.go
Detection priority:
- Config file:
language = "uk"in~/.config/matcha/config.toml - Environment:
LANG,LC_ALL,LC_MESSAGES - System locale: OS-specific detection
- Default: Falls back to
"en"
func DetectLanguage(cfg *config.Config) string {
// 1. Check config
if cfg.Language != "" && isValidLanguage(cfg.Language) {
return cfg.Language
}
// 2. Check environment
if lang := detectFromEnv(); lang != "" {
return lang
}
// 3. Check system
if lang := detectFromSystem(); lang != "" {
return lang
}
// 4. Default
return "en"
}
Normalizes language codes: en_US.UTF-8 β en, uk_UA β uk
Adding/Editing Languages
Adding a New Language
1. Create translation file:
cd i18n/locales
cp en.json xx.json # Replace 'xx' with language code (ISO 639-1)
2. Update language code:
{
"language": "xx",
"messages": { ... }
}
3. Translate all strings:
- Keep JSON structure identical to
en.json - Translate all message values
- Preserve placeholders:
{count},{latest},{current} - Don't translate technical terms: S/MIME, PGP, IMAP, SMTP
- Don't translate commands:
matcha update
4. Handle plural forms:
Check plural rules for your language at CLDR Plural Rules.
Add plural forms as needed:
"hours_ago": {
"one": "{count} hour ago",
"other": "{count} hours ago"
}
For complex plurals (Ukrainian, Arabic, Polish):
"address_count": {
"one": "{count} Π°Π΄ΡΠ΅ΡΠ°",
"few": "{count} Π°Π΄ΡΠ΅ΡΠΈ",
"other": "{count} Π°Π΄ΡΠ΅Ρ"
}
5. Register language (if not already in languages/):
Create languages/xx.go:
package languages
import "github.com/floatpane/matcha/i18n"
func init() {
i18n.RegisterLanguage(&i18n.Locale{
Code: "xx",
Name: "YourLanguage",
NativeName: "YourLanguageInNativeScript",
Direction: "ltr", // or "rtl" for Arabic, Hebrew, etc.
PluralFunc: i18n.YourLanguagePlural,
})
}
If plural function doesn't exist in plural_rules.go, add it:
// In plural_rules.go
func YourLanguagePlural(n int) PluralForm {
// Implement CLDR rules for your language
if n == 1 {
return One
}
return Other
}
6. Test:
go build
# Edit config: language = "xx"
./matcha
Verify:
- All UI elements translated
- Plural forms work (test with 0, 1, 2, 5, 21 items)
- Variables interpolate correctly
- No English leaks through
Editing Existing Translations
1. Open translation file:
vi i18n/locales/uk.json
2. Find key using dot notation:
Translation keys follow UI structure:
composer.*- Email composerinbox.*- Inbox viewsettings_general.*- General settingscommon.*- Shared elements
3. Update translation:
{
"composer": {
"title": "Old translation" // Change this
}
}
4. Rebuild and test:
go build
./matcha
Language changes apply instantly (no restart needed).
Translation Guidelines
Do translate:
- All visible UI text
- Button labels
- Menu items
- Help text
- Tips
- Error messages shown to user
- Status messages
Don't translate:
- Backend error logs
- Debug messages
- Protocol names (IMAP, SMTP, S/MIME, PGP)
- File paths (
~/.config/matcha/) - Environment variables (
$EDITOR) - Commands (
matcha update) - Technical identifiers
Variable placeholders:
Always keep variables unchanged:
// β
Correct
"update_available": "ΠΠΎΡΡΡΠΏΠ½Π΅ ΠΎΠ½ΠΎΠ²Π»Π΅Π½Π½Ρ: {latest} (Π²ΡΡΠ°Π½ΠΎΠ²Π»Π΅Π½ΠΎ: {current})"
// β Wrong - renamed variable
"update_available": "ΠΠΎΡΡΡΠΏΠ½Π΅ ΠΎΠ½ΠΎΠ²Π»Π΅Π½Π½Ρ: {ΠΎΡΡΠ°Π½Π½ΡΠΉ} (Π²ΡΡΠ°Π½ΠΎΠ²Π»Π΅Π½ΠΎ: {ΠΏΠΎΡΠΎΡΠ½ΠΈΠΉ})"
Testing Checklist
- All screens display in target language
- Test plural forms with different counts (0, 1, 2, 5, 21)
- Variables interpolate correctly
- No untranslated English text (except technical terms)
- Text fits in UI (not truncated)
- Special characters render properly
- RTL languages display correctly (Arabic, Hebrew)
- Date/time formats work
- Help text makes sense in context
Contributing Translations
- Fork repository
- Add/edit translation file in
i18n/locales/ - Test thoroughly with checklist above
- Submit pull request with:
- Translation file
- Screenshots of translated UI
- Note about plural form testing
- List any technical challenges
Translation quality > completeness. Better to have 80% high-quality translation than 100% machine-translated text.
For more details on using translations in code, see: Documentation.