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:
@@ -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 + "/"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user