diff --git a/docs/architecture.md b/docs/architecture.md index 2ab933a..06eee0a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,8 +62,9 @@ visible, it is a Stage wearing a disguise. `state.md`. Scheduling is an in-process ticker inside the single binary — no cron container and no queue until ADR-0010's second-service test is actually met. -**Routing** = URL → (Bundle, View), via a resolver. **STATUS: buildable, inline.** Keep cases inline; -extract the resolver when the routing counter in `state.md` is due, not before. +**Routing** = URL → (Bundle, View), via a resolver. **STATUS: live.** The resolver was earned at the +second routing case — the default locale at the root, every other language under a prefix — and now owns +URL shape so the mux has one entry. --- diff --git a/docs/content-model.md b/docs/content-model.md index 02c0892..17384e4 100644 --- a/docs/content-model.md +++ b/docs/content-model.md @@ -138,6 +138,10 @@ published URL never changes meaning; renames add `aliases` and emit permanent re bundle between sections changes the URL the engine emits, and the old path resolves only through `aliases` — so a section list is effectively permanent once anything is published. +A language prefix wins over a section of the same name, so a site with Bengali content may not also have +a section called `bn`. The engine treats a leading segment as a language only when some bundle is written +in it. + Language routing is decided (ADR-0009): English at root, other languages under `//` on the same path — `/pages/about/` and `/bn/pages/about/`. `/en/…` permanently redirects to the root form and is never live. diff --git a/docs/state.md b/docs/state.md index 5d4991d..fc7e5e6 100644 --- a/docs/state.md +++ b/docs/state.md @@ -1,6 +1,6 @@ # State -**Verified against:** `HEAD` on 2026-07-30 — update this line every change. +**Verified against:** `51143d3` on 2026-07-30 — update this line every change. If this file disagrees with the code, the code is right and this file is a bug. ## Inventory @@ -11,7 +11,8 @@ If this file disagrees with the code, the code is right and this file is a bug. | `internal/content/content.go` | site root → bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, collision drop, key index | 245 | | `internal/render/render.go` | goldmark + the embedded reference theme; `Page` is what templates receive | 92 | | `internal/render/templates/` | reference theme: `base.html`, `theme.css` (ADR-0026) | — | -| `internal/web/web.go` | one routing case: path → bundle key, canonical trailing slash, 404, degrade on render failure | 62 | +| `internal/web/resolve.go` | URL → (key, lang) or a canonical redirect: language prefix, `/en/…` fork guard, trailing slash | 56 | +| `internal/web/web.go` | handler: resolve, look up with fallback, render, degrade on failure | 62 | | `cmd/khosra/main.go` | flags, wiring, startup — the only place things are assembled | 50 | | `*_test.go` | table-driven; symlink escape, permalink, redirect, 404 | 245 | @@ -28,7 +29,7 @@ this change*. | Counter | Now | Extraction due at | What it buys | |---|---|---|---| | Render transforms | 0 | **3** | Stage pipeline (ordered `func(ctx,*Page) error`) | -| Routing cases | 1 | **2** | Resolver extraction | +| Routing cases | 2 | **2** — done | Resolver extracted at `internal/web/resolve.go` | | Collection pages | 0 | **1** | Query primitive | | Views / output formats | 1 | **2** | View layer (contract per `theme-contract.md`) | | Effects | 0 | **2** | Effect runner + trigger wiring (change / schedule / demand) | diff --git a/docs/theme-contract.md b/docs/theme-contract.md index 065ffaa..b881fb5 100644 --- a/docs/theme-contract.md +++ b/docs/theme-contract.md @@ -19,6 +19,8 @@ A bundle page receives: | `.HTML` | the rendered body, already escaped | | `.Extra` | every frontmatter key the parser does not name (ADR-0002) | | `.Style` | the reference theme's stylesheet, inlined so a bare site root needs no asset route | +| `.Canonical` | the permalink of the variant actually served — not the URL requested, which differs when the fallback chain supplied another language | +| `.Alternates` | every language this key exists in, as `.Lang` and `.URL`, for `hreflang` | Two named templates: `base` is executed for every page; `main` is the block a theme redefines to change the body while inheriting the document. Nothing else is promised yet. diff --git a/internal/content/content.go b/internal/content/content.go index a338ec3..91d3f07 100644 --- a/internal/content/content.go +++ b/internal/content/content.go @@ -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 + "/" +} diff --git a/internal/content/content_test.go b/internal/content/content_test.go index abd9056..92e3efa 100644 --- a/internal/content/content_test.go +++ b/internal/content/content_test.go @@ -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) + } + } +} diff --git a/internal/render/render.go b/internal/render/render.go index b4af257..016fa61 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -34,6 +34,17 @@ type Page struct { Extra map[string]any // Style is the reference theme's stylesheet, inlined so a bare site root needs no asset route. Style template.CSS + // Canonical is the permalink of the variant actually served, which differs from the requested URL + // when the fallback chain supplied another language (ADR-0009). + Canonical string + // Alternates lists every language this key exists in, for hreflang. + Alternates []Alternate +} + +// Alternate is one language a bundle exists in. +type Alternate struct { + Lang string + URL string } // Renderer holds the parsed template set and the Markdown converter. Templates are parsed once, never @@ -61,18 +72,25 @@ func New() (*Renderer, error) { } // Bundle renders one bundle into a complete page. -func (r *Renderer) Bundle(b content.Bundle) ([]byte, error) { +// +// served is the language actually chosen by the fallback chain, and variants every language the key +// exists in; both feed canonical and hreflang, which a theme must not construct itself. +func (r *Renderer) Bundle(b content.Bundle, served string, variants []string) ([]byte, error) { var body bytes.Buffer if err := r.md.Convert(b.Body, &body); err != nil { return nil, fmt.Errorf("markdown %s: %w", b.Path, err) } p := Page{ - Title: b.Title, - Lang: b.Lang, - Key: b.Key, - HTML: template.HTML(body.String()), - Extra: b.Extra, - Style: r.style, + Title: b.Title, + Lang: b.Lang, + Key: b.Key, + HTML: template.HTML(body.String()), + Extra: b.Extra, + Style: r.style, + Canonical: content.URL(b.Key, served), + } + for _, l := range variants { + p.Alternates = append(p.Alternates, Alternate{Lang: l, URL: content.URL(b.Key, l)}) } var out bytes.Buffer if err := r.tmpl.ExecuteTemplate(&out, "base", p); err != nil { diff --git a/internal/render/render_test.go b/internal/render/render_test.go index d473134..14d9ec8 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -16,7 +16,7 @@ func TestBundleRendersMarkdownIntoTheTheme(t *testing.T) { if err != nil { t.Fatal(err) } - out, err := r.Bundle(b) + out, err := r.Bundle(b, "en", []string{"en"}) if err != nil { t.Fatal(err) } @@ -40,7 +40,7 @@ func TestBundleWithoutTitleFallsBackToKey(t *testing.T) { if err != nil { t.Fatal(err) } - out, err := r.Bundle(b) + out, err := r.Bundle(b, "en", []string{"en"}) if err != nil { t.Fatal(err) } diff --git a/internal/render/templates/base.html b/internal/render/templates/base.html index 33f613c..dea5e79 100644 --- a/internal/render/templates/base.html +++ b/internal/render/templates/base.html @@ -5,6 +5,10 @@ {{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}} + +{{- range .Alternates}} + +{{- end}} diff --git a/internal/web/resolve.go b/internal/web/resolve.go new file mode 100644 index 0000000..7fb1ea9 --- /dev/null +++ b/internal/web/resolve.go @@ -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 +} diff --git a/internal/web/web.go b/internal/web/web.go index be0bd4c..9802d62 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -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. diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 922b687..a5c57a8 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -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) + } +}