2026-01-30 14:50:25 +00:00
|
|
|
// Package i18n provides internationalization for the CLI.
|
|
|
|
|
package i18n
|
|
|
|
|
|
|
|
|
|
import (
|
refactor(i18n): implement extensible handler chain architecture
Refactor the i18n package for extensibility without breaking changes:
- Add KeyHandler interface for pluggable namespace handlers
- Add Loader interface for format-agnostic translation loading
- Add TranslationContext for translation disambiguation
- Implement 6 built-in handlers (Label, Progress, Count, Done, Fail, Numeric)
- Update T() to use handler chain instead of hardcoded logic
- Add handler management methods (AddHandler, PrependHandler, ClearHandlers)
File reorganisation:
- types.go: all type definitions
- loader.go: Loader interface + FSLoader (from mutate.go, checks.go)
- handler.go: KeyHandler interface + built-in handlers
- context.go: TranslationContext + C() builder
- hooks.go: renamed from actions.go
- service.go: merged interfaces.go content
Deleted: interfaces.go, mode.go, mutate.go, checks.go
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:42:41 +00:00
|
|
|
"embed"
|
2026-01-30 14:50:25 +00:00
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io/fs"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
refactor(i18n): implement extensible handler chain architecture
Refactor the i18n package for extensibility without breaking changes:
- Add KeyHandler interface for pluggable namespace handlers
- Add Loader interface for format-agnostic translation loading
- Add TranslationContext for translation disambiguation
- Implement 6 built-in handlers (Label, Progress, Count, Done, Fail, Numeric)
- Update T() to use handler chain instead of hardcoded logic
- Add handler management methods (AddHandler, PrependHandler, ClearHandlers)
File reorganisation:
- types.go: all type definitions
- loader.go: Loader interface + FSLoader (from mutate.go, checks.go)
- handler.go: KeyHandler interface + built-in handlers
- context.go: TranslationContext + C() builder
- hooks.go: renamed from actions.go
- service.go: merged interfaces.go content
Deleted: interfaces.go, mode.go, mutate.go, checks.go
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:42:41 +00:00
|
|
|
"sync"
|
2026-01-30 14:50:25 +00:00
|
|
|
|
|
|
|
|
"golang.org/x/text/language"
|
|
|
|
|
)
|
|
|
|
|
|
refactor(i18n): implement extensible handler chain architecture
Refactor the i18n package for extensibility without breaking changes:
- Add KeyHandler interface for pluggable namespace handlers
- Add Loader interface for format-agnostic translation loading
- Add TranslationContext for translation disambiguation
- Implement 6 built-in handlers (Label, Progress, Count, Done, Fail, Numeric)
- Update T() to use handler chain instead of hardcoded logic
- Add handler management methods (AddHandler, PrependHandler, ClearHandlers)
File reorganisation:
- types.go: all type definitions
- loader.go: Loader interface + FSLoader (from mutate.go, checks.go)
- handler.go: KeyHandler interface + built-in handlers
- context.go: TranslationContext + C() builder
- hooks.go: renamed from actions.go
- service.go: merged interfaces.go content
Deleted: interfaces.go, mode.go, mutate.go, checks.go
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:42:41 +00:00
|
|
|
// Service provides internationalization and localization.
|
|
|
|
|
type Service struct {
|
2026-01-30 18:47:11 +00:00
|
|
|
loader Loader // Source for loading translations
|
refactor(i18n): implement extensible handler chain architecture
Refactor the i18n package for extensibility without breaking changes:
- Add KeyHandler interface for pluggable namespace handlers
- Add Loader interface for format-agnostic translation loading
- Add TranslationContext for translation disambiguation
- Implement 6 built-in handlers (Label, Progress, Count, Done, Fail, Numeric)
- Update T() to use handler chain instead of hardcoded logic
- Add handler management methods (AddHandler, PrependHandler, ClearHandlers)
File reorganisation:
- types.go: all type definitions
- loader.go: Loader interface + FSLoader (from mutate.go, checks.go)
- handler.go: KeyHandler interface + built-in handlers
- context.go: TranslationContext + C() builder
- hooks.go: renamed from actions.go
- service.go: merged interfaces.go content
Deleted: interfaces.go, mode.go, mutate.go, checks.go
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:42:41 +00:00
|
|
|
messages map[string]map[string]Message // lang -> key -> message
|
|
|
|
|
currentLang string
|
|
|
|
|
fallbackLang string
|
|
|
|
|
availableLangs []language.Tag
|
|
|
|
|
mode Mode // Translation mode (Normal, Strict, Collect)
|
|
|
|
|
debug bool // Debug mode shows key prefixes
|
|
|
|
|
formality Formality // Default formality level for translations
|
|
|
|
|
handlers []KeyHandler // Handler chain for dynamic key patterns
|
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 18:47:11 +00:00
|
|
|
// Option configures a Service during construction.
|
|
|
|
|
type Option func(*Service)
|
|
|
|
|
|
|
|
|
|
// WithFallback sets the fallback language for missing translations.
|
|
|
|
|
func WithFallback(lang string) Option {
|
|
|
|
|
return func(s *Service) {
|
|
|
|
|
s.fallbackLang = lang
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WithFormality sets the default formality level.
|
|
|
|
|
func WithFormality(f Formality) Option {
|
|
|
|
|
return func(s *Service) {
|
|
|
|
|
s.formality = f
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WithHandlers sets custom handlers (replaces default handlers).
|
|
|
|
|
func WithHandlers(handlers ...KeyHandler) Option {
|
|
|
|
|
return func(s *Service) {
|
|
|
|
|
s.handlers = handlers
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WithDefaultHandlers adds the default i18n.* namespace handlers.
|
|
|
|
|
// Use this after WithHandlers to add defaults back, or to ensure defaults are present.
|
|
|
|
|
func WithDefaultHandlers() Option {
|
|
|
|
|
return func(s *Service) {
|
|
|
|
|
s.handlers = append(s.handlers, DefaultHandlers()...)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WithMode sets the translation mode.
|
|
|
|
|
func WithMode(m Mode) Option {
|
|
|
|
|
return func(s *Service) {
|
|
|
|
|
s.mode = m
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WithDebug enables or disables debug mode.
|
|
|
|
|
func WithDebug(enabled bool) Option {
|
|
|
|
|
return func(s *Service) {
|
|
|
|
|
s.debug = enabled
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
refactor(i18n): implement extensible handler chain architecture
Refactor the i18n package for extensibility without breaking changes:
- Add KeyHandler interface for pluggable namespace handlers
- Add Loader interface for format-agnostic translation loading
- Add TranslationContext for translation disambiguation
- Implement 6 built-in handlers (Label, Progress, Count, Done, Fail, Numeric)
- Update T() to use handler chain instead of hardcoded logic
- Add handler management methods (AddHandler, PrependHandler, ClearHandlers)
File reorganisation:
- types.go: all type definitions
- loader.go: Loader interface + FSLoader (from mutate.go, checks.go)
- handler.go: KeyHandler interface + built-in handlers
- context.go: TranslationContext + C() builder
- hooks.go: renamed from actions.go
- service.go: merged interfaces.go content
Deleted: interfaces.go, mode.go, mutate.go, checks.go
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:42:41 +00:00
|
|
|
// Default is the global i18n service instance.
|
|
|
|
|
var (
|
|
|
|
|
defaultService *Service
|
|
|
|
|
defaultOnce sync.Once
|
|
|
|
|
defaultErr error
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
//go:embed locales/*.json
|
|
|
|
|
var localeFS embed.FS
|
|
|
|
|
|
|
|
|
|
// Ensure Service implements Translator at compile time.
|
|
|
|
|
var _ Translator = (*Service)(nil)
|
|
|
|
|
|
2026-01-30 18:47:11 +00:00
|
|
|
// New creates a new i18n service with embedded locales and default options.
|
|
|
|
|
func New(opts ...Option) (*Service, error) {
|
|
|
|
|
return NewWithLoader(NewFSLoader(localeFS, "locales"), opts...)
|
2026-01-30 14:50:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewWithFS creates a new i18n service loading locales from the given filesystem.
|
2026-01-30 18:47:11 +00:00
|
|
|
func NewWithFS(fsys fs.FS, dir string, opts ...Option) (*Service, error) {
|
|
|
|
|
return NewWithLoader(NewFSLoader(fsys, dir), opts...)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewWithLoader creates a new i18n service with a custom loader.
|
|
|
|
|
// Use this for custom storage backends (database, remote API, etc.).
|
|
|
|
|
//
|
|
|
|
|
// loader := NewFSLoader(customFS, "translations")
|
|
|
|
|
// svc, err := NewWithLoader(loader, WithFallback("de-DE"))
|
|
|
|
|
func NewWithLoader(loader Loader, opts ...Option) (*Service, error) {
|
2026-01-30 14:50:25 +00:00
|
|
|
s := &Service{
|
2026-01-30 18:47:11 +00:00
|
|
|
loader: loader,
|
2026-01-30 14:50:25 +00:00
|
|
|
messages: make(map[string]map[string]Message),
|
|
|
|
|
fallbackLang: "en-GB",
|
refactor(i18n): implement extensible handler chain architecture
Refactor the i18n package for extensibility without breaking changes:
- Add KeyHandler interface for pluggable namespace handlers
- Add Loader interface for format-agnostic translation loading
- Add TranslationContext for translation disambiguation
- Implement 6 built-in handlers (Label, Progress, Count, Done, Fail, Numeric)
- Update T() to use handler chain instead of hardcoded logic
- Add handler management methods (AddHandler, PrependHandler, ClearHandlers)
File reorganisation:
- types.go: all type definitions
- loader.go: Loader interface + FSLoader (from mutate.go, checks.go)
- handler.go: KeyHandler interface + built-in handlers
- context.go: TranslationContext + C() builder
- hooks.go: renamed from actions.go
- service.go: merged interfaces.go content
Deleted: interfaces.go, mode.go, mutate.go, checks.go
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:42:41 +00:00
|
|
|
handlers: DefaultHandlers(),
|
2026-01-30 14:50:25 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-30 18:47:11 +00:00
|
|
|
// Apply options
|
|
|
|
|
for _, opt := range opts {
|
|
|
|
|
opt(s)
|
2026-01-30 14:50:25 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-30 18:47:11 +00:00
|
|
|
// Load all available languages
|
|
|
|
|
langs := loader.Languages()
|
|
|
|
|
if len(langs) == 0 {
|
|
|
|
|
return nil, fmt.Errorf("no languages available from loader")
|
|
|
|
|
}
|
2026-01-30 14:50:25 +00:00
|
|
|
|
2026-01-30 18:47:11 +00:00
|
|
|
for _, lang := range langs {
|
|
|
|
|
messages, grammar, err := loader.Load(lang)
|
2026-01-30 14:50:25 +00:00
|
|
|
if err != nil {
|
2026-01-30 18:47:11 +00:00
|
|
|
return nil, fmt.Errorf("failed to load locale %q: %w", lang, err)
|
2026-01-30 14:50:25 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-30 18:47:11 +00:00
|
|
|
s.messages[lang] = messages
|
|
|
|
|
if grammar != nil && (len(grammar.Verbs) > 0 || len(grammar.Nouns) > 0 || len(grammar.Words) > 0) {
|
|
|
|
|
SetGrammarData(lang, grammar)
|
2026-01-30 14:50:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tag := language.Make(lang)
|
|
|
|
|
s.availableLangs = append(s.availableLangs, tag)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try to detect system language
|
|
|
|
|
if detected := detectLanguage(s.availableLangs); detected != "" {
|
|
|
|
|
s.currentLang = detected
|
|
|
|
|
} else {
|
|
|
|
|
s.currentLang = s.fallbackLang
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return s, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// loadJSON parses nested JSON and flattens to dot-notation keys.
|
|
|
|
|
// Also extracts grammar data (verbs, nouns, articles) for the language.
|
|
|
|
|
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)
|
|
|
|
|
grammarData := &GrammarData{
|
|
|
|
|
Verbs: make(map[string]VerbForms),
|
|
|
|
|
Nouns: make(map[string]NounForms),
|
|
|
|
|
Words: make(map[string]string),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
flattenWithGrammar("", raw, messages, grammarData)
|
|
|
|
|
s.messages[lang] = messages
|
|
|
|
|
|
|
|
|
|
// Store grammar data if any was found
|
|
|
|
|
if len(grammarData.Verbs) > 0 || len(grammarData.Nouns) > 0 || len(grammarData.Words) > 0 {
|
|
|
|
|
SetGrammarData(lang, grammarData)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 {
|
2026-01-30 18:08:33 +00:00
|
|
|
return fmt.Errorf("unsupported language: %q", lang)
|
2026-01-30 14:50:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SetMode sets the translation mode for missing key handling.
|
|
|
|
|
func (s *Service) SetMode(m Mode) {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
s.mode = m
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mode returns the current translation mode.
|
|
|
|
|
func (s *Service) Mode() Mode {
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
return s.mode
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SetFormality sets the default formality level for translations.
|
|
|
|
|
// This affects languages that distinguish formal/informal address (Sie/du, vous/tu).
|
|
|
|
|
//
|
|
|
|
|
// svc.SetFormality(FormalityFormal) // Use formal address
|
|
|
|
|
func (s *Service) SetFormality(f Formality) {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
s.formality = f
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Formality returns the current formality level.
|
|
|
|
|
func (s *Service) Formality() Formality {
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
return s.formality
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Direction returns the text direction for the current language.
|
|
|
|
|
func (s *Service) Direction() TextDirection {
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
if IsRTLLanguage(s.currentLang) {
|
|
|
|
|
return DirRTL
|
|
|
|
|
}
|
|
|
|
|
return DirLTR
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// IsRTL returns true if the current language uses right-to-left text direction.
|
|
|
|
|
func (s *Service) IsRTL() bool {
|
|
|
|
|
return s.Direction() == DirRTL
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PluralCategory returns the plural category for a count in the current language.
|
|
|
|
|
func (s *Service) PluralCategory(n int) PluralCategory {
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
return GetPluralCategory(s.currentLang, n)
|
|
|
|
|
}
|
|
|
|
|
|
refactor(i18n): implement extensible handler chain architecture
Refactor the i18n package for extensibility without breaking changes:
- Add KeyHandler interface for pluggable namespace handlers
- Add Loader interface for format-agnostic translation loading
- Add TranslationContext for translation disambiguation
- Implement 6 built-in handlers (Label, Progress, Count, Done, Fail, Numeric)
- Update T() to use handler chain instead of hardcoded logic
- Add handler management methods (AddHandler, PrependHandler, ClearHandlers)
File reorganisation:
- types.go: all type definitions
- loader.go: Loader interface + FSLoader (from mutate.go, checks.go)
- handler.go: KeyHandler interface + built-in handlers
- context.go: TranslationContext + C() builder
- hooks.go: renamed from actions.go
- service.go: merged interfaces.go content
Deleted: interfaces.go, mode.go, mutate.go, checks.go
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:42:41 +00:00
|
|
|
// AddHandler appends a handler to the end of the handler chain.
|
|
|
|
|
// Later handlers have lower priority (run if earlier handlers don't match).
|
|
|
|
|
func (s *Service) AddHandler(h KeyHandler) {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
s.handlers = append(s.handlers, h)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PrependHandler inserts a handler at the start of the handler chain.
|
|
|
|
|
// Prepended handlers have highest priority (run first).
|
|
|
|
|
func (s *Service) PrependHandler(h KeyHandler) {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
s.handlers = append([]KeyHandler{h}, s.handlers...)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ClearHandlers removes all handlers from the chain.
|
|
|
|
|
// Useful for testing or disabling all i18n.* magic.
|
|
|
|
|
func (s *Service) ClearHandlers() {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
s.handlers = nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handlers returns a copy of the current handler chain.
|
|
|
|
|
func (s *Service) Handlers() []KeyHandler {
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
result := make([]KeyHandler, len(s.handlers))
|
|
|
|
|
copy(result, s.handlers)
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// T translates a message by its ID with handler chain support.
|
2026-01-30 14:50:25 +00:00
|
|
|
//
|
|
|
|
|
// # i18n Namespace Magic
|
|
|
|
|
//
|
|
|
|
|
// The i18n.* namespace provides auto-composed grammar shortcuts:
|
|
|
|
|
//
|
|
|
|
|
// T("i18n.label.status") // → "Status:"
|
|
|
|
|
// T("i18n.progress.build") // → "Building..."
|
|
|
|
|
// T("i18n.progress.check", "config") // → "Checking config..."
|
|
|
|
|
// T("i18n.count.file", 5) // → "5 files"
|
|
|
|
|
// T("i18n.done.delete", "file") // → "File deleted"
|
|
|
|
|
// T("i18n.fail.delete", "file") // → "Failed to delete file"
|
|
|
|
|
//
|
|
|
|
|
// For semantic intents, pass a Subject:
|
|
|
|
|
//
|
|
|
|
|
// T("core.delete", S("file", "config.yaml")) // → "Delete config.yaml?"
|
|
|
|
|
//
|
refactor(i18n): implement extensible handler chain architecture
Refactor the i18n package for extensibility without breaking changes:
- Add KeyHandler interface for pluggable namespace handlers
- Add Loader interface for format-agnostic translation loading
- Add TranslationContext for translation disambiguation
- Implement 6 built-in handlers (Label, Progress, Count, Done, Fail, Numeric)
- Update T() to use handler chain instead of hardcoded logic
- Add handler management methods (AddHandler, PrependHandler, ClearHandlers)
File reorganisation:
- types.go: all type definitions
- loader.go: Loader interface + FSLoader (from mutate.go, checks.go)
- handler.go: KeyHandler interface + built-in handlers
- context.go: TranslationContext + C() builder
- hooks.go: renamed from actions.go
- service.go: merged interfaces.go content
Deleted: interfaces.go, mode.go, mutate.go, checks.go
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:42:41 +00:00
|
|
|
// Use Raw() for direct key lookup without handler chain processing.
|
2026-01-30 14:50:25 +00:00
|
|
|
func (s *Service) T(messageID string, args ...any) string {
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
|
refactor(i18n): implement extensible handler chain architecture
Refactor the i18n package for extensibility without breaking changes:
- Add KeyHandler interface for pluggable namespace handlers
- Add Loader interface for format-agnostic translation loading
- Add TranslationContext for translation disambiguation
- Implement 6 built-in handlers (Label, Progress, Count, Done, Fail, Numeric)
- Update T() to use handler chain instead of hardcoded logic
- Add handler management methods (AddHandler, PrependHandler, ClearHandlers)
File reorganisation:
- types.go: all type definitions
- loader.go: Loader interface + FSLoader (from mutate.go, checks.go)
- handler.go: KeyHandler interface + built-in handlers
- context.go: TranslationContext + C() builder
- hooks.go: renamed from actions.go
- service.go: merged interfaces.go content
Deleted: interfaces.go, mode.go, mutate.go, checks.go
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:42:41 +00:00
|
|
|
// Run handler chain - handlers can intercept and process keys
|
|
|
|
|
result := RunHandlerChain(s.handlers, messageID, args, func() string {
|
|
|
|
|
// Fallback: standard message lookup
|
|
|
|
|
var data any
|
2026-01-30 14:50:25 +00:00
|
|
|
if len(args) > 0 {
|
refactor(i18n): implement extensible handler chain architecture
Refactor the i18n package for extensibility without breaking changes:
- Add KeyHandler interface for pluggable namespace handlers
- Add Loader interface for format-agnostic translation loading
- Add TranslationContext for translation disambiguation
- Implement 6 built-in handlers (Label, Progress, Count, Done, Fail, Numeric)
- Update T() to use handler chain instead of hardcoded logic
- Add handler management methods (AddHandler, PrependHandler, ClearHandlers)
File reorganisation:
- types.go: all type definitions
- loader.go: Loader interface + FSLoader (from mutate.go, checks.go)
- handler.go: KeyHandler interface + built-in handlers
- context.go: TranslationContext + C() builder
- hooks.go: renamed from actions.go
- service.go: merged interfaces.go content
Deleted: interfaces.go, mode.go, mutate.go, checks.go
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:42:41 +00:00
|
|
|
data = args[0]
|
2026-01-30 14:50:25 +00:00
|
|
|
}
|
refactor(i18n): implement extensible handler chain architecture
Refactor the i18n package for extensibility without breaking changes:
- Add KeyHandler interface for pluggable namespace handlers
- Add Loader interface for format-agnostic translation loading
- Add TranslationContext for translation disambiguation
- Implement 6 built-in handlers (Label, Progress, Count, Done, Fail, Numeric)
- Update T() to use handler chain instead of hardcoded logic
- Add handler management methods (AddHandler, PrependHandler, ClearHandlers)
File reorganisation:
- types.go: all type definitions
- loader.go: Loader interface + FSLoader (from mutate.go, checks.go)
- handler.go: KeyHandler interface + built-in handlers
- context.go: TranslationContext + C() builder
- hooks.go: renamed from actions.go
- service.go: merged interfaces.go content
Deleted: interfaces.go, mode.go, mutate.go, checks.go
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:42:41 +00:00
|
|
|
text := s.resolveWithFallback(messageID, data)
|
|
|
|
|
if text == "" {
|
|
|
|
|
return s.handleMissingKey(messageID, args)
|
2026-01-30 14:50:25 +00:00
|
|
|
}
|
refactor(i18n): implement extensible handler chain architecture
Refactor the i18n package for extensibility without breaking changes:
- Add KeyHandler interface for pluggable namespace handlers
- Add Loader interface for format-agnostic translation loading
- Add TranslationContext for translation disambiguation
- Implement 6 built-in handlers (Label, Progress, Count, Done, Fail, Numeric)
- Update T() to use handler chain instead of hardcoded logic
- Add handler management methods (AddHandler, PrependHandler, ClearHandlers)
File reorganisation:
- types.go: all type definitions
- loader.go: Loader interface + FSLoader (from mutate.go, checks.go)
- handler.go: KeyHandler interface + built-in handlers
- context.go: TranslationContext + C() builder
- hooks.go: renamed from actions.go
- service.go: merged interfaces.go content
Deleted: interfaces.go, mode.go, mutate.go, checks.go
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:42:41 +00:00
|
|
|
return text
|
|
|
|
|
})
|
2026-01-30 14:50:25 +00:00
|
|
|
|
refactor(i18n): implement extensible handler chain architecture
Refactor the i18n package for extensibility without breaking changes:
- Add KeyHandler interface for pluggable namespace handlers
- Add Loader interface for format-agnostic translation loading
- Add TranslationContext for translation disambiguation
- Implement 6 built-in handlers (Label, Progress, Count, Done, Fail, Numeric)
- Update T() to use handler chain instead of hardcoded logic
- Add handler management methods (AddHandler, PrependHandler, ClearHandlers)
File reorganisation:
- types.go: all type definitions
- loader.go: Loader interface + FSLoader (from mutate.go, checks.go)
- handler.go: KeyHandler interface + built-in handlers
- context.go: TranslationContext + C() builder
- hooks.go: renamed from actions.go
- service.go: merged interfaces.go content
Deleted: interfaces.go, mode.go, mutate.go, checks.go
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:42:41 +00:00
|
|
|
// Debug mode: prefix with key
|
|
|
|
|
if s.debug {
|
|
|
|
|
return debugFormat(messageID, result)
|
2026-01-30 14:50:25 +00:00
|
|
|
}
|
|
|
|
|
|
refactor(i18n): implement extensible handler chain architecture
Refactor the i18n package for extensibility without breaking changes:
- Add KeyHandler interface for pluggable namespace handlers
- Add Loader interface for format-agnostic translation loading
- Add TranslationContext for translation disambiguation
- Implement 6 built-in handlers (Label, Progress, Count, Done, Fail, Numeric)
- Update T() to use handler chain instead of hardcoded logic
- Add handler management methods (AddHandler, PrependHandler, ClearHandlers)
File reorganisation:
- types.go: all type definitions
- loader.go: Loader interface + FSLoader (from mutate.go, checks.go)
- handler.go: KeyHandler interface + built-in handlers
- context.go: TranslationContext + C() builder
- hooks.go: renamed from actions.go
- service.go: merged interfaces.go content
Deleted: interfaces.go, mode.go, mutate.go, checks.go
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 18:42:41 +00:00
|
|
|
return result
|
2026-01-30 14:50:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// resolveWithFallback implements the fallback chain for message resolution.
|
|
|
|
|
// Must be called with s.mu.RLock held.
|
|
|
|
|
func (s *Service) resolveWithFallback(messageID string, data any) string {
|
|
|
|
|
// 1. Try exact key in current language
|
|
|
|
|
if text := s.tryResolve(s.currentLang, messageID, data); text != "" {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Try exact key in fallback language
|
|
|
|
|
if text := s.tryResolve(s.fallbackLang, messageID, data); text != "" {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Try fallback patterns for intent-like keys
|
|
|
|
|
if strings.Contains(messageID, ".") {
|
|
|
|
|
parts := strings.Split(messageID, ".")
|
|
|
|
|
verb := parts[len(parts)-1]
|
|
|
|
|
|
|
|
|
|
// Try common.action.{verb}
|
|
|
|
|
commonKey := "common.action." + verb
|
|
|
|
|
if text := s.tryResolve(s.currentLang, commonKey, data); text != "" {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
if text := s.tryResolve(s.fallbackLang, commonKey, data); text != "" {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try common.{verb}
|
|
|
|
|
commonKey = "common." + verb
|
|
|
|
|
if text := s.tryResolve(s.currentLang, commonKey, data); text != "" {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
if text := s.tryResolve(s.fallbackLang, commonKey, data); text != "" {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// tryResolve attempts to resolve a single key in a single language.
|
|
|
|
|
// Returns empty string if not found.
|
|
|
|
|
// Must be called with s.mu.RLock held.
|
|
|
|
|
func (s *Service) tryResolve(lang, key string, data any) string {
|
feat(i18n): implement formality-based message selection
Add support for formal/informal translation variants using suffix convention:
- key._formal for formal address (Sie, vous, usted)
- key._informal for informal address (du, tu, tú)
Formality priority: Subject.Formal() > Service.SetFormality() > neutral
Example locale file:
"greeting": "Hello",
"greeting._formal": "Good morning, sir",
"greeting._informal": "Hey there"
Usage:
svc.SetFormality(FormalityFormal)
svc.T("greeting") // "Good morning, sir"
svc.T("greeting", S("user", name).Informal()) // "Hey there"
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 17:13:12 +00:00
|
|
|
// Determine effective formality
|
|
|
|
|
formality := s.getEffectiveFormality(data)
|
|
|
|
|
|
|
|
|
|
// Try formality-specific key first (key._formal or key._informal)
|
|
|
|
|
if formality != FormalityNeutral {
|
|
|
|
|
formalityKey := key + "._" + formality.String()
|
|
|
|
|
if text := s.resolveMessage(lang, formalityKey, data); text != "" {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fall back to base key
|
|
|
|
|
return s.resolveMessage(lang, key, data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// resolveMessage resolves a single message key without formality fallback.
|
|
|
|
|
// Must be called with s.mu.RLock held.
|
|
|
|
|
func (s *Service) resolveMessage(lang, key string, data any) string {
|
2026-01-30 14:50:25 +00:00
|
|
|
msg, ok := s.getMessage(lang, key)
|
|
|
|
|
if !ok {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
text := msg.Text
|
|
|
|
|
if msg.IsPlural() {
|
|
|
|
|
count := getCount(data)
|
|
|
|
|
category := GetPluralCategory(lang, count)
|
|
|
|
|
text = msg.ForCategory(category)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if text == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Apply template if we have data
|
|
|
|
|
if data != nil {
|
|
|
|
|
text = applyTemplate(text, data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
|
feat(i18n): implement formality-based message selection
Add support for formal/informal translation variants using suffix convention:
- key._formal for formal address (Sie, vous, usted)
- key._informal for informal address (du, tu, tú)
Formality priority: Subject.Formal() > Service.SetFormality() > neutral
Example locale file:
"greeting": "Hello",
"greeting._formal": "Good morning, sir",
"greeting._informal": "Hey there"
Usage:
svc.SetFormality(FormalityFormal)
svc.T("greeting") // "Good morning, sir"
svc.T("greeting", S("user", name).Informal()) // "Hey there"
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 17:13:12 +00:00
|
|
|
// getEffectiveFormality returns the formality to use for translation.
|
2026-01-30 18:56:09 +00:00
|
|
|
// Priority: TranslationContext > Subject > map["Formality"] > Service.formality
|
feat(i18n): implement formality-based message selection
Add support for formal/informal translation variants using suffix convention:
- key._formal for formal address (Sie, vous, usted)
- key._informal for informal address (du, tu, tú)
Formality priority: Subject.Formal() > Service.SetFormality() > neutral
Example locale file:
"greeting": "Hello",
"greeting._formal": "Good morning, sir",
"greeting._informal": "Hey there"
Usage:
svc.SetFormality(FormalityFormal)
svc.T("greeting") // "Good morning, sir"
svc.T("greeting", S("user", name).Informal()) // "Hey there"
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 17:13:12 +00:00
|
|
|
// Must be called with s.mu.RLock held.
|
|
|
|
|
func (s *Service) getEffectiveFormality(data any) Formality {
|
2026-01-30 18:56:09 +00:00
|
|
|
// Check if data is a TranslationContext with explicit formality
|
|
|
|
|
if ctx, ok := data.(*TranslationContext); ok && ctx != nil {
|
|
|
|
|
if ctx.Formality != FormalityNeutral {
|
|
|
|
|
return ctx.Formality
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(i18n): implement formality-based message selection
Add support for formal/informal translation variants using suffix convention:
- key._formal for formal address (Sie, vous, usted)
- key._informal for informal address (du, tu, tú)
Formality priority: Subject.Formal() > Service.SetFormality() > neutral
Example locale file:
"greeting": "Hello",
"greeting._formal": "Good morning, sir",
"greeting._informal": "Hey there"
Usage:
svc.SetFormality(FormalityFormal)
svc.T("greeting") // "Good morning, sir"
svc.T("greeting", S("user", name).Informal()) // "Hey there"
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 17:13:12 +00:00
|
|
|
// Check if data is a Subject with explicit formality
|
|
|
|
|
if subj, ok := data.(*Subject); ok && subj != nil {
|
|
|
|
|
if subj.formality != FormalityNeutral {
|
|
|
|
|
return subj.formality
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if data is a map with Formality field
|
|
|
|
|
if m, ok := data.(map[string]any); ok {
|
|
|
|
|
if f, ok := m["Formality"].(Formality); ok && f != FormalityNeutral {
|
|
|
|
|
return f
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fall back to service default
|
|
|
|
|
return s.formality
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 14:50:25 +00:00
|
|
|
// handleMissingKey handles a missing translation key based on the current mode.
|
|
|
|
|
// Must be called with s.mu.RLock held.
|
|
|
|
|
func (s *Service) handleMissingKey(key string, args []any) string {
|
|
|
|
|
switch s.mode {
|
|
|
|
|
case ModeStrict:
|
|
|
|
|
panic(fmt.Sprintf("i18n: missing translation key %q", key))
|
|
|
|
|
case ModeCollect:
|
|
|
|
|
// Convert args to map for the action
|
|
|
|
|
var argsMap map[string]any
|
|
|
|
|
if len(args) > 0 {
|
|
|
|
|
if m, ok := args[0].(map[string]any); ok {
|
|
|
|
|
argsMap = m
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
dispatchMissingKey(key, argsMap)
|
|
|
|
|
return "[" + key + "]"
|
|
|
|
|
default:
|
|
|
|
|
return key
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Raw is the raw translation helper without i18n.* namespace magic.
|
|
|
|
|
// Use T() for smart i18n.* handling, Raw() for direct key lookup.
|
|
|
|
|
func (s *Service) Raw(messageID string, args ...any) string {
|
|
|
|
|
s.mu.RLock()
|
|
|
|
|
defer s.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
var data any
|
|
|
|
|
if len(args) > 0 {
|
|
|
|
|
data = args[0]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
text := s.resolveWithFallback(messageID, data)
|
|
|
|
|
if text == "" {
|
|
|
|
|
return s.handleMissingKey(messageID, args)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if s.debug {
|
|
|
|
|
return debugFormat(messageID, text)
|
|
|
|
|
}
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 18:15:28 +00:00
|
|
|
// getMessage retrieves a message by language and key.
|
|
|
|
|
// Returns the message and true if found, or empty Message and false if not.
|
2026-01-30 14:50:25 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 {
|
2026-01-30 18:08:33 +00:00
|
|
|
return fmt.Errorf("failed to read locale %q: %w", entry.Name(), err)
|
2026-01-30 14:50:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lang := strings.TrimSuffix(entry.Name(), ".json")
|
|
|
|
|
lang = strings.ReplaceAll(lang, "_", "-")
|
|
|
|
|
|
|
|
|
|
if err := s.loadJSON(lang, data); err != nil {
|
2026-01-30 18:08:33 +00:00
|
|
|
return fmt.Errorf("failed to parse locale %q: %w", entry.Name(), err)
|
2026-01-30 14:50:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|