cli/docs/pkg/i18n/EXTENDING.md

400 lines
8.8 KiB
Markdown
Raw Permalink Normal View History

# Extending the i18n Package
This guide covers how to extend the i18n package with custom loaders, handlers, and integrations.
## Custom Loaders
The `Loader` interface allows loading translations from any source:
```go
type Loader interface {
Load(lang string) (map[string]Message, *GrammarData, error)
Languages() []string
}
```
### Database Loader Example
```go
type PostgresLoader struct {
db *sql.DB
}
func (l *PostgresLoader) Languages() []string {
rows, err := l.db.Query("SELECT DISTINCT lang FROM translations")
if err != nil {
return nil
}
defer rows.Close()
var langs []string
for rows.Next() {
var lang string
rows.Scan(&lang)
langs = append(langs, lang)
}
return langs
}
func (l *PostgresLoader) Load(lang string) (map[string]i18n.Message, *i18n.GrammarData, error) {
rows, err := l.db.Query(
"SELECT key, text, plural_one, plural_other FROM translations WHERE lang = $1",
lang,
)
if err != nil {
return nil, nil, err
}
defer rows.Close()
messages := make(map[string]i18n.Message)
for rows.Next() {
var key, text string
var one, other sql.NullString
rows.Scan(&key, &text, &one, &other)
if one.Valid || other.Valid {
messages[key] = i18n.Message{One: one.String, Other: other.String}
} else {
messages[key] = i18n.Message{Text: text}
}
}
return messages, nil, nil
}
// Usage
svc, err := i18n.NewWithLoader(&PostgresLoader{db: db})
```
### Remote API Loader Example
```go
type APILoader struct {
baseURL string
client *http.Client
}
func (l *APILoader) Languages() []string {
resp, _ := l.client.Get(l.baseURL + "/languages")
defer resp.Body.Close()
var langs []string
json.NewDecoder(resp.Body).Decode(&langs)
return langs
}
func (l *APILoader) Load(lang string) (map[string]i18n.Message, *i18n.GrammarData, error) {
resp, err := l.client.Get(l.baseURL + "/translations/" + lang)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
var data struct {
Messages map[string]i18n.Message `json:"messages"`
Grammar *i18n.GrammarData `json:"grammar"`
}
json.NewDecoder(resp.Body).Decode(&data)
return data.Messages, data.Grammar, nil
}
```
### Multi-Source Loader
Combine multiple loaders with fallback:
```go
type FallbackLoader struct {
primary i18n.Loader
secondary i18n.Loader
}
func (l *FallbackLoader) Languages() []string {
// Merge languages from both sources
langs := make(map[string]bool)
for _, lang := range l.primary.Languages() {
langs[lang] = true
}
for _, lang := range l.secondary.Languages() {
langs[lang] = true
}
result := make([]string, 0, len(langs))
for lang := range langs {
result = append(result, lang)
}
return result
}
func (l *FallbackLoader) Load(lang string) (map[string]i18n.Message, *i18n.GrammarData, error) {
msgs, grammar, err := l.primary.Load(lang)
if err != nil {
return l.secondary.Load(lang)
}
// Merge with secondary for missing keys
secondary, secGrammar, _ := l.secondary.Load(lang)
for k, v := range secondary {
if _, exists := msgs[k]; !exists {
msgs[k] = v
}
}
if grammar == nil {
grammar = secGrammar
}
return msgs, grammar, nil
}
```
## Custom Handlers
Handlers process keys before standard lookup. Use for dynamic patterns.
### Handler Interface
```go
type KeyHandler interface {
Match(key string) bool
Handle(key string, args []any, next func() string) string
}
```
### Emoji Handler Example
```go
type EmojiHandler struct{}
func (h EmojiHandler) Match(key string) bool {
return strings.HasPrefix(key, "emoji.")
}
func (h EmojiHandler) Handle(key string, args []any, next func() string) string {
name := strings.TrimPrefix(key, "emoji.")
emojis := map[string]string{
"success": "✅",
"error": "❌",
"warning": "⚠️",
"info": "",
}
if emoji, ok := emojis[name]; ok {
return emoji
}
return next() // Delegate to next handler
}
// Usage
i18n.AddHandler(EmojiHandler{})
i18n.T("emoji.success") // "✅"
```
### Conditional Handler Example
```go
type FeatureFlagHandler struct {
flags map[string]bool
}
func (h FeatureFlagHandler) Match(key string) bool {
return strings.HasPrefix(key, "feature.")
}
func (h FeatureFlagHandler) Handle(key string, args []any, next func() string) string {
feature := strings.TrimPrefix(key, "feature.")
parts := strings.SplitN(feature, ".", 2)
if len(parts) < 2 {
return next()
}
flag, subkey := parts[0], parts[1]
if h.flags[flag] {
// Feature enabled - translate the subkey
return i18n.T(subkey, args...)
}
// Feature disabled - return empty or fallback
return ""
}
```
### Handler Chain Priority
```go
// Prepend for highest priority (runs first)
svc.PrependHandler(CriticalHandler{})
// Append for lower priority (runs after defaults)
svc.AddHandler(FallbackHandler{})
// Clear all handlers
svc.ClearHandlers()
// Add back defaults
svc.AddHandler(i18n.DefaultHandlers()...)
```
## Integrating with Frameworks
### Cobra CLI
```go
func init() {
// Initialise i18n before command setup
if err := i18n.Init(); err != nil {
log.Fatal(err)
}
}
var rootCmd = &cobra.Command{
Use: "myapp",
Short: i18n.T("cmd.root.short"),
Long: i18n.T("cmd.root.long"),
}
var buildCmd = &cobra.Command{
Use: "build",
Short: i18n.T("cmd.build.short"),
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println(i18n.T("i18n.progress.build"))
// ...
fmt.Println(i18n.T("i18n.done.build", "project"))
return nil
},
}
```
### Error Messages
```go
type LocalisedError struct {
Key string
Args map[string]any
}
func (e LocalisedError) Error() string {
return i18n.T(e.Key, e.Args)
}
// Usage
return LocalisedError{
Key: "error.file_not_found",
Args: map[string]any{"Name": filename},
}
```
### Structured Logging
```go
func LogInfo(key string, args ...any) {
msg := i18n.T(key, args...)
slog.Info(msg, "i18n_key", key)
}
func LogError(key string, err error, args ...any) {
msg := i18n.T(key, args...)
slog.Error(msg, "i18n_key", key, "error", err)
}
```
## Testing
### Mock Loader for Tests
```go
type MockLoader struct {
messages map[string]map[string]i18n.Message
}
func (l *MockLoader) Languages() []string {
langs := make([]string, 0, len(l.messages))
for lang := range l.messages {
langs = append(langs, lang)
}
return langs
}
func (l *MockLoader) Load(lang string) (map[string]i18n.Message, *i18n.GrammarData, error) {
if msgs, ok := l.messages[lang]; ok {
return msgs, nil, nil
}
return nil, nil, fmt.Errorf("language not found: %s", lang)
}
// Usage in tests
func TestMyFeature(t *testing.T) {
loader := &MockLoader{
messages: map[string]map[string]i18n.Message{
"en-GB": {
"test.greeting": {Text: "Hello"},
"test.farewell": {Text: "Goodbye"},
},
},
}
svc, _ := i18n.NewWithLoader(loader)
i18n.SetDefault(svc)
// Test your code
assert.Equal(t, "Hello", i18n.T("test.greeting"))
}
```
### Testing Missing Keys
```go
func TestMissingKeys(t *testing.T) {
svc, _ := i18n.New(i18n.WithMode(i18n.ModeCollect))
i18n.SetDefault(svc)
var missing []string
i18n.OnMissingKey(func(m i18n.MissingKey) {
missing = append(missing, m.Key)
})
// Run your code that uses translations
runMyFeature()
// Check for missing keys
assert.Empty(t, missing, "Found missing translation keys: %v", missing)
}
```
## Hot Reloading
Implement a loader that watches for file changes:
```go
type HotReloadLoader struct {
base *i18n.FSLoader
service *i18n.Service
watcher *fsnotify.Watcher
}
func (l *HotReloadLoader) Watch() {
for {
select {
case event := <-l.watcher.Events:
if event.Op&fsnotify.Write == fsnotify.Write {
// Reload translations
l.service.LoadFS(os.DirFS("."), "locales")
}
}
}
}
```
## Performance Considerations
1. **Cache translations**: The service caches all loaded messages
2. **Template caching**: Parsed templates are cached in `sync.Map`
3. **Handler chain**: Keep chain short (6 default handlers is fine)
4. **Grammar cache**: Grammar lookups are cached per-language
For high-throughput applications:
- Pre-warm the cache by calling common translations at startup
- Consider using `Raw()` to bypass handler chain when not needed
- Profile with `go test -bench` if performance is critical