1# i18n - Custom Internationalization Library
2
3Custom-built internationalization system for matcha email client. Zero external i18n dependencies - everything implemented from scratch.
4
5## Table of Contents
6
7- [Architecture Overview](#architecture-overview)
8- [Directory Structure](#directory-structure)
9- [Core Components](#core-components)
10 - [Translation Manager](#translation-manager)
11 - [Bundle System](#bundle-system)
12 - [Localizer](#localizer)
13 - [Plural Rules Engine](#plural-rules-engine)
14 - [Template System](#template-system)
15 - [Language Registry](#language-registry)
16- [Data Flow](#data-flow)
17- [Translation File Format](#translation-file-format)
18- [Plural Forms](#plural-forms)
19- [Caching Strategy](#caching-strategy)
20- [Language Detection](#language-detection)
21- [Adding/Editing Languages](#addingediting-languages)
22
23## Architecture Overview
24
25```
26βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
27β Application Layer β
28β (TUI components call t(), tn(), tpl()) β
29ββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
30 β
31ββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββ
32β Global Manager β
33β β’ Singleton instance β
34β β’ Current language state β
35β β’ Localizer instances β
36ββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
37 β
38 βββββββββββββββββββΌββββββββββββββββββ
39 β β β
40ββββββββββΌβββββββββ βββββββΌβββββββ βββββββββΌβββββββββ
41β Bundle β β Localizer β β Cache β
42β All messages β β Per-lang β β Translations β
43β All locales β β instance β β Rendered text β
44ββββββββββ¬βββββββββ βββββββ¬βββββββ ββββββββββββββββββ
45 β β
46 β ββββββββΌβββββββββββ
47 β β Pluralizer β
48 β β Plural rules β
49 β βββββββββββββββββββ
50 β
51ββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββββ
52β Translation Files β
53β locales/en.json, locales/uk.json, ... β
54βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
55```
56
57## Directory Structure
58
59```
60i18n/
61βββ manager.go # Global singleton manager
62βββ bundle.go # Translation bundle storage
63βββ locale.go # Locale representation
64βββ message.go # Message structure (with plural forms)
65βββ localizer.go # Per-language translation engine
66βββ loader.go # Load & embed translation files
67βββ parser.go # JSON parser for translation files
68βββ plural_rules.go # Plural rule functions for all languages
69βββ pluralizer.go # Plural form selection logic
70βββ template.go # Simple {var} template engine
71βββ interpolator.go # Variable interpolation in strings
72βββ cache.go # In-memory translation cache
73βββ fallback.go # Fallback chain (uk β en)
74βββ detector.go # Auto-detect user language
75βββ registry.go # Available languages registry
76βββ formatter.go # Number formatting per locale
77βββ date_formatter.go # Date/time formatting per locale
78βββ context.go # Context keys for locale passing
79βββ errors.go # Error types
80βββ init.go # Package initialization
81βββ embed.go # Embed translation files in binary
82βββ languages/ # Language-specific implementations
83β βββ base.go # Base language interface
84β βββ en.go # English locale registration
85β βββ uk.go # Ukrainian locale registration
86β βββ es.go # Spanish locale registration
87β βββ ... # Other languages
88βββ locales/ # Translation JSON files
89 βββ en.json # English translations (base)
90 βββ uk.json # Ukrainian translations
91 βββ ... # Other language files
92```
93
94## Core Components
95
96### Translation Manager
97
98**File:** `manager.go`
99
100Global singleton that coordinates all i18n operations. Provides three main translation functions:
101
102- `T(key)` - Simple translation lookup
103- `Tn(key, count, data)` - Translation with pluralization
104- `Tpl(key, data)` - Translation with template variables
105
106```go
107// Usage in TUI components
108import "github.com/floatpane/matcha/i18n"
109
110func render() string {
111 title := i18n.GetManager().T("composer.title")
112 count := i18n.GetManager().Tn("inbox.unread", 5, map[string]interface{}{
113 "count": 5,
114 })
115 return title + " - " + count
116}
117```
118
119Maintains:
120- Current active language
121- Bundle with all loaded translations
122- Localizer instances per language
123- Translation cache
124
125### Bundle System
126
127**File:** `bundle.go`
128
129Central storage for all translation messages across all languages. Thread-safe with RWMutex.
130
131```go
132type Bundle struct {
133 defaultLang string
134 messages map[string]MessageMap // lang β message ID β Message
135 locales map[string]*Locale // lang β Locale info
136 mu sync.RWMutex
137}
138```
139
140Responsibilities:
141- Store translations for all languages
142- Retrieve messages by language + key
143- Register locale definitions
144- List available languages
145
146### Localizer
147
148**File:** `localizer.go`
149
150Per-language translation engine. Each active language gets one localizer instance.
151
152```go
153type Localizer struct {
154 lang string
155 bundle *Bundle
156 locale *Locale
157 cache *Cache
158}
159```
160
161Core methods:
162- `Localize(messageID)` - Basic lookup
163- `LocalizePlural(messageID, count, data)` - With plural rules
164- `LocalizeTemplate(messageID, data)` - With variable substitution
165
166Handles:
167- Message lookup with fallback
168- Plural form selection
169- Template interpolation
170- Result caching
171
172### Plural Rules Engine
173
174**Files:** `plural_rules.go`, `pluralizer.go`
175
176Implements CLDR plural rules for all supported languages.
177
178```go
179type PluralForm int
180
181const (
182 Zero PluralForm = iota // 0 items
183 One // 1 item
184 Two // 2 items (Arabic, Welsh)
185 Few // 2-4 items (Slavic languages)
186 Many // 5+ items (Slavic languages)
187 Other // Default/fallback
188)
189```
190
191Each language has specific rules:
192
193**English/Spanish:** Simple (one/other)
194```go
195func EnglishPlural(n int) PluralForm {
196 if n == 1 {
197 return One
198 }
199 return Other
200}
201```
202
203**Ukrainian/Russian:** Complex (one/few/many)
204```go
205func UkrainianPlural(n int) PluralForm {
206 mod10 := n % 10
207 mod100 := n % 100
208
209 if mod10 == 1 && mod100 != 11 {
210 return One // 1, 21, 31, 41...
211 }
212 if mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) {
213 return Few // 2-4, 22-24, 32-34...
214 }
215 return Many // 0, 5-20, 25-30...
216}
217```
218
219**Arabic:** Very complex (zero/one/two/few/many/other)
220
221### Template System
222
223**Files:** `template.go`, `interpolator.go`
224
225Simple variable substitution using `{variable}` syntax.
226
227```go
228template := "Update available: {latest} (current: {current})"
229data := map[string]interface{}{
230 "latest": "1.5.0",
231 "current": "1.4.0",
232}
233result := Interpolate(template, data)
234// β "Update available: 1.5.0 (current: 1.4.0)"
235```
236
237**Design philosophy:** Keep simple - no complex logic, just variable replacement. Complex formatting done in Go code before passing to templates.
238
239### Language Registry
240
241**File:** `registry.go`
242
243Global registry of all available languages. Languages self-register on init.
244
245```go
246// In languages/uk.go
247func init() {
248 i18n.RegisterLanguage(&i18n.Locale{
249 Code: "uk",
250 Name: "Ukrainian",
251 NativeName: "Π£ΠΊΡΠ°ΡΠ½ΡΡΠΊΠ°",
252 Direction: "ltr",
253 PluralFunc: i18n.UkrainianPlural,
254 })
255}
256```
257
258Registry provides:
259- `GetLanguage(code)` - Lookup locale by code
260- `AvailableLanguages()` - List all registered locales
261- `LanguageCodes()` - List all language codes
262
263## Data Flow
264
265### Translation Lookup Flow
266
267```
2681. TUI calls t("composer.title")
269 β
2702. Manager.T() β GetLocalizer(currentLang)
271 β
2723. Localizer checks cache
273 β’ Cache hit? β return cached value
274 β’ Cache miss? β continue
275 β
2764. Bundle.GetMessage(lang, "composer.title")
277 β’ Found? β return Message
278 β’ Not found? β try fallback chain (uk β en)
279 β
2805. Return message.One (singular form)
281 β
2826. Cache result for future lookups
283 β
2847. Return translated string to TUI
285```
286
287### Pluralization Flow
288
289```
2901. TUI calls tn("inbox.unread", 5, {"count": 5})
291 β
2922. Manager.Tn() β GetLocalizer(currentLang)
293 β
2943. Get Message from bundle
295 β
2964. Call locale.PluralFunc(5)
297 β’ English: 5 β Other
298 β’ Ukrainian: 5 β Many
299 β’ Arabic: 5 β Few
300 β
3015. Select appropriate form from Message:
302 β’ message.One (n=1)
303 β’ message.Few (n=2-4 in Ukrainian)
304 β’ message.Many (n=5+ in Ukrainian)
305 β’ message.Other (fallback)
306 β
3076. Interpolate {count} β "5"
308 β
3097. Return "5 Π»ΠΈΡΡΡΠ²" (Ukrainian) or "5 emails" (English)
310```
311
312## Translation File Format
313
314**Location:** `locales/*.json`
315
316**Structure:**
317
318```json
319{
320 "language": "uk",
321 "messages": {
322 "common": {
323 "yes": "Π’Π°ΠΊ",
324 "no": "ΠΡ",
325 "save": "ΠΠ±Π΅ΡΠ΅Π³ΡΠΈ"
326 },
327 "composer": {
328 "title": "ΠΠ°ΠΏΠΈΡΠ°ΡΠΈ Π½ΠΎΠ²ΠΈΠΉ Π»ΠΈΡΡ",
329 "send": "ΠΠ°Π΄ΡΡΠ»Π°ΡΠΈ",
330 "from": "ΠΡΠ΄"
331 },
332 "inbox": {
333 "unread": {
334 "one": "{count} Π½Π΅ΠΏΡΠΎΡΠΈΡΠ°Π½ΠΈΠΉ Π»ΠΈΡΡ",
335 "few": "{count} Π½Π΅ΠΏΡΠΎΡΠΈΡΠ°Π½Ρ Π»ΠΈΡΡΠΈ",
336 "other": "{count} Π½Π΅ΠΏΡΠΎΡΠΈΡΠ°Π½ΠΈΡ
Π»ΠΈΡΡΡΠ²"
337 }
338 }
339 }
340}
341```
342
343**Nested structure:** Dot notation for keys (`composer.title`, `inbox.unread`)
344
345**Plural objects:** When value is object with `one`/`few`/`many`/`other`, it's treated as plural form.
346
347## Plural Forms
348
349Languages use different plural categories:
350
351| Language | Forms Used | Example (emails) |
352|----------|-----------|------------------|
353| English | one, other | 1 email, 2 emails |
354| Spanish | one, other | 1 correo, 2 correos |
355| German | one, other | 1 E-Mail, 2 E-Mails |
356| French | one, other | 0 courriel, 1 courriel, 2 courriels |
357| Ukrainian | one, few, other | 1 Π»ΠΈΡΡ, 2 Π»ΠΈΡΡΠΈ, 5 Π»ΠΈΡΡΡΠ² |
358| Russian | one, few, other | 1 ΠΏΠΈΡΡΠΌΠΎ, 2 ΠΏΠΈΡΡΠΌΠ°, 5 ΠΏΠΈΡΠ΅ΠΌ |
359| Polish | one, few, many, other | 1 e-mail, 2 e-maile, 5 e-maili |
360| Arabic | zero, one, two, few, many, other | Complex rules |
361| Japanese | other | All numbers use same form |
362| Chinese | other | All numbers use same form |
363
364## Caching Strategy
365
366**File:** `cache.go`
367
368Simple in-memory string cache with RWMutex for thread safety.
369
370**Cache key format:** `{lang}:{messageID}:{count}:{hash(data)}`
371
372**Why cache?**
373- Translation lookup involves: map lookups, plural calculation, interpolation
374- Typical UI has ~100 translated strings
375- Redrawing UI on every keypress = wasted CPU
376- Cache hit = instant string return
377
378**Cache invalidation:** Cache cleared when language changes via `SetLanguage()`.
379
380**No size limit:** Translation cache is small (~KB for entire UI) and only grows to unique message combinations actually used.
381
382## Language Detection
383
384**File:** `detector.go`
385
386Detection priority:
387
3881. **Config file:** `language = "uk"` in `~/.config/matcha/config.toml`
3892. **Environment:** `LANG`, `LC_ALL`, `LC_MESSAGES`
3903. **System locale:** OS-specific detection
3914. **Default:** Falls back to `"en"`
392
393```go
394func DetectLanguage(cfg *config.Config) string {
395 // 1. Check config
396 if cfg.Language != "" && isValidLanguage(cfg.Language) {
397 return cfg.Language
398 }
399
400 // 2. Check environment
401 if lang := detectFromEnv(); lang != "" {
402 return lang
403 }
404
405 // 3. Check system
406 if lang := detectFromSystem(); lang != "" {
407 return lang
408 }
409
410 // 4. Default
411 return "en"
412}
413```
414
415Normalizes language codes: `en_US.UTF-8` β `en`, `uk_UA` β `uk`
416
417## Adding/Editing Languages
418
419### Adding a New Language
420
421**1. Create translation file:**
422
423```bash
424cd i18n/locales
425cp en.json xx.json # Replace 'xx' with language code (ISO 639-1)
426```
427
428**2. Update language code:**
429
430```json
431{
432 "language": "xx",
433 "messages": { ... }
434}
435```
436
437**3. Translate all strings:**
438
439- Keep JSON structure identical to `en.json`
440- Translate all message values
441- Preserve placeholders: `{count}`, `{latest}`, `{current}`
442- Don't translate technical terms: S/MIME, PGP, IMAP, SMTP
443- Don't translate commands: `matcha update`
444
445**4. Handle plural forms:**
446
447Check plural rules for your language at [CLDR Plural Rules](https://cldr.unicode.org/index/cldr-spec/plural-rules).
448
449Add plural forms as needed:
450
451```json
452"hours_ago": {
453 "one": "{count} hour ago",
454 "other": "{count} hours ago"
455}
456```
457
458For complex plurals (Ukrainian, Arabic, Polish):
459
460```json
461"address_count": {
462 "one": "{count} Π°Π΄ΡΠ΅ΡΠ°",
463 "few": "{count} Π°Π΄ΡΠ΅ΡΠΈ",
464 "other": "{count} Π°Π΄ΡΠ΅Ρ"
465}
466```
467
468**5. Register language (if not already in `languages/`):**
469
470Create `languages/xx.go`:
471
472```go
473package languages
474
475import "github.com/floatpane/matcha/i18n"
476
477func init() {
478 i18n.RegisterLanguage(&i18n.Locale{
479 Code: "xx",
480 Name: "YourLanguage",
481 NativeName: "YourLanguageInNativeScript",
482 Direction: "ltr", // or "rtl" for Arabic, Hebrew, etc.
483 PluralFunc: i18n.YourLanguagePlural,
484 })
485}
486```
487
488If plural function doesn't exist in `plural_rules.go`, add it:
489
490```go
491// In plural_rules.go
492func YourLanguagePlural(n int) PluralForm {
493 // Implement CLDR rules for your language
494 if n == 1 {
495 return One
496 }
497 return Other
498}
499```
500
501**6. Test:**
502
503```bash
504go build
505# Edit config: language = "xx"
506./matcha
507```
508
509Verify:
510- All UI elements translated
511- Plural forms work (test with 0, 1, 2, 5, 21 items)
512- Variables interpolate correctly
513- No English leaks through
514
515### Editing Existing Translations
516
517**1. Open translation file:**
518
519```bash
520vi i18n/locales/uk.json
521```
522
523**2. Find key using dot notation:**
524
525Translation keys follow UI structure:
526- `composer.*` - Email composer
527- `inbox.*` - Inbox view
528- `settings_general.*` - General settings
529- `common.*` - Shared elements
530
531**3. Update translation:**
532
533```json
534{
535 "composer": {
536 "title": "Old translation" // Change this
537 }
538}
539```
540
541**4. Rebuild and test:**
542
543```bash
544go build
545./matcha
546```
547
548Language changes apply instantly (no restart needed).
549
550### Translation Guidelines
551
552**Do translate:**
553- All visible UI text
554- Button labels
555- Menu items
556- Help text
557- Tips
558- Error messages shown to user
559- Status messages
560
561**Don't translate:**
562- Backend error logs
563- Debug messages
564- Protocol names (IMAP, SMTP, S/MIME, PGP)
565- File paths (`~/.config/matcha/`)
566- Environment variables (`$EDITOR`)
567- Commands (`matcha update`)
568- Technical identifiers
569
570**Variable placeholders:**
571
572Always keep variables unchanged:
573
574```json
575// β
Correct
576"update_available": "ΠΠΎΡΡΡΠΏΠ½Π΅ ΠΎΠ½ΠΎΠ²Π»Π΅Π½Π½Ρ: {latest} (Π²ΡΡΠ°Π½ΠΎΠ²Π»Π΅Π½ΠΎ: {current})"
577
578// β Wrong - renamed variable
579"update_available": "ΠΠΎΡΡΡΠΏΠ½Π΅ ΠΎΠ½ΠΎΠ²Π»Π΅Π½Π½Ρ: {ΠΎΡΡΠ°Π½Π½ΡΠΉ} (Π²ΡΡΠ°Π½ΠΎΠ²Π»Π΅Π½ΠΎ: {ΠΏΠΎΡΠΎΡΠ½ΠΈΠΉ})"
580```
581
582### Testing Checklist
583
584- [ ] All screens display in target language
585- [ ] Test plural forms with different counts (0, 1, 2, 5, 21)
586- [ ] Variables interpolate correctly
587- [ ] No untranslated English text (except technical terms)
588- [ ] Text fits in UI (not truncated)
589- [ ] Special characters render properly
590- [ ] RTL languages display correctly (Arabic, Hebrew)
591- [ ] Date/time formats work
592- [ ] Help text makes sense in context
593
594### Contributing Translations
595
5961. Fork repository
5972. Add/edit translation file in `i18n/locales/`
5983. Test thoroughly with checklist above
5994. Submit pull request with:
600 - Translation file
601 - Screenshots of translated UI
602 - Note about plural form testing
603 - List any technical challenges
604
605**Translation quality > completeness.** Better to have 80% high-quality translation than 100% machine-translated text.
606
607---
608
609**For more details on using translations in code, see:** [Documentation](https://docs.matcha.email/localization).