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 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-30 18:47:11 +00:00
parent 67e7e552f3
commit 945db09966
4 changed files with 157 additions and 33 deletions

View file

@ -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.
//

View file

@ -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)
})

View file

@ -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())
})
}

View file

@ -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