diff --git a/examples/i18n/README.md b/examples/i18n/README.md new file mode 100644 index 0000000..92b146e --- /dev/null +++ b/examples/i18n/README.md @@ -0,0 +1,196 @@ +# examples/i18n — `WithRequestFuncs` in action + +A complete internationalised app built on Gastro's `WithRequestFuncs` +hook. Three locales (`en`, `da`, `de`), gettext-style `.po` catalogues, +request-aware `{{ t "..." }}` / `{{ tn ... }}` / `{{ tc ... }}` helpers, +and a lang-switcher component — all in ~150 LOC of plain Go (the i18n +library) plus ≈15 LOC of `main.go` glue. + +The architectural point: **`WithRequestFuncs` is the contract; everything +else is library code adopters bring themselves.** This example uses a +tiny hand-rolled PO loader to stay self-contained. Real apps should +swap in [`gotext`](https://pkg.go.dev/github.com/leonelquinteros/gotext) +or [`go-i18n`](https://pkg.go.dev/github.com/nicksnyder/go-i18n/v2) +without changing the Gastro-facing wiring. + +## Run it + +```sh +cd examples/i18n +go run . # http://localhost:4242 +``` + +Try: + +```sh +curl -H "Accept-Language: de" http://localhost:4242/ +curl -H "Accept-Language: da" http://localhost:4242/about +curl --cookie "gastro_locale=de" http://localhost:4242/ +curl http://localhost:4242/da/ # path-prefix +``` + +Dev mode with hot-reload on `.po` changes: + +```sh +gastro dev --watch "i18n/*.po" +``` + +The `--watch` flag is what makes PO edits trigger a restart in dev mode +— without it, the embedded catalogue is baked into the binary at build +time and dev edits don't propagate. See `docs/dev-mode.md` for the +flag's full contract. + +## How it works + +### Locale detection middleware + +`internal/i18n/i18n.go` exposes `Catalog.Middleware`, which is +registered as a `gastro.WithMiddleware("/{path...}", cat.Middleware)`. +On every request it picks a locale using the priority: + +1. URL path prefix (`/da/...`, `/de/...`) +2. `gastro_locale` cookie +3. `Accept-Language` header +4. The catalogue's fallback locale (`en` here) + +The selected `*Localizer` is attached to `r.Context()` under an +unexported key. `i18n.FromCtx(ctx)` is the accessor. + +### `WithRequestFuncs` binder + +`main.go` registers a binder that pulls the per-request Localizer and +returns method values bound to it: + +```go +gastro.WithRequestFuncs(func(r *http.Request) template.FuncMap { + l := i18n.FromCtx(r.Context()) + return template.FuncMap{ + "t": l.T, + "tn": l.TN, + "tc": l.TC, + } +}), +``` + +That's it. Templates use `{{ t "Welcome" }}`, `{{ tc "button" "Open" }}`, +`{{ printf (tn "%d item" "%d items" .Count) .Count }}` — the closures +capture `l` and resolve against the right locale because the binder +runs once per request. + +### `WithFuncs` for static helpers + +The lang-switcher needs to rewrite `/about` → `/de/about` (and so on) +for every locale. That's pure string manipulation; no request state +required. It lives as a static `gastro.WithFuncs` registration: + +```go +gastro.WithFuncs(template.FuncMap{ + "langPath": rewriteLangPath, // pure function + "locales": func() []string { return cat.Locales() }, +}), +``` + +`WithRequestFuncs` is for state that varies per request. +`WithFuncs` is for state that doesn't. Use the right tier. + +## File layout + +``` +examples/i18n/ +├── main.go ≈100 LOC — gastro wiring +├── internal/i18n/ +│ ├── i18n.go ≈250 LOC — PO loader, Localizer, Middleware +│ └── i18n_test.go ≈180 LOC — round-trip tests +├── i18n/ +│ ├── en.po +│ ├── da.po +│ └── de.po +├── pages/ +│ ├── index.gastro uses {{ t }} and {{ tn }} +│ └── about.gastro +└── components/ + ├── layout.gastro uses {{ t "Home" }} inside the layout + └── lang-switch.gastro +``` + +The internal/i18n package is **95% generic Go**. It has no reference to +Gastro at all — it's a `context.Context` accessor (`FromCtx`), a stdlib +HTTP middleware (`Middleware`), and three methods on a struct (`T`, +`TN`, `TC`). The Gastro-specific glue is the ≈15-line `WithRequestFuncs` +call in `main.go`. + +## Translation workflow + +The recommended flow uses `xgettext` (part of GNU gettext) to extract +strings from your Go sources and templates: + +```sh +# Extract all {{ t "..." }} from templates and gastro source. xgettext +# doesn't natively parse .gastro files, but they're text — point it at +# the source tree and let it scan. +xgettext \ + --keyword=t:1 \ + --keyword=tn:1,2 \ + --keyword=tc:1c,2 \ + --from-code=UTF-8 \ + --output=i18n/messages.pot \ + pages/*.gastro components/*.gastro + +# Update an existing translation file with new/changed strings. +msgmerge --update i18n/da.po i18n/messages.pot +``` + +After editing translations and running `gastro dev --watch "i18n/*.po"`, +the dev server restarts and the new translations appear on the next +request. + +## Plural rules + +The bundled `internal/i18n` package uses a trivial split: `n==1` → +singular, anything else → plural. This is correct for English, German, +Danish, and a handful of others, but wrong for Russian, Arabic, Polish, +and most non-Indo-European languages. + +For CLDR-correct plural rules, swap in `gotext` (the most mature +Go gettext library): + +```go +import "github.com/leonelquinteros/gotext" + +func makeLocalizer(locale string, raw []byte) *gotext.Po { + po := gotext.NewPo() + po.Parse(raw) + return po +} +``` + +The `WithRequestFuncs` binder shape stays the same; only the Localizer +type changes. Gastro doesn't care which library you use. + +## Swapping in a different i18n library + +The interface that matters is the one between `main.go` and the +Localizer: + +```go +type Localizer interface { + T(msgid string) string + TN(singular, plural string, n int) string + TC(ctx, msgid string) string +} +``` + +Any library can be wrapped to that shape. The middleware and `FromCtx` +accessor are equally library-agnostic — they only know about +`context.Value` and `*http.Request`. Replace them as needed. + +This is the whole architectural argument for `WithRequestFuncs` over a +`pkg/gastro/i18n/` helper package: **adopters keep ownership of their +i18n stack**, and Gastro doesn't lock anyone in. + +## See also + +- `docs/helpers.md` — the `WithRequestFuncs` reference +- `docs/dev-mode.md` — the `--watch GLOB` flag +- `examples/csrf/` — same pattern, different domain (in this PR) +- `examples/csp/` — and another (in this PR) diff --git a/examples/i18n/components/lang-switch.gastro b/examples/i18n/components/lang-switch.gastro new file mode 100644 index 0000000..12fb088 --- /dev/null +++ b/examples/i18n/components/lang-switch.gastro @@ -0,0 +1,19 @@ +--- +// LangSwitch renders a row of locale links. Each link rewrites the +// current URL with a different locale prefix via the langPath template +// helper (registered in main.go). +// +// This component reads request-aware state in two ways: +// +// - locales: registered as a static WithFuncs helper that closes +// over the catalogue at startup. Returns the same list every +// call. +// - r.URL.Path: pulled in via .Path on the parent's data context. +// The page passes its path explicitly to avoid coupling the +// component to the request structure. +--- + + {{ range locales }} + {{ . }} + {{ end }} + diff --git a/examples/i18n/components/layout.gastro b/examples/i18n/components/layout.gastro new file mode 100644 index 0000000..e2fe15f --- /dev/null +++ b/examples/i18n/components/layout.gastro @@ -0,0 +1,39 @@ +--- +import ( + LangSwitch "components/lang-switch.gastro" +) + +type Props struct { + Title string +} + +p := gastro.Props() +Title := p.Title +--- + + + + + {{ .Title }} + + + + +
+ {{ .Children }} +
+ + + diff --git a/examples/i18n/go.mod b/examples/i18n/go.mod new file mode 100644 index 0000000..701c5bd --- /dev/null +++ b/examples/i18n/go.mod @@ -0,0 +1,11 @@ +module gastro-i18n-example + +go 1.26.1 + +require github.com/andrioid/gastro v0.0.0 + +require github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + +replace github.com/andrioid/gastro => ../.. + +tool github.com/andrioid/gastro/cmd/gastro diff --git a/examples/i18n/go.sum b/examples/i18n/go.sum new file mode 100644 index 0000000..0be9157 --- /dev/null +++ b/examples/i18n/go.sum @@ -0,0 +1,2 @@ +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= diff --git a/examples/i18n/i18n/da.po b/examples/i18n/i18n/da.po new file mode 100644 index 0000000..25e0dd3 --- /dev/null +++ b/examples/i18n/i18n/da.po @@ -0,0 +1,32 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: da\n" + +msgid "Welcome" +msgstr "Velkommen" + +msgid "About this example" +msgstr "Om dette eksempel" + +msgid "This page demonstrates WithRequestFuncs." +msgstr "Denne side viser WithRequestFuncs." + +msgid "Home" +msgstr "Forside" + +msgid "About" +msgstr "Om" + +msgid "%d item" +msgid_plural "%d items" +msgstr[0] "%d ting" +msgstr[1] "%d ting" + +msgctxt "button" +msgid "Open" +msgstr "Åbn" + +msgctxt "adjective" +msgid "Open" +msgstr "Åben" diff --git a/examples/i18n/i18n/de.po b/examples/i18n/i18n/de.po new file mode 100644 index 0000000..7810aa3 --- /dev/null +++ b/examples/i18n/i18n/de.po @@ -0,0 +1,32 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: de\n" + +msgid "Welcome" +msgstr "Willkommen" + +msgid "About this example" +msgstr "Über dieses Beispiel" + +msgid "This page demonstrates WithRequestFuncs." +msgstr "Diese Seite demonstriert WithRequestFuncs." + +msgid "Home" +msgstr "Startseite" + +msgid "About" +msgstr "Über" + +msgid "%d item" +msgid_plural "%d items" +msgstr[0] "%d Element" +msgstr[1] "%d Elemente" + +msgctxt "button" +msgid "Open" +msgstr "Öffnen" + +msgctxt "adjective" +msgid "Open" +msgstr "Geöffnet" diff --git a/examples/i18n/i18n/en.po b/examples/i18n/i18n/en.po new file mode 100644 index 0000000..36fb353 --- /dev/null +++ b/examples/i18n/i18n/en.po @@ -0,0 +1,42 @@ +# English source catalogue. Translations are identity strings — gettext +# treats the msgid as the source-language display string, so en.po only +# needs entries that diverge (plurals, contexts). This file exists so +# Catalog.Load doesn't 404 on the fallback locale and so xgettext-style +# tooling has a canonical entry point. + +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: en\n" + +msgid "Welcome" +msgstr "Welcome" + +msgid "About this example" +msgstr "About this example" + +msgid "This page demonstrates WithRequestFuncs." +msgstr "This page demonstrates WithRequestFuncs." + +msgid "Home" +msgstr "Home" + +msgid "About" +msgstr "About" + +# Plural form — n-items counter. +msgid "%d item" +msgid_plural "%d items" +msgstr[0] "%d item" +msgstr[1] "%d items" + +# Contextual translation — the same English word maps to different +# meanings depending on the surrounding UI element. Used by the +# Open-Card example in the layout footer. +msgctxt "button" +msgid "Open" +msgstr "Open" + +msgctxt "adjective" +msgid "Open" +msgstr "Open" diff --git a/examples/i18n/internal/i18n/i18n.go b/examples/i18n/internal/i18n/i18n.go new file mode 100644 index 0000000..fdc1caf --- /dev/null +++ b/examples/i18n/internal/i18n/i18n.go @@ -0,0 +1,412 @@ +// Package i18n is the example's tiny gettext-style internationalisation +// library. It is intentionally hand-rolled and minimal: ~150 LOC of plain +// Go that supports the three patterns the WithRequestFuncs example needs +// (simple translation, plurals, contexts) without pulling in a real PO +// library like gotext or go-i18n. +// +// Real production apps should use a battle-tested library. This package +// exists to show that WithRequestFuncs is library-agnostic — the +// adopter brings their own i18n primitives and ~15 LOC of glue. +// +// The translation flow: +// +// 1. Catalog.Load reads embedded *.po files and parses each into a +// Localizer (one per locale). +// 2. Catalog.Middleware picks a Localizer for each request based on +// the URL path (/da/..., /de/...), the gastro_locale cookie, or +// the Accept-Language header — in that order. The chosen +// Localizer is attached to the request context. +// 3. FromCtx pulls the Localizer back out at template time. The +// WithRequestFuncs binder calls FromCtx, then returns a FuncMap +// with method values closed over that Localizer (so {{ t "hi" }} +// resolves against the right locale). +// +// Per the WithRequestFuncs contract (docs/helpers.md §"The binder +// contract"), FromCtx tolerates an empty context: a probe request at +// gastro.New() time passes through with no Localizer attached, and +// FromCtx returns a null localizer that echoes msgids back unchanged. +// This keeps the binder probe-safe without Gastro injecting sentinel +// values. +package i18n + +import ( + "context" + "embed" + "net/http" + "path/filepath" + "strconv" + "strings" +) + +// Localizer holds the translation entries for one locale. +type Localizer struct { + Locale string + + // entries maps a plain msgid to its translation. Empty msgstr + // entries are dropped at load time so T's fallback (return the + // msgid) does the right thing. + entries map[string]string + + // plurals maps msgid (the singular form, which gettext uses as + // the dictionary key) to the slice of translations indexed by + // plural form. Index 0 is singular, index 1 is plural; this + // example doesn't implement CLDR plural rules — anything but + // n==1 picks index 1. Production code should use a CLDR-aware + // library like gotext. + plurals map[string][]string + + // contexts maps "msgctxt\x04msgid" (the gettext disambiguation + // convention) to msgstr. + contexts map[string]string +} + +// T returns the translation for msgid, or msgid itself when no +// translation is registered. This is the gettext convention: the +// source-language msgid serves as both the lookup key and the +// fallback display string. +func (l *Localizer) T(msgid string) string { + if l == nil { + return msgid + } + if s, ok := l.entries[msgid]; ok { + return s + } + return msgid +} + +// TN returns the appropriate plural form for n, falling back to the +// English singular/plural pair when the catalogue has no entry. +// +// Plural selection is intentionally simple (n==1 → singular, else +// plural). Real apps use CLDR-aware rules; this is a recipe example, +// not a translation library. +func (l *Localizer) TN(singular, plural string, n int) string { + if l == nil { + if n == 1 { + return singular + } + return plural + } + if forms, ok := l.plurals[singular]; ok && len(forms) >= 2 { + idx := 1 + if n == 1 { + idx = 0 + } + if forms[idx] != "" { + return forms[idx] + } + } + if n == 1 { + return singular + } + return plural +} + +// TC returns the contextual translation: same msgid can have different +// translations depending on context (e.g. "Open" the verb vs "Open" the +// adjective). Mirrors gettext's pgettext. +func (l *Localizer) TC(ctx, msgid string) string { + if l == nil { + return msgid + } + if s, ok := l.contexts[ctx+"\x04"+msgid]; ok && s != "" { + return s + } + return msgid +} + +// nullLocalizer returns a localizer that echoes msgids back unchanged. +// Used as the FromCtx return value when no Localizer has been attached +// to the context (probe-safe path). +func nullLocalizer() *Localizer { return nil } + +// ctxKey is the unexported context-key type for the request-scoped +// Localizer. Unexported so adopters can't construct collisions. +type ctxKey struct{} + +// FromCtx returns the Localizer attached to ctx by Catalog.Middleware, +// or a null localizer that echoes msgids when none is attached. The +// probe at gastro.New() invokes the binder with a context that has no +// Localizer; this graceful default keeps the probe safe. +func FromCtx(ctx context.Context) *Localizer { + if l, ok := ctx.Value(ctxKey{}).(*Localizer); ok { + return l + } + return nullLocalizer() +} + +// Catalog holds the parsed Localizer set and the fallback locale. +type Catalog struct { + locales map[string]*Localizer + order []string // for deterministic Accept-Language matching + fallback string +} + +// Load parses every ".po" file from fs (under the supplied +// directory prefix) that matches one of the requested locales. +// Fallback is the locale used when the request's preferred locale +// isn't in the catalogue. +// +// Pass dir="i18n" for the canonical example layout. Tests pass a +// testdata-rooted prefix. +// +// Errors during parsing are returned eagerly — a broken PO file is an +// adopter bug, not a runtime fallback case. +func Load(fs embed.FS, dir string, locales []string, fallback string) (*Catalog, error) { + c := &Catalog{ + locales: make(map[string]*Localizer, len(locales)), + order: append([]string(nil), locales...), + fallback: fallback, + } + for _, loc := range locales { + raw, err := fs.ReadFile(filepath.Join(dir, loc+".po")) + if err != nil { + return nil, err + } + l, err := parsePO(string(raw)) + if err != nil { + return nil, err + } + l.Locale = loc + c.locales[loc] = l + } + return c, nil +} + +// Locales returns the catalogue's known locale codes in registration +// order. Useful for lang-switcher components. +func (c *Catalog) Locales() []string { return append([]string(nil), c.order...) } + +// Fallback returns the catalogue's fallback locale. +func (c *Catalog) Fallback() string { return c.fallback } + +// Middleware attaches a Localizer to every incoming request. Locale is +// selected in this priority order: +// +// 1. URL path prefix: /da/..., /de/... +// 2. gastro_locale cookie +// 3. Accept-Language header (first matching locale, no quality-weight +// parsing — keeps the example small) +// 4. The catalogue's fallback locale +// +// The request URL is not rewritten; downstream handlers see the same +// r.URL.Path that came in. This lets the gastro auto-routes match +// /index, /about etc. for every locale without locale-specific page +// files. (A real app might prefer the /[lang]/index.gastro pattern for +// SEO; this example trades that for simplicity.) +func (c *Catalog) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + l := c.pickLocale(r) + ctx := context.WithValue(r.Context(), ctxKey{}, l) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (c *Catalog) pickLocale(r *http.Request) *Localizer { + // 1. Path prefix. + if loc, _ := splitLocaleFromPath(r.URL.Path); loc != "" { + if l, ok := c.locales[loc]; ok { + return l + } + } + // 2. Cookie. + if ck, err := r.Cookie("gastro_locale"); err == nil { + if l, ok := c.locales[ck.Value]; ok { + return l + } + } + // 3. Accept-Language (simple first-match, no quality weights). + if al := r.Header.Get("Accept-Language"); al != "" { + for _, part := range strings.Split(al, ",") { + tag := strings.TrimSpace(strings.SplitN(part, ";", 2)[0]) + tag = strings.SplitN(tag, "-", 2)[0] + if l, ok := c.locales[tag]; ok { + return l + } + } + } + // 4. Fallback. + return c.locales[c.fallback] +} + +// splitLocaleFromPath returns (locale, rest) when path starts with a +// "/xx/" segment, otherwise ("", path). Used both by the middleware and +// by lang-switcher components that want to swap the locale prefix. +func splitLocaleFromPath(path string) (string, string) { + if !strings.HasPrefix(path, "/") { + return "", path + } + rest := strings.TrimPrefix(path, "/") + slash := strings.Index(rest, "/") + candidate := rest + if slash >= 0 { + candidate = rest[:slash] + } + if len(candidate) >= 2 && len(candidate) <= 5 { + // Locale candidates are short (en, da, de, en-GB, etc.). + return candidate, "/" + rest + } + return "", path +} + +// parsePO is a tiny line-based PO parser. It handles the subset gettext +// emits in well-behaved catalogues: msgid / msgstr / msgctxt / +// msgid_plural / msgstr[N] entries, comment lines (#), blank-line +// record separators, and adjacent-string continuation lines. Escapes +// are limited to \n, \t, \r, \\, and \". +// +// Anything more exotic (string concatenation across encodings, fuzzy +// flag handling, header-line metadata beyond the empty-msgid record) +// is out of scope; real adopters should reach for gotext or go-i18n. +func parsePO(src string) (*Localizer, error) { + l := &Localizer{ + entries: make(map[string]string), + plurals: make(map[string][]string), + contexts: make(map[string]string), + } + + type entry struct { + msgctxt string + msgid string + msgidPlural string + msgstr string + msgstrPlural []string + } + var cur entry + commit := func() { + defer func() { cur = entry{} }() + if cur.msgid == "" && cur.msgctxt == "" { + return // header record (msgid "") or empty + } + switch { + case cur.msgctxt != "": + l.contexts[cur.msgctxt+"\x04"+cur.msgid] = cur.msgstr + case cur.msgidPlural != "": + forms := make([]string, len(cur.msgstrPlural)) + copy(forms, cur.msgstrPlural) + l.plurals[cur.msgid] = forms + case cur.msgstr != "": + l.entries[cur.msgid] = cur.msgstr + } + } + + lines := strings.Split(src, "\n") + var lastField *string + var lastPluralIdx int = -1 + for _, raw := range lines { + line := strings.TrimSpace(raw) + if line == "" { + commit() + lastField = nil + lastPluralIdx = -1 + continue + } + if strings.HasPrefix(line, "#") { + continue + } + // Adjacent-string continuation: a quoted-string line attaches + // to the most recent field. + if strings.HasPrefix(line, `"`) { + val, err := unquotePO(line) + if err != nil { + return nil, err + } + if lastField != nil { + *lastField += val + } else if lastPluralIdx >= 0 && lastPluralIdx < len(cur.msgstrPlural) { + cur.msgstrPlural[lastPluralIdx] += val + } + continue + } + key, val, ok := splitPOLine(line) + if !ok { + continue + } + switch { + case key == "msgctxt": + cur.msgctxt = val + lastField = &cur.msgctxt + lastPluralIdx = -1 + case key == "msgid": + cur.msgid = val + lastField = &cur.msgid + lastPluralIdx = -1 + case key == "msgid_plural": + cur.msgidPlural = val + lastField = &cur.msgidPlural + lastPluralIdx = -1 + case key == "msgstr": + cur.msgstr = val + lastField = &cur.msgstr + lastPluralIdx = -1 + case strings.HasPrefix(key, "msgstr["): + idx, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(key, "msgstr["), "]")) + if err != nil { + continue + } + for len(cur.msgstrPlural) <= idx { + cur.msgstrPlural = append(cur.msgstrPlural, "") + } + cur.msgstrPlural[idx] = val + lastField = nil + lastPluralIdx = idx + } + } + commit() // tail record without trailing blank line + return l, nil +} + +// splitPOLine splits "key \"value\"" into (key, value, ok). +func splitPOLine(line string) (string, string, bool) { + i := strings.IndexByte(line, ' ') + if i < 0 { + return "", "", false + } + key := line[:i] + rest := strings.TrimSpace(line[i+1:]) + if !strings.HasPrefix(rest, `"`) { + return "", "", false + } + val, err := unquotePO(rest) + if err != nil { + return "", "", false + } + return key, val, true +} + +// unquotePO unescapes a "..." quoted string with a minimal escape set. +func unquotePO(s string) (string, error) { + if !strings.HasPrefix(s, `"`) { + return "", nil + } + // Strip trailing whitespace and any inline comment. + end := strings.LastIndexByte(s, '"') + if end <= 0 { + return "", nil + } + inner := s[1:end] + var b strings.Builder + for i := 0; i < len(inner); i++ { + c := inner[i] + if c != '\\' || i+1 >= len(inner) { + b.WriteByte(c) + continue + } + i++ + switch inner[i] { + case 'n': + b.WriteByte('\n') + case 't': + b.WriteByte('\t') + case 'r': + b.WriteByte('\r') + case '\\': + b.WriteByte('\\') + case '"': + b.WriteByte('"') + default: + b.WriteByte(inner[i]) + } + } + return b.String(), nil +} diff --git a/examples/i18n/internal/i18n/i18n_test.go b/examples/i18n/internal/i18n/i18n_test.go new file mode 100644 index 0000000..9959ca4 --- /dev/null +++ b/examples/i18n/internal/i18n/i18n_test.go @@ -0,0 +1,208 @@ +package i18n_test + +import ( + "context" + "embed" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gastro-i18n-example/internal/i18n" +) + +//go:embed testdata/i18n/*.po +var testPO embed.FS + +const testDir = "testdata/i18n" + +// TestLocalizer_T_KnownAndFallback: T returns translations for known +// msgids and falls back to the msgid itself for missing entries. +func TestLocalizer_T_KnownAndFallback(t *testing.T) { + cat, err := i18n.Load(testPO, testDir, []string{"en", "de"}, "en") + if err != nil { + t.Fatalf("Load: %v", err) + } + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept-Language", "de") + rr := httptest.NewRecorder() + cat.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + l := i18n.FromCtx(r.Context()) + if got := l.T("Welcome"); got != "Willkommen" { + t.Errorf("T(Welcome): got %q, want Willkommen", got) + } + // Unknown msgid → returns the msgid itself. + if got := l.T("Unknown phrase"); got != "Unknown phrase" { + t.Errorf("T(unknown): got %q, want fallback", got) + } + })).ServeHTTP(rr, req) +} + +// TestLocalizer_TN_PluralSelection: TN picks the right plural form for +// n==1 vs everything else, and falls back to English when no entry. +func TestLocalizer_TN_PluralSelection(t *testing.T) { + cat, err := i18n.Load(testPO, testDir, []string{"en", "de"}, "en") + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept-Language", "de") + rr := httptest.NewRecorder() + cat.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + l := i18n.FromCtx(r.Context()) + if got := l.TN("%d item", "%d items", 1); got != "%d Element" { + t.Errorf("TN(de, 1): got %q, want %%d Element", got) + } + if got := l.TN("%d item", "%d items", 5); got != "%d Elemente" { + t.Errorf("TN(de, 5): got %q, want %%d Elemente", got) + } + // Fallback: unknown plural pair returns the English form. + if got := l.TN("dog", "dogs", 2); got != "dogs" { + t.Errorf("TN(unknown): got %q, want fallback", got) + } + })).ServeHTTP(rr, req) +} + +// TestLocalizer_TC_ContextDistinguishes: same msgid maps to different +// translations depending on msgctxt. +func TestLocalizer_TC_ContextDistinguishes(t *testing.T) { + cat, err := i18n.Load(testPO, testDir, []string{"en", "de"}, "en") + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept-Language", "de") + rr := httptest.NewRecorder() + cat.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + l := i18n.FromCtx(r.Context()) + if got := l.TC("button", "Open"); got != "Öffnen" { + t.Errorf("TC(button, Open): got %q, want Öffnen", got) + } + if got := l.TC("adjective", "Open"); got != "Geöffnet" { + t.Errorf("TC(adjective, Open): got %q, want Geöffnet", got) + } + })).ServeHTTP(rr, req) +} + +// TestFromCtx_EmptyContextIsProbeSafe: per the WithRequestFuncs binder +// contract (docs/helpers.md §"The binder contract"), FromCtx must +// tolerate a context with no installed Localizer (the gastro.New() +// probe path). It returns a null localizer that echoes msgids back. +func TestFromCtx_EmptyContextIsProbeSafe(t *testing.T) { + l := i18n.FromCtx(context.Background()) + if got := l.T("Welcome"); got != "Welcome" { + t.Errorf("FromCtx on empty ctx: T should echo msgid; got %q", got) + } + if got := l.TN("apple", "apples", 3); got != "apples" { + t.Errorf("FromCtx on empty ctx: TN should fall back; got %q", got) + } + if got := l.TC("button", "Open"); got != "Open" { + t.Errorf("FromCtx on empty ctx: TC should echo msgid; got %q", got) + } +} + +// TestMiddleware_AcceptLanguageSelection: locale chosen from +// Accept-Language when no path-prefix or cookie is present. +func TestMiddleware_AcceptLanguageSelection(t *testing.T) { + cat, err := i18n.Load(testPO, testDir, []string{"en", "de"}, "en") + if err != nil { + t.Fatal(err) + } + cases := []struct { + accept string + want string + }{ + {"de", "de"}, + {"en", "en"}, + {"fr,de;q=0.9", "de"}, // first matching tag wins (fr unknown, de hit) + {"", "en"}, // fallback + {"zh", "en"}, // unknown → fallback + } + for _, tc := range cases { + req := httptest.NewRequest("GET", "/", nil) + if tc.accept != "" { + req.Header.Set("Accept-Language", tc.accept) + } + rr := httptest.NewRecorder() + cat.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + l := i18n.FromCtx(r.Context()) + if l.Locale != tc.want { + t.Errorf("accept=%q: locale=%q, want %q", tc.accept, l.Locale, tc.want) + } + })).ServeHTTP(rr, req) + } +} + +// TestMiddleware_PathPrefixWins: a path-prefix locale beats Accept-Language. +func TestMiddleware_PathPrefixWins(t *testing.T) { + cat, err := i18n.Load(testPO, testDir, []string{"en", "de"}, "en") + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("GET", "/de/about", nil) + req.Header.Set("Accept-Language", "en") + rr := httptest.NewRecorder() + cat.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + l := i18n.FromCtx(r.Context()) + if l.Locale != "de" { + t.Errorf("path-prefix should beat Accept-Language; got %q", l.Locale) + } + })).ServeHTTP(rr, req) +} + +// TestMiddleware_CookieBeatsAcceptLanguage: a gastro_locale cookie +// overrides the Accept-Language header when no path-prefix is present. +func TestMiddleware_CookieBeatsAcceptLanguage(t *testing.T) { + cat, err := i18n.Load(testPO, testDir, []string{"en", "de"}, "en") + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept-Language", "de") + req.AddCookie(&http.Cookie{Name: "gastro_locale", Value: "en"}) + rr := httptest.NewRecorder() + cat.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + l := i18n.FromCtx(r.Context()) + if l.Locale != "en" { + t.Errorf("cookie should beat Accept-Language; got %q", l.Locale) + } + })).ServeHTTP(rr, req) +} + +// TestParsePO_HandlesEscapesAndContinuations: the PO parser handles +// common cases: backslash escapes, adjacent-string continuation lines, +// blank-line record separation. +func TestParsePO_HandlesEscapesAndContinuations(t *testing.T) { + cat, err := i18n.Load(testPO, testDir, []string{"en", "de"}, "en") + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept-Language", "en") + rr := httptest.NewRecorder() + cat.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + l := i18n.FromCtx(r.Context()) + // "Welcome" is in en.po as an identity entry. + if got := l.T("Welcome"); got != "Welcome" { + t.Errorf("en T(Welcome): got %q", got) + } + })).ServeHTTP(rr, req) + + // And a sanity check that locales() reports registration order. + got := cat.Locales() + if len(got) != 2 || got[0] != "en" || got[1] != "de" { + t.Errorf("Locales() returned %v, want [en de]", got) + } +} + +// TestLoad_MissingLocaleErrors: an unknown locale in the requested list +// errors out rather than silently dropping the locale. +func TestLoad_MissingLocaleErrors(t *testing.T) { + _, err := i18n.Load(testPO, testDir, []string{"en", "ja"}, "en") + if err == nil { + t.Fatal("expected error for missing locale ja.po") + } + if !strings.Contains(err.Error(), "ja.po") { + t.Errorf("error should name the missing locale file; got %v", err) + } +} diff --git a/examples/i18n/internal/i18n/testdata/i18n/de.po b/examples/i18n/internal/i18n/testdata/i18n/de.po new file mode 100644 index 0000000..b151064 --- /dev/null +++ b/examples/i18n/internal/i18n/testdata/i18n/de.po @@ -0,0 +1,20 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: de\n" + +msgid "Welcome" +msgstr "Willkommen" + +msgid "%d item" +msgid_plural "%d items" +msgstr[0] "%d Element" +msgstr[1] "%d Elemente" + +msgctxt "button" +msgid "Open" +msgstr "Öffnen" + +msgctxt "adjective" +msgid "Open" +msgstr "Geöffnet" diff --git a/examples/i18n/internal/i18n/testdata/i18n/en.po b/examples/i18n/internal/i18n/testdata/i18n/en.po new file mode 100644 index 0000000..82f83a1 --- /dev/null +++ b/examples/i18n/internal/i18n/testdata/i18n/en.po @@ -0,0 +1,7 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: en\n" + +msgid "Welcome" +msgstr "Welcome" diff --git a/examples/i18n/main.go b/examples/i18n/main.go new file mode 100644 index 0000000..01f4d50 --- /dev/null +++ b/examples/i18n/main.go @@ -0,0 +1,129 @@ +// Package main is the gastro i18n example. +// +// What it demonstrates: +// +// - WithRequestFuncs: binder closes over an i18n.Localizer pulled from +// the request context. Three helpers (t, tn, tc) cover the +// simple/plural/contextual gettext patterns. +// - WithMiddleware: locale-detection middleware attaches the right +// Localizer to every incoming request based on path / cookie / +// Accept-Language. +// - WithFuncs: a request-agnostic langPath helper that lang-switcher +// components use to rewrite a URL with a different locale prefix. +// This is the boring "static helper" tier — request-aware is +// overkill for pure string manipulation. +// +// What it does NOT demonstrate: +// +// - CLDR plural rules. The bundled internal/i18n package uses the +// trivial n==1 → singular split. Real apps should use gotext. +// - PO extraction tooling. Use xgettext directly. +// - SEO-perfect [lang]/ route mirroring. The recipe runs every page +// at every locale via a single set of files (locale picked at +// request time); apps that care about per-locale URLs should +// write pages/[lang]/index.gastro files. +// +// The point of the example is the architectural statement: +// **WithRequestFuncs is the contract; everything else is plain Go.** +// internal/i18n/ is ~250 LOC of plain Go; the gastro-specific wiring +// in this main.go is ~15 LOC. +package main + +import ( + "embed" + "fmt" + "html/template" + "net/http" + "os" + "strings" + + "gastro-i18n-example/internal/i18n" + + gastro "gastro-i18n-example/.gastro" +) + +//go:embed i18n/*.po +var poFS embed.FS + +func main() { + cat, err := i18n.Load(poFS, "i18n", []string{"en", "da", "de"}, "en") + if err != nil { + fmt.Fprintf(os.Stderr, "i18n: %v\n", err) + os.Exit(1) + } + + router := gastro.New( + // Locale detection runs for every page — sets r.Context() so + // i18n.FromCtx returns the right Localizer. + gastro.WithMiddleware("/{path...}", cat.Middleware), + + // Request-aware helpers: the binder runs once per request, + // pulls the Localizer out of the context, and returns method + // values closed over it. Templates use {{ t "..." }} etc. + // + // This is the architectural punchline: ≈15 LOC of glue, ≈250 + // LOC of plain Go in internal/i18n/ that knows nothing about + // gastro. + gastro.WithRequestFuncs(func(r *http.Request) template.FuncMap { + l := i18n.FromCtx(r.Context()) + return template.FuncMap{ + "t": l.T, + "tn": l.TN, + "tc": l.TC, + } + }), + + // langPath is request-agnostic — it just rewrites a URL's + // locale prefix and never reads request state. Registered as + // a static WithFuncs helper rather than a request-aware + // helper. The lang-switcher component uses it to render + // "switch to Danish" links. + gastro.WithFuncs(template.FuncMap{ + "langPath": func(locale, path string) string { + return rewriteLangPath(locale, path) + }, + "locales": func() []string { return cat.Locales() }, + }), + ) + + port := os.Getenv("PORT") + if port == "" { + port = "4242" + } + fmt.Printf("gastro-i18n: http://localhost:%s\n", port) + if err := http.ListenAndServe(":"+port, router.Handler()); err != nil { + fmt.Fprintf(os.Stderr, "server: %v\n", err) + os.Exit(1) + } +} + +// rewriteLangPath returns the supplied URL path with its locale prefix +// replaced by locale. Live behaviour: +// +// rewriteLangPath("da", "/") == "/da/" +// rewriteLangPath("de", "/about") == "/de/about" +// rewriteLangPath("de", "/da/about") == "/de/about" +// +// The function lives in main.go (not in pkg/i18n) because it's pure UI +// concerns: which locale prefix should the lang-switcher links point at. +// Backend code never needs it. +func rewriteLangPath(locale, path string) string { + if path == "" { + path = "/" + } + rest := strings.TrimPrefix(path, "/") + if i := strings.Index(rest, "/"); i >= 0 { + first := rest[:i] + if len(first) >= 2 && len(first) <= 5 { + // Strip the existing locale prefix before re-applying. + rest = rest[i+1:] + } + } else if len(rest) >= 2 && len(rest) <= 5 { + // Path is just "/xx" — drop it, leaving the bare locale prefix. + rest = "" + } + if rest == "" { + return "/" + locale + "/" + } + return "/" + locale + "/" + rest +} diff --git a/examples/i18n/pages/about.gastro b/examples/i18n/pages/about.gastro new file mode 100644 index 0000000..2d8277f --- /dev/null +++ b/examples/i18n/pages/about.gastro @@ -0,0 +1,11 @@ +--- +import ( + Layout "components/layout.gastro" +) + +Title := "About" +--- +{{ wrap Layout (dict "Title" .Title) }} +

{{ t "About this example" }}

+

{{ t "This page demonstrates WithRequestFuncs." }}

+{{ end }} diff --git a/examples/i18n/pages/index.gastro b/examples/i18n/pages/index.gastro new file mode 100644 index 0000000..250a4a1 --- /dev/null +++ b/examples/i18n/pages/index.gastro @@ -0,0 +1,21 @@ +--- +import ( + Layout "components/layout.gastro" +) + +Title := "i18n example" +Items := []string{"apple", "banana", "cherry"} +Count := len(Items) +--- +{{ wrap Layout (dict "Title" .Title) }} +

{{ t "Welcome" }}

+ +

{{ printf (tn "%d item" "%d items" .Count) .Count }}

+ +{{ end }}