612c417024
gates / gates (push) Successful in 19s
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
212 lines
6.9 KiB
Go
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
|
|
}
|