summaryrefslogtreecommitdiff
path: root/ki18n
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-05-12 17:12:07 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-05-12 17:12:07 +0200
commit19cfae39af4e7cbb8a0719e64d619ba0248f9d76 (patch)
treeaf289634fac81e78ebe92029062eb13016622b8a /ki18n
parent783028348a17803ba2e0d2745229d57c0626a464 (diff)
ki18n: derive language from request context via strategy chain
Replace Tr(lang, ...) with Tr(ctx, ...) — language is now resolved once per request by LangMiddleware and stored in context. Callers no longer need to thread a lang string through to templates. Adds Strategy interface with CookieStrategy and AcceptLanguageStrategy built-ins; middleware chains them in order, falling back to en-GB. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'ki18n')
-rw-r--r--ki18n/ki18n.go73
-rw-r--r--ki18n/ki18n_test.go93
2 files changed, 159 insertions, 7 deletions
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) }