go/docs/pkg/i18n/EXTENDING.md
Snider e9be13bcd3 docs(i18n): add comprehensive package documentation
- README.md: Quick start, all features, configuration options
- GRAMMAR.md: Verb conjugation, pluralisation, articles, templates
- EXTENDING.md: Custom loaders, handlers, framework integration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:18:43 +00:00

8.8 KiB
Raw Permalink Blame History

Extending the i18n Package

This guide covers how to extend the i18n package with custom loaders, handlers, and integrations.

Custom Loaders

The Loader interface allows loading translations from any source:

type Loader interface {
    Load(lang string) (map[string]Message, *GrammarData, error)
    Languages() []string
}

Database Loader Example

type PostgresLoader struct {
    db *sql.DB
}

func (l *PostgresLoader) Languages() []string {
    rows, err := l.db.Query("SELECT DISTINCT lang FROM translations")
    if err != nil {
        return nil
    }
    defer rows.Close()

    var langs []string
    for rows.Next() {
        var lang string
        rows.Scan(&lang)
        langs = append(langs, lang)
    }
    return langs
}

func (l *PostgresLoader) Load(lang string) (map[string]i18n.Message, *i18n.GrammarData, error) {
    rows, err := l.db.Query(
        "SELECT key, text, plural_one, plural_other FROM translations WHERE lang = $1",
        lang,
    )
    if err != nil {
        return nil, nil, err
    }
    defer rows.Close()

    messages := make(map[string]i18n.Message)
    for rows.Next() {
        var key, text string
        var one, other sql.NullString
        rows.Scan(&key, &text, &one, &other)

        if one.Valid || other.Valid {
            messages[key] = i18n.Message{One: one.String, Other: other.String}
        } else {
            messages[key] = i18n.Message{Text: text}
        }
    }

    return messages, nil, nil
}

// Usage
svc, err := i18n.NewWithLoader(&PostgresLoader{db: db})

Remote API Loader Example

type APILoader struct {
    baseURL string
    client  *http.Client
}

func (l *APILoader) Languages() []string {
    resp, _ := l.client.Get(l.baseURL + "/languages")
    defer resp.Body.Close()

    var langs []string
    json.NewDecoder(resp.Body).Decode(&langs)
    return langs
}

func (l *APILoader) Load(lang string) (map[string]i18n.Message, *i18n.GrammarData, error) {
    resp, err := l.client.Get(l.baseURL + "/translations/" + lang)
    if err != nil {
        return nil, nil, err
    }
    defer resp.Body.Close()

    var data struct {
        Messages map[string]i18n.Message `json:"messages"`
        Grammar  *i18n.GrammarData       `json:"grammar"`
    }
    json.NewDecoder(resp.Body).Decode(&data)

    return data.Messages, data.Grammar, nil
}

Multi-Source Loader

Combine multiple loaders with fallback:

type FallbackLoader struct {
    primary   i18n.Loader
    secondary i18n.Loader
}

func (l *FallbackLoader) Languages() []string {
    // Merge languages from both sources
    langs := make(map[string]bool)
    for _, lang := range l.primary.Languages() {
        langs[lang] = true
    }
    for _, lang := range l.secondary.Languages() {
        langs[lang] = true
    }

    result := make([]string, 0, len(langs))
    for lang := range langs {
        result = append(result, lang)
    }
    return result
}

func (l *FallbackLoader) Load(lang string) (map[string]i18n.Message, *i18n.GrammarData, error) {
    msgs, grammar, err := l.primary.Load(lang)
    if err != nil {
        return l.secondary.Load(lang)
    }

    // Merge with secondary for missing keys
    secondary, secGrammar, _ := l.secondary.Load(lang)
    for k, v := range secondary {
        if _, exists := msgs[k]; !exists {
            msgs[k] = v
        }
    }

    if grammar == nil {
        grammar = secGrammar
    }

    return msgs, grammar, nil
}

Custom Handlers

Handlers process keys before standard lookup. Use for dynamic patterns.

Handler Interface

type KeyHandler interface {
    Match(key string) bool
    Handle(key string, args []any, next func() string) string
}

Emoji Handler Example

type EmojiHandler struct{}

func (h EmojiHandler) Match(key string) bool {
    return strings.HasPrefix(key, "emoji.")
}

func (h EmojiHandler) Handle(key string, args []any, next func() string) string {
    name := strings.TrimPrefix(key, "emoji.")
    emojis := map[string]string{
        "success": "✅",
        "error":   "❌",
        "warning": "⚠️",
        "info":    "",
    }
    if emoji, ok := emojis[name]; ok {
        return emoji
    }
    return next() // Delegate to next handler
}

// Usage
i18n.AddHandler(EmojiHandler{})
i18n.T("emoji.success")  // "✅"

Conditional Handler Example

