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:
parent
67e7e552f3
commit
945db09966
4 changed files with 157 additions and 33 deletions
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue