summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-05-12 17:27:59 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-05-12 17:27:59 +0200
commit3e09f3b6dc1484820658d07187096cda541fed46 (patch)
tree0819bfc676e613b334da9719f56258768881cbbe
parent19cfae39af4e7cbb8a0719e64d619ba0248f9d76 (diff)
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 <noreply@anthropic.com>
-rw-r--r--README.md22
-rw-r--r--ki18n/ki18n.go57
-rw-r--r--ki18n/ki18n_test.go59
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 `<lang>/*.yml` translation files.
+1. Call `ki18n.Init(localesFS)` at startup with a filesystem containing `<lang>/*.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"})