Files
felhom-controller/controller/internal/i18n/i18n.go
T
admin 612c417024
gates / gates (push) Successful in 19s
v0.247.0: i18n spike — the dashboard can speak English, Hungarian byte-identical
Message bundles (internal/i18n) expanded into templates before parsing, one
template set per language. Launcher, /backups, /apps/<slug> and the layout
converted; household language setting, POST /settings/language, ?lang= override,
report field. Parity test against fixtures captured from unconverted templates;
copy gates read templates expanded; new i18n_missing_gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 14:50:57 +02:00

212 lines
6.9 KiB
Go

// Package i18n is the controller's message bundle: one flat key → text map per language, embedded in
// the binary, Hungarian first and authoritative.
//
// Design: felhom.eu/documentation/architecture/10-localisation.md. The three rules that matter here:
//
// 1. HUNGARIAN IS THE SOURCE. hu.json holds every key. A key missing from hu is a build error (the
// template set refuses to load, TestBundleKeysUsedExistInHungarian fails) — never a runtime blank.
// 2. FALLBACK. A key missing from another language shows the Hungarian text, and is COUNTED
// (MissingKeys). A missing line never shows a key or an empty box.
// 3. TEMPLATES ARE EXPANDED, NOT CALLED. A `{{T "key"}}` marker in a dashboard template is replaced by
// the bundle text BEFORE html/template parses the file (Expand). The Hungarian template set is
// therefore parsed from exactly the bytes the template carried before it was converted, in the same
// escaping contexts — which is what makes byte-identical Hungarian output achievable at all.
// A runtime T func would route every string through the contextual escaper (`+`, `'`, `"` change
// bytes) and would force per-context handling inside <script>. Pinned by the parity test
// (internal/web/i18n_parity_test.go), not by this comment.
//
// A consequence of rule 3, stated so nobody is surprised by it: a bundle value MAY contain template
// actions ({{.Domain}}) and markup (<strong>). They are the message's parameters and emphasis, kept
// inside one message so a translator can move them. TestBundleActionsMatchAcrossLanguages pins that a
// translation carries the same actions as the Hungarian.
package i18n
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"regexp"
"sort"
"strings"
"sync"
)
//go:embed locales/*.json
var localeFS embed.FS
// Default is the language of a box that never chose one — and the only language every key has.
const Default = "hu"
// Supported lists the languages a household may pick, Default first.
var Supported = []string{"hu", "en"}
// markerRe matches a template marker. Deliberately strict: a malformed marker is NOT expanded, so it
// reaches html/template as a call to an undefined function "T" and the template set fails to load —
// loud at startup and in every render test, never a silently shown marker.
var markerRe = regexp.MustCompile(`\{\{\s*T\s+"([A-Za-z0-9_.\-]+)"\s*\}\}`)
// Bundle holds every language's messages.
type Bundle struct {
msgs map[string]map[string]string
}
// Normalize maps any input to a supported language; everything unknown is Default.
func Normalize(lang string) string {
l := strings.ToLower(strings.TrimSpace(lang))
for _, s := range Supported {
if l == s {
return s
}
}
return Default
}
// IsSupported reports whether lang is exactly one of Supported.
func IsSupported(lang string) bool {
for _, s := range Supported {
if lang == s {
return true
}
}
return false
}
// Load reads the embedded bundles.
func Load() (*Bundle, error) {
return loadFrom(localeFS)
}
func loadFrom(fsys fs.FS) (*Bundle, error) {
b := &Bundle{msgs: map[string]map[string]string{}}
for _, lang := range Supported {
data, err := fs.ReadFile(fsys, "locales/"+lang+".json")
if err != nil {
return nil, fmt.Errorf("i18n: read %s bundle: %w", lang, err)
}
m := map[string]string{}
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("i18n: parse %s bundle: %w", lang, err)
}
b.msgs[lang] = m
}
return b, nil
}
var (
shared *Bundle
sharedErr error
sharedOnce sync.Once
)
// Shared returns the process-wide bundle, loaded once. A bundle that fails to load is a broken build
// (the files are embedded), so callers treat the error as fatal.
func Shared() (*Bundle, error) {
sharedOnce.Do(func() { shared, sharedErr = Load() })
return shared, sharedErr
}
// Text returns key in lang. fellBack is true when the Hungarian text was used instead (lang lacks the
// key); ok is false only when even Hungarian lacks it.
func (b *Bundle) Text(lang, key string) (text string, fellBack, ok bool) {
lang = Normalize(lang)
if v, found := b.msgs[lang][key]; found {
return v, false, true
}
if v, found := b.msgs[Default][key]; found {
return v, lang != Default, true
}
return "", false, false
}
// Msg is Text for Go-side copy. A key absent from Hungarian too is a programming error that the key
// test refuses to ship; at runtime it returns the Hungarian-less key rather than an empty string so the
// defect is visible, never a blank box.
func (b *Bundle) Msg(lang, key string) string {
if v, _, ok := b.Text(lang, key); ok {
return v
}
return key
}
// Plural picks a count-dependent form and formats n into it (%d). Hungarian does not inflect a noun
// after a numeral ("3 perce", "1 perce"), so hu carries ONE form under the key itself. English carries
// key+".one" and key+".other". A language without the split forms falls back to the plain key.
func (b *Bundle) Plural(lang, key string, n int) string {
lang = Normalize(lang)
form := ".other"
if n == 1 {
form = ".one"
}
if v, ok := b.msgs[lang][key+form]; ok {
return fmt.Sprintf(v, n)
}
return fmt.Sprintf(b.Msg(lang, key), n)
}
// Keys returns lang's keys, sorted.
func (b *Bundle) Keys(lang string) []string {
out := make([]string, 0, len(b.msgs[lang]))
for k := range b.msgs[lang] {
out = append(out, k)
}
sort.Strings(out)
return out
}
// Has reports whether lang itself carries key (a plural key counts when its ".other" form exists).
func (b *Bundle) Has(lang, key string) bool {
if _, ok := b.msgs[lang][key]; ok {
return true
}
_, ok := b.msgs[lang][key+".other"]
return ok
}
// MissingKeys lists the Hungarian keys lang does not carry — the lines that show Hungarian.
func (b *Bundle) MissingKeys(lang string) []string {
var out []string
for _, k := range b.Keys(Default) {
if !b.Has(lang, k) {
out = append(out, k)
}
}
return out
}
// ExpandStats counts what one Expand did.
type ExpandStats struct {
Markers int // markers replaced
FellBack []string // keys that used the Hungarian text
Undefined []string // keys absent from every language — left unexpanded, so the parse fails
}
// Expand replaces every `{{T "key"}}` marker in a template source with lang's text. Undefined keys are
// left in place on purpose (see markerRe): the caller's template parse then fails naming "T".
func (b *Bundle) Expand(lang, src string) (string, ExpandStats) {
var st ExpandStats
out := markerRe.ReplaceAllStringFunc(src, func(m string) string {
key := markerRe.FindStringSubmatch(m)[1]
text, fellBack, ok := b.Text(lang, key)
if !ok {
st.Undefined = append(st.Undefined, key)
return m
}
st.Markers++
if fellBack {
st.FellBack = append(st.FellBack, key)
}
return text
})
return out, st
}
// MarkerKeys returns the keys referenced by markers in src, in order of appearance.
func MarkerKeys(src string) []string {
var out []string
for _, m := range markerRe.FindAllStringSubmatch(src, -1) {
out = append(out, m[1])
}
return out
}