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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 `/<lang>/` on the same
|
||||
path — `/pages/about/` and `/bn/pages/about/`. `/en/…` permanently redirects to the root form and is
|
||||
never live.
|
||||
|
||||
+4
-3
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}}</title>
|
||||
<link rel="canonical" href="{{.Canonical}}">
|
||||
{{- range .Alternates}}
|
||||
<link rel="alternate" hreflang="{{.Lang}}" href="{{.URL}}">
|
||||
{{- end}}
|
||||
<style>{{.Style}}</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+15
-23
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user