README.md

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

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                      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 lookup
  • Tn(key, count, data) - Translation with pluralization
  • Tpl(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 lookup
  • LocalizePlural(messageID, count, data) - With plural rules
  • LocalizeTemplate(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 code
  • AvailableLanguages() - List all registered locales
  • LanguageCodes() - 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:

  1. Config file: language = "uk" in ~/.config/matcha/config.toml
  2. Environment: LANG, LC_ALL, LC_MESSAGES
  3. System locale: OS-specific detection
  4. 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 composer
  • inbox.* - Inbox view
  • settings_general.* - General settings
  • common.* - 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

  1. Fork repository
  2. Add/edit translation file in i18n/locales/
  3. Test thoroughly with checklist above
  4. 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.