summaryrefslogtreecommitdiff
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
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>
-rw-r--r--CLAUDE.md5
-rw-r--r--README.md37
-rw-r--r--ki18n/ki18n.go73
-rw-r--r--ki18n/ki18n_test.go93
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.
diff --git a/README.md b/README.md
index fc63e0d..ce2d349 100644
--- a/README.md
+++ b/README.md
@@ -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) }