feat(i18n): add RegisterLocales for package locale registration

- Add i18n.RegisterLocales(fsys, dir) for packages to register translations
- Locales are automatically loaded when i18n.Init() is called
- Fix gram.word.* loading bug (strings were in wrong switch case)
- Fix loadJSON to merge messages instead of replacing
- Add common.* keys to base locale (labels, flags, progress, etc.)
- Add pkg/php/locales with PHP-specific translations
- pkg/php/i18n.go registers locales via init()

This enables the idiomatic pattern where packages register their
locale files and they're automatically loaded by the i18n system.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-30 20:51:32 +00:00
parent 415e558c19
commit 71e15236e2
6 changed files with 263 additions and 14 deletions

View file

@ -2,12 +2,61 @@
package i18n
import (
"io/fs"
"runtime"
"sync"
"sync/atomic"
)
var missingKeyHandler atomic.Value // stores MissingKeyHandler
// localeRegistration holds a filesystem and directory for locale loading.
type localeRegistration struct {
fsys fs.FS
dir string
}
var (
registeredLocales []localeRegistration
registeredLocalesMu sync.Mutex
localesLoaded bool
)
// RegisterLocales registers a filesystem containing locale files to be loaded.
// Call this in your package's init() to register translations.
// Locales are loaded when the i18n service initialises.
//
// //go:embed locales/*.json
// var localeFS embed.FS
//
// func init() {
// i18n.RegisterLocales(localeFS, "locales")
// }
func RegisterLocales(fsys fs.FS, dir string) {
registeredLocalesMu.Lock()
defer registeredLocalesMu.Unlock()
registeredLocales = append(registeredLocales, localeRegistration{fsys: fsys, dir: dir})
// If locales already loaded (service already running), load immediately
if localesLoaded {
if svc := Default(); svc != nil {
_ = svc.LoadFS(fsys, dir)
}
}
}
// loadRegisteredLocales loads all registered locale filesystems into the service.
// Called by the service during initialisation.
func loadRegisteredLocales(svc *Service) {
registeredLocalesMu.Lock()
defer registeredLocalesMu.Unlock()
for _, reg := range registeredLocales {
_ = svc.LoadFS(reg.fsys, reg.dir)
}
localesLoaded = true
}
// OnMissingKey registers a handler for missing translation keys.
// Called when T() can't find a key in ModeCollect.
// Thread-safe: can be called concurrently with translations.

View file

@ -120,6 +120,12 @@ func flattenWithGrammar(prefix string, data map[string]any, out map[string]Messa
switch v := value.(type) {
case string:
// Check if this is a word in gram.word.*
if grammar != nil && strings.HasPrefix(fullKey, "gram.word.") {
wordKey := strings.TrimPrefix(fullKey, "gram.word.")
grammar.Words[strings.ToLower(wordKey)] = v
continue
}
out[fullKey] = Message{Text: v}
case map[string]any:
@ -188,19 +194,6 @@ func flattenWithGrammar(prefix string, data map[string]any, out map[string]Messa
continue
}
// Check if this is a base word in gram.word.*
if grammar != nil && strings.HasPrefix(fullKey, "gram.word.") {
wordKey := strings.TrimPrefix(fullKey, "gram.word.")
// v could be a string or a nested object
if str, ok := value.(string); ok {
if grammar.Words == nil {
grammar.Words = make(map[string]string)
}
grammar.Words[strings.ToLower(wordKey)] = str
}
continue
}
// Check if this is a plural object (has CLDR plural category keys)
if isPluralObject(v) {
msg := Message{}

View file

@ -252,6 +252,39 @@
"templates.short": "Manage LinuxKit templates"
}
},
"common": {
"label": {
"done": "Done",
"error": "Error",
"warning": "Warning",
"info": "Info",
"fix": "Fix:",
"install": "Install:",
"summary": "Summary:",
"setup": "Setup:"
},
"flag": {
"fix": "Auto-fix issues where possible",
"diff": "Show diff of changes",
"json": "Output as JSON"
},
"progress": {
"running": "Running {{.Task}}...",
"checking": "Checking {{.Item}}..."
},
"result": {
"no_issues": "No issues found"
},
"success": {
"completed": "{{.Action}} successfully"
},
"error": {
"failed": "Failed to {{.Action}}"
},
"hint": {
"fix_deps": "Update dependencies to fix vulnerabilities"
}
},
"error": {
"gh_not_found": "'gh' CLI not found. Install from https://cli.github.com/",
"registry_not_found": "No repos.yaml found",

View file

@ -152,6 +152,8 @@ func Init() error {
svc, err := New()
if err == nil {
defaultService.Store(svc)
// Load any locales registered by packages before Init was called
loadRegisteredLocales(svc)
}
defaultErr = err
})
@ -177,6 +179,7 @@ func SetDefault(s *Service) {
// loadJSON parses nested JSON and flattens to dot-notation keys.
// Also extracts grammar data (verbs, nouns, articles) for the language.
// If messages already exist for the language, new messages are merged in.
func (s *Service) loadJSON(lang string, data []byte) error {
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
@ -191,7 +194,15 @@ func (s *Service) loadJSON(lang string, data []byte) error {
}
flattenWithGrammar("", raw, messages, grammarData)
s.messages[lang] = messages
// Merge new messages into existing (or create new map)
if existing, ok := s.messages[lang]; ok {
for key, msg := range messages {
existing[key] = msg
}
} else {
s.messages[lang] = messages
}
// Store grammar data if any was found
if len(grammarData.Verbs) > 0 || len(grammarData.Nouns) > 0 || len(grammarData.Words) > 0 {

16
pkg/php/i18n.go Normal file
View file

@ -0,0 +1,16 @@
// Package php provides PHP/Laravel development tools.
package php
import (
"embed"
"github.com/host-uk/core/pkg/i18n"
)
//go:embed locales/*.json
var localeFS embed.FS
func init() {
// Register PHP translations with the i18n system
i18n.RegisterLocales(localeFS, "locales")
}

147
pkg/php/locales/en_GB.json Normal file
View file

@ -0,0 +1,147 @@
{
"cmd": {
"php": {
"short": "Laravel/PHP development tools",
"long": "Laravel and PHP development tools including testing, formatting, static analysis, and deployment",
"label": {
"php": "PHP:",
"audit": "Audit:",
"psalm": "Psalm:",
"rector": "Rector:",
"security": "Security:",
"infection": "Infection:",
"info": "Info:",
"setup": "Setup:"
},
"error": {
"not_php": "Not a PHP project (no composer.json found)",
"fmt_failed": "Formatting failed",
"fmt_issues": "Style issues found",
"analysis_issues": "Analysis errors found",
"audit_failed": "Audit failed",
"vulns_found": "Vulnerabilities found",
"psalm_not_installed": "Psalm not installed",
"psalm_issues": "Psalm found type errors",
"rector_not_installed": "Rector not installed",
"rector_failed": "Rector failed",
"infection_not_installed": "Infection not installed",
"infection_failed": "Mutation testing failed",
"security_failed": "Security check failed",
"critical_high_issues": "Critical or high severity issues found"
},
"test": {
"short": "Run PHPUnit/Pest tests",
"long": "Run PHPUnit or Pest tests with optional filtering, parallel execution, and coverage",
"flag": {
"parallel": "Run tests in parallel",
"coverage": "Generate code coverage report",
"filter": "Filter tests by name",
"group": "Run only tests in this group"
}
},
"fmt": {
"short": "Format PHP code with Laravel Pint",
"long": "Format PHP code using Laravel Pint code style fixer",
"no_formatter": "No code formatter found (install laravel/pint)",
"no_issues": "No style issues found",
"formatting": "Formatting with {{.Formatter}}...",
"flag": {
"fix": "Fix style issues (default: check only)"
}
},
"analyse": {
"short": "Run PHPStan static analysis",
"long": "Run PHPStan/Larastan for static code analysis",
"no_analyser": "No static analyser found (install phpstan/phpstan or nunomaduro/larastan)",
"flag": {
"level": "Analysis level (0-9, default: from config)",
"memory": "Memory limit (e.g., 2G)"
}
},
"audit": {
"short": "Security audit for dependencies",
"long": "Audit Composer and NPM dependencies for known vulnerabilities",
"scanning": "Scanning dependencies for vulnerabilities...",
"secure": "No vulnerabilities",
"error": "Audit error",
"vulnerabilities": "{{.Count}} vulnerabilities found",
"found_vulns": "Found {{.Count}} vulnerabilities",
"all_secure": "All dependencies secure",
"completed_errors": "Audit completed with errors",
"flag": {
"fix": "Attempt to fix vulnerabilities"
}
},
"psalm": {
"short": "Run Psalm static analysis",
"long": "Run Psalm for deep static analysis and type checking",
"not_found": "Psalm not found",
"install": "composer require --dev vimeo/psalm",
"setup": "vendor/bin/psalm --init",
"analysing": "Analysing with Psalm...",
"analysing_fixing": "Analysing and fixing with Psalm...",
"flag": {
"level": "Analysis level (1-8)",
"baseline": "Generate or update baseline",
"show_info": "Show informational issues"
}
},
"rector": {
"short": "Automated code refactoring",
"long": "Run Rector for automated code upgrades and refactoring",
"not_found": "Rector not found",
"install": "composer require --dev rector/rector",
"setup": "vendor/bin/rector init",
"analysing": "Analysing code for refactoring opportunities...",
"refactoring": "Refactoring code...",
"no_changes": "No refactoring changes needed",
"changes_suggested": "Rector suggests changes (run with --fix to apply)",
"flag": {
"fix": "Apply refactoring changes",
"diff": "Show diff of changes",
"clear_cache": "Clear Rector cache before running"
}
},
"infection": {
"short": "Mutation testing for test quality",
"long": "Run Infection mutation testing to measure test suite quality",
"not_found": "Infection not found",
"install": "composer require --dev infection/infection",
"note": "This may take a while depending on test suite size",
"complete": "Mutation testing complete",
"flag": {
"min_msi": "Minimum Mutation Score Indicator (0-100)",
"min_covered_msi": "Minimum covered code MSI (0-100)",
"threads": "Number of parallel threads",
"filter": "Filter mutants by file path",
"only_covered": "Only mutate covered code"
}
},
"security": {
"short": "Security vulnerability scanning",
"long": "Run comprehensive security checks on PHP codebase",
"checks_suffix": " CHECKS",
"summary": "Security scan complete",
"passed": "Passed:",
"critical": "Critical:",
"high": "High:",
"medium": "Medium:",
"low": "Low:",
"flag": {
"severity": "Minimum severity to report (low, medium, high, critical)",
"sarif": "Output in SARIF format",
"url": "Application URL for runtime checks"
}
},
"qa": {
"short": "Run full QA pipeline",
"long": "Run comprehensive quality assurance: audit, format, analyse, test, and more",
"flag": {
"quick": "Run quick checks only (audit, fmt, stan)",
"full": "Run all stages including slow checks",
"fix": "Auto-fix issues where possible"
}
}
}
}
}