Implements the final features from the semantic i18n plan:
- Template caching: sync.Map cache for compiled templates
- Translator interface: enables mocking for tests
- Custom intent registration: thread-safe RegisterIntents(), UnregisterIntent()
- JSON-based grammar: verb/noun forms in locale files, checked before computed
- Fallback chain: T() tries common.action.{verb} and common.{verb}
- CLI enhancements: Timeout(), Filter(), Multi() options, ChooseMulti()
- Intent key constants: type-safe IntentCore* and Key* constants
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
980 lines
25 KiB
Go
980 lines
25 KiB
Go
// Package i18n provides internationalization for the CLI.
|
|
//
|
|
// Locale files use nested JSON for compatibility with translation tools:
|
|
//
|
|
// {
|
|
// "cli": {
|
|
// "success": "Operation completed",
|
|
// "count": {
|
|
// "items": {
|
|
// "one": "{{.Count}} item",
|
|
// "other": "{{.Count}} items"
|
|
// }
|
|
// }
|
|
// }
|
|
// }
|
|
//
|
|
// Keys are accessed with dot notation: T("cli.success"), T("cli.count.items")
|
|
//
|
|
// # Getting Started
|
|
//
|
|
// svc, err := i18n.New()
|
|
// fmt.Println(svc.T("cli.success"))
|
|
// fmt.Println(svc.T("cli.count.items", map[string]any{"Count": 5}))
|
|
package i18n
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"text/template"
|
|
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
//go:embed locales/*.json
|
|
var localeFS embed.FS
|
|
|
|
// Message represents a translation - either a simple string or plural forms.
|
|
// Supports full CLDR plural categories for languages with complex plural rules.
|
|
type Message struct {
|
|
Text string // Simple string value (non-plural)
|
|
Zero string // count == 0 (Arabic, Latvian, Welsh)
|
|
One string // count == 1 (most languages)
|
|
Two string // count == 2 (Arabic, Welsh)
|
|
Few string // Small numbers (Slavic: 2-4, Arabic: 3-10)
|
|
Many string // Larger numbers (Slavic: 5+, Arabic: 11-99)
|
|
Other string // Default/fallback form
|
|
}
|
|
|
|
// IsPlural returns true if this message has any plural forms.
|
|
func (m Message) IsPlural() bool {
|
|
return m.Zero != "" || m.One != "" || m.Two != "" ||
|
|
m.Few != "" || m.Many != "" || m.Other != ""
|
|
}
|
|
|
|
// ForCategory returns the appropriate text for a plural category.
|
|
// Falls back through the category hierarchy to find a non-empty string.
|
|
func (m Message) ForCategory(cat PluralCategory) string {
|
|
switch cat {
|
|
case PluralZero:
|
|
if m.Zero != "" {
|
|
return m.Zero
|
|
}
|
|
case PluralOne:
|
|
if m.One != "" {
|
|
return m.One
|
|
}
|
|
case PluralTwo:
|
|
if m.Two != "" {
|
|
return m.Two
|
|
}
|
|
case PluralFew:
|
|
if m.Few != "" {
|
|
return m.Few
|
|
}
|
|
case PluralMany:
|
|
if m.Many != "" {
|
|
return m.Many
|
|
}
|
|
}
|
|
// Fallback to Other, then One, then Text
|
|
if m.Other != "" {
|
|
return m.Other
|
|
}
|
|
if m.One != "" {
|
|
return m.One
|
|
}
|
|
return m.Text
|
|
}
|
|
|
|
// Service provides internationalization and localization.
|
|
type Service struct {
|
|
messages map[string]map[string]Message // lang -> key -> message
|
|
currentLang string
|
|
fallbackLang string
|
|
availableLangs []language.Tag
|
|
mode Mode // Translation mode (Normal, Strict, Collect)
|
|
debug bool // Debug mode shows key prefixes
|
|
formality Formality // Default formality level for translations
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// Default is the global i18n service instance.
|
|
var (
|
|
defaultService *Service
|
|
defaultOnce sync.Once
|
|
defaultErr error
|
|
)
|
|
|
|
// New creates a new i18n service with embedded locales.
|
|
func New() (*Service, error) {
|
|
return NewWithFS(localeFS, "locales")
|
|
}
|
|
|
|
// NewWithFS creates a new i18n service loading locales from the given filesystem.
|
|
func NewWithFS(fsys fs.FS, dir string) (*Service, error) {
|
|
s := &Service{
|
|
messages: make(map[string]map[string]Message),
|
|
fallbackLang: "en-GB",
|
|
}
|
|
|
|
entries, err := fs.ReadDir(fsys, dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read locales directory: %w", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
|
continue
|
|
}
|
|
|
|
filePath := filepath.Join(dir, entry.Name())
|
|
data, err := fs.ReadFile(fsys, filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read locale %s: %w", entry.Name(), err)
|
|
}
|
|
|
|
lang := strings.TrimSuffix(entry.Name(), ".json")
|
|
// Normalise underscore to hyphen (en_GB -> en-GB)
|
|
lang = strings.ReplaceAll(lang, "_", "-")
|
|
|
|
if err := s.loadJSON(lang, data); err != nil {
|
|
return nil, fmt.Errorf("failed to parse locale %s: %w", entry.Name(), err)
|
|
}
|
|
|
|
tag := language.Make(lang)
|
|
s.availableLangs = append(s.availableLangs, tag)
|
|
}
|
|
|
|
if len(s.availableLangs) == 0 {
|
|
return nil, fmt.Errorf("no locale files found in %s", dir)
|
|
}
|
|
|
|
// Try to detect system language
|
|
if detected := detectLanguage(s.availableLangs); detected != "" {
|
|
s.currentLang = detected
|
|
} else {
|
|
s.currentLang = s.fallbackLang
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// loadJSON parses nested JSON and flattens to dot-notation keys.
|
|
// Also extracts grammar data (verbs, nouns, articles) for the language.
|
|
func (s *Service) loadJSON(lang string, data []byte) error {
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return err
|
|
}
|
|
|
|
messages := make(map[string]Message)
|
|
grammarData := &GrammarData{
|
|
Verbs: make(map[string]VerbForms),
|
|
Nouns: make(map[string]NounForms),
|
|
}
|
|
|
|
flattenWithGrammar("", raw, messages, grammarData)
|
|
s.messages[lang] = messages
|
|
|
|
// Store grammar data if any was found
|
|
if len(grammarData.Verbs) > 0 || len(grammarData.Nouns) > 0 {
|
|
SetGrammarData(lang, grammarData)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// flatten recursively flattens nested maps into dot-notation keys.
|
|
func flatten(prefix string, data map[string]any, out map[string]Message) {
|
|
flattenWithGrammar(prefix, data, out, nil)
|
|
}
|
|
|
|
// flattenWithGrammar recursively flattens nested maps and extracts grammar data.
|
|
func flattenWithGrammar(prefix string, data map[string]any, out map[string]Message, grammar *GrammarData) {
|
|
for key, value := range data {
|
|
fullKey := key
|
|
if prefix != "" {
|
|
fullKey = prefix + "." + key
|
|
}
|
|
|
|
switch v := value.(type) {
|
|
case string:
|
|
out[fullKey] = Message{Text: v}
|
|
|
|
case map[string]any:
|
|
// Check if this is a verb form object
|
|
if grammar != nil && isVerbFormObject(v) {
|
|
verbName := key
|
|
if strings.HasPrefix(fullKey, "common.verb.") {
|
|
verbName = strings.TrimPrefix(fullKey, "common.verb.")
|
|
}
|
|
forms := VerbForms{}
|
|
if base, ok := v["base"].(string); ok {
|
|
_ = base // base form stored but not used in VerbForms
|
|
}
|
|
if past, ok := v["past"].(string); ok {
|
|
forms.Past = past
|
|
}
|
|
if gerund, ok := v["gerund"].(string); ok {
|
|
forms.Gerund = gerund
|
|
}
|
|
grammar.Verbs[strings.ToLower(verbName)] = forms
|
|
continue
|
|
}
|
|
|
|
// Check if this is a noun form object
|
|
if grammar != nil && isNounFormObject(v) {
|
|
nounName := key
|
|
if strings.HasPrefix(fullKey, "common.noun.") {
|
|
nounName = strings.TrimPrefix(fullKey, "common.noun.")
|
|
}
|
|
forms := NounForms{}
|
|
if one, ok := v["one"].(string); ok {
|
|
forms.One = one
|
|
}
|
|
if other, ok := v["other"].(string); ok {
|
|
forms.Other = other
|
|
}
|
|
if gender, ok := v["gender"].(string); ok {
|
|
forms.Gender = gender
|
|
}
|
|
grammar.Nouns[strings.ToLower(nounName)] = forms
|
|
continue
|
|
}
|
|
|
|
// Check if this is an article object
|
|
if grammar != nil && fullKey == "common.article" {
|
|
if indef, ok := v["indefinite"].(map[string]any); ok {
|
|
if def, ok := indef["default"].(string); ok {
|
|
grammar.Articles.IndefiniteDefault = def
|
|
}
|
|
if vowel, ok := indef["vowel"].(string); ok {
|
|
grammar.Articles.IndefiniteVowel = vowel
|
|
}
|
|
}
|
|
if def, ok := v["definite"].(string); ok {
|
|
grammar.Articles.Definite = def
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Check if this is a plural object (has CLDR plural category keys)
|
|
if isPluralObject(v) {
|
|
msg := Message{}
|
|
if zero, ok := v["zero"].(string); ok {
|
|
msg.Zero = zero
|
|
}
|
|
if one, ok := v["one"].(string); ok {
|
|
msg.One = one
|
|
}
|
|
if two, ok := v["two"].(string); ok {
|
|
msg.Two = two
|
|
}
|
|
if few, ok := v["few"].(string); ok {
|
|
msg.Few = few
|
|
}
|
|
if many, ok := v["many"].(string); ok {
|
|
msg.Many = many
|
|
}
|
|
if other, ok := v["other"].(string); ok {
|
|
msg.Other = other
|
|
}
|
|
out[fullKey] = msg
|
|
} else {
|
|
// Recurse into nested object
|
|
flattenWithGrammar(fullKey, v, out, grammar)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// isVerbFormObject checks if a map represents verb conjugation forms.
|
|
func isVerbFormObject(m map[string]any) bool {
|
|
_, hasBase := m["base"]
|
|
_, hasPast := m["past"]
|
|
_, hasGerund := m["gerund"]
|
|
return (hasBase || hasPast || hasGerund) && !isPluralObject(m)
|
|
}
|
|
|
|
// isNounFormObject checks if a map represents noun forms (with gender).
|
|
// Noun form objects have "gender" field, distinguishing them from CLDR plural objects.
|
|
func isNounFormObject(m map[string]any) bool {
|
|
_, hasGender := m["gender"]
|
|
// Only consider it a noun form if it has a gender field
|
|
// This distinguishes noun forms from CLDR plural objects which use one/other
|
|
return hasGender
|
|
}
|
|
|
|
// hasPluralCategories checks if a map has CLDR plural categories beyond one/other.
|
|
func hasPluralCategories(m map[string]any) bool {
|
|
_, hasZero := m["zero"]
|
|
_, hasTwo := m["two"]
|
|
_, hasFew := m["few"]
|
|
_, hasMany := m["many"]
|
|
return hasZero || hasTwo || hasFew || hasMany
|
|
}
|
|
|
|
// isPluralObject checks if a map represents plural forms.
|
|
// Recognizes all CLDR plural categories: zero, one, two, few, many, other.
|
|
func isPluralObject(m map[string]any) bool {
|
|
_, hasZero := m["zero"]
|
|
_, hasOne := m["one"]
|
|
_, hasTwo := m["two"]
|
|
_, hasFew := m["few"]
|
|
_, hasMany := m["many"]
|
|
_, hasOther := m["other"]
|
|
|
|
// It's a plural object if it has any plural category key
|
|
if !hasZero && !hasOne && !hasTwo && !hasFew && !hasMany && !hasOther {
|
|
return false
|
|
}
|
|
// But not if it contains nested objects (those are namespace containers)
|
|
for _, v := range m {
|
|
if _, isMap := v.(map[string]any); isMap {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func detectLanguage(supported []language.Tag) string {
|
|
langEnv := os.Getenv("LANG")
|
|
if langEnv == "" {
|
|
langEnv = os.Getenv("LC_ALL")
|
|
if langEnv == "" {
|
|
langEnv = os.Getenv("LC_MESSAGES")
|
|
}
|
|
}
|
|
if langEnv == "" {
|
|
return ""
|
|
}
|
|
|
|
// Parse LANG format: en_GB.UTF-8 -> en-GB
|
|
baseLang := strings.Split(langEnv, ".")[0]
|
|
baseLang = strings.ReplaceAll(baseLang, "_", "-")
|
|
|
|
parsedLang, err := language.Parse(baseLang)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
if len(supported) == 0 {
|
|
return ""
|
|
}
|
|
|
|
matcher := language.NewMatcher(supported)
|
|
bestMatch, _, confidence := matcher.Match(parsedLang)
|
|
|
|
if confidence >= language.Low {
|
|
return bestMatch.String()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// --- Global convenience functions ---
|
|
|
|
// Init initializes the default global service.
|
|
func Init() error {
|
|
defaultOnce.Do(func() {
|
|
defaultService, defaultErr = New()
|
|
})
|
|
return defaultErr
|
|
}
|
|
|
|
// Default returns the global i18n service, initializing if needed.
|
|
func Default() *Service {
|
|
if defaultService == nil {
|
|
_ = Init()
|
|
}
|
|
return defaultService
|
|
}
|
|
|
|
// SetDefault sets the global i18n service.
|
|
func SetDefault(s *Service) {
|
|
defaultService = s
|
|
}
|
|
|
|
// SetDebug enables or disables debug mode on the default service.
|
|
// In debug mode, translations show their keys: [key] translation
|
|
//
|
|
// SetDebug(true)
|
|
// T("cli.success") // "[cli.success] Success"
|
|
func SetDebug(enabled bool) {
|
|
if svc := Default(); svc != nil {
|
|
svc.SetDebug(enabled)
|
|
}
|
|
}
|
|
|
|
// SetFormality sets the default formality level on the default service.
|
|
//
|
|
// SetFormality(FormalityFormal) // Use formal address (Sie, vous)
|
|
func SetFormality(f Formality) {
|
|
if svc := Default(); svc != nil {
|
|
svc.SetFormality(f)
|
|
}
|
|
}
|
|
|
|
// Direction returns the text direction for the current language.
|
|
func Direction() TextDirection {
|
|
if svc := Default(); svc != nil {
|
|
return svc.Direction()
|
|
}
|
|
return DirLTR
|
|
}
|
|
|
|
// IsRTL returns true if the current language uses right-to-left text.
|
|
func IsRTL() bool {
|
|
return Direction() == DirRTL
|
|
}
|
|
|
|
// T translates a message using the default service.
|
|
// For semantic intents (core.* namespace), pass a Subject as the first argument.
|
|
//
|
|
// T("cli.success") // Simple translation
|
|
// T("core.delete", S("file", "config.yaml")) // Semantic intent
|
|
func T(messageID string, args ...any) string {
|
|
if svc := Default(); svc != nil {
|
|
return svc.T(messageID, args...)
|
|
}
|
|
return messageID
|
|
}
|
|
|
|
// C composes a semantic intent using the default service.
|
|
// Returns all output forms (Question, Confirm, Success, Failure) for the intent.
|
|
//
|
|
// result := C("core.delete", S("file", "config.yaml"))
|
|
// fmt.Println(result.Question) // "Delete config.yaml?"
|
|
func C(intent string, subject *Subject) *Composed {
|
|
if svc := Default(); svc != nil {
|
|
return svc.C(intent, subject)
|
|
}
|
|
return &Composed{
|
|
Question: intent,
|
|
Confirm: intent,
|
|
Success: intent,
|
|
Failure: intent,
|
|
}
|
|
}
|
|
|
|
// --- Grammar convenience functions (package-level) ---
|
|
// These provide direct access to grammar functions without needing a service instance.
|
|
|
|
// P returns a progress message for a verb: "Building...", "Checking..."
|
|
// Use this instead of T("cli.progress.building") for dynamic progress messages.
|
|
//
|
|
// P("build") // "Building..."
|
|
// P("fetch") // "Fetching..."
|
|
func P(verb string) string {
|
|
return Progress(verb)
|
|
}
|
|
|
|
// PS returns a progress message with a subject: "Building project...", "Checking config..."
|
|
//
|
|
// PS("build", "project") // "Building project..."
|
|
// PS("check", "config.yaml") // "Checking config.yaml..."
|
|
func PS(verb, subject string) string {
|
|
return ProgressSubject(verb, subject)
|
|
}
|
|
|
|
// L returns a label with colon: "Status:", "Version:"
|
|
// Use this instead of T("common.label.status") for simple labels.
|
|
//
|
|
// L("status") // "Status:"
|
|
// L("version") // "Version:"
|
|
func L(word string) string {
|
|
return Label(word)
|
|
}
|
|
|
|
// _ is the standard gettext-style translation helper.
|
|
// Alias for T() - use whichever you prefer.
|
|
//
|
|
// i18n._("cli.success")
|
|
// i18n._("cli.greeting", map[string]any{"Name": "World"})
|
|
func _(messageID string, args ...any) string {
|
|
return T(messageID, args...)
|
|
}
|
|
|
|
// --- Service methods ---
|
|
|
|
// SetLanguage sets the language for translations.
|
|
func (s *Service) SetLanguage(lang string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
requestedLang, err := language.Parse(lang)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid language tag %q: %w", lang, err)
|
|
}
|
|
|
|
if len(s.availableLangs) == 0 {
|
|
return fmt.Errorf("no languages available")
|
|
}
|
|
|
|
matcher := language.NewMatcher(s.availableLangs)
|
|
bestMatch, _, confidence := matcher.Match(requestedLang)
|
|
|
|
if confidence == language.No {
|
|
return fmt.Errorf("unsupported language: %s", lang)
|
|
}
|
|
|
|
s.currentLang = bestMatch.String()
|
|
return nil
|
|
}
|
|
|
|
// Language returns the current language code.
|
|
func (s *Service) Language() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.currentLang
|
|
}
|
|
|
|
// AvailableLanguages returns the list of available language codes.
|
|
func (s *Service) AvailableLanguages() []string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
langs := make([]string, len(s.availableLangs))
|
|
for i, tag := range s.availableLangs {
|
|
langs[i] = tag.String()
|
|
}
|
|
return langs
|
|
}
|
|
|
|
// SetMode sets the translation mode for missing key handling.
|
|
func (s *Service) SetMode(m Mode) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.mode = m
|
|
}
|
|
|
|
// Mode returns the current translation mode.
|
|
func (s *Service) Mode() Mode {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.mode
|
|
}
|
|
|
|
// SetDebug enables or disables debug mode.
|
|
// In debug mode, translations are prefixed with their key:
|
|
//
|
|
// [cli.success] Success
|
|
// [core.delete] Delete config.yaml?
|
|
func (s *Service) SetDebug(enabled bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.debug = enabled
|
|
}
|
|
|
|
// Debug returns whether debug mode is enabled.
|
|
func (s *Service) Debug() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.debug
|
|
}
|
|
|
|
// SetFormality sets the default formality level for translations.
|
|
// This affects languages that distinguish formal/informal address (Sie/du, vous/tu).
|
|
//
|
|
// svc.SetFormality(FormalityFormal) // Use formal address
|
|
func (s *Service) SetFormality(f Formality) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.formality = f
|
|
}
|
|
|
|
// Formality returns the current formality level.
|
|
func (s *Service) Formality() Formality {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.formality
|
|
}
|
|
|
|
// Direction returns the text direction for the current language.
|
|
func (s *Service) Direction() TextDirection {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if IsRTLLanguage(s.currentLang) {
|
|
return DirRTL
|
|
}
|
|
return DirLTR
|
|
}
|
|
|
|
// IsRTL returns true if the current language uses right-to-left text direction.
|
|
func (s *Service) IsRTL() bool {
|
|
return s.Direction() == DirRTL
|
|
}
|
|
|
|
// PluralCategory returns the plural category for a count in the current language.
|
|
func (s *Service) PluralCategory(n int) PluralCategory {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return GetPluralCategory(s.currentLang, n)
|
|
}
|
|
|
|
// T translates a message by its ID.
|
|
// Optional template data can be passed for interpolation.
|
|
//
|
|
// For plural messages, pass a map with "Count" to select the form:
|
|
//
|
|
// svc.T("cli.count.items", map[string]any{"Count": 5})
|
|
//
|
|
// For semantic intents (core.* namespace), pass a Subject to get the Question form:
|
|
//
|
|
// svc.T("core.delete", S("file", "config.yaml")) // "Delete config.yaml?"
|
|
//
|
|
// # Fallback Chain
|
|
//
|
|
// When a key is not found, T() tries a fallback chain:
|
|
// 1. Try the exact key in current language
|
|
// 2. Try the exact key in fallback language
|
|
// 3. If key looks like an intent (contains "."), try common.action.{verb}
|
|
// 4. Return the key as-is (or handle according to mode)
|
|
func (s *Service) T(messageID string, args ...any) string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
// Check for semantic intent with Subject
|
|
if strings.HasPrefix(messageID, "core.") && len(args) > 0 {
|
|
if subject, ok := args[0].(*Subject); ok {
|
|
// Use C() to resolve the intent, return Question form
|
|
s.mu.RUnlock()
|
|
result := s.C(messageID, subject)
|
|
s.mu.RLock()
|
|
return result.Question
|
|
}
|
|
}
|
|
|
|
// Get template data
|
|
var data any
|
|
if len(args) > 0 {
|
|
data = args[0]
|
|
}
|
|
|
|
// Try fallback chain
|
|
text := s.resolveWithFallback(messageID, data)
|
|
if text == "" {
|
|
return s.handleMissingKey(messageID, args)
|
|
}
|
|
|
|
// Debug mode: prefix with key
|
|
if s.debug {
|
|
return "[" + messageID + "] " + text
|
|
}
|
|
|
|
return text
|
|
}
|
|
|
|
// resolveWithFallback implements the fallback chain for message resolution.
|
|
// Must be called with s.mu.RLock held.
|
|
func (s *Service) resolveWithFallback(messageID string, data any) string {
|
|
// 1. Try exact key in current language
|
|
if text := s.tryResolve(s.currentLang, messageID, data); text != "" {
|
|
return text
|
|
}
|
|
|
|
// 2. Try exact key in fallback language
|
|
if text := s.tryResolve(s.fallbackLang, messageID, data); text != "" {
|
|
return text
|
|
}
|
|
|
|
// 3. Try fallback patterns for intent-like keys
|
|
if strings.Contains(messageID, ".") {
|
|
parts := strings.Split(messageID, ".")
|
|
verb := parts[len(parts)-1]
|
|
|
|
// Try common.action.{verb}
|
|
commonKey := "common.action." + verb
|
|
if text := s.tryResolve(s.currentLang, commonKey, data); text != "" {
|
|
return text
|
|
}
|
|
if text := s.tryResolve(s.fallbackLang, commonKey, data); text != "" {
|
|
return text
|
|
}
|
|
|
|
// Try common.{verb}
|
|
commonKey = "common." + verb
|
|
if text := s.tryResolve(s.currentLang, commonKey, data); text != "" {
|
|
return text
|
|
}
|
|
if text := s.tryResolve(s.fallbackLang, commonKey, data); text != "" {
|
|
return text
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// tryResolve attempts to resolve a single key in a single language.
|
|
// Returns empty string if not found.
|
|
// Must be called with s.mu.RLock held.
|
|
func (s *Service) tryResolve(lang, key string, data any) string {
|
|
msg, ok := s.getMessage(lang, key)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
text := msg.Text
|
|
if msg.IsPlural() {
|
|
count := getCount(data)
|
|
category := GetPluralCategory(lang, count)
|
|
text = msg.ForCategory(category)
|
|
}
|
|
|
|
if text == "" {
|
|
return ""
|
|
}
|
|
|
|
// Apply template if we have data
|
|
if data != nil {
|
|
text = applyTemplate(text, data)
|
|
}
|
|
|
|
return text
|
|
}
|
|
|
|
// handleMissingKey handles a missing translation key based on the current mode.
|
|
// Must be called with s.mu.RLock held.
|
|
func (s *Service) handleMissingKey(key string, args []any) string {
|
|
switch s.mode {
|
|
case ModeStrict:
|
|
panic(fmt.Sprintf("i18n: missing translation key %q", key))
|
|
case ModeCollect:
|
|
// Convert args to map for the action
|
|
var argsMap map[string]any
|
|
if len(args) > 0 {
|
|
if m, ok := args[0].(map[string]any); ok {
|
|
argsMap = m
|
|
}
|
|
}
|
|
dispatchMissingKey(key, argsMap)
|
|
return "[" + key + "]"
|
|
default:
|
|
return key
|
|
}
|
|
}
|
|
|
|
// C composes a semantic intent with a subject.
|
|
// Returns all output forms (Question, Confirm, Success, Failure) for the intent.
|
|
//
|
|
// result := svc.C("core.delete", S("file", "config.yaml"))
|
|
// fmt.Println(result.Question) // "Delete config.yaml?"
|
|
// fmt.Println(result.Success) // "Config.yaml deleted"
|
|
func (s *Service) C(intent string, subject *Subject) *Composed {
|
|
// Look up the intent definition
|
|
intentDef := getIntent(intent)
|
|
if intentDef == nil {
|
|
// Intent not found, handle as missing key
|
|
s.mu.RLock()
|
|
mode := s.mode
|
|
s.mu.RUnlock()
|
|
|
|
switch mode {
|
|
case ModeStrict:
|
|
panic(fmt.Sprintf("i18n: missing intent %q", intent))
|
|
case ModeCollect:
|
|
dispatchMissingKey(intent, nil)
|
|
return &Composed{
|
|
Question: "[" + intent + "]",
|
|
Confirm: "[" + intent + "]",
|
|
Success: "[" + intent + "]",
|
|
Failure: "[" + intent + "]",
|
|
}
|
|
default:
|
|
return &Composed{
|
|
Question: intent,
|
|
Confirm: intent,
|
|
Success: intent,
|
|
Failure: intent,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create template data from subject
|
|
data := newTemplateData(subject)
|
|
|
|
result := &Composed{
|
|
Question: executeIntentTemplate(intentDef.Question, data),
|
|
Confirm: executeIntentTemplate(intentDef.Confirm, data),
|
|
Success: executeIntentTemplate(intentDef.Success, data),
|
|
Failure: executeIntentTemplate(intentDef.Failure, data),
|
|
Meta: intentDef.Meta,
|
|
}
|
|
|
|
// Debug mode: prefix each form with the intent key
|
|
s.mu.RLock()
|
|
debug := s.debug
|
|
s.mu.RUnlock()
|
|
if debug {
|
|
prefix := "[" + intent + "] "
|
|
result.Question = prefix + result.Question
|
|
result.Confirm = prefix + result.Confirm
|
|
result.Success = prefix + result.Success
|
|
result.Failure = prefix + result.Failure
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// templateCache stores compiled templates for reuse.
|
|
// Key is the template string, value is the compiled template.
|
|
var templateCache sync.Map
|
|
|
|
// executeIntentTemplate executes an intent template with the given data.
|
|
// Templates are cached for performance - repeated calls with the same template
|
|
// string will reuse the compiled template.
|
|
func executeIntentTemplate(tmplStr string, data templateData) string {
|
|
if tmplStr == "" {
|
|
return ""
|
|
}
|
|
|
|
// Check cache first
|
|
if cached, ok := templateCache.Load(tmplStr); ok {
|
|
var buf bytes.Buffer
|
|
if err := cached.(*template.Template).Execute(&buf, data); err != nil {
|
|
return tmplStr
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
// Parse and cache
|
|
tmpl, err := template.New("").Funcs(TemplateFuncs()).Parse(tmplStr)
|
|
if err != nil {
|
|
return tmplStr
|
|
}
|
|
|
|
// Store in cache (safe even if another goroutine stored it first)
|
|
templateCache.Store(tmplStr, tmpl)
|
|
|
|
var buf bytes.Buffer
|
|
if err := tmpl.Execute(&buf, data); err != nil {
|
|
return tmplStr
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
// _ is the standard gettext-style translation helper. Alias for T().
|
|
func (s *Service) _(messageID string, args ...any) string {
|
|
return s.T(messageID, args...)
|
|
}
|
|
|
|
func (s *Service) getMessage(lang, key string) (Message, bool) {
|
|
msgs, ok := s.messages[lang]
|
|
if !ok {
|
|
return Message{}, false
|
|
}
|
|
msg, ok := msgs[key]
|
|
return msg, ok
|
|
}
|
|
|
|
func getCount(data any) int {
|
|
if data == nil {
|
|
return 0
|
|
}
|
|
switch d := data.(type) {
|
|
case map[string]any:
|
|
if c, ok := d["Count"]; ok {
|
|
return toInt(c)
|
|
}
|
|
case map[string]int:
|
|
if c, ok := d["Count"]; ok {
|
|
return c
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func toInt(v any) int {
|
|
switch n := v.(type) {
|
|
case int:
|
|
return n
|
|
case int64:
|
|
return int(n)
|
|
case float64:
|
|
return int(n)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func applyTemplate(text string, data any) string {
|
|
// Quick check for template syntax
|
|
if !strings.Contains(text, "{{") {
|
|
return text
|
|
}
|
|
|
|
tmpl, err := template.New("").Parse(text)
|
|
if err != nil {
|
|
return text
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := tmpl.Execute(&buf, data); err != nil {
|
|
return text
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
// AddMessages adds messages for a language at runtime.
|
|
func (s *Service) AddMessages(lang string, messages map[string]string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.messages[lang] == nil {
|
|
s.messages[lang] = make(map[string]Message)
|
|
}
|
|
for key, text := range messages {
|
|
s.messages[lang][key] = Message{Text: text}
|
|
}
|
|
}
|
|
|
|
// LoadFS loads additional locale files from a filesystem.
|
|
func (s *Service) LoadFS(fsys fs.FS, dir string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
entries, err := fs.ReadDir(fsys, dir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read locales directory: %w", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
|
continue
|
|
}
|
|
|
|
filePath := filepath.Join(dir, entry.Name())
|
|
data, err := fs.ReadFile(fsys, filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read locale %s: %w", entry.Name(), err)
|
|
}
|
|
|
|
lang := strings.TrimSuffix(entry.Name(), ".json")
|
|
lang = strings.ReplaceAll(lang, "_", "-")
|
|
|
|
if err := s.loadJSON(lang, data); err != nil {
|
|
return fmt.Errorf("failed to parse locale %s: %w", entry.Name(), err)
|
|
}
|
|
|
|
// Add to available languages if new
|
|
tag := language.Make(lang)
|
|
found := false
|
|
for _, existing := range s.availableLangs {
|
|
if existing == tag {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
s.availableLangs = append(s.availableLangs, tag)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|