type FeatureFlagHandler struct {
    flags map[string]bool
}

func (h FeatureFlagHandler) Match(key string) bool {
    return strings.HasPrefix(key, "feature.")
}

func (h FeatureFlagHandler) Handle(key string, args []any, next func() string) string {
    feature := strings.TrimPrefix(key, "feature.")
    parts := strings.SplitN(feature, ".", 2)

    if len(parts) < 2 {
        return next()
    }

    flag, subkey := parts[0], parts[1]
    if h.flags[flag] {
        // Feature enabled - translate the subkey
        return i18n.T(subkey, args...)
    }

    // Feature disabled - return empty or fallback
    return ""
}

Handler Chain Priority

// Prepend for highest priority (runs first)
svc.PrependHandler(CriticalHandler{})

// Append for lower priority (runs after defaults)
svc.AddHandler(FallbackHandler{})

// Clear all handlers
svc.ClearHandlers()

// Add back defaults
svc.AddHandler(i18n.DefaultHandlers()...)

Integrating with Frameworks

Cobra CLI

func init() {
    // Initialise i18n before command setup
    if err := i18n.Init(); err != nil {
        log.Fatal(err)
    }
}

var rootCmd = &cobra.Command{
    Use:   "myapp",
    Short: i18n.T("cmd.root.short"),
    Long:  i18n.T("cmd.root.long"),
}

var buildCmd = &cobra.Command{
    Use:   "build",
    Short: i18n.T("cmd.build.short"),
    RunE: func(cmd *cobra.Command, args []string) error {
        fmt.Println(i18n.T("i18n.progress.build"))
        // ...
        fmt.Println(i18n.T("i18n.done.build", "project"))
        return nil
    },
}

Error Messages

type LocalisedError struct {
    Key  string
    Args map[string]any
}

func (e LocalisedError) Error() string {
    return i18n.T(e.Key, e.Args)
}

// Usage
return LocalisedError{
    Key:  "error.file_not_found",
    Args: map[string]any{"Name": filename},
}

Structured Logging

func LogInfo(key string, args ...any) {
    msg := i18n.T(key, args...)
    slog.Info(msg, "i18n_key", key)
}

func LogError(key string, err error, args ...any) {
    msg := i18n.T(key, args...)
    slog.Error(msg, "i18n_key", key, "error", err)
}

Testing

Mock Loader for Tests

type MockLoader struct {
    messages map[string]map[string]i18n.Message
}

func (l *MockLoader) Languages() []string {
    langs := make([]string, 0, len(l.messages))
    for lang := range l.messages {
        langs = append(langs, lang)
    }
    return langs
}

func (l *MockLoader) Load(lang string) (map[string]i18n.Message, *i18n.GrammarData, error) {
    if msgs, ok := l.messages[lang]; ok {
        return msgs, nil, nil
    }
    return nil, nil, fmt.Errorf("language not found: %s", lang)
}

// Usage in tests
func TestMyFeature(t *testing.T) {
    loader := &MockLoader{
        messages: map[string]map[string]i18n.Message{
            "en-GB": {
                "test.greeting": {Text: "Hello"},
                "test.farewell": {Text: "Goodbye"},
            },
        },
    }

    svc, _ := i18n.NewWithLoader(loader)
    i18n.SetDefault(svc)

    // Test your code
    assert.Equal(t, "Hello", i18n.T("test.greeting"))
}

Testing Missing Keys

func TestMissingKeys(t *testing.T) {
    svc, _ := i18n.New(i18n.WithMode(i18n.ModeCollect))
    i18n.SetDefault(svc)

    var missing []string
    i18n.OnMissingKey(func(m i18n.MissingKey) {
        missing = append(missing, m.Key)
    })

    // Run your code that uses translations
    runMyFeature()

    // Check for missing keys
    assert.Empty(t, missing, "Found missing translation keys: %v", missing)
}

Hot Reloading

Implement a loader that watches for file changes:

type HotReloadLoader struct {
    base    *i18n.FSLoader
    service *i18n.Service
    watcher *fsnotify.Watcher
}

func (l *HotReloadLoader) Watch() {
    for {
        select {
        case event := <-l.watcher.Events:
            if event.Op&fsnotify.Write == fsnotify.Write {
                // Reload translations
                l.service.LoadFS(os.DirFS("."), "locales")
            }
        }
    }
}

Performance Considerations

  1. Cache translations: The service caches all loaded messages
  2. Template caching: Parsed templates are cached in sync.Map
  3. Handler chain: Keep chain short (6 default handlers is fine)
  4. Grammar cache: Grammar lookups are cached per-language

For high-throughput applications:

  • Pre-warm the cache by calling common translations at startup
  • Consider using Raw() to bypass handler chain when not needed
  • Profile with go test -bench if performance is critical