diff options
| -rw-r--r-- | CLAUDE.md | 5 | ||||
| -rw-r--r-- | README.md | 37 | ||||
| -rw-r--r-- | ki18n/ki18n.go | 73 | ||||
| -rw-r--r-- | ki18n/ki18n_test.go | 93 |
4 files changed, 195 insertions, 13 deletions
diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2583284 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# kagamigo project notes + +## Testing style + +Tests are NOT table-driven. Write individual test functions per case. @@ -15,15 +15,40 @@ ## ki18n -- [ ] Document - [ ] Add gettext to CI - [ ] Add time handling -- [ ] Use request data for default lang -1. `go run github.com/martinlehoux/kagamigo/cmd/gettext -write` -2. Use `Tr("key", value...)` in you templ files -3. Complete generated files -4. Run +### Setup + +1. Call `ki18n.Init(localesFS)` at startup with a filesystem containing `<lang>/*.yml` translation files. +2. Register `ki18n.LangMiddleware(...)` on your router, passing one or more strategies in priority order. +3. Run `go run github.com/martinlehoux/kagamigo/cmd/gettext -write` to generate translation files. +4. Complete the generated files. + +### Language detection strategies + +```go +// Detect from a cookie named "lang", then fall back to Accept-Language header +ki18n.LangMiddleware( + ki18n.CookieStrategy("lang"), + ki18n.AcceptLanguageStrategy, +) +``` + +Built-in strategies: +- `ki18n.CookieStrategy(name string)` — reads the named cookie +- `ki18n.AcceptLanguageStrategy` — parses the `Accept-Language` request header + +If no strategy resolves a language, the middleware falls back to `en-GB`. + +### Translating in templ files + +```go +// ctx is the context passed to your templ component +@ki18n.Tr(ctx, "Hello %s", userName) +``` + +If the key is not found in the resolved language, the format string itself is returned. ## web diff --git a/ki18n/ki18n.go b/ki18n/ki18n.go index f2bed8d..8f20ad4 100644 --- a/ki18n/ki18n.go +++ b/ki18n/ki18n.go @@ -1,12 +1,81 @@ package ki18n import ( + "context" "io/fs" + "net/http" "github.com/a-h/templ" "github.com/kataras/i18n" ) +type contextKey struct{} + +const defaultLang = "en-GB" + +type Strategy interface { + Detect(r *http.Request) string +} + +type cookieStrategy struct{ name string } + +func (s cookieStrategy) Detect(r *http.Request) string { + c, err := r.Cookie(s.name) + if err != nil { + return "" + } + return c.Value +} + +func CookieStrategy(name string) Strategy { + return cookieStrategy{name: name} +} + +type acceptLanguageStrategy struct{} + +func (acceptLanguageStrategy) Detect(r *http.Request) string { + lang := i18n.Default.GetLocale(r).Language() + if lang == defaultLang { + if r.Header.Get("Accept-Language") == "" { + return "" + } + } + return lang +} + +var AcceptLanguageStrategy Strategy = acceptLanguageStrategy{} + +func LangMiddleware(strategies ...Strategy) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lang := "" + for _, s := range strategies { + if l := s.Detect(r); l != "" { + lang = l + break + } + } + if lang == "" { + lang = defaultLang + } + ctx := context.WithValue(r.Context(), contextKey{}, lang) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func Tr(ctx context.Context, format string, args ...any) templ.Component { + lang, _ := ctx.Value(contextKey{}).(string) + if lang == "" { + lang = defaultLang + } + msg := i18n.Tr(lang, format, args...) + if msg == "" { + msg = format + } + return templ.Raw(msg) +} + func Init(fs fs.FS) error { loader, err := i18n.FS(fs, "*/*.yml") if err != nil { @@ -19,7 +88,3 @@ func Init(fs fs.FS) error { i18n.Default = inst return nil } - -func Tr(lang string, format string, args ...any) templ.Component { - return templ.Raw(i18n.Tr(lang, format, args...)) -} diff --git a/ki18n/ki18n_test.go b/ki18n/ki18n_test.go index d2e7e16..fa1f226 100644 --- a/ki18n/ki18n_test.go +++ b/ki18n/ki18n_test.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "fmt" + "net/http" + "net/http/httptest" "strings" "testing" "testing/fstest" @@ -17,8 +19,9 @@ func TestSimpleText(t *testing.T) { initFrenchLocale(map[string]string{ "Hello you": "Bonjour toi", }) + ctx := context.WithValue(context.Background(), contextKey{}, "fr-FR") - result := render(t, Tr("fr-FR", "Hello you")) + result := render(t, Tr(ctx, "Hello you")) assert.Equal(t, "Bonjour toi", result) } @@ -26,8 +29,9 @@ func TestVariables(t *testing.T) { initFrenchLocale(map[string]string{ "Hello %s": "Bonjour %s", }) + ctx := context.WithValue(context.Background(), contextKey{}, "fr-FR") - result := render(t, Tr("fr-FR", "Hello %s", "John")) + result := render(t, Tr(ctx, "Hello %s", "John")) assert.Equal(t, "Bonjour John", result) } @@ -35,11 +39,89 @@ func TestSpanClass(t *testing.T) { initFrenchLocale(map[string]string{ "Hello <span class=\"text-bold\">%s</span>": "Bonjour <span class=\"text-bold\">%s</span>", }) + ctx := context.WithValue(context.Background(), contextKey{}, "fr-FR") - result := render(t, Tr("fr-FR", "Hello <span class=\"text-bold\">%s</span>", "John")) + result := render(t, Tr(ctx, "Hello <span class=\"text-bold\">%s</span>", "John")) assert.Equal(t, "Bonjour <span class=\"text-bold\">John</span>", result) } +func TestTr_withoutLangInContext(t *testing.T) { + initFrenchLocale(map[string]string{ + "Hello": "Bonjour", + }) + + result := render(t, Tr(context.Background(), "Hello")) + assert.Equal(t, "Hello", result) +} + +func TestCookieStrategy_found(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "lang", Value: "fr-FR"}) + + s := CookieStrategy("lang") + assert.Equal(t, "fr-FR", s.Detect(r)) +} + +func TestCookieStrategy_missing(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + + s := CookieStrategy("lang") + assert.Equal(t, "", s.Detect(r)) +} + +func TestAcceptLanguageStrategy_found(t *testing.T) { + initFrenchLocale(map[string]string{}) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Accept-Language", "fr-FR,fr;q=0.9") + + assert.Equal(t, "fr-FR", AcceptLanguageStrategy.Detect(r)) +} + +func TestAcceptLanguageStrategy_missing(t *testing.T) { + initFrenchLocale(map[string]string{}) + r := httptest.NewRequest(http.MethodGet, "/", nil) + + assert.Equal(t, "", AcceptLanguageStrategy.Detect(r)) +} + +func TestLangMiddleware_firstStrategyWins(t *testing.T) { + first := strategyFunc(func(_ *http.Request) string { return "fr-FR" }) + second := strategyFunc(func(_ *http.Request) string { return "de-DE" }) + + var capturedLang string + handler := LangMiddleware(first, second)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + capturedLang, _ = r.Context().Value(contextKey{}).(string) + })) + + handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + assert.Equal(t, "fr-FR", capturedLang) +} + +func TestLangMiddleware_fallsThrough(t *testing.T) { + first := strategyFunc(func(_ *http.Request) string { return "" }) + second := strategyFunc(func(_ *http.Request) string { return "fr-FR" }) + + var capturedLang string + handler := LangMiddleware(first, second)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + capturedLang, _ = r.Context().Value(contextKey{}).(string) + })) + + handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + assert.Equal(t, "fr-FR", capturedLang) +} + +func TestLangMiddleware_fallback(t *testing.T) { + first := strategyFunc(func(_ *http.Request) string { return "" }) + + var capturedLang string + handler := LangMiddleware(first)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + capturedLang, _ = r.Context().Value(contextKey{}).(string) + })) + + handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + assert.Equal(t, "en-GB", capturedLang) +} + // render is a test helper that renders a component and returns the result as a string func render(t *testing.T, c templ.Component) string { t.Helper() @@ -63,3 +145,8 @@ func initFrenchLocale(translations map[string]string) { } kcore.Expect(Init(fs), "failed to init fs") } + +// strategyFunc is a test helper to create a Strategy from a plain function +type strategyFunc func(*http.Request) string + +func (f strategyFunc) Detect(r *http.Request) string { return f(r) } |
