localizer.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package i18n
  6
  7import (
  8	"embed"
  9	"errors"
 10	"fmt"
 11	"io/fs"
 12	"strings"
 13
 14	"github.com/BurntSushi/toml"
 15)
 16
 17const (
 18	// DefaultLanguage is the fallback locale when no other language is available.
 19	DefaultLanguage = "en"
 20)
 21
 22var (
 23	//go:embed locales/*.toml
 24	localeFiles embed.FS
 25
 26	languageAliases = map[string]string{
 27		"en":  "en",
 28		"eng": "en",
 29		"tok": "tok",
 30		"tp":  "tok",
 31	}
 32)
 33
 34// Localizer provides translated strings with an English fallback.
 35type Localizer struct {
 36	language string
 37	entries  map[string]string
 38	fallback map[string]string
 39}
 40
 41// Language reports the canonical language code for the localizer.
 42func (l *Localizer) Language() string {
 43	if l == nil || l.language == "" {
 44		return DefaultLanguage
 45	}
 46	return l.language
 47}
 48
 49// T returns the translated string for key, formatting with args when provided.
 50// When the key does not exist in the active catalog, the English fallback is
 51// used. Missing keys return the key itself as a last resort.
 52func (l *Localizer) T(key string, args ...any) string {
 53	if key == "" {
 54		return ""
 55	}
 56
 57	template := ""
 58	if l != nil {
 59		if value, ok := l.entries[key]; ok && value != "" {
 60			template = value
 61		} else if value, ok := l.fallback[key]; ok && value != "" {
 62			template = value
 63		}
 64	}
 65	if template == "" {
 66		template = key
 67	}
 68	if len(args) == 0 {
 69		return template
 70	}
 71	return fmt.Sprintf(template, args...)
 72}
 73
 74// Load constructs a Localizer for language, falling back to English when the
 75// requested catalog is unavailable.
 76func Load(language string) (*Localizer, error) {
 77	lang := canonicalLanguage(language)
 78
 79	fallback, err := loadCatalog(DefaultLanguage)
 80	if err != nil {
 81		return nil, fmt.Errorf("i18n: load fallback %q: %w", DefaultLanguage, err)
 82	}
 83
 84	var entries map[string]string
 85	if lang == DefaultLanguage {
 86		entries = cloneCatalog(fallback)
 87	} else {
 88		entries, err = loadCatalog(lang)
 89		if err != nil {
 90			if !errors.Is(err, fs.ErrNotExist) {
 91				return nil, fmt.Errorf("i18n: load %q: %w", lang, err)
 92			}
 93			entries = map[string]string{}
 94			lang = DefaultLanguage
 95		}
 96	}
 97
 98	return &Localizer{
 99		language: lang,
100		entries:  entries,
101		fallback: fallback,
102	}, nil
103}
104
105func canonicalLanguage(input string) string {
106	code := strings.TrimSpace(strings.ToLower(input))
107	if code == "" {
108		return DefaultLanguage
109	}
110	if canonical, ok := languageAliases[code]; ok {
111		return canonical
112	}
113	return code
114}
115
116func loadCatalog(language string) (map[string]string, error) {
117	filename := fmt.Sprintf("locales/%s.toml", language)
118	data, err := localeFiles.ReadFile(filename)
119	if err != nil {
120		return nil, err
121	}
122	return parseCatalog(data)
123}
124
125func parseCatalog(data []byte) (map[string]string, error) {
126	var raw map[string]any
127	if err := toml.Unmarshal(data, &raw); err != nil {
128		return nil, fmt.Errorf("parse toml: %w", err)
129	}
130	return flattenToml(raw, ""), nil
131}
132
133// flattenToml recursively flattens nested TOML structures into dot-notation keys.
134func flattenToml(data map[string]any, prefix string) map[string]string {
135	result := make(map[string]string)
136	for key, value := range data {
137		fullKey := key
138		if prefix != "" {
139			fullKey = prefix + "." + key
140		}
141
142		switch v := value.(type) {
143		case string:
144			result[fullKey] = v
145		case map[string]any:
146			nested := flattenToml(v, fullKey)
147			for k, val := range nested {
148				result[k] = val
149			}
150		default:
151			// Convert other types to strings
152			result[fullKey] = fmt.Sprint(v)
153		}
154	}
155	return result
156}
157
158func cloneCatalog(src map[string]string) map[string]string {
159	clone := make(map[string]string, len(src))
160	for k, v := range src {
161		clone[k] = v
162	}
163	return clone
164}