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>
This commit is contained in:
parent
5e2d058b26
commit
a00a3240a6
5 changed files with 1976 additions and 1581 deletions
2
go.mod
2
go.mod
|
|
@ -7,7 +7,6 @@ require (
|
|||
github.com/charmbracelet/huh v0.8.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/getkin/kin-openapi v0.133.0
|
||||
github.com/leaanthony/clir v1.7.0
|
||||
github.com/leaanthony/debme v1.2.1
|
||||
github.com/leaanthony/gosod v1.0.4
|
||||
github.com/minio/selfupdate v0.6.0
|
||||
|
|
@ -67,7 +66,6 @@ require (
|
|||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -115,8 +115,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leaanthony/clir v1.7.0 h1:xiAnhl7ryPwuH3ERwPWZp/pCHk8wTeiwuAOt6MiNyAw=
|
||||
github.com/leaanthony/clir v1.7.0/go.mod h1:k/RBkdkFl18xkkACMCLt09bhiZnrGORoxmomeMvDpE0=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
|
|
@ -151,8 +149,6 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
|||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||
github.com/oasdiff/oasdiff v1.11.8 h1:3LalSR0yYVM5sAYNInlIG4TVckLCJBkgjcnst2GKWVg=
|
||||
github.com/oasdiff/oasdiff v1.11.8/go.mod h1:YtP/1VnQo8FCdSWGJ11a98HFgLnFvUffH//FTDuEpls=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||
|
|
|
|||
505
pkg/i18n/i18n.go
505
pkg/i18n/i18n.go
|
|
@ -1,38 +1,30 @@
|
|||
// Package i18n provides internationalization for the CLI.
|
||||
//
|
||||
// It is designed to be extended by the GUI version, which can import this
|
||||
// package and add additional translations for GUI-specific strings.
|
||||
// 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()
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// fmt.Println(svc.T("cli.success"))
|
||||
//
|
||||
// # Extending for GUI
|
||||
//
|
||||
// The GUI can extend this package by creating its own Service that embeds
|
||||
// this one and loads additional locale files:
|
||||
//
|
||||
// guiService, err := i18n.NewWithFS(guiLocaleFS, "locales")
|
||||
//
|
||||
// # Locale Files
|
||||
//
|
||||
// Locale files are JSON with message IDs as keys. Supports both simple strings
|
||||
// and go-i18n format with pluralization:
|
||||
//
|
||||
// {
|
||||
// "cli.success": "Operation completed successfully",
|
||||
// "cli.items_found": {
|
||||
// "one": "{{.Count}} item found",
|
||||
// "other": "{{.Count}} items found"
|
||||
// }
|
||||
// }
|
||||
// fmt.Println(svc.T("cli.count.items", map[string]any{"Count": 5}))
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
|
@ -41,78 +33,193 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
"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.
|
||||
// Initialized lazily on first use or via Init().
|
||||
var (
|
||||
defaultService *Service
|
||||
defaultOnce sync.Once
|
||||
defaultErr error
|
||||
)
|
||||
|
||||
// Service provides internationalization and localization.
|
||||
type Service struct {
|
||||
bundle *i18n.Bundle
|
||||
localizer *i18n.Localizer
|
||||
currentLang string
|
||||
availableLangs []language.Tag
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// New creates a new i18n service with embedded locales.
|
||||
// The service is initialized with the system language or English as fallback.
|
||||
func New() (*Service, error) {
|
||||
return NewWithFS(localeFS, "locales")
|
||||
}
|
||||
|
||||
// NewWithFS creates a new i18n service loading locales from the given filesystem.
|
||||
// This allows the GUI to provide its own locale files.
|
||||
func NewWithFS(fsys fs.FS, dir string) (*Service, error) {
|
||||
bundle := i18n.NewBundle(language.BritishEnglish)
|
||||
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||
|
||||
availableLangs, err := loadLocalesFromFS(bundle, fsys, dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
s := &Service{
|
||||
messages: make(map[string]map[string]Message),
|
||||
fallbackLang: "en-GB",
|
||||
}
|
||||
|
||||
s := &Service{
|
||||
bundle: bundle,
|
||||
availableLangs: availableLangs,
|
||||
currentLang: "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, err := detectLanguage(availableLangs); err == nil && detected != "" {
|
||||
_ = s.SetLanguage(detected)
|
||||
if detected := detectLanguage(s.availableLangs); detected != "" {
|
||||
s.currentLang = detected
|
||||
} else {
|
||||
_ = s.SetLanguage("en-GB")
|
||||
s.currentLang = s.fallbackLang
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// NewWithBundle creates a service from an existing bundle.
|
||||
// Useful for extending the CLI i18n with GUI-specific translations.
|
||||
func NewWithBundle(bundle *i18n.Bundle, langs []language.Tag) *Service {
|
||||
s := &Service{
|
||||
bundle: bundle,
|
||||
availableLangs: langs,
|
||||
currentLang: "en-GB",
|
||||
// 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
|
||||
}
|
||||
_ = s.SetLanguage("en-GB")
|
||||
return s
|
||||
|
||||
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.
|
||||
// Safe to call multiple times; only the first call has effect.
|
||||
func Init() error {
|
||||
defaultOnce.Do(func() {
|
||||
defaultService, defaultErr = New()
|
||||
|
|
@ -129,87 +236,21 @@ func Default() *Service {
|
|||
}
|
||||
|
||||
// SetDefault sets the global i18n service.
|
||||
// Useful for GUI to replace with an extended service.
|
||||
func SetDefault(s *Service) {
|
||||
defaultService = s
|
||||
}
|
||||
|
||||
// T translates a message using the default service.
|
||||
// Shorthand for Default().T(messageID, args...).
|
||||
func T(messageID string, args ...interface{}) string {
|
||||
return Default().T(messageID, args...)
|
||||
func T(messageID string, args ...any) string {
|
||||
if svc := Default(); svc != nil {
|
||||
return svc.T(messageID, args...)
|
||||
}
|
||||
return messageID
|
||||
}
|
||||
|
||||
// --- Language Management ---
|
||||
|
||||
func loadLocalesFromFS(bundle *i18n.Bundle, fsys fs.FS, dir string) ([]language.Tag, error) {
|
||||
entries, err := fs.ReadDir(fsys, dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read locales directory: %w", err)
|
||||
}
|
||||
|
||||
var langs []language.Tag
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(dir, entry.Name())
|
||||
if _, err := bundle.LoadMessageFileFS(fsys, filePath); err != nil {
|
||||
return nil, fmt.Errorf("failed to load locale %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
lang := strings.TrimSuffix(entry.Name(), ".json")
|
||||
tag := language.Make(lang)
|
||||
langs = append(langs, tag)
|
||||
}
|
||||
|
||||
if len(langs) == 0 {
|
||||
return nil, fmt.Errorf("no locale files found in %s", dir)
|
||||
}
|
||||
|
||||
return langs, nil
|
||||
}
|
||||
|
||||
func detectLanguage(supported []language.Tag) (string, error) {
|
||||
langEnv := os.Getenv("LANG")
|
||||
if langEnv == "" {
|
||||
// Try LC_ALL, LC_MESSAGES as fallbacks
|
||||
langEnv = os.Getenv("LC_ALL")
|
||||
if langEnv == "" {
|
||||
langEnv = os.Getenv("LC_MESSAGES")
|
||||
}
|
||||
}
|
||||
if langEnv == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// 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 "", fmt.Errorf("failed to parse language tag '%s': %w", baseLang, err)
|
||||
}
|
||||
|
||||
if len(supported) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
matcher := language.NewMatcher(supported)
|
||||
_, index, confidence := matcher.Match(parsedLang)
|
||||
|
||||
if confidence >= language.Low {
|
||||
return supported[index].String(), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// --- Public Service Methods ---
|
||||
// --- Service methods ---
|
||||
|
||||
// SetLanguage sets the language for translations.
|
||||
// The language tag should be a valid BCP 47 tag (e.g., "en", "en-GB", "de").
|
||||
func (s *Service) SetLanguage(lang string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
@ -230,7 +271,6 @@ func (s *Service) SetLanguage(lang string) error {
|
|||
return fmt.Errorf("unsupported language: %s", lang)
|
||||
}
|
||||
|
||||
s.localizer = i18n.NewLocalizer(s.bundle, bestMatch.String())
|
||||
s.currentLang = bestMatch.String()
|
||||
return nil
|
||||
}
|
||||
|
|
@ -257,121 +297,162 @@ func (s *Service) AvailableLanguages() []string {
|
|||
// T translates a message by its ID.
|
||||
// Optional template data can be passed for interpolation.
|
||||
//
|
||||
// Examples:
|
||||
// For plural messages, pass a map with "Count" to select the form:
|
||||
//
|
||||
// svc.T("cli.success")
|
||||
// svc.T("cli.items_found", map[string]int{"Count": 5})
|
||||
// svc.T("cli.greeting", map[string]string{"Name": "Alice"})
|
||||
func (s *Service) T(messageID string, args ...interface{}) string {
|
||||
// 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()
|
||||
|
||||
if s.localizer == nil {
|
||||
// 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
|
||||
}
|
||||
|
||||
config := &i18n.LocalizeConfig{MessageID: messageID}
|
||||
if len(args) > 0 {
|
||||
config.TemplateData = args[0]
|
||||
// Apply template if we have data
|
||||
if data != nil {
|
||||
text = applyTemplate(text, data)
|
||||
}
|
||||
|
||||
translation, err := s.localizer.Localize(config)
|
||||
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 the message ID if translation not found
|
||||
return messageID
|
||||
}
|
||||
return translation
|
||||
}
|
||||
|
||||
// Translate is an alias for T.
|
||||
func (s *Service) Translate(messageID string, args ...interface{}) string {
|
||||
return s.T(messageID, args...)
|
||||
}
|
||||
|
||||
// MustT translates a message, panicking if not found.
|
||||
// Use sparingly, mainly for critical messages that must exist.
|
||||
func (s *Service) MustT(messageID string, args ...interface{}) string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if s.localizer == nil {
|
||||
panic(fmt.Sprintf("i18n: localizer not initialized for message %q", messageID))
|
||||
return text
|
||||
}
|
||||
|
||||
config := &i18n.LocalizeConfig{MessageID: messageID}
|
||||
if len(args) > 0 {
|
||||
config.TemplateData = args[0]
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return text
|
||||
}
|
||||
|
||||
translation, err := s.localizer.Localize(config)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("i18n: translation not found for %q: %v", messageID, err))
|
||||
}
|
||||
return translation
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// Bundle returns the underlying i18n.Bundle.
|
||||
// Useful for extending with additional translations.
|
||||
func (s *Service) Bundle() *i18n.Bundle {
|
||||
return s.bundle
|
||||
}
|
||||
|
||||
// AddMessages adds additional messages to the bundle.
|
||||
// This allows runtime extension of translations.
|
||||
func (s *Service) AddMessages(lang string, messages map[string]string) error {
|
||||
// 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()
|
||||
|
||||
tag := language.Make(lang)
|
||||
var i18nMessages []*i18n.Message
|
||||
for id, text := range messages {
|
||||
i18nMessages = append(i18nMessages, &i18n.Message{
|
||||
ID: id,
|
||||
Other: text,
|
||||
})
|
||||
if s.messages[lang] == nil {
|
||||
s.messages[lang] = make(map[string]Message)
|
||||
}
|
||||
|
||||
if err := s.bundle.AddMessages(tag, i18nMessages...); err != nil {
|
||||
return fmt.Errorf("failed to add messages for %s: %w", lang, err)
|
||||
for key, text := range messages {
|
||||
s.messages[lang][key] = Message{Text: text}
|
||||
}
|
||||
|
||||
// Check if this is a new language
|
||||
found := false
|
||||
for _, existing := range s.availableLangs {
|
||||
if existing == tag {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
s.availableLangs = append(s.availableLangs, tag)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadFS loads additional locale files from a filesystem.
|
||||
// Useful for GUI to add its translations on top of CLI translations.
|
||||
func (s *Service) LoadFS(fsys fs.FS, dir string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
newLangs, err := loadLocalesFromFS(s.bundle, fsys, dir)
|
||||
entries, err := fs.ReadDir(fsys, dir)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to read locales directory: %w", err)
|
||||
}
|
||||
|
||||
// Merge new languages
|
||||
for _, newTag := range newLangs {
|
||||
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 == newTag {
|
||||
if existing == tag {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
s.availableLangs = append(s.availableLangs, newTag)
|
||||
s.availableLangs = append(s.availableLangs, tag)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ func TestNew(t *testing.T) {
|
|||
|
||||
// Should have English available
|
||||
langs := svc.AvailableLanguages()
|
||||
assert.Contains(t, langs, "en")
|
||||
assert.Contains(t, langs, "en-GB")
|
||||
}
|
||||
|
||||
func TestTranslate(t *testing.T) {
|
||||
|
|
@ -47,15 +47,15 @@ func TestSetLanguage(t *testing.T) {
|
|||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Default is English
|
||||
assert.Equal(t, "en", svc.Language())
|
||||
// Default is en-GB
|
||||
assert.Equal(t, "en-GB", svc.Language())
|
||||
|
||||
// Setting invalid language should error
|
||||
err = svc.SetLanguage("xx-invalid")
|
||||
assert.Error(t, err)
|
||||
|
||||
// Language should still be English
|
||||
assert.Equal(t, "en", svc.Language())
|
||||
// Language should still be en-GB
|
||||
assert.Equal(t, "en-GB", svc.Language())
|
||||
}
|
||||
|
||||
func TestDefaultService(t *testing.T) {
|
||||
|
|
@ -80,10 +80,9 @@ func TestAddMessages(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
|
||||
// Add custom messages
|
||||
err = svc.AddMessages("en", map[string]string{
|
||||
svc.AddMessages("en-GB", map[string]string{
|
||||
"custom.greeting": "Hello, {{.Name}}!",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result := svc.T("custom.greeting", map[string]string{"Name": "World"})
|
||||
assert.Equal(t, "Hello, World!", result)
|
||||
|
|
@ -95,7 +94,7 @@ func TestAvailableLanguages(t *testing.T) {
|
|||
|
||||
langs := svc.AvailableLanguages()
|
||||
assert.NotEmpty(t, langs)
|
||||
assert.Contains(t, langs, "en")
|
||||
assert.Contains(t, langs, "en-GB")
|
||||
}
|
||||
|
||||
func TestDetectLanguage(t *testing.T) {
|
||||
|
|
@ -106,13 +105,13 @@ func TestDetectLanguage(t *testing.T) {
|
|||
}{
|
||||
{
|
||||
name: "English exact",
|
||||
langEnv: "en",
|
||||
expected: "en",
|
||||
langEnv: "en-GB",
|
||||
expected: "en-GB",
|
||||
},
|
||||
{
|
||||
name: "English with region and encoding",
|
||||
name: "English with encoding",
|
||||
langEnv: "en_GB.UTF-8",
|
||||
expected: "en",
|
||||
expected: "en-GB",
|
||||
},
|
||||
{
|
||||
name: "Empty LANG",
|
||||
|
|
@ -130,8 +129,38 @@ func TestDetectLanguage(t *testing.T) {
|
|||
t.Setenv("LC_ALL", "")
|
||||
t.Setenv("LC_MESSAGES", "")
|
||||
|
||||
result, _ := detectLanguage(svc.availableLangs)
|
||||
result := detectLanguage(svc.availableLangs)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralization(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Singular
|
||||
result := svc.T("cli.count.items", map[string]any{"Count": 1})
|
||||
assert.Equal(t, "1 item", result)
|
||||
|
||||
// Plural
|
||||
result = svc.T("cli.count.items", map[string]any{"Count": 5})
|
||||
assert.Equal(t, "5 items", result)
|
||||
|
||||
// Zero uses plural
|
||||
result = svc.T("cli.count.items", map[string]any{"Count": 0})
|
||||
assert.Equal(t, "0 items", result)
|
||||
}
|
||||
|
||||
func TestNestedKeys(t *testing.T) {
|
||||
svc, err := New()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Deeply nested key
|
||||
result := svc.T("cmd.dev.work.short")
|
||||
assert.Equal(t, "Multi-repo git operations", result)
|
||||
|
||||
// Nested with flag
|
||||
result = svc.T("cmd.dev.work.flag.status")
|
||||
assert.Equal(t, "Show status only, don't push", result)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue