From 71e15236e2eb955dcd2f2b853c18a4916907ad92 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 30 Jan 2026 20:51:32 +0000 Subject: [PATCH] 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 --- pkg/i18n/hooks.go | 49 ++++++++++++ pkg/i18n/loader.go | 19 ++--- pkg/i18n/locales/en_GB.json | 33 ++++++++ pkg/i18n/service.go | 13 +++- pkg/php/i18n.go | 16 ++++ pkg/php/locales/en_GB.json | 147 ++++++++++++++++++++++++++++++++++++ 6 files changed, 263 insertions(+), 14 deletions(-) create mode 100644 pkg/php/i18n.go create mode 100644 pkg/php/locales/en_GB.json diff --git a/pkg/i18n/hooks.go b/pkg/i18n/hooks.go index 6694d9c..5a8049e 100644 --- a/pkg/i18n/hooks.go +++ b/pkg/i18n/hooks.go @@ -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. diff --git a/pkg/i18n/loader.go b/pkg/i18n/loader.go index b767861..b6df355 100644 --- a/pkg/i18n/loader.go +++ b/pkg/i18n/loader.go @@ -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{} diff --git a/pkg/i18n/locales/en_GB.json b/pkg/i18n/locales/en_GB.json index e605c38..0b82d79 100644 --- a/pkg/i18n/locales/en_GB.json +++ b/pkg/i18n/locales/en_GB.json @@ -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", diff --git a/pkg/i18n/service.go b/pkg/i18n/service.go index cd14a4b..91d1181 100644 --- a/pkg/i18n/service.go +++ b/pkg/i18n/service.go @@ -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 { diff --git a/pkg/php/i18n.go b/pkg/php/i18n.go new file mode 100644 index 0000000..f0f7194 --- /dev/null +++ b/pkg/php/i18n.go @@ -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") +} diff --git a/pkg/php/locales/en_GB.json b/pkg/php/locales/en_GB.json new file mode 100644 index 0000000..4f74cd8 --- /dev/null +++ b/pkg/php/locales/en_GB.json @@ -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" + } + } + } + } +}