From 945db099663cdef176c939d9743334cf935cb35f Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 30 Jan 2026 18:47:11 +0000 Subject: [PATCH] feat(i18n): add options pattern and NewWithLoader constructor - Add NewWithLoader(loader, opts...) for custom storage backends - Add Option type with WithFallback, WithFormality, WithHandlers, WithDefaultHandlers, WithMode, WithDebug options - Update New() and NewWithFS() to accept options - Add loader field to Service struct - Remove NewSubject() alias (use S() instead) - Add tests for new options and NewWithLoader Co-Authored-By: Claude Opus 4.5 --- pkg/i18n/compose.go | 8 +--- pkg/i18n/compose_test.go | 4 +- pkg/i18n/i18n_test.go | 79 ++++++++++++++++++++++++++++++++ pkg/i18n/service.go | 99 ++++++++++++++++++++++++++++++---------- 4 files changed, 157 insertions(+), 33 deletions(-) diff --git a/pkg/i18n/compose.go b/pkg/i18n/compose.go index f05c0147..b72ad175 100644 --- a/pkg/i18n/compose.go +++ b/pkg/i18n/compose.go @@ -10,6 +10,7 @@ import ( // // S("file", "config.yaml") // "config.yaml" // S("repo", repo) // Uses repo.String() or fmt.Sprint() +// S("file", path).Count(3).In("workspace") func S(noun string, value any) *Subject { return &Subject{ Noun: noun, @@ -18,13 +19,6 @@ func S(noun string, value any) *Subject { } } -// NewSubject is an alias for S() for readability in longer expressions. -// -// NewSubject("file", path).Count(3).In("workspace") -func NewSubject(noun string, value any) *Subject { - return S(noun, value) -} - // Count sets the count for pluralization. // Used to determine singular/plural forms in templates. // diff --git a/pkg/i18n/compose_test.go b/pkg/i18n/compose_test.go index 25a7f4fb..dffda780 100644 --- a/pkg/i18n/compose_test.go +++ b/pkg/i18n/compose_test.go @@ -25,8 +25,8 @@ func TestSubject_Good(t *testing.T) { assert.Equal(t, "", s.location) }) - t.Run("NewSubject alias", func(t *testing.T) { - s := NewSubject("repo", "core-php") + t.Run("S with different value types", func(t *testing.T) { + s := S("repo", "core-php") assert.Equal(t, "repo", s.Noun) assert.Equal(t, "core-php", s.Value) }) diff --git a/pkg/i18n/i18n_test.go b/pkg/i18n/i18n_test.go index 4fc3170b..e3f21f79 100644 --- a/pkg/i18n/i18n_test.go +++ b/pkg/i18n/i18n_test.go @@ -396,3 +396,82 @@ func TestFormalityMessageSelection(t *testing.T) { assert.Equal(t, "Hey there", result) }) } + +func TestNewWithOptions(t *testing.T) { + t.Run("WithFallback", func(t *testing.T) { + svc, err := New(WithFallback("de-DE")) + require.NoError(t, err) + assert.Equal(t, "de-DE", svc.fallbackLang) + }) + + t.Run("WithFormality", func(t *testing.T) { + svc, err := New(WithFormality(FormalityFormal)) + require.NoError(t, err) + assert.Equal(t, FormalityFormal, svc.Formality()) + }) + + t.Run("WithMode", func(t *testing.T) { + svc, err := New(WithMode(ModeStrict)) + require.NoError(t, err) + assert.Equal(t, ModeStrict, svc.Mode()) + }) + + t.Run("WithDebug", func(t *testing.T) { + svc, err := New(WithDebug(true)) + require.NoError(t, err) + assert.True(t, svc.Debug()) + }) + + t.Run("WithHandlers replaces defaults", func(t *testing.T) { + customHandler := LabelHandler{} + svc, err := New(WithHandlers(customHandler)) + require.NoError(t, err) + assert.Len(t, svc.Handlers(), 1) + }) + + t.Run("WithDefaultHandlers adds back defaults", func(t *testing.T) { + svc, err := New(WithHandlers(), WithDefaultHandlers()) + require.NoError(t, err) + assert.Len(t, svc.Handlers(), 6) // 6 default handlers + }) + + t.Run("multiple options", func(t *testing.T) { + svc, err := New( + WithFallback("fr-FR"), + WithFormality(FormalityInformal), + WithMode(ModeCollect), + WithDebug(true), + ) + require.NoError(t, err) + assert.Equal(t, "fr-FR", svc.fallbackLang) + assert.Equal(t, FormalityInformal, svc.Formality()) + assert.Equal(t, ModeCollect, svc.Mode()) + assert.True(t, svc.Debug()) + }) +} + +func TestNewWithLoader(t *testing.T) { + t.Run("uses custom loader", func(t *testing.T) { + loader := NewFSLoader(localeFS, "locales") + svc, err := NewWithLoader(loader) + require.NoError(t, err) + assert.NotNil(t, svc.loader) + assert.Contains(t, svc.AvailableLanguages(), "en-GB") + }) + + t.Run("with options", func(t *testing.T) { + loader := NewFSLoader(localeFS, "locales") + svc, err := NewWithLoader(loader, WithFallback("de-DE"), WithFormality(FormalityFormal)) + require.NoError(t, err) + assert.Equal(t, "de-DE", svc.fallbackLang) + assert.Equal(t, FormalityFormal, svc.Formality()) + }) +} + +func TestNewWithFS(t *testing.T) { + t.Run("with options", func(t *testing.T) { + svc, err := NewWithFS(localeFS, "locales", WithDebug(true)) + require.NoError(t, err) + assert.True(t, svc.Debug()) + }) +} diff --git a/pkg/i18n/service.go b/pkg/i18n/service.go index 531f92bd..1bcf832b 100644 --- a/pkg/i18n/service.go +++ b/pkg/i18n/service.go @@ -15,6 +15,7 @@ import ( // Service provides internationalization and localization. type Service struct { + loader Loader // Source for loading translations messages map[string]map[string]Message // lang -> key -> message currentLang string fallbackLang string @@ -26,6 +27,52 @@ type Service struct { mu sync.RWMutex } +// 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 + } +} + // Default is the global i18n service instance. var ( defaultService *Service @@ -39,51 +86,55 @@ var localeFS embed.FS // Ensure Service implements Translator at compile time. var _ Translator = (*Service)(nil) -// New creates a new i18n service with embedded locales. -func New() (*Service, error) { - return NewWithFS(localeFS, "locales") +// New creates a new i18n service with embedded locales and default options. +func New(opts ...Option) (*Service, error) { + return NewWithLoader(NewFSLoader(localeFS, "locales"), opts...) } // NewWithFS creates a new i18n service loading locales from the given filesystem. -func NewWithFS(fsys fs.FS, dir string) (*Service, error) { +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) { s := &Service{ + loader: loader, messages: make(map[string]map[string]Message), fallbackLang: "en-GB", handlers: DefaultHandlers(), } - entries, err := fs.ReadDir(fsys, dir) - if err != nil { - return nil, fmt.Errorf("failed to read locales directory: %w", err) + // Apply options + for _, opt := range opts { + opt(s) } - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { - continue - } + // Load all available languages + langs := loader.Languages() + if len(langs) == 0 { + return nil, fmt.Errorf("no languages available from loader") + } - filePath := filepath.Join(dir, entry.Name()) - data, err := fs.ReadFile(fsys, filePath) + for _, lang := range langs { + messages, grammar, err := loader.Load(lang) if err != nil { - return nil, fmt.Errorf("failed to read locale %q: %w", entry.Name(), err) + return nil, fmt.Errorf("failed to load locale %q: %w", lang, 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 %q: %w", entry.Name(), err) + s.messages[lang] = messages + if grammar != nil && (len(grammar.Verbs) > 0 || len(grammar.Nouns) > 0 || len(grammar.Words) > 0) { + SetGrammarData(lang, grammar) } tag := language.Make(lang) s.availableLangs = append(s.availableLangs, tag) } - if len(s.availableLangs) == 0 { - return nil, fmt.Errorf("no locale files found in %q", dir) - } - // Try to detect system language if detected := detectLanguage(s.availableLangs); detected != "" { s.currentLang = detected