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