feat(04-02): apply locale fallback and missing-key logging
- Resolve pl-PL then parent then app.fallback_locale then the raw key - Read towel.Locale from request context and default app.locale/fallback to en - Log each missing key once outside production and never log parameters Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -130,6 +130,16 @@ func TestHelloPluginResolvesLang(t *testing.T) {
|
|||||||
if got := tr.GetIn("en", "golem15.hello::lang.greeting", nil); got != "Hello" {
|
if got := tr.GetIn("en", "golem15.hello::lang.greeting", nil); got != "Hello" {
|
||||||
t.Fatalf("greeting = %q", got)
|
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) {
|
func TestTypedItemRoute(t *testing.T) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package phrasebook
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -55,6 +56,7 @@ type Translator struct {
|
|||||||
locale string
|
locale string
|
||||||
fallback string
|
fallback string
|
||||||
production bool
|
production bool
|
||||||
|
missing sync.Map
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTranslator returns a translator that reads from cat. Empty locale or
|
// 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 {
|
if t == nil {
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
e, ok := t.cat.lookup(locale, key)
|
e, _, ok := t.find(locale, key)
|
||||||
if !ok {
|
if !ok {
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
@@ -143,20 +145,74 @@ func (t *Translator) ChoiceIn(locale, key string, count any, params map[string]s
|
|||||||
if t == nil {
|
if t == nil {
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
e, ok := t.cat.lookup(locale, key)
|
e, found, ok := t.find(locale, key)
|
||||||
if !ok {
|
if !ok {
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
text := e.text
|
text := e.text
|
||||||
switch {
|
switch {
|
||||||
case len(e.pipes) > 0:
|
case len(e.pipes) > 0:
|
||||||
text = selectPipe(e.pipes, locale, count)
|
text = selectPipe(e.pipes, found, count)
|
||||||
case e.plurals != nil:
|
case e.plurals != nil:
|
||||||
text = selectMap(e.plurals, locale, count)
|
text = selectMap(e.plurals, found, count)
|
||||||
}
|
}
|
||||||
return interpolate(text, withCount(params, 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 {
|
func selectMap(plurals map[string]string, locale string, count any) string {
|
||||||
cat, err := cldrCategory(locale, count)
|
cat, err := cldrCategory(locale, count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package phrasebook
|
package phrasebook
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/backpack"
|
"git.golem15.com/golem15/summercms/backpack"
|
||||||
"git.golem15.com/golem15/summercms/compass"
|
"git.golem15.com/golem15/summercms/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/towel"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTranslationSmoke(t *testing.T) {
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user