400 lines
8.8 KiB
Markdown
400 lines
8.8 KiB
Markdown
|
|
# 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
|