diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go index 0c156bb..cbb68e2 100644 --- a/examples/hello/hello_test.go +++ b/examples/hello/hello_test.go @@ -130,6 +130,16 @@ func TestHelloPluginResolvesLang(t *testing.T) { if got := tr.GetIn("en", "golem15.hello::lang.greeting", nil); got != "Hello" { t.Fatalf("greeting = %q", got) } + if got := tr.GetIn("pl-PL", "golem15.hello::lang.greeting", nil); got != "Cześć" { + t.Fatalf("pl-PL = %q", got) + } + if got := tr.GetIn("de", "golem15.hello::lang.greeting", nil); got != "Hello" { + t.Fatalf("fallback en = %q", got) + } + const missing = "golem15.hello::lang.does.not.exist" + if got := tr.GetIn("pl-PL", missing, nil); got != missing { + t.Fatalf("raw key = %q", got) + } } func TestTypedItemRoute(t *testing.T) { diff --git a/phrasebook/translator.go b/phrasebook/translator.go index 0087917..a0f6ea4 100644 --- a/phrasebook/translator.go +++ b/phrasebook/translator.go @@ -3,6 +3,7 @@ package phrasebook import ( "context" "fmt" + "log/slog" "strconv" "strings" "sync" @@ -55,6 +56,7 @@ type Translator struct { locale string fallback string production bool + missing sync.Map } // NewTranslator returns a translator that reads from cat. Empty locale or @@ -122,7 +124,7 @@ func (t *Translator) GetIn(locale, key string, params map[string]string) string if t == nil { return key } - e, ok := t.cat.lookup(locale, key) + e, _, ok := t.find(locale, key) if !ok { return key } @@ -143,20 +145,74 @@ func (t *Translator) ChoiceIn(locale, key string, count any, params map[string]s if t == nil { return key } - e, ok := t.cat.lookup(locale, key) + e, found, ok := t.find(locale, key) if !ok { return key } text := e.text switch { case len(e.pipes) > 0: - text = selectPipe(e.pipes, locale, count) + text = selectPipe(e.pipes, found, count) case e.plurals != nil: - text = selectMap(e.plurals, locale, count) + text = selectMap(e.plurals, found, count) } return interpolate(text, withCount(params, count)) } +func (t *Translator) find(locale, key string) (entry, string, bool) { + if t == nil { + return entry{}, locale, false + } + fallback := defaultFallback + if t.fallback != "" { + fallback = t.fallback + } + for _, step := range fallbackChain(locale, fallback) { + if e, ok := t.cat.lookup(step, key); ok { + return e, step, true + } + } + t.logMissing(key) + return entry{}, locale, false +} + +func (t *Translator) logMissing(key string) { + if t.production { + return + } + if _, loaded := t.missing.LoadOrStore(key, struct{}{}); loaded { + return + } + slog.Warn("phrasebook: missing translation key", "key", key) +} + +func fallbackChain(requested, fallback string) []string { + out := make([]string, 0, 4) + seen := make(map[string]struct{}, 4) + add := func(s string) { + s = strings.TrimSpace(s) + if s == "" { + return + } + if _, ok := seen[s]; ok { + return + } + seen[s] = struct{}{} + out = append(out, s) + } + add(requested) + if tag, err := language.Parse(requested); err == nil { + add(tag.String()) + for p := tag.Parent(); !p.IsRoot(); p = p.Parent() { + add(p.String()) + } + } else if i := strings.IndexAny(requested, "-_"); i > 0 { + add(requested[:i]) + } + add(fallback) + return out +} + func selectMap(plurals map[string]string, locale string, count any) string { cat, err := cldrCategory(locale, count) if err != nil { diff --git a/phrasebook/translator_test.go b/phrasebook/translator_test.go index 4fc4224..911750b 100644 --- a/phrasebook/translator_test.go +++ b/phrasebook/translator_test.go @@ -1,13 +1,17 @@ package phrasebook import ( + "bytes" + "context" "io/fs" + "log/slog" "strings" "testing" "testing/fstest" "git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/compass" + "git.golem15.com/golem15/summercms/towel" ) func TestTranslationSmoke(t *testing.T) { @@ -189,3 +193,59 @@ func TestPluralSmoke(t *testing.T) { } }) } + +func TestLocaleFallbackSmoke(t *testing.T) { + fsys := fstest.MapFS{ + "lang/en/lang.yaml": {Data: []byte("greeting: Hello\nonlyEn: English only\n")}, + "lang/pl/lang.yaml": {Data: []byte("greeting: Cześć\n")}, + } + cat := NewCatalog() + if err := cat.Load("golem15.hello", fsys); err != nil { + t.Fatalf("Load: %v", err) + } + tr := NewTranslator(cat, Options{}) + if tr.locale != "en" || tr.fallback != "en" { + t.Fatalf("defaults locale=%q fallback=%q, want en/en", tr.locale, tr.fallback) + } + if got := tr.GetIn("pl-PL", "golem15.hello::lang.greeting", nil); got != "Cześć" { + t.Fatalf("pl-PL parent = %q", got) + } + if got := tr.GetIn("pl", "golem15.hello::lang.onlyEn", nil); got != "English only" { + t.Fatalf("fallback en = %q", got) + } + const missing = "golem15.hello::lang.missing" + if got := tr.GetIn("pl-PL", missing, nil); got != missing { + t.Fatalf("raw key = %q", got) + } + ctx := towel.WithLocale(context.Background(), "pl") + if got := tr.Get(ctx, "golem15.hello::lang.greeting", nil); got != "Cześć" { + t.Fatalf("towel locale = %q", got) + } + if got := tr.Get(context.Background(), "golem15.hello::lang.greeting", nil); got != "Hello" { + t.Fatalf("app locale default = %q", got) + } + + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))) + defer slog.SetDefault(prev) + + logged := "golem15.hello::lang.loggedOnce" + params := map[string]string{"secret": "nope"} + tr.GetIn("en", logged, params) + tr.GetIn("en", logged, params) + log := buf.String() + if strings.Count(log, logged) != 1 { + t.Fatalf("missing key should log once, got %q", log) + } + if strings.Contains(log, "secret") || strings.Contains(log, "nope") { + t.Fatalf("must not log parameters: %q", log) + } + + buf.Reset() + prod := NewTranslator(cat, Options{Production: true}) + prod.GetIn("en", "golem15.hello::lang.otherMissing", nil) + if buf.Len() != 0 { + t.Fatalf("production lookup must be silent, got %q", buf.String()) + } +}