// Package i18n provides internationalization for the CLI. // // Locale files use nested JSON for compatibility with translation tools: // // { // "cli": { // "success": "Operation completed", // "count": { // "items": { // "one": "{{.Count}} item", // "other": "{{.Count}} items" // } // } // } // } // // Keys are accessed with dot notation: T("cli.success"), T("cli.count.items") // // # Getting Started // // svc, err := i18n.New() // fmt.Println(svc.T("cli.success")) // fmt.Println(svc.T("cli.count.items", map[string]any{"Count": 5})) package i18n import ( "bytes" "embed" "encoding/json" "fmt" "io/fs" "os" "path/filepath" "strings" "sync" "text/template" "golang.org/x/text/language" ) //go:embed locales/*.json var localeFS embed.FS // Message represents a translation - either a simple string or plural forms. type Message struct { Text string // Simple string value One string // Singular form (count == 1) Other string // Plural form (count != 1) } // IsPlural returns true if this message has plural forms. func (m Message) IsPlural() bool { return m.One != "" || m.Other != "" } // Service provides internationalization and localization. type Service struct { messages map[string]map[string]Message // lang -> key -> message currentLang string fallbackLang string availableLangs []language.Tag mu sync.RWMutex } // Default is the global i18n service instance. var ( defaultService *Service defaultOnce sync.Once defaultErr error ) // New creates a new i18n service with embedded locales. func New() (*Service, error) { return NewWithFS(localeFS, "locales") } // NewWithFS creates a new i18n service loading locales from the given filesystem. func NewWithFS(fsys fs.FS, dir string) (*Service, error) { s := &Service{ messages: make(map[string]map[string]Message), fallbackLang: "en-GB", } entries, err := fs.ReadDir(fsys, dir) if err != nil { return nil, fmt.Errorf("failed to read locales directory: %w", err) } for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { continue } filePath := filepath.Join(dir, entry.Name()) data, err := fs.ReadFile(fsys, filePath) if err != nil { return nil, fmt.Errorf("failed to read locale %s: %w", entry.Name(), err) } lang := strings.TrimSuffix(entry.Name(), ".json") // Normalise underscore to hyphen (en_GB -> en-GB) lang = strings.ReplaceAll(lang, "_", "-") if err := s.loadJSON(lang, data); err != nil { return nil, fmt.Errorf("failed to parse locale %s: %w", entry.Name(), err) } tag := language.Make(lang) s.availableLangs = append(s.availableLangs, tag) } if len(s.availableLangs) == 0 { return nil, fmt.Errorf("no locale files found in %s", dir) } // Try to detect system language if detected := detectLanguage(s.availableLangs); detected != "" { s.currentLang = detected } else { s.currentLang = s.fallbackLang } return s, nil } // loadJSON parses nested JSON and flattens to dot-notation keys. func (s *Service) loadJSON(lang string, data []byte) error { var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { return err } messages := make(map[string]Message) flatten("", raw, messages) s.messages[lang] = messages return nil } // flatten recursively flattens nested maps into dot-notation keys. func flatten(prefix string, data map[string]any, out map[string]Message) { for key, value := range data { fullKey := key if prefix != "" { fullKey = prefix + "." + key } switch v := value.(type) { case string: out[fullKey] = Message{Text: v} case map[string]any: // Check if this is a plural object (has "one" or "other" keys) if isPluralObject(v) { msg := Message{} if one, ok := v["one"].(string); ok { msg.One = one } if other, ok := v["other"].(string); ok { msg.Other = other } out[fullKey] = msg } else { // Recurse into nested object flatten(fullKey, v, out) } } } } // isPluralObject checks if a map represents plural forms. func isPluralObject(m map[string]any) bool { _, hasOne := m["one"] _, hasOther := m["other"] // It's a plural object if it has one/other and no nested objects if !hasOne && !hasOther { return false } for _, v := range m { if _, isMap := v.(map[string]any); isMap { return false } } return true } func detectLanguage(supported []language.Tag) string { langEnv := os.Getenv("LANG") if langEnv == "" { langEnv = os.Getenv("LC_ALL") if langEnv == "" { langEnv = os.Getenv("LC_MESSAGES") } } if langEnv == "" { return "" } // Parse LANG format: en_GB.UTF-8 -> en-GB baseLang := strings.Split(langEnv, ".")[0] baseLang = strings.ReplaceAll(baseLang, "_", "-") parsedLang, err := language.Parse(baseLang) if err != nil { return "" } if len(supported) == 0 { return "" } matcher := language.NewMatcher(supported) bestMatch, _, confidence := matcher.Match(parsedLang) if confidence >= language.Low { return bestMatch.String() } return "" } // --- Global convenience functions --- // Init initializes the default global service. func Init() error { defaultOnce.Do(func() { defaultService, defaultErr = New() }) return defaultErr } // Default returns the global i18n service, initializing if needed. func Default() *Service { if defaultService == nil { _ = Init() } return defaultService } // SetDefault sets the global i18n service. func SetDefault(s *Service) { defaultService = s } // T translates a message using the default service. func T(messageID string, args ...any) string { if svc := Default(); svc != nil { return svc.T(messageID, args...) } return messageID } // --- Service methods --- // SetLanguage sets the language for translations. func (s *Service) SetLanguage(lang string) error { s.mu.Lock() defer s.mu.Unlock() requestedLang, err := language.Parse(lang) if err != nil { return fmt.Errorf("invalid language tag %q: %w", lang, err) } if len(s.availableLangs) == 0 { return fmt.Errorf("no languages available") } matcher := language.NewMatcher(s.availableLangs) bestMatch, _, confidence := matcher.Match(requestedLang) if confidence == language.No { return fmt.Errorf("unsupported language: %s", lang) } s.currentLang = bestMatch.String() return nil } // Language returns the current language code. func (s *Service) Language() string { s.mu.RLock() defer s.mu.RUnlock() return s.currentLang } // AvailableLanguages returns the list of available language codes. func (s *Service) AvailableLanguages() []string { s.mu.RLock() defer s.mu.RUnlock() langs := make([]string, len(s.availableLangs)) for i, tag := range s.availableLangs { langs[i] = tag.String() } return langs } // T translates a message by its ID. // Optional template data can be passed for interpolation. // // For plural messages, pass a map with "Count" to select the form: // // svc.T("cli.count.items", map[string]any{"Count": 5}) func (s *Service) T(messageID string, args ...any) string { s.mu.RLock() defer s.mu.RUnlock() // Try current language, then fallback msg, ok := s.getMessage(s.currentLang, messageID) if !ok { msg, ok = s.getMessage(s.fallbackLang, messageID) if !ok { return messageID } } // Get template data var data any if len(args) > 0 { data = args[0] } // Get the appropriate text text := msg.Text if msg.IsPlural() { count := getCount(data) if count == 1 { text = msg.One } else { text = msg.Other } if text == "" { text = msg.Other // Fallback to other } } if text == "" { return messageID } // Apply template if we have data if data != nil { text = applyTemplate(text, data) } return text } func (s *Service) getMessage(lang, key string) (Message, bool) { msgs, ok := s.messages[lang] if !ok { return Message{}, false } msg, ok := msgs[key] return msg, ok } func getCount(data any) int { if data == nil { return 0 } switch d := data.(type) { case map[string]any: if c, ok := d["Count"]; ok { return toInt(c) } case map[string]int: if c, ok := d["Count"]; ok { return c } } return 0 } func toInt(v any) int { switch n := v.(type) { case int: return n case int64: return int(n) case float64: return int(n) } return 0 } func applyTemplate(text string, data any) string { // Quick check for template syntax if !strings.Contains(text, "{{") { return text } tmpl, err := template.New("").Parse(text) if err != nil { return text } var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { return text } return buf.String() } // AddMessages adds messages for a language at runtime. func (s *Service) AddMessages(lang string, messages map[string]string) { s.mu.Lock() defer s.mu.Unlock() if s.messages[lang] == nil { s.messages[lang] = make(map[string]Message) } for key, text := range messages { s.messages[lang][key] = Message{Text: text} } } // LoadFS loads additional locale files from a filesystem. func (s *Service) LoadFS(fsys fs.FS, dir string) error { s.mu.Lock() defer s.mu.Unlock() entries, err := fs.ReadDir(fsys, dir) if err != nil { return fmt.Errorf("failed to read locales directory: %w", err) } for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { continue } filePath := filepath.Join(dir, entry.Name()) data, err := fs.ReadFile(fsys, filePath) if err != nil { return fmt.Errorf("failed to read locale %s: %w", entry.Name(), err) } lang := strings.TrimSuffix(entry.Name(), ".json") lang = strings.ReplaceAll(lang, "_", "-") if err := s.loadJSON(lang, data); err != nil { return fmt.Errorf("failed to parse locale %s: %w", entry.Name(), err) } // Add to available languages if new tag := language.Make(lang) found := false for _, existing := range s.availableLangs { if existing == tag { found = true break } } if !found { s.availableLangs = append(s.availableLangs, tag) } } return nil }