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:
parent
415e558c19
commit
71e15236e2
6 changed files with 263 additions and 14 deletions
|
|
@ -2,12 +2,61 @@
|
||||||
package i18n
|
package i18n
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
)
|
)
|
||||||
|
|
||||||
var missingKeyHandler atomic.Value // stores MissingKeyHandler
|
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.
|
// OnMissingKey registers a handler for missing translation keys.
|
||||||
// Called when T() can't find a key in ModeCollect.
|
// Called when T() can't find a key in ModeCollect.
|
||||||
// Thread-safe: can be called concurrently with translations.
|
// Thread-safe: can be called concurrently with translations.
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,12 @@ func flattenWithGrammar(prefix string, data map[string]any, out map[string]Messa
|
||||||
|
|
||||||
switch v := value.(type) {
|
switch v := value.(type) {
|
||||||
case string:
|
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}
|
out[fullKey] = Message{Text: v}
|
||||||
|
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
|
|
@ -188,19 +194,6 @@ func flattenWithGrammar(prefix string, data map[string]any, out map[string]Messa
|
||||||
continue
|
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)
|
// Check if this is a plural object (has CLDR plural category keys)
|
||||||
if isPluralObject(v) {
|
if isPluralObject(v) {
|
||||||
msg := Message{}
|
msg := Message{}
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,39 @@
|
||||||
"templates.short": "Manage LinuxKit templates"
|
"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": {
|
"error": {
|
||||||
"gh_not_found": "'gh' CLI not found. Install from https://cli.github.com/",
|
"gh_not_found": "'gh' CLI not found. Install from https://cli.github.com/",
|
||||||
"registry_not_found": "No repos.yaml found",
|
"registry_not_found": "No repos.yaml found",
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,8 @@ func Init() error {
|
||||||
svc, err := New()
|
svc, err := New()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defaultService.Store(svc)
|
defaultService.Store(svc)
|
||||||
|
// Load any locales registered by packages before Init was called
|
||||||
|
loadRegisteredLocales(svc)
|
||||||
}
|
}
|
||||||
defaultErr = err
|
defaultErr = err
|
||||||
})
|
})
|
||||||
|
|
@ -177,6 +179,7 @@ func SetDefault(s *Service) {
|
||||||
|
|
||||||
// loadJSON parses nested JSON and flattens to dot-notation keys.
|
// loadJSON parses nested JSON and flattens to dot-notation keys.
|
||||||
// Also extracts grammar data (verbs, nouns, articles) for the language.
|
// 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 {
|
func (s *Service) loadJSON(lang string, data []byte) error {
|
||||||
var raw map[string]any
|
var raw map[string]any
|
||||||
if err := json.Unmarshal(data, &raw); err != nil {
|
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)
|
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
|
// Store grammar data if any was found
|
||||||
if len(grammarData.Verbs) > 0 || len(grammarData.Nouns) > 0 || len(grammarData.Words) > 0 {
|
if len(grammarData.Verbs) > 0 || len(grammarData.Nouns) > 0 || len(grammarData.Words) > 0 {
|
||||||
|
|
|
||||||
16
pkg/php/i18n.go
Normal file
16
pkg/php/i18n.go
Normal 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
147
pkg/php/locales/en_GB.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue