Co-authored-by: Charon (snider-linux) <charon@lethean.io> Co-authored-by: Snider <snider@host.uk.com> Co-authored-by: Virgil <virgil@lethean.io> Co-authored-by: Claude <developers@lethean.io> Reviewed-on: #2 Co-authored-by: Snider <snider@lethean.io> Co-committed-by: Snider <snider@lethean.io>
420 lines
9.6 KiB
Markdown
420 lines
9.6 KiB
Markdown
# i18n Package
|
|
|
|
The `pkg/i18n` package provides internationalisation and localisation for Go CLI applications. It features a grammar engine for automatic verb conjugation and noun pluralisation, CLDR plural support, and an extensible handler chain for dynamic key patterns.
|
|
|
|
## Quick Start
|
|
|
|
```go
|
|
import "forge.lthn.ai/core/cli/pkg/i18n"
|
|
|
|
func main() {
|
|
// Initialise with embedded locales
|
|
svc, err := i18n.New()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
i18n.SetDefault(svc)
|
|
|
|
// Translate messages
|
|
fmt.Println(i18n.T("cli.success")) // "Operation completed"
|
|
fmt.Println(i18n.T("i18n.count.file", 5)) // "5 files"
|
|
fmt.Println(i18n.T("i18n.progress.build")) // "Building..."
|
|
fmt.Println(i18n.T("i18n.done.delete", "config.yaml")) // "Config.yaml deleted"
|
|
}
|
|
```
|
|
|
|
## Table of Contents
|
|
|
|
- [Basic Translation](#basic-translation)
|
|
- [Template Variables](#template-variables)
|
|
- [Pluralisation](#pluralisation)
|
|
- [Magic Namespaces](#magic-namespaces)
|
|
- [Subjects](#subjects)
|
|
- [Grammar Engine](#grammar-engine)
|
|
- [Formality](#formality)
|
|
- [Modes](#modes)
|
|
- [Custom Loaders](#custom-loaders)
|
|
- [Custom Handlers](#custom-handlers)
|
|
- [Locale File Format](#locale-file-format)
|
|
|
|
## Basic Translation
|
|
|
|
The `T()` function translates message keys:
|
|
|
|
```go
|
|
// Simple translation
|
|
msg := i18n.T("cli.success")
|
|
|
|
// With template variables
|
|
msg := i18n.T("error.not_found", map[string]any{
|
|
"Name": "config.yaml",
|
|
})
|
|
```
|
|
|
|
Use `Raw()` to bypass magic namespace handling:
|
|
|
|
```go
|
|
// T() handles i18n.* magic
|
|
i18n.T("i18n.label.status") // "Status:"
|
|
|
|
// Raw() does direct lookup only
|
|
i18n.Raw("i18n.label.status") // Returns key as-is (not in JSON)
|
|
```
|
|
|
|
## Template Variables
|
|
|
|
Translation strings support Go templates:
|
|
|
|
```json
|
|
{
|
|
"greeting": "Hello, {{.Name}}!",
|
|
"summary": "Found {{.Count}} {{if eq .Count 1}}item{{else}}items{{end}}"
|
|
}
|
|
```
|
|
|
|
```go
|
|
i18n.T("greeting", map[string]any{"Name": "World"}) // "Hello, World!"
|
|
i18n.T("summary", map[string]any{"Count": 3}) // "Found 3 items"
|
|
```
|
|
|
|
### Available Template Functions
|
|
|
|
| Function | Description | Example |
|
|
|----------|-------------|---------|
|
|
| `title` | Title case | `{{title .Name}}` |
|
|
| `lower` | Lowercase | `{{lower .Name}}` |
|
|
| `upper` | Uppercase | `{{upper .Name}}` |
|
|
| `past` | Past tense | `{{past "delete"}}` → "deleted" |
|
|
| `gerund` | -ing form | `{{gerund "build"}}` → "building" |
|
|
| `plural` | Pluralise | `{{plural "file" .Count}}` |
|
|
| `article` | Add article | `{{article "apple"}}` → "an apple" |
|
|
| `quote` | Add quotes | `{{quote .Name}}` → `"name"` |
|
|
|
|
## Pluralisation
|
|
|
|
The package supports full CLDR plural categories:
|
|
|
|
```json
|
|
{
|
|
"item_count": {
|
|
"zero": "No items",
|
|
"one": "{{.Count}} item",
|
|
"two": "{{.Count}} items",
|
|
"few": "{{.Count}} items",
|
|
"many": "{{.Count}} items",
|
|
"other": "{{.Count}} items"
|
|
}
|
|
}
|
|
```
|
|
|
|
```go
|
|
i18n.T("item_count", map[string]any{"Count": 0}) // "No items" (if zero defined)
|
|
i18n.T("item_count", map[string]any{"Count": 1}) // "1 item"
|
|
i18n.T("item_count", map[string]any{"Count": 5}) // "5 items"
|
|
```
|
|
|
|
For simple cases, use `i18n.count.*`:
|
|
|
|
```go
|
|
i18n.T("i18n.count.file", 1) // "1 file"
|
|
i18n.T("i18n.count.file", 5) // "5 files"
|
|
```
|
|
|
|
## Magic Namespaces
|
|
|
|
The `i18n.*` namespace provides automatic message composition:
|
|
|
|
### Labels (`i18n.label.*`)
|
|
|
|
```go
|
|
i18n.T("i18n.label.status") // "Status:"
|
|
i18n.T("i18n.label.version") // "Version:"
|
|
```
|
|
|
|
### Progress (`i18n.progress.*`)
|
|
|
|
```go
|
|
i18n.T("i18n.progress.build") // "Building..."
|
|
i18n.T("i18n.progress.check", "config") // "Checking config..."
|
|
```
|
|
|
|
### Counts (`i18n.count.*`)
|
|
|
|
```go
|
|
i18n.T("i18n.count.file", 1) // "1 file"
|
|
i18n.T("i18n.count.file", 5) // "5 files"
|
|
i18n.T("i18n.count.repo", 10) // "10 repos"
|
|
```
|
|
|
|
### Done (`i18n.done.*`)
|
|
|
|
```go
|
|
i18n.T("i18n.done.delete", "file") // "File deleted"
|
|
i18n.T("i18n.done.create", "project") // "Project created"
|
|
```
|
|
|
|
### Fail (`i18n.fail.*`)
|
|
|
|
```go
|
|
i18n.T("i18n.fail.delete", "file") // "Failed to delete file"
|
|
i18n.T("i18n.fail.save", "config") // "Failed to save config"
|
|
```
|
|
|
|
### Numeric (`i18n.numeric.*`)
|
|
|
|
```go
|
|
i18n.N("number", 1234567) // "1,234,567"
|
|
i18n.N("percent", 0.85) // "85%"
|
|
i18n.N("bytes", 1536000) // "1.46 MB"
|
|
i18n.N("ordinal", 1) // "1st"
|
|
```
|
|
|
|
## Subjects
|
|
|
|
Subjects provide typed context for translations:
|
|
|
|
```go
|
|
// Create a subject
|
|
subj := i18n.S("file", "config.yaml")
|
|
|
|
// Chain methods for additional context
|
|
subj := i18n.S("file", files).
|
|
Count(len(files)).
|
|
In("workspace").
|
|
Formal()
|
|
|
|
// Use in translations
|
|
i18n.T("i18n.done.delete", subj.String())
|
|
```
|
|
|
|
### Subject Methods
|
|
|
|
| Method | Description |
|
|
|--------|-------------|
|
|
| `Count(n)` | Set count for pluralisation |
|
|
| `Gender(g)` | Set grammatical gender |
|
|
| `In(loc)` | Set location context |
|
|
| `Formal()` | Set formal address |
|
|
| `Informal()` | Set informal address |
|
|
|
|
## Grammar Engine
|
|
|
|
The grammar engine handles verb conjugation and noun forms:
|
|
|
|
```go
|
|
// Verb conjugation
|
|
i18n.PastTense("delete") // "deleted"
|
|
i18n.PastTense("run") // "ran" (irregular)
|
|
i18n.Gerund("build") // "building"
|
|
i18n.Gerund("run") // "running"
|
|
|
|
// Noun pluralisation
|
|
i18n.Pluralize("file", 1) // "file"
|
|
i18n.Pluralize("file", 5) // "files"
|
|
i18n.Pluralize("child", 2) // "children" (irregular)
|
|
|
|
// Articles
|
|
i18n.Article("apple") // "an apple"
|
|
i18n.Article("banana") // "a banana"
|
|
|
|
// Composed messages
|
|
i18n.Label("status") // "Status:"
|
|
i18n.Progress("build") // "Building..."
|
|
i18n.ProgressSubject("check", "cfg") // "Checking cfg..."
|
|
i18n.ActionResult("delete", "file") // "File deleted"
|
|
i18n.ActionFailed("save", "config") // "Failed to save config"
|
|
```
|
|
|
|
### Customising Grammar
|
|
|
|
Add irregular forms in your locale JSON:
|
|
|
|
```json
|
|
{
|
|
"gram": {
|
|
"verb": {
|
|
"deploy": { "past": "deployed", "gerund": "deploying" }
|
|
},
|
|
"noun": {
|
|
"repository": { "one": "repository", "other": "repositories" }
|
|
},
|
|
"punct": {
|
|
"label": ":",
|
|
"progress": "..."
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Formality
|
|
|
|
For languages with formal/informal address (German Sie/du, French vous/tu):
|
|
|
|
```go
|
|
// Set service-wide formality
|
|
svc.SetFormality(i18n.FormalityFormal)
|
|
|
|
// Per-translation formality via Subject
|
|
i18n.T("greeting", i18n.S("user", name).Formal())
|
|
i18n.T("greeting", i18n.S("user", name).Informal())
|
|
|
|
// Per-translation via TranslationContext
|
|
i18n.T("greeting", i18n.C("customer support").Formal())
|
|
```
|
|
|
|
Define formality variants in JSON:
|
|
|
|
```json
|
|
{
|
|
"greeting": "Hello",
|
|
"greeting._formal": "Good morning, sir",
|
|
"greeting._informal": "Hey there"
|
|
}
|
|
```
|
|
|
|
## Modes
|
|
|
|
Three modes control missing key behaviour:
|
|
|
|
```go
|
|
// Normal (default): Returns key as-is
|
|
i18n.SetMode(i18n.ModeNormal)
|
|
i18n.T("missing.key") // "missing.key"
|
|
|
|
// Strict: Panics on missing keys (dev/CI)
|
|
i18n.SetMode(i18n.ModeStrict)
|
|
i18n.T("missing.key") // panic!
|
|
|
|
// Collect: Dispatches to handler (QA testing)
|
|
i18n.SetMode(i18n.ModeCollect)
|
|
i18n.OnMissingKey(func(m i18n.MissingKey) {
|
|
log.Printf("MISSING: %s at %s:%d", m.Key, m.CallerFile, m.CallerLine)
|
|
})
|
|
```
|
|
|
|
## Custom Loaders
|
|
|
|
Implement the `Loader` interface for custom storage:
|
|
|
|
```go
|
|
type Loader interface {
|
|
Load(lang string) (map[string]Message, *GrammarData, error)
|
|
Languages() []string
|
|
}
|
|
```
|
|
|
|
Example database loader:
|
|
|
|
```go
|
|
type DBLoader struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func (l *DBLoader) Languages() []string {
|
|
// Query available languages from database
|
|
}
|
|
|
|
func (l *DBLoader) Load(lang string) (map[string]i18n.Message, *i18n.GrammarData, error) {
|
|
// Load translations from database
|
|
}
|
|
|
|
// Use custom loader
|
|
svc, err := i18n.NewWithLoader(&DBLoader{db: db})
|
|
```
|
|
|
|
## Custom Handlers
|
|
|
|
Add custom key handlers for dynamic patterns:
|
|
|
|
```go
|
|
type MyHandler struct{}
|
|
|
|
func (h MyHandler) Match(key string) bool {
|
|
return strings.HasPrefix(key, "my.prefix.")
|
|
}
|
|
|
|
func (h MyHandler) Handle(key string, args []any, next func() string) string {
|
|
// Handle the key or call next() to delegate
|
|
return "custom result"
|
|
}
|
|
|
|
// Add to handler chain
|
|
svc.AddHandler(MyHandler{}) // Append (lower priority)
|
|
svc.PrependHandler(MyHandler{}) // Prepend (higher priority)
|
|
```
|
|
|
|
## Locale File Format
|
|
|
|
Locale files use nested JSON with dot-notation access:
|
|
|
|
```json
|
|
{
|
|
"cli": {
|
|
"success": "Operation completed",
|
|
"error": {
|
|
"not_found": "{{.Name}} not found"
|
|
}
|
|
},
|
|
"cmd": {
|
|
"build": {
|
|
"short": "Build the project",
|
|
"long": "Build compiles source files into an executable"
|
|
}
|
|
},
|
|
"gram": {
|
|
"verb": {
|
|
"build": { "past": "built", "gerund": "building" }
|
|
},
|
|
"noun": {
|
|
"file": { "one": "file", "other": "files" }
|
|
},
|
|
"punct": {
|
|
"label": ":",
|
|
"progress": "..."
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Access keys with dot notation:
|
|
|
|
```go
|
|
i18n.T("cli.success") // "Operation completed"
|
|
i18n.T("cli.error.not_found") // "{{.Name}} not found"
|
|
i18n.T("cmd.build.short") // "Build the project"
|
|
```
|
|
|
|
## Configuration Options
|
|
|
|
Use functional options when creating a service:
|
|
|
|
```go
|
|
svc, err := i18n.New(
|
|
i18n.WithFallback("de-DE"), // Fallback language
|
|
i18n.WithFormality(i18n.FormalityFormal), // Default formality
|
|
i18n.WithMode(i18n.ModeStrict), // Missing key mode
|
|
i18n.WithDebug(true), // Show [key] prefix
|
|
)
|
|
```
|
|
|
|
## Thread Safety
|
|
|
|
The package is fully thread-safe:
|
|
|
|
- `Service` uses `sync.RWMutex` for state
|
|
- Global `Default()` uses `atomic.Pointer`
|
|
- `OnMissingKey` uses `atomic.Value`
|
|
- `FSLoader.Languages()` uses `sync.Once`
|
|
|
|
Safe for concurrent use from multiple goroutines.
|
|
|
|
## Debug Mode
|
|
|
|
Enable debug mode to see translation keys:
|
|
|
|
```go
|
|
i18n.SetDebug(true)
|
|
i18n.T("cli.success") // "[cli.success] Operation completed"
|
|
```
|
|
|
|
Useful for identifying which keys are used where.
|