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>
9.6 KiB
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
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
- Template Variables
- Pluralisation
- Magic Namespaces
- Subjects
- Grammar Engine
- Formality
- Modes
- Custom Loaders
- Custom Handlers
- Locale File Format
Basic Translation
The T() function translates message keys:
// 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:
// 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:
{
"greeting": "Hello, {{.Name}}!",
"summary": "Found {{.Count}} {{if eq .Count 1}}item{{else}}items{{end}}"
}
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:
{
"item_count": {
"zero": "No items",
"one": "{{.Count}} item",
"two": "{{.Count}} items",
"few": "{{.Count}} items",
"many": "{{.Count}} items",
"other": "{{.Count}} items"
}
}
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.*:
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.*)
i18n.T("i18n.label.status") // "Status:"
i18n.T("i18n.label.version") // "Version:"
Progress (i18n.progress.*)
i18n.T("i18n.progress.build") // "Building..."
i18n.T("i18n.progress.check", "config") // "Checking config..."
Counts (i18n.count.*)
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.*)
i18n.T("i18n.done.delete", "file") // "File deleted"
i18n.T("i18n.done.create", "project") // "Project created"
Fail (i18n.fail.*)
i18n.T("i18n.fail.delete", "file") // "Failed to delete file"
i18n.T("i18n.fail.save", "config") // "Failed to save config"
Numeric (i18n.numeric.*)
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:
// 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:
// 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:
{
"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):
// 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:
{
"greeting": "Hello",
"greeting._formal": "Good morning, sir",
"greeting._informal": "Hey there"
}
Modes
Three modes control missing key behaviour:
// 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:
type Loader interface {
Load(lang string) (map[string]Message, *GrammarData, error)
Languages() []string
}
Example database loader:
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:
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:
{
"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:
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:
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:
Serviceusessync.RWMutexfor state- Global
Default()usesatomic.Pointer OnMissingKeyusesatomic.ValueFSLoader.Languages()usessync.Once
Safe for concurrent use from multiple goroutines.
Debug Mode
Enable debug mode to see translation keys:
i18n.SetDebug(true)
i18n.T("cli.success") // "[cli.success] Operation completed"
Useful for identifying which keys are used where.