serve language variants under a prefix

The default locale stays at the root; every other language is the same key under
/{lang}/ (ADR-0009). /en/… is never live and redirects to the root form so the URL
space cannot fork. Lookup now takes a language and reports which one it served,
following requested → default → any rather than 404ing when a translation is
missing.

That is the second routing case, so the resolver is extracted to resolve.go and
the mux keeps one entry: URL shape is the resolver's business. A leading segment
counts as a language only when some bundle is written in it, so an unknown prefix
is a 404 rather than a stripped path — and a section may not be named after a
language in use, now recorded in content-model.md.

Because the served variant can differ from the URL requested, Page gained
.Canonical (the variant actually served) and .Alternates for hreflang. A theme
must never build a path, so both come from the engine.

Evidence: /bn/pages/about/ serves the Bengali body with lang="bn" and canonical
/bn/pages/about/; /bn/posts/hello-world/ falls back to English with canonical
/posts/hello-world/; /en/pages/about/ 301s to /pages/about/; /fr/… is 404.
This commit is contained in:
Claude Opus 5
2026-07-30 01:43:12 +06:00
committed by bdeshi
parent f37e7b1308
commit 275f67dc52
12 changed files with 301 additions and 47 deletions
+3 -2
View File
@@ -62,8 +62,9 @@ visible, it is a Stage wearing a disguise.
`state.md`. Scheduling is an in-process ticker inside the single binary — no cron container and no `state.md`. Scheduling is an in-process ticker inside the single binary — no cron container and no
queue until ADR-0010's second-service test is actually met. queue until ADR-0010's second-service test is actually met.
**Routing** = URL → (Bundle, View), via a resolver. **STATUS: buildable, inline.** Keep cases inline; **Routing** = URL → (Bundle, View), via a resolver. **STATUS: live.** The resolver was earned at the
extract the resolver when the routing counter in `state.md` is due, not before. second routing case — the default locale at the root, every other language under a prefix — and now owns
URL shape so the mux has one entry.
--- ---
+4
View File
@@ -138,6 +138,10 @@ published URL never changes meaning; renames add `aliases` and emit permanent re
bundle between sections changes the URL the engine emits, and the old path resolves only through bundle between sections changes the URL the engine emits, and the old path resolves only through
`aliases` — so a section list is effectively permanent once anything is published. `aliases` — so a section list is effectively permanent once anything is published.
A language prefix wins over a section of the same name, so a site with Bengali content may not also have
a section called `bn`. The engine treats a leading segment as a language only when some bundle is written
in it.
Language routing is decided (ADR-0009): English at root, other languages under `/<lang>/` on the same Language routing is decided (ADR-0009): English at root, other languages under `/<lang>/` on the same
path — `/pages/about/` and `/bn/pages/about/`. `/en/…` permanently redirects to the root form and is path — `/pages/about/` and `/bn/pages/about/`. `/en/…` permanently redirects to the root form and is
never live. never live.
+4 -3
View File
@@ -1,6 +1,6 @@
# State # State
**Verified against:** `HEAD` on 2026-07-30 — update this line every change. **Verified against:** `51143d3` on 2026-07-30 — update this line every change.
If this file disagrees with the code, the code is right and this file is a bug. If this file disagrees with the code, the code is right and this file is a bug.
## Inventory ## Inventory
@@ -11,7 +11,8 @@ If this file disagrees with the code, the code is right and this file is a bug.
| `internal/content/content.go` | site root → bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, collision drop, key index | 245 | | `internal/content/content.go` | site root → bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, collision drop, key index | 245 |
| `internal/render/render.go` | goldmark + the embedded reference theme; `Page` is what templates receive | 92 | | `internal/render/render.go` | goldmark + the embedded reference theme; `Page` is what templates receive | 92 |
| `internal/render/templates/` | reference theme: `base.html`, `theme.css` (ADR-0026) | — | | `internal/render/templates/` | reference theme: `base.html`, `theme.css` (ADR-0026) | — |
| `internal/web/web.go` | one routing case: path → bundle key, canonical trailing slash, 404, degrade on render failure | 62 | | `internal/web/resolve.go` | URL → (key, lang) or a canonical redirect: language prefix, `/en/…` fork guard, trailing slash | 56 |
| `internal/web/web.go` | handler: resolve, look up with fallback, render, degrade on failure | 62 |
| `cmd/khosra/main.go` | flags, wiring, startup — the only place things are assembled | 50 | | `cmd/khosra/main.go` | flags, wiring, startup — the only place things are assembled | 50 |
| `*_test.go` | table-driven; symlink escape, permalink, redirect, 404 | 245 | | `*_test.go` | table-driven; symlink escape, permalink, redirect, 404 | 245 |
@@ -28,7 +29,7 @@ this change*.
| Counter | Now | Extraction due at | What it buys | | Counter | Now | Extraction due at | What it buys |
|---|---|---|---| |---|---|---|---|
| Render transforms | 0 | **3** | Stage pipeline (ordered `func(ctx,*Page) error`) | | Render transforms | 0 | **3** | Stage pipeline (ordered `func(ctx,*Page) error`) |
| Routing cases | 1 | **2** | Resolver extraction | | Routing cases | 2 | **2** — done | Resolver extracted at `internal/web/resolve.go` |
| Collection pages | 0 | **1** | Query primitive | | Collection pages | 0 | **1** | Query primitive |
| Views / output formats | 1 | **2** | View layer (contract per `theme-contract.md`) | | Views / output formats | 1 | **2** | View layer (contract per `theme-contract.md`) |
| Effects | 0 | **2** | Effect runner + trigger wiring (change / schedule / demand) | | Effects | 0 | **2** | Effect runner + trigger wiring (change / schedule / demand) |
+2
View File
@@ -19,6 +19,8 @@ A bundle page receives:
| `.HTML` | the rendered body, already escaped | | `.HTML` | the rendered body, already escaped |
| `.Extra` | every frontmatter key the parser does not name (ADR-0002) | | `.Extra` | every frontmatter key the parser does not name (ADR-0002) |
| `.Style` | the reference theme's stylesheet, inlined so a bare site root needs no asset route | | `.Style` | the reference theme's stylesheet, inlined so a bare site root needs no asset route |
| `.Canonical` | the permalink of the variant actually served — not the URL requested, which differs when the fallback chain supplied another language |
| `.Alternates` | every language this key exists in, as `.Lang` and `.URL`, for `hreflang` |
Two named templates: `base` is executed for every page; `main` is the block a theme redefines to change Two named templates: `base` is executed for every page; `main` is the block a theme redefines to change
the body while inheriting the document. Nothing else is promised yet. the body while inheriting the document. Nothing else is promised yet.
+51 -6
View File
@@ -11,6 +11,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"path" "path"
"sort"
"strings" "strings"
"golang.org/x/text/unicode/norm" "golang.org/x/text/unicode/norm"
@@ -231,14 +232,58 @@ func NewSite(bundles []Bundle) *Site {
return s return s
} }
// Lookup returns the default-locale variant of a key. // Lookup returns the best variant of a key for a requested language, and the language actually served.
// //
// Other languages are reachable only once language routing exists; until then a key means its default // The fallback chain is requested → default → any (ADR-0009); "any" is resolved in sorted order so the
// variant (ADR-0021). // same request always answers the same way. A key with no variants at all reports false.
func (s *Site) Lookup(key string) (Bundle, bool) { func (s *Site) Lookup(key, lang string) (b Bundle, served string, ok bool) {
b, ok := s.byKeyLang[key+"\x00"+DefaultLang] for _, try := range []string{lang, DefaultLang} {
return b, ok if try == "" {
continue
}
if b, ok = s.byKeyLang[key+"\x00"+try]; ok {
return b, try, true
}
}
for _, l := range s.Variants(key) {
b = s.byKeyLang[key+"\x00"+l]
return b, l, true
}
return Bundle{}, "", false
}
// Variants lists the languages a key exists in, sorted.
func (s *Site) Variants(key string) []string {
var langs []string
for kl := range s.byKeyLang {
k, l, found := strings.Cut(kl, "\x00")
if found && k == key {
langs = append(langs, l)
}
}
sort.Strings(langs)
return langs
}
// HasLang reports whether any bundle is written in lang. The resolver needs this to tell a language
// prefix from a section that happens to share its name.
func (s *Site) HasLang(lang string) bool {
for kl := range s.byKeyLang {
if _, l, found := strings.Cut(kl, "\x00"); found && l == lang {
return true
}
}
return false
} }
// Len reports how many bundles the site holds. // Len reports how many bundles the site holds.
func (s *Site) Len() int { return len(s.byKeyLang) } func (s *Site) Len() int { return len(s.byKeyLang) }
// URL is the permalink of a variant: /{section}/{slug}/, with a language prefix for anything but the
// default locale (ADR-0008, ADR-0009). Templates never build a path by hand.
func URL(key, lang string) string {
if lang == "" || lang == DefaultLang {
return "/" + key + "/"
}
return "/" + lang + "/" + key + "/"
}
+44 -4
View File
@@ -167,11 +167,51 @@ func TestSiteLookupFindsDefaultVariant(t *testing.T) {
if site.Len() != 2 { if site.Len() != 2 {
t.Fatalf("indexed %d bundles, want 2", site.Len()) t.Fatalf("indexed %d bundles, want 2", site.Len())
} }
b, ok := site.Lookup("pages/about") b, served, ok := site.Lookup("pages/about", "en")
if !ok || b.Title != "About" { if !ok || b.Title != "About" || served != "en" {
t.Fatalf("Lookup gave %+v %v, want the English variant", b, ok) t.Fatalf("Lookup gave %+v %q %v, want the English variant", b, served, ok)
} }
if _, ok := site.Lookup("pages/missing"); ok { if _, _, ok := site.Lookup("pages/missing", "en"); ok {
t.Error("Lookup invented a bundle") t.Error("Lookup invented a bundle")
} }
} }
func TestLookupFallsBackThroughLanguages(t *testing.T) {
fsys := fstest.MapFS{
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\n")},
"content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\n")},
"content/posts/only.bn.md": {Data: []byte("---\ntitle: শুধু\n---\n")},
"content/posts/plain.md": {Data: []byte("---\ntitle: Plain\n---\n")},
}
bundles, err := Scan(fsys)
if err != nil {
t.Fatal(err)
}
site := NewSite(bundles)
cases := []struct{ key, want, served string }{
{"pages/about", "পরিচিতি", "bn"}, // the requested language exists
{"posts/plain", "Plain", "en"}, // falls back to the default
{"posts/only", "শুধু", "bn"}, // no default either: any variant beats a 404
}
for _, c := range cases {
b, served, ok := site.Lookup(c.key, "bn")
if !ok || b.Title != c.want || served != c.served {
t.Errorf("Lookup(%q, bn) = %q/%q ok=%v, want %q/%q", c.key, b.Title, served, ok, c.want, c.served)
}
}
if got := site.Variants("pages/about"); len(got) != 2 || got[0] != "bn" || got[1] != "en" {
t.Errorf("Variants = %v, want [bn en]", got)
}
if !site.HasLang("bn") || site.HasLang("fr") {
t.Error("HasLang misreported")
}
}
func TestURLPrefixesOnlyNonDefaultLanguages(t *testing.T) {
cases := map[string]string{"en": "/pages/about/", "": "/pages/about/", "bn": "/bn/pages/about/"}
for lang, want := range cases {
if got := URL("pages/about", lang); got != want {
t.Errorf("URL(pages/about, %q) = %q, want %q", lang, got, want)
}
}
}
+19 -1
View File
@@ -34,6 +34,17 @@ type Page struct {
Extra map[string]any Extra map[string]any
// Style is the reference theme's stylesheet, inlined so a bare site root needs no asset route. // Style is the reference theme's stylesheet, inlined so a bare site root needs no asset route.
Style template.CSS Style template.CSS
// Canonical is the permalink of the variant actually served, which differs from the requested URL
// when the fallback chain supplied another language (ADR-0009).
Canonical string
// Alternates lists every language this key exists in, for hreflang.
Alternates []Alternate
}
// Alternate is one language a bundle exists in.
type Alternate struct {
Lang string
URL string
} }
// Renderer holds the parsed template set and the Markdown converter. Templates are parsed once, never // Renderer holds the parsed template set and the Markdown converter. Templates are parsed once, never
@@ -61,7 +72,10 @@ func New() (*Renderer, error) {
} }
// Bundle renders one bundle into a complete page. // Bundle renders one bundle into a complete page.
func (r *Renderer) Bundle(b content.Bundle) ([]byte, error) { //
// served is the language actually chosen by the fallback chain, and variants every language the key
// exists in; both feed canonical and hreflang, which a theme must not construct itself.
func (r *Renderer) Bundle(b content.Bundle, served string, variants []string) ([]byte, error) {
var body bytes.Buffer var body bytes.Buffer
if err := r.md.Convert(b.Body, &body); err != nil { if err := r.md.Convert(b.Body, &body); err != nil {
return nil, fmt.Errorf("markdown %s: %w", b.Path, err) return nil, fmt.Errorf("markdown %s: %w", b.Path, err)
@@ -73,6 +87,10 @@ func (r *Renderer) Bundle(b content.Bundle) ([]byte, error) {
HTML: template.HTML(body.String()), HTML: template.HTML(body.String()),
Extra: b.Extra, Extra: b.Extra,
Style: r.style, Style: r.style,
Canonical: content.URL(b.Key, served),
}
for _, l := range variants {
p.Alternates = append(p.Alternates, Alternate{Lang: l, URL: content.URL(b.Key, l)})
} }
var out bytes.Buffer var out bytes.Buffer
if err := r.tmpl.ExecuteTemplate(&out, "base", p); err != nil { if err := r.tmpl.ExecuteTemplate(&out, "base", p); err != nil {
+2 -2
View File
@@ -16,7 +16,7 @@ func TestBundleRendersMarkdownIntoTheTheme(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
out, err := r.Bundle(b) out, err := r.Bundle(b, "en", []string{"en"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -40,7 +40,7 @@ func TestBundleWithoutTitleFallsBackToKey(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
out, err := r.Bundle(b) out, err := r.Bundle(b, "en", []string{"en"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+4
View File
@@ -5,6 +5,10 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}}</title> <title>{{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}}</title>
<link rel="canonical" href="{{.Canonical}}">
{{- range .Alternates}}
<link rel="alternate" hreflang="{{.Lang}}" href="{{.URL}}">
{{- end}}
<style>{{.Style}}</style> <style>{{.Style}}</style>
</head> </head>
<body> <body>
+55
View File
@@ -0,0 +1,55 @@
package web
import (
"strings"
"khosra/internal/content"
)
// resolution is what a request path means: which bundle key, in which language, or where to send the
// client instead.
type resolution struct {
key string
lang string
// redirect is the canonical path when the request named a non-canonical one. Non-empty means answer
// with a permanent redirect and nothing else.
redirect string
}
// resolve maps a request path to a bundle key and language.
//
// Two cases, which is why this is a resolver rather than an inline branch: the default locale lives at
// the root of the URL space, every other language under a prefix on the same key (ADR-0009). `/en/…` is
// never a live URL — it redirects to the root form so the space cannot fork. The canonical form ends in
// a slash (ADR-0008).
//
// A language prefix wins over a section of the same name, so a site with Bengali content cannot also
// have a section called `bn` (content-model.md).
func resolve(path string, site *content.Site) (resolution, bool) {
if path == "" || path[0] != '/' {
return resolution{}, false
}
trimmed := strings.Trim(path, "/")
if trimmed == "" {
return resolution{}, false
}
lang := content.DefaultLang
key := content.Normalise(trimmed)
if head, rest, found := strings.Cut(key, "/"); found && head != "" {
switch {
case head == content.DefaultLang:
// /en/… is a second spelling of the root form; send the client to the real one.
return resolution{redirect: content.URL(rest, content.DefaultLang)}, true
case site.HasLang(head):
lang, key = head, rest
}
} else if key == content.DefaultLang {
return resolution{redirect: "/"}, true
}
if !strings.HasSuffix(path, "/") {
return resolution{key: key, lang: lang, redirect: content.URL(key, lang)}, true
}
return resolution{key: key, lang: lang}, true
}
+15 -23
View File
@@ -5,7 +5,6 @@ package web
import ( import (
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"khosra/internal/content" "khosra/internal/content"
"khosra/internal/render" "khosra/internal/render"
@@ -13,8 +12,7 @@ import (
// Handler serves a site. // Handler serves a site.
// //
// One routing case for now: a path is a bundle key. Extracting a resolver waits for the second case, // One mux entry, because URL shape is the resolver's business rather than the mux's: see resolve.
// which language routing brings — check the counter in docs/state.md rather than anticipating it.
func Handler(site *content.Site, r *render.Renderer) http.Handler { func Handler(site *content.Site, r *render.Renderer) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) { mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {
@@ -23,31 +21,25 @@ func Handler(site *content.Site, r *render.Renderer) http.Handler {
return mux return mux
} }
// serve resolves one request. // serve resolves one request and writes its bundle.
//
// The canonical form of every bundle URL ends in a slash (ADR-0008), so a slashless path that names a
// bundle redirects permanently rather than serving a second URL for the same content.
func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer) { func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer) {
p := req.URL.Path res, ok := resolve(req.URL.Path, site)
if p == "/" {
http.NotFound(w, req)
return
}
key := content.Normalise(strings.Trim(p, "/"))
if !strings.HasSuffix(p, "/") {
if _, ok := site.Lookup(key); ok {
http.Redirect(w, req, p+"/", http.StatusMovedPermanently)
return
}
http.NotFound(w, req)
return
}
b, ok := site.Lookup(key)
if !ok { if !ok {
http.NotFound(w, req) http.NotFound(w, req)
return return
} }
out, err := r.Bundle(b) // A redirect target only exists for a path that resolves, so check the bundle before sending one:
// otherwise a nonexistent page answers 301 and confirms nothing.
b, served, found := site.Lookup(res.key, res.lang)
if res.redirect != "" && (found || res.key == "") {
http.Redirect(w, req, res.redirect, http.StatusMovedPermanently)
return
}
if !found {
http.NotFound(w, req)
return
}
out, err := r.Bundle(b, served, site.Variants(res.key))
if err != nil { if err != nil {
// A render failure degrades: log it and say nothing more to the client than that it failed // A render failure degrades: log it and say nothing more to the client than that it failed
// (conventions.md). It must never leak a template or filesystem detail. // (conventions.md). It must never leak a template or filesystem detail.
+92
View File
@@ -69,3 +69,95 @@ func TestUnknownPathsAre404(t *testing.T) {
} }
} }
} }
func multilingualHandler(t *testing.T) http.Handler {
t.Helper()
fsys := fstest.MapFS{
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nEnglish.\n")},
"content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\nবাংলা।\n")},
"content/pages/now.md": {Data: []byte("---\ntitle: Now\n---\nOnly English.\n")},
}
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
r, err := render.New()
if err != nil {
t.Fatal(err)
}
return Handler(content.NewSite(bundles), r)
}
func TestPrefixedLanguageServesThatVariant(t *testing.T) {
h := multilingualHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/bn/pages/about/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "বাংলা।") || !strings.Contains(body, `lang="bn"`) {
t.Errorf("did not serve the Bengali variant:\n%s", body)
}
if !strings.Contains(body, `rel="canonical" href="/bn/pages/about/"`) {
t.Error("canonical should name the variant actually served")
}
if !strings.Contains(body, `hreflang="en" href="/pages/about/"`) {
t.Error("hreflang should list the English variant at the root form")
}
}
func TestMissingVariantFallsBackAndSaysSo(t *testing.T) {
h := multilingualHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/bn/pages/now/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200: the fallback chain must not 404 (ADR-0009)", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Only English.") {
t.Error("expected the English body as fallback")
}
if !strings.Contains(body, `rel="canonical" href="/pages/now/"`) {
t.Error("canonical must point at the variant served, not the URL requested")
}
}
func TestDefaultLanguagePrefixRedirectsToRoot(t *testing.T) {
h := multilingualHandler(t)
for path, want := range map[string]string{
"/en/pages/about/": "/pages/about/",
"/en/pages/about": "/pages/about/",
} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusMovedPermanently {
t.Errorf("GET %s = %d, want 301: /en/… must never be live (ADR-0009)", path, rec.Code)
continue
}
if loc := rec.Header().Get("Location"); loc != want {
t.Errorf("GET %s → %q, want %q", path, loc, want)
}
}
}
func TestPrefixedSlashlessPathRedirectsWithItsPrefix(t *testing.T) {
h := multilingualHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/bn/pages/about", nil))
if rec.Code != http.StatusMovedPermanently {
t.Fatalf("got %d, want 301", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/bn/pages/about/" {
t.Errorf("Location = %q, want /bn/pages/about/", loc)
}
}
func TestUnknownLanguagePrefixIsNotALanguage(t *testing.T) {
h := multilingualHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/fr/pages/about/", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("got %d, want 404: fr is not a language this site has", rec.Code)
}
}