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
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
// 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
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