cli/docs/pkg/i18n/EXTENDING.md
Snider e9be13bcd3 docs(i18n): add comprehensive package documentation
- README.md: Quick start, all features, configuration options
- GRAMMAR.md: Verb conjugation, pluralisation, articles, templates
- EXTENDING.md: Custom loaders, handlers, framework integration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:18:43 +00:00

399 lines
8.8 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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