cli/pkg/i18n/i18n.go
Snider a00a3240a6 refactor(i18n): use nested JSON format for translation files
- Rewrite i18n package to handle nested JSON natively
- Remove go-i18n dependency in favour of simple custom implementation
- Flatten nested keys to dot notation internally (cli.confirm.yes)
- Support pluralisation with one/other keys
- Template interpolation with {{.Var}} syntax
- Update tests for new API and nested structure

Nested JSON is the standard format for translation tools,
making it easier to manage with external translation services.

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

460 lines
9.9 KiB
Go

// 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
}