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
+51 -6
View File
@@ -11,6 +11,7 @@ import (
"log/slog"
"os"
"path"
"sort"
"strings"
"golang.org/x/text/unicode/norm"
@@ -231,14 +232,58 @@ func NewSite(bundles []Bundle) *Site {
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
// variant (ADR-0021).
func (s *Site) Lookup(key string) (Bundle, bool) {
b, ok := s.byKeyLang[key+"\x00"+DefaultLang]
return b, ok
// The fallback chain is requested → default → any (ADR-0009); "any" is resolved in sorted order so the
// same request always answers the same way. A key with no variants at all reports false.
func (s *Site) Lookup(key, lang string) (b Bundle, served string, ok bool) {
for _, try := range []string{lang, DefaultLang} {
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.
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 {
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)
}
}
}