diff --git a/docs/pkg/i18n/EXTENDING.md b/docs/pkg/i18n/EXTENDING.md new file mode 100644 index 0000000..b979b0a --- /dev/null +++ b/docs/pkg/i18n/EXTENDING.md @@ -0,0 +1,399 @@ +# 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 diff --git a/docs/pkg/i18n/GRAMMAR.md b/docs/pkg/i18n/GRAMMAR.md new file mode 100644 index 0000000..2736f96 --- /dev/null +++ b/docs/pkg/i18n/GRAMMAR.md @@ -0,0 +1,228 @@ +# Grammar Engine + +The i18n grammar engine automatically handles verb conjugation, noun pluralisation, and article selection. It uses a combination of locale-defined rules and built-in English defaults. + +## Verb Conjugation + +### Past Tense + +```go +i18n.PastTense("delete") // "deleted" +i18n.PastTense("create") // "created" +i18n.PastTense("run") // "ran" (irregular) +i18n.PastTense("build") // "built" (irregular) +``` + +**Rules applied (in order):** + +1. Check locale JSON `gram.verb.{verb}.past` +2. Check built-in irregular verbs map +3. Apply regular conjugation rules: + - Ends in 'e' → add 'd' (delete → deleted) + - Ends in consonant + 'y' → change to 'ied' (try → tried) + - Short verb ending in CVC → double consonant (stop → stopped) + - Otherwise → add 'ed' (walk → walked) + +### Gerund (-ing form) + +```go +i18n.Gerund("build") // "building" +i18n.Gerund("run") // "running" +i18n.Gerund("make") // "making" +i18n.Gerund("die") // "dying" +``` + +**Rules applied:** + +1. Check locale JSON `gram.verb.{verb}.gerund` +2. Check built-in irregular verbs map +3. Apply regular rules: + - Ends in 'ie' → change to 'ying' (die → dying) + - Ends in 'e' (not 'ee') → drop 'e', add 'ing' (make → making) + - Short verb ending in CVC → double consonant (run → running) + - Otherwise → add 'ing' (build → building) + +## Noun Pluralisation + +```go +i18n.Pluralize("file", 1) // "file" +i18n.Pluralize("file", 5) // "files" +i18n.Pluralize("child", 2) // "children" (irregular) +i18n.Pluralize("analysis", 3) // "analyses" (Latin) +``` + +**Rules applied (in order):** + +1. Check locale JSON `gram.noun.{noun}.other` +2. Check built-in irregular nouns map +3. Apply regular rules: + - Ends in 's', 'x', 'z', 'ch', 'sh' → add 'es' + - Ends in consonant + 'y' → change to 'ies' + - Ends in 'f' or 'fe' → change to 'ves' (leaf → leaves) + - Otherwise → add 's' + +### Built-in Irregular Nouns + +| Singular | Plural | +|----------|--------| +| child | children | +| person | people | +| man | men | +| woman | women | +| foot | feet | +| tooth | teeth | +| mouse | mice | +| datum | data | +| index | indices | +| crisis | crises | +| fish | fish | +| sheep | sheep | + +## Articles + +```go +i18n.Article("apple") // "an apple" +i18n.Article("banana") // "a banana" +i18n.Article("hour") // "an hour" (silent h) +i18n.Article("user") // "a user" (y sound) +i18n.Article("umbrella") // "an umbrella" +``` + +**Rules:** + +1. Vowel sound words get "an" (a, e, i, o, u start) +2. Consonant sound words get "a" +3. Exception lists handle: + - Silent 'h' words: hour, honest, honour, heir, herb + - 'Y' sound words: user, union, unique, unit, universe + +## Composed Messages + +### Labels + +```go +i18n.Label("status") // "Status:" +i18n.Label("version") // "Version:" +``` + +Uses `gram.punct.label` suffix (default `:`) from locale. + +### Progress Messages + +```go +i18n.Progress("build") // "Building..." +i18n.ProgressSubject("check", "config") // "Checking config..." +``` + +Uses `gram.punct.progress` suffix (default `...`) from locale. + +### Action Results + +```go +i18n.ActionResult("delete", "file") // "File deleted" +i18n.ActionResult("create", "project") // "Project created" +``` + +Pattern: `{Title(subject)} {past(verb)}` + +### Action Failures + +```go +i18n.ActionFailed("delete", "file") // "Failed to delete file" +i18n.ActionFailed("save", "config") // "Failed to save config" +``` + +Pattern: `Failed to {verb} {subject}` + +## Locale Configuration + +Define grammar in your locale JSON: + +```json +{ + "gram": { + "verb": { + "deploy": { + "past": "deployed", + "gerund": "deploying" + }, + "sync": { + "past": "synced", + "gerund": "syncing" + } + }, + "noun": { + "repository": { + "one": "repository", + "other": "repositories" + }, + "schema": { + "one": "schema", + "other": "schemata" + } + }, + "article": { + "indefinite": { + "default": "a", + "vowel": "an" + }, + "definite": "the" + }, + "punct": { + "label": ":", + "progress": "..." + }, + "word": { + "status": "status", + "version": "version" + } + } +} +``` + +## Template Functions + +Use grammar functions in templates: + +```go +template.New("").Funcs(i18n.TemplateFuncs()) +``` + +| Function | Example | Result | +|----------|---------|--------| +| `past` | `{{past "delete"}}` | "deleted" | +| `gerund` | `{{gerund "build"}}` | "building" | +| `plural` | `{{plural "file" 5}}` | "files" | +| `article` | `{{article "apple"}}` | "an apple" | +| `title` | `{{title "hello world"}}` | "Hello World" | +| `lower` | `{{lower "HELLO"}}` | "hello" | +| `upper` | `{{upper "hello"}}` | "HELLO" | +| `quote` | `{{quote "text"}}` | `"text"` | + +## Language-Specific Grammar + +The grammar engine loads language-specific data when available: + +```go +// Get grammar data for a language +data := i18n.GetGrammarData("de-DE") +if data != nil { + // Access verb forms, noun forms, etc. +} + +// Set grammar data programmatically +i18n.SetGrammarData("de-DE", &i18n.GrammarData{ + Verbs: map[string]i18n.VerbForms{ + "machen": {Past: "gemacht", Gerund: "machend"}, + }, +}) +``` + +## Performance + +Grammar results are computed on-demand but templates are cached: + +- First call: Parse template + apply grammar +- Subsequent calls: Reuse cached template + +The template cache uses `sync.Map` for thread-safe concurrent access. diff --git a/docs/pkg/i18n/README.md b/docs/pkg/i18n/README.md new file mode 100644 index 0000000..a70dfc3 --- /dev/null +++ b/docs/pkg/i18n/README.md @@ -0,0 +1,420 @@ +# 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 "github.com/host-uk/core/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.