feat(i18n): add internationalization package for CLI
- Service with embedded locale files (en, de) - Auto-detect system language from LANG/LC_* env vars - Template support for interpolation and pluralization - Extensible: GUI can load additional translations via LoadFS() - Global default service with T() shorthand - Thread-safe with sync.RWMutex Designed to be extended by core-gui which can import this package and add GUI-specific translations on top. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e4603b96ea
commit
0c3bccfceb
6 changed files with 798 additions and 0 deletions
1
go.mod
1
go.mod
|
|
@ -67,6 +67,7 @@ require (
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // 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/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
||||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -151,6 +151,8 @@ 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/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 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
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 h1:3LalSR0yYVM5sAYNInlIG4TVckLCJBkgjcnst2GKWVg=
|
||||||
github.com/oasdiff/oasdiff v1.11.8/go.mod h1:YtP/1VnQo8FCdSWGJ11a98HFgLnFvUffH//FTDuEpls=
|
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=
|
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||||
|
|
|
||||||
379
pkg/i18n/i18n.go
Normal file
379
pkg/i18n/i18n.go
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// # 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"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed locales/*.json
|
||||||
|
var localeFS embed.FS
|
||||||
|
|
||||||
|
// 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.English)
|
||||||
|
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||||
|
|
||||||
|
availableLangs, err := loadLocalesFromFS(bundle, fsys, dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Service{
|
||||||
|
bundle: bundle,
|
||||||
|
availableLangs: availableLangs,
|
||||||
|
currentLang: "en",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to detect system language
|
||||||
|
if detected, err := detectLanguage(availableLangs); err == nil && detected != "" {
|
||||||
|
_ = s.SetLanguage(detected)
|
||||||
|
} else {
|
||||||
|
_ = s.SetLanguage("en")
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
_ = s.SetLanguage("en")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
})
|
||||||
|
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.
|
||||||
|
// 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...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 ---
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
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.localizer = i18n.NewLocalizer(s.bundle, bestMatch.String())
|
||||||
|
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.
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
//
|
||||||
|
// 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 {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if s.localizer == nil {
|
||||||
|
return messageID
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &i18n.LocalizeConfig{MessageID: messageID}
|
||||||
|
if len(args) > 0 {
|
||||||
|
config.TemplateData = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
translation, err := s.localizer.Localize(config)
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &i18n.LocalizeConfig{MessageID: messageID}
|
||||||
|
if len(args) > 0 {
|
||||||
|
config.TemplateData = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
translation, err := s.localizer.Localize(config)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("i18n: translation not found for %q: %v", messageID, err))
|
||||||
|
}
|
||||||
|
return translation
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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 err := s.bundle.AddMessages(tag, i18nMessages...); err != nil {
|
||||||
|
return fmt.Errorf("failed to add messages for %s: %w", lang, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge new languages
|
||||||
|
for _, newTag := range newLangs {
|
||||||
|
found := false
|
||||||
|
for _, existing := range s.availableLangs {
|
||||||
|
if existing == newTag {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
s.availableLangs = append(s.availableLangs, newTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
137
pkg/i18n/i18n_test.go
Normal file
137
pkg/i18n/i18n_test.go
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
svc, err := New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, svc)
|
||||||
|
|
||||||
|
// Should have English available
|
||||||
|
langs := svc.AvailableLanguages()
|
||||||
|
assert.Contains(t, langs, "en")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranslate(t *testing.T) {
|
||||||
|
svc, err := New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Basic translation
|
||||||
|
result := svc.T("cli.success")
|
||||||
|
assert.Equal(t, "Success", result)
|
||||||
|
|
||||||
|
// Missing key returns the key
|
||||||
|
result = svc.T("nonexistent.key")
|
||||||
|
assert.Equal(t, "nonexistent.key", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranslateWithArgs(t *testing.T) {
|
||||||
|
svc, err := New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Translation with template data
|
||||||
|
result := svc.T("error.not_found", map[string]string{"Item": "config.yaml"})
|
||||||
|
assert.Equal(t, "Not found: config.yaml", result)
|
||||||
|
|
||||||
|
result = svc.T("cli.time.minutes_ago", map[string]int{"Count": 5})
|
||||||
|
assert.Equal(t, "5 minutes ago", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetLanguage(t *testing.T) {
|
||||||
|
svc, err := New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Default is English
|
||||||
|
assert.Equal(t, "en", 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultService(t *testing.T) {
|
||||||
|
// Reset default for test
|
||||||
|
defaultService = nil
|
||||||
|
defaultOnce = sync.Once{}
|
||||||
|
defaultErr = nil
|
||||||
|
|
||||||
|
err := Init()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
svc := Default()
|
||||||
|
require.NotNil(t, svc)
|
||||||
|
|
||||||
|
// Global T function should work
|
||||||
|
result := T("cli.success")
|
||||||
|
assert.Equal(t, "Success", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddMessages(t *testing.T) {
|
||||||
|
svc, err := New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Add custom messages
|
||||||
|
err = svc.AddMessages("en", 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAvailableLanguages(t *testing.T) {
|
||||||
|
svc, err := New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
langs := svc.AvailableLanguages()
|
||||||
|
assert.NotEmpty(t, langs)
|
||||||
|
assert.Contains(t, langs, "en")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectLanguage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
langEnv string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "English exact",
|
||||||
|
langEnv: "en",
|
||||||
|
expected: "en",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "English with region and encoding",
|
||||||
|
langEnv: "en_GB.UTF-8",
|
||||||
|
expected: "en",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty LANG",
|
||||||
|
langEnv: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, err := New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Setenv("LANG", tt.langEnv)
|
||||||
|
t.Setenv("LC_ALL", "")
|
||||||
|
t.Setenv("LC_MESSAGES", "")
|
||||||
|
|
||||||
|
result, _ := detectLanguage(svc.availableLangs)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
94
pkg/i18n/locales/de.json
Normal file
94
pkg/i18n/locales/de.json
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
{
|
||||||
|
"cli.success": "Erfolg",
|
||||||
|
"cli.error": "Fehler",
|
||||||
|
"cli.warning": "Warnung",
|
||||||
|
"cli.info": "Info",
|
||||||
|
"cli.done": "Fertig",
|
||||||
|
"cli.failed": "Fehlgeschlagen",
|
||||||
|
"cli.pass": "BESTANDEN",
|
||||||
|
"cli.fail": "FEHLGESCHLAGEN",
|
||||||
|
"cli.ok": "OK",
|
||||||
|
"cli.skip": "Übersprungen",
|
||||||
|
"cli.pending": "Ausstehend",
|
||||||
|
"cli.running": "Läuft",
|
||||||
|
"cli.completed": "Abgeschlossen",
|
||||||
|
"cli.cancelled": "Abgebrochen",
|
||||||
|
"cli.aborted": "Abgebrochen",
|
||||||
|
|
||||||
|
"cli.confirm.yes": "Ja",
|
||||||
|
"cli.confirm.no": "Nein",
|
||||||
|
"cli.confirm.proceed": "Fortfahren?",
|
||||||
|
"cli.confirm.continue": "Weiter?",
|
||||||
|
"cli.confirm.abort": "Vorgang abgebrochen",
|
||||||
|
|
||||||
|
"cli.progress.checking": "Prüfe",
|
||||||
|
"cli.progress.fetching": "Lade",
|
||||||
|
"cli.progress.loading": "Lade",
|
||||||
|
"cli.progress.processing": "Verarbeite",
|
||||||
|
"cli.progress.installing": "Installiere",
|
||||||
|
"cli.progress.building": "Baue",
|
||||||
|
"cli.progress.deploying": "Deploye",
|
||||||
|
"cli.progress.testing": "Teste",
|
||||||
|
|
||||||
|
"cli.time.just_now": "gerade eben",
|
||||||
|
"cli.time.seconds_ago": "vor {{.Count}} Sekunden",
|
||||||
|
"cli.time.minute_ago": "vor 1 Minute",
|
||||||
|
"cli.time.minutes_ago": "vor {{.Count}} Minuten",
|
||||||
|
"cli.time.hour_ago": "vor 1 Stunde",
|
||||||
|
"cli.time.hours_ago": "vor {{.Count}} Stunden",
|
||||||
|
"cli.time.day_ago": "vor 1 Tag",
|
||||||
|
"cli.time.days_ago": "vor {{.Count}} Tagen",
|
||||||
|
"cli.time.week_ago": "vor 1 Woche",
|
||||||
|
"cli.time.weeks_ago": "vor {{.Count}} Wochen",
|
||||||
|
|
||||||
|
"cli.count.items": {
|
||||||
|
"one": "{{.Count}} Element",
|
||||||
|
"other": "{{.Count}} Elemente"
|
||||||
|
},
|
||||||
|
"cli.count.files": {
|
||||||
|
"one": "{{.Count}} Datei",
|
||||||
|
"other": "{{.Count}} Dateien"
|
||||||
|
},
|
||||||
|
"cli.count.repos": {
|
||||||
|
"one": "{{.Count}} Repository",
|
||||||
|
"other": "{{.Count}} Repositories"
|
||||||
|
},
|
||||||
|
"cli.count.commits": {
|
||||||
|
"one": "{{.Count}} Commit",
|
||||||
|
"other": "{{.Count}} Commits"
|
||||||
|
},
|
||||||
|
|
||||||
|
"cmd.dev.short": "Multi-Repository-Entwicklung",
|
||||||
|
"cmd.dev.status.dirty": "geändert",
|
||||||
|
"cmd.dev.status.clean": "sauber",
|
||||||
|
"cmd.dev.status.ahead": "voraus",
|
||||||
|
"cmd.dev.status.behind": "zurück",
|
||||||
|
"cmd.dev.status.synced": "synchronisiert",
|
||||||
|
|
||||||
|
"cmd.dev.push.confirm": "Alle pushen?",
|
||||||
|
"cmd.dev.commit.committing": "Committe geänderte Repos mit Claude...",
|
||||||
|
|
||||||
|
"cmd.doctor.short": "Entwicklungsumgebung prüfen",
|
||||||
|
"cmd.doctor.checking": "Prüfe Entwicklungsumgebung...",
|
||||||
|
"cmd.doctor.required": "Erforderlich",
|
||||||
|
"cmd.doctor.optional": "Optional",
|
||||||
|
"cmd.doctor.ready": "Umgebung bereit",
|
||||||
|
|
||||||
|
"error.not_found": "Nicht gefunden: {{.Item}}",
|
||||||
|
"error.invalid": "Ungültig: {{.Item}}",
|
||||||
|
"error.permission": "Zugriff verweigert: {{.Item}}",
|
||||||
|
"error.timeout": "Zeitüberschreitung",
|
||||||
|
"error.gh_not_found": "'gh' CLI nicht gefunden. Installieren von https://cli.github.com/",
|
||||||
|
|
||||||
|
"label.status": "Status",
|
||||||
|
"label.branch": "Branch",
|
||||||
|
"label.commit": "Commit",
|
||||||
|
"label.coverage": "Abdeckung",
|
||||||
|
"label.total": "Gesamt",
|
||||||
|
|
||||||
|
"lang.en": "Englisch",
|
||||||
|
"lang.de": "Deutsch",
|
||||||
|
"lang.es": "Spanisch",
|
||||||
|
"lang.fr": "Französisch",
|
||||||
|
"lang.zh": "Chinesisch"
|
||||||
|
}
|
||||||
185
pkg/i18n/locales/en.json
Normal file
185
pkg/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
{
|
||||||
|
"cli.success": "Success",
|
||||||
|
"cli.error": "Error",
|
||||||
|
"cli.warning": "Warning",
|
||||||
|
"cli.info": "Info",
|
||||||
|
"cli.done": "Done",
|
||||||
|
"cli.failed": "Failed",
|
||||||
|
"cli.pass": "PASS",
|
||||||
|
"cli.fail": "FAIL",
|
||||||
|
"cli.ok": "OK",
|
||||||
|
"cli.skip": "Skipped",
|
||||||
|
"cli.pending": "Pending",
|
||||||
|
"cli.running": "Running",
|
||||||
|
"cli.completed": "Completed",
|
||||||
|
"cli.cancelled": "Cancelled",
|
||||||
|
"cli.aborted": "Aborted",
|
||||||
|
|
||||||
|
"cli.confirm.yes": "Yes",
|
||||||
|
"cli.confirm.no": "No",
|
||||||
|
"cli.confirm.proceed": "Proceed?",
|
||||||
|
"cli.confirm.continue": "Continue?",
|
||||||
|
"cli.confirm.abort": "Operation aborted",
|
||||||
|
|
||||||
|
"cli.progress.checking": "Checking",
|
||||||
|
"cli.progress.fetching": "Fetching",
|
||||||
|
"cli.progress.loading": "Loading",
|
||||||
|
"cli.progress.processing": "Processing",
|
||||||
|
"cli.progress.installing": "Installing",
|
||||||
|
"cli.progress.building": "Building",
|
||||||
|
"cli.progress.deploying": "Deploying",
|
||||||
|
"cli.progress.testing": "Testing",
|
||||||
|
|
||||||
|
"cli.time.just_now": "just now",
|
||||||
|
"cli.time.seconds_ago": "{{.Count}} seconds ago",
|
||||||
|
"cli.time.minute_ago": "1 minute ago",
|
||||||
|
"cli.time.minutes_ago": "{{.Count}} minutes ago",
|
||||||
|
"cli.time.hour_ago": "1 hour ago",
|
||||||
|
"cli.time.hours_ago": "{{.Count}} hours ago",
|
||||||
|
"cli.time.day_ago": "1 day ago",
|
||||||
|
"cli.time.days_ago": "{{.Count}} days ago",
|
||||||
|
"cli.time.week_ago": "1 week ago",
|
||||||
|
"cli.time.weeks_ago": "{{.Count}} weeks ago",
|
||||||
|
|
||||||
|
"cli.count.items": {
|
||||||
|
"one": "{{.Count}} item",
|
||||||
|
"other": "{{.Count}} items"
|
||||||
|
},
|
||||||
|
"cli.count.files": {
|
||||||
|
"one": "{{.Count}} file",
|
||||||
|
"other": "{{.Count}} files"
|
||||||
|
},
|
||||||
|
"cli.count.repos": {
|
||||||
|
"one": "{{.Count}} repo",
|
||||||
|
"other": "{{.Count}} repos"
|
||||||
|
},
|
||||||
|
"cli.count.commits": {
|
||||||
|
"one": "{{.Count}} commit",
|
||||||
|
"other": "{{.Count}} commits"
|
||||||
|
},
|
||||||
|
|
||||||
|
"cmd.dev.short": "Multi-repo development workflow",
|
||||||
|
"cmd.dev.work.short": "Combined status, commit, and push workflow",
|
||||||
|
"cmd.dev.health.short": "Quick repo health summary",
|
||||||
|
"cmd.dev.commit.short": "Claude-assisted commit messages",
|
||||||
|
"cmd.dev.push.short": "Push repos with unpushed commits",
|
||||||
|
"cmd.dev.pull.short": "Pull repos behind remote",
|
||||||
|
"cmd.dev.issues.short": "List open issues across repos",
|
||||||
|
"cmd.dev.reviews.short": "List PRs awaiting review",
|
||||||
|
"cmd.dev.ci.short": "Check GitHub Actions status",
|
||||||
|
"cmd.dev.impact.short": "Analyse dependency impact",
|
||||||
|
|
||||||
|
"cmd.dev.status.dirty": "dirty",
|
||||||
|
"cmd.dev.status.clean": "clean",
|
||||||
|
"cmd.dev.status.ahead": "ahead",
|
||||||
|
"cmd.dev.status.behind": "behind",
|
||||||
|
"cmd.dev.status.synced": "synced",
|
||||||
|
"cmd.dev.status.diverged": "diverged",
|
||||||
|
"cmd.dev.status.conflict": "conflict",
|
||||||
|
|
||||||
|
"cmd.dev.push.confirm": "Push all?",
|
||||||
|
"cmd.dev.push.success": "Pushed successfully",
|
||||||
|
"cmd.dev.push.failed": "Push failed",
|
||||||
|
"cmd.dev.push.nothing": "Nothing to push",
|
||||||
|
|
||||||
|
"cmd.dev.commit.committing": "Committing dirty repos with Claude...",
|
||||||
|
"cmd.dev.commit.success": "Committed successfully",
|
||||||
|
"cmd.dev.commit.failed": "Commit failed",
|
||||||
|
|
||||||
|
"cmd.php.short": "Laravel/PHP development tools",
|
||||||
|
"cmd.php.dev.short": "Start development servers",
|
||||||
|
"cmd.php.test.short": "Run PHPUnit tests",
|
||||||
|
"cmd.php.fmt.short": "Format code with Pint",
|
||||||
|
"cmd.php.analyse.short": "Run static analysis",
|
||||||
|
"cmd.php.build.short": "Build production assets",
|
||||||
|
"cmd.php.deploy.short": "Deploy to Coolify",
|
||||||
|
"cmd.php.qa.short": "Run all quality checks",
|
||||||
|
|
||||||
|
"cmd.php.test.running": "Running tests",
|
||||||
|
"cmd.php.test.passed": "All tests passed",
|
||||||
|
"cmd.php.test.failed": "Some tests failed",
|
||||||
|
|
||||||
|
"cmd.go.short": "Go development tools",
|
||||||
|
"cmd.go.test.short": "Run tests with coverage",
|
||||||
|
"cmd.go.fmt.short": "Format Go code",
|
||||||
|
"cmd.go.build.short": "Build Go binaries",
|
||||||
|
"cmd.go.cov.short": "Run tests with coverage report",
|
||||||
|
|
||||||
|
"cmd.go.test.running": "Running tests",
|
||||||
|
"cmd.go.test.passed": "All tests passed",
|
||||||
|
"cmd.go.test.failed": "Some tests failed",
|
||||||
|
"cmd.go.test.coverage": "Coverage",
|
||||||
|
|
||||||
|
"cmd.doctor.short": "Check development environment",
|
||||||
|
"cmd.doctor.checking": "Checking development environment...",
|
||||||
|
"cmd.doctor.required": "Required",
|
||||||
|
"cmd.doctor.optional": "Optional",
|
||||||
|
"cmd.doctor.github": "GitHub Access",
|
||||||
|
"cmd.doctor.workspace": "Workspace",
|
||||||
|
"cmd.doctor.ready": "Environment ready",
|
||||||
|
"cmd.doctor.issues": "{{.Count}} issues found",
|
||||||
|
|
||||||
|
"cmd.setup.short": "Set up development workspace",
|
||||||
|
"cmd.setup.cloning": "Cloning repositories...",
|
||||||
|
"cmd.setup.complete": "Setup complete",
|
||||||
|
|
||||||
|
"cmd.build.short": "Build and release tools",
|
||||||
|
"cmd.build.building": "Building...",
|
||||||
|
"cmd.build.complete": "Build complete",
|
||||||
|
|
||||||
|
"cmd.ai.short": "AI task management",
|
||||||
|
"cmd.ai.tasks.short": "List tasks",
|
||||||
|
"cmd.ai.task.short": "View task details",
|
||||||
|
|
||||||
|
"cmd.vm.short": "LinuxKit VM management",
|
||||||
|
"cmd.vm.run.short": "Run a VM from image or template",
|
||||||
|
"cmd.vm.ps.short": "List running VMs",
|
||||||
|
"cmd.vm.stop.short": "Stop a running VM",
|
||||||
|
"cmd.vm.logs.short": "View VM logs",
|
||||||
|
|
||||||
|
"cmd.docs.short": "Documentation tools",
|
||||||
|
"cmd.docs.list.short": "List documentation files",
|
||||||
|
"cmd.docs.sync.short": "Sync docs to central location",
|
||||||
|
|
||||||
|
"cmd.pkg.short": "Package management",
|
||||||
|
"cmd.pkg.install.short": "Install packages",
|
||||||
|
"cmd.pkg.search.short": "Search packages",
|
||||||
|
|
||||||
|
"cmd.ci.short": "CI/CD tools",
|
||||||
|
"cmd.ci.release.short": "Create a release",
|
||||||
|
|
||||||
|
"cmd.sdk.short": "SDK validation tools",
|
||||||
|
"cmd.sdk.diff.short": "Check for breaking API changes",
|
||||||
|
"cmd.sdk.validate.short": "Validate OpenAPI spec",
|
||||||
|
|
||||||
|
"cmd.test.short": "Test runner",
|
||||||
|
"cmd.test.running": "Running tests...",
|
||||||
|
|
||||||
|
"error.not_found": "Not found: {{.Item}}",
|
||||||
|
"error.invalid": "Invalid: {{.Item}}",
|
||||||
|
"error.permission": "Permission denied: {{.Item}}",
|
||||||
|
"error.timeout": "Operation timed out",
|
||||||
|
"error.network": "Network error: {{.Message}}",
|
||||||
|
"error.gh_not_found": "'gh' CLI not found. Install from https://cli.github.com/",
|
||||||
|
"error.registry_not_found": "No repos.yaml found",
|
||||||
|
"error.repo_not_found": "Repository '{{.Name}}' not found",
|
||||||
|
|
||||||
|
"label.status": "Status",
|
||||||
|
"label.branch": "Branch",
|
||||||
|
"label.commit": "Commit",
|
||||||
|
"label.message": "Message",
|
||||||
|
"label.author": "Author",
|
||||||
|
"label.date": "Date",
|
||||||
|
"label.duration": "Duration",
|
||||||
|
"label.coverage": "Coverage",
|
||||||
|
"label.package": "Package",
|
||||||
|
"label.registry": "Registry",
|
||||||
|
"label.scanning": "Scanning",
|
||||||
|
"label.total": "Total",
|
||||||
|
|
||||||
|
"lang.en": "English",
|
||||||
|
"lang.de": "German",
|
||||||
|
"lang.es": "Spanish",
|
||||||
|
"lang.fr": "French",
|
||||||
|
"lang.zh": "Chinese"
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue