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:
2026-08-01 02:23:34 +06:00
parent c6d5b4abb7
commit 80057aa183
12 changed files with 301 additions and 47 deletions
+44 -4
View File
@@ -167,11 +167,51 @@ func TestSiteLookupFindsDefaultVariant(t *testing.T) {
if site.Len() != 2 {
t.Fatalf("indexed %d bundles, want 2", site.Len())
}
b, ok := site.Lookup("pages/about")
if !ok || b.Title != "About" {
t.Fatalf("Lookup gave %+v %v, want the English variant", b, ok)
b, served, ok := site.Lookup("pages/about", "en")
if !ok || b.Title != "About" || served != "en" {
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")
}
}
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)
}
}
}