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
+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 (
"log/slog"
"net/http"
"strings"
"khosra/internal/content"
"khosra/internal/render"
@@ -13,8 +12,7 @@ import (
// Handler serves a site.
//
// One routing case for now: a path is a bundle key. Extracting a resolver waits for the second case,
// which language routing brings — check the counter in docs/state.md rather than anticipating it.
// One mux entry, because URL shape is the resolver's business rather than the mux's: see resolve.
func Handler(site *content.Site, r *render.Renderer) http.Handler {
mux := http.NewServeMux()
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
}
// serve resolves one request.
//
// 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.
// serve resolves one request and writes its bundle.
func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer) {
p := req.URL.Path
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)
res, ok := resolve(req.URL.Path, site)
if !ok {
http.NotFound(w, req)
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 {
// 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.
+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)
}
}