From 3e09f3b6dc1484820658d07187096cda541fed46 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Tue, 12 May 2026 17:27:59 +0200 Subject: ki18n: add locale-aware date formatting via FormatTime Adds FormatTime(ctx, t) for locale-aware date output (e.g. "12 mai 2026" in fr-FR). Init now accepts extra Locale values so callers can register additional languages with custom formatters; en-GB and fr-FR are built-in defaults. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 22 ++++++++++++++++++-- ki18n/ki18n.go | 57 ++++++++++++++++++++++++++++++++++++++++++++++++--- ki18n/ki18n_test.go | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ce2d349..9e6cf5a 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,10 @@ ## ki18n - [ ] Add gettext to CI -- [ ] Add time handling ### Setup -1. Call `ki18n.Init(localesFS)` at startup with a filesystem containing `/*.yml` translation files. +1. Call `ki18n.Init(localesFS)` at startup with a filesystem containing `/*.yml` translation files. Pass extra `ki18n.Locale` values to register additional languages. 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. @@ -41,6 +40,25 @@ Built-in strategies: If no strategy resolves a language, the middleware falls back to `en-GB`. +### Formatting dates + +```go +ki18n.FormatTime(ctx, time.Now()) // "12 May 2026" in en-GB, "12 mai 2026" in fr-FR +``` + +To add a language, pass a `Locale` to `Init`: + +```go +ki18n.Init(localesFS, ki18n.Locale{ + Lang: "es-ES", + FormatTime: func(t time.Time) string { + return fmt.Sprintf("%d de %s de %d", t.Day(), spanishMonths[t.Month()-1], t.Year()) + }, +}) +``` + +`FormatTime` is optional on `Locale` — omit it to fall back to `en-GB` date formatting. + ### Translating in templ files ```go diff --git a/ki18n/ki18n.go b/ki18n/ki18n.go index 8f20ad4..b517d06 100644 --- a/ki18n/ki18n.go +++ b/ki18n/ki18n.go @@ -2,8 +2,10 @@ package ki18n import ( "context" + "fmt" "io/fs" "net/http" + "time" "github.com/a-h/templ" "github.com/kataras/i18n" @@ -64,6 +66,42 @@ func LangMiddleware(strategies ...Strategy) func(http.Handler) http.Handler { } } +var frenchMonths = [...]string{ + "janvier", "février", "mars", "avril", "mai", "juin", + "juillet", "août", "septembre", "octobre", "novembre", "décembre", +} + +// Locale configures a language for use with ki18n. Lang is a BCP-47 tag (e.g. "es-ES"). +// FormatTime is optional; if nil, en-GB formatting is used. +type Locale struct { + Lang string + FormatTime func(time.Time) string +} + +var defaultLocales = []Locale{ + { + Lang: "en-GB", + FormatTime: func(t time.Time) string { return t.Format("2 January 2006") }, + }, + { + Lang: "fr-FR", + FormatTime: func(t time.Time) string { + return fmt.Sprintf("%d %s %d", t.Day(), frenchMonths[t.Month()-1], t.Year()) + }, + }, +} + +var timeFormatters map[string]func(time.Time) string + +func FormatTime(ctx context.Context, t time.Time) string { + lang, _ := ctx.Value(contextKey{}).(string) + f, ok := timeFormatters[lang] + if !ok { + f = timeFormatters[defaultLang] + } + return f(t) +} + func Tr(ctx context.Context, format string, args ...any) templ.Component { lang, _ := ctx.Value(contextKey{}).(string) if lang == "" { @@ -76,12 +114,25 @@ func Tr(ctx context.Context, format string, args ...any) templ.Component { return templ.Raw(msg) } -func Init(fs fs.FS) error { - loader, err := i18n.FS(fs, "*/*.yml") +func Init(localesFS fs.FS, extra ...Locale) error { + all := append(defaultLocales, extra...) + + langs := make([]string, len(all)) + timeFormatters = make(map[string]func(time.Time) string, len(all)) + for i, loc := range all { + langs[i] = loc.Lang + if loc.FormatTime != nil { + timeFormatters[loc.Lang] = loc.FormatTime + } else { + timeFormatters[loc.Lang] = timeFormatters[defaultLang] + } + } + + loader, err := i18n.FS(localesFS, "*/*.yml") if err != nil { return err } - inst, err := i18n.New(loader, "en-GB", "fr-FR") + inst, err := i18n.New(loader, langs...) if err != nil { return err } diff --git a/ki18n/ki18n_test.go b/ki18n/ki18n_test.go index fa1f226..5956be5 100644 --- a/ki18n/ki18n_test.go +++ b/ki18n/ki18n_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" "testing/fstest" + "time" "github.com/a-h/templ" "github.com/martinlehoux/kagamigo/kcore" @@ -54,6 +55,64 @@ func TestTr_withoutLangInContext(t *testing.T) { assert.Equal(t, "Hello", result) } +func TestFormatTime_englishLocale(t *testing.T) { + ctx := context.WithValue(context.Background(), contextKey{}, "en-GB") + d := time.Date(2026, time.May, 12, 0, 0, 0, 0, time.UTC) + + assert.Equal(t, "12 May 2026", FormatTime(ctx, d)) +} + +func TestFormatTime_frenchLocale(t *testing.T) { + ctx := context.WithValue(context.Background(), contextKey{}, "fr-FR") + d := time.Date(2026, time.May, 12, 0, 0, 0, 0, time.UTC) + + assert.Equal(t, "12 mai 2026", FormatTime(ctx, d)) +} + +func TestFormatTime_unknownLocale(t *testing.T) { + ctx := context.WithValue(context.Background(), contextKey{}, "de-DE") + d := time.Date(2026, time.May, 12, 0, 0, 0, 0, time.UTC) + + assert.Equal(t, "12 May 2026", FormatTime(ctx, d)) +} + +func TestFormatTime_extraLocale(t *testing.T) { + spanishMonths := [...]string{ + "enero", "febrero", "marzo", "abril", "mayo", "junio", + "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre", + } + fs := fstest.MapFS{ + "es-ES/index.yml": &fstest.MapFile{Data: []byte{}}, + } + kcore.Expect(Init(fs, Locale{ + Lang: "es-ES", + FormatTime: func(t time.Time) string { + return fmt.Sprintf("%d de %s de %d", t.Day(), spanishMonths[t.Month()-1], t.Year()) + }, + }), "failed to init fs") + ctx := context.WithValue(context.Background(), contextKey{}, "es-ES") + d := time.Date(2026, time.May, 12, 0, 0, 0, 0, time.UTC) + + assert.Equal(t, "12 de mayo de 2026", FormatTime(ctx, d)) +} + +func TestFormatTime_extraLocaleNoFormatter(t *testing.T) { + fs := fstest.MapFS{ + "es-ES/index.yml": &fstest.MapFile{Data: []byte{}}, + } + kcore.Expect(Init(fs, Locale{Lang: "es-ES"}), "failed to init fs") + ctx := context.WithValue(context.Background(), contextKey{}, "es-ES") + d := time.Date(2026, time.May, 12, 0, 0, 0, 0, time.UTC) + + assert.Equal(t, "12 May 2026", FormatTime(ctx, d)) +} + +func TestFormatTime_noLocaleInContext(t *testing.T) { + d := time.Date(2026, time.May, 12, 0, 0, 0, 0, time.UTC) + + assert.Equal(t, "12 May 2026", FormatTime(context.Background(), d)) +} + func TestCookieStrategy_found(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "/", nil) r.AddCookie(&http.Cookie{Name: "lang", Value: "fr-FR"}) -- cgit v1.3