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." }}