serve a declared slug as an address, leaving the key alone

The human chose the second option: a route sits beside the key rather than
replacing it. So `slug` renames what a bundle is served at, in every language, and
identity stays derived from the path — which is exactly what keeps ADR-0033 intact,
since series membership is the directory. A series landing page can now be renamed
without orphaning its chapters, and there is a test that says so.

`Site` resolves routes at index time, because only it can see whether every variant
agrees. Disagreement is dropped rather than resolved, as is a slug landing where
another bundle already answers — the same rule colliding keys and contested aliases
already follow. The key a slug moved away from stops answering, so the old address
does not quietly keep working.

Two bugs surfaced doing this, both older than this change:

An alias naming its own bundle's former key was rejected as "an alias that names a
real bundle" — which made rename-plus-alias, the entire point of ADR-0008's alias
mechanism, impossible. The check now asks what a request asks: is anything actually
served there.

Aliases were counted per declaring *file*, so a bundle whose two language variants
both listed the same alias looked like two rival claimants and lost the alias. It is
a set of keys now. This one only appears with translated content, which is why no
fixture had caught it since entry 4 — the real binary did, on the first multilingual
rename.
This commit is contained in:
Claude Opus 5
2026-07-31 02:34:07 +06:00
committed by bdeshi
parent e6c0673374
commit e7287a0370
10 changed files with 330 additions and 26 deletions
+3 -2
View File
@@ -75,7 +75,7 @@ readable by templates (ADR-0002). Never add a required field.
| `title` | string | Only required field |
| `date` / `updated` | date | Publication; `updated` drives feeds and `Last-Modified`. An unquoted `2026-07-30` or an RFC 3339 timestamp; undated bundles sort after dated ones |
| `type` | string | Post type; defaults from the top-level section |
| `slug` | string | Overrides the derived slug for the bundle in **every** language (ADR-0035). The engine serves the new path only; the old one 404s unless it appears in `aliases`. Two variants declaring different slugs is ambiguous — logged, dropped, derived path kept |
| `slug` | string | Renames the bundle's final path segment in **every** language (ADR-0035). One segment: a slash would move the bundle to another section, which is a move rather than a rename. The **key does not change**, so identity, caching and series membership are untouched; only the address moves. The old path 404s unless `aliases` lists it. Two variants declaring different slugs, or a slug landing where another bundle already answers, is ambiguous — logged, dropped, derived path kept |
| `aliases` | []string | Paths the engine redirects permanently to this bundle's canonical URL (ADR-0008). A scalar or a list; surrounding slashes optional. An alias naming a real bundle, or claimed by two bundles, is ambiguous — logged and dropped, and the real bundle keeps its URL |
| `draft` | bool | Excluded from queries and feeds |
| `nocache` | bool | Never cache this bundle's render. Named so absence means cacheable, per ADR-0002 |
@@ -123,7 +123,8 @@ the page it names. Unfixable after publication except by accumulating aliases.
Unicode is preserved rather than transliterated. Any derived slug may be replaced by hand:
- a bundle, with `slug` in frontmatter — the engine then serves that path, and the previous one only if
`aliases` lists it
`aliases` lists it. The key stays as derived: a slug is an address, not an identity, which is what lets a
series landing page be renamed without orphaning the chapters nested under it (ADR-0033, ADR-0035)
- a taxonomy term or section segment, with a term-to-slug mapping in the type declaration, so a Bengali
tag can carry a chosen URL form instead of a derived one
+8 -7
View File
@@ -1,6 +1,6 @@
# State
**Verified against:** `2b0387e` on 2026-07-30 — update this line every change.
**Verified against:** `ebf63d1` 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
@@ -9,9 +9,9 @@ If this file disagrees with the code, the code is right and this file is a bug.
|---|---|---|
| `go.mod` | module `khosra`; `goldmark`, `x/text`, `yaml.v3` direct | 10 |
| `internal/content/doc.go` | package comment | 5 |
| `internal/content/content.go` | bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, tag slugs, partial files, permalink building | 368 |
| `internal/content/content.go` | bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, tag slugs, partial files, permalink building | 381 |
| `internal/content/settings.go` | `site.yaml`: the site's own declarations (`base`, `title`) and absolute-URL building (ADR-0039) | 59 |
| `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections, `Sequence`, `Everything` | 300 |
| `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections, `Sequence`, `Everything`, slug routes | 395 |
| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the `Partial`/`Origin` seams features render and resolve through, `Page`/`List`/`Sequence`/`head` | 409 |
| `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) | 105 |
| `internal/render/templates/` | reference theme: `base.html`, `page.html`, `list.html`, `shortcodes.html`, `theme.css` (ADR-0026) | — |
@@ -22,9 +22,10 @@ If this file disagrees with the code, the code is right and this file is a bug.
| `internal/web/discover.go` | `/robots.txt` and `/sitemap.xml`, absolute and only with a declared base (ADR-0039) | 74 |
| `internal/web/web.go` | handler: resolve, look up with fallback, section and tag listings, sequence, `/static/` (misses and refusals alike answer 404), degrade on failure | 152 |
| `cmd/khosra/main.go` | flags (`-site`, `-addr`, `-base`), wiring, startup — the only place things are assembled | 60 |
| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, widows, site settings, absolute URLs, robots, sitemap, 404 | 1755 |
| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, widows, site settings, absolute URLs, robots, sitemap, slug routes, 404 | 1946 |
Serves a bundle at `/{section}/{slug}/`, a paginated listing per section, tag listings global and
Serves a bundle at `/{section}/{slug}/` — the slug derived, or declared in frontmatter without moving the
key (ADR-0035) — a paginated listing per section, tag listings global and
section-narrowed, sequence navigation and a series archive on any nested bundle, `static/` verbatim, plus `/robots.txt` and
`/sitemap.xml`.
Chrome text, dates and digits render in English or Bengali; authored text is untouched but for typographic
@@ -32,8 +33,8 @@ smoothing and widow prevention (ADR-0034). This repo holds engine source only
`-site` (ADR-0011). `site.yaml` declares `base` and `title`; with a base, canonical, hreflang and OpenGraph
URLs go absolute (ADR-0039).
Frontmatter the parser lifts today: `title`, `date`, `tags`, `aliases`, `order`. Every other key in
`content-model.md`'s table — including `slug`, `draft` and `type` — lands in `Extra` unread, so that table
Frontmatter the parser lifts today: `title`, `date`, `tags`, `aliases`, `order`, `slug`. Every other key in
`content-model.md`'s table — including `draft` and `type` — lands in `Extra` unread, so that table
is the accepted format, not a list of what runs.
Dependencies: three, all allowlisted — `goldmark`, `golang.org/x/text`, `gopkg.in/yaml.v3`.
+1 -1
View File
@@ -15,7 +15,7 @@ A bundle page receives:
|---|---|
| `.Title` | may be empty; a template falls back to `.Key` rather than failing |
| `.Lang` | the locale of this variant, always set |
| `.Key` | the bundle's identity, without language or extension |
| `.Key` | the bundle's identity, without language or extension. **Not its address** — a `slug` moves the address and leaves the key alone, so link with `.URL` or `.Canonical` and never by assembling a key (ADR-0035) |
| `.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 |
+13
View File
@@ -43,6 +43,12 @@ type Bundle struct {
// Aliases are paths that must keep resolving to this bundle, each redirecting to its canonical URL
// (ADR-0008). Additive only: an alias is a promise never withdrawn.
Aliases []string
// Slug is a hand-chosen final path segment, empty unless frontmatter declares one. It renames the
// bundle's address in every language (ADR-0035) and never its Key, which stays the identity.
Slug string
// Route is the path this bundle is served at: its Key, unless a slug renamed the last segment. Set by
// NewSite, which is the only place that can see whether every variant agrees.
Route string
// Order is this bundle's position in the series it is nested under, zero when frontmatter omits it.
// The convention is sparse (10, 20, 30), so zero is not a position: an unordered member sorts by name
// after every ordered one (ADR-0033).
@@ -131,6 +137,13 @@ func Parse(name string, data []byte) (Bundle, error) {
delete(b.Extra, "tags")
b.Order = asInt(b.Extra["order"])
delete(b.Extra, "order")
if slug, isStr := b.Extra["slug"].(string); isStr {
// One segment, normalised like every other identifier (ADR-0015). Slashes would let a slug move the
// bundle to another section, which is a move, not a rename.
b.Slug = Normalise(strings.Trim(strings.TrimSpace(slug), "/"))
}
delete(b.Extra, "slug")
b.Route = b.Key
return b, nil
}
+103 -8
View File
@@ -2,6 +2,7 @@ package content
import (
"log/slog"
"path"
"sort"
"strings"
)
@@ -10,37 +11,131 @@ import (
type Site struct {
byKeyLang map[string]Bundle
aliases map[string]string
// keyByRoute maps a served path back to the identity it belongs to. Only renamed bundles appear: a
// bundle with no slug is served at its key, so route and key are the same string (ADR-0035).
keyByRoute map[string]string
// renamed records keys that a slug moved away from, so the old path answers 404 instead of still working
// — the engine serves the new path only (ADR-0035), and an author who wants both writes an alias.
renamed map[string]bool
}
// NewSite indexes bundles for lookup. Later variants of a key and language cannot occur, because Scan
// drops ambiguity before this sees it.
func NewSite(bundles []Bundle) *Site {
s := &Site{
byKeyLang: make(map[string]Bundle, len(bundles)),
aliases: map[string]string{},
byKeyLang: make(map[string]Bundle, len(bundles)),
aliases: map[string]string{},
keyByRoute: map[string]string{},
renamed: map[string]bool{},
}
for _, b := range bundles {
s.byKeyLang[b.Key+"\x00"+b.Lang] = b
}
s.indexRoutes(bundles)
s.indexAliases(bundles)
return s
}
// indexRoutes resolves each key's served path from the slugs its variants declare.
//
// A slug renames the bundle in every language, so the variants have to agree: two declaring different slugs
// is ambiguous, and ambiguity is dropped rather than resolved, exactly as it is for colliding keys and
// contested aliases (ADR-0035, ADR-0029). A slug that would collide with another bundle's path is dropped
// the same way, since the bundle already there must keep its URL.
func (s *Site) indexRoutes(bundles []Bundle) {
declared := map[string]map[string]bool{}
for _, b := range bundles {
if b.Slug == "" {
continue
}
if declared[b.Key] == nil {
declared[b.Key] = map[string]bool{}
}
declared[b.Key][b.Slug] = true
}
for _, key := range s.keys() {
slugs := declared[key]
if len(slugs) == 0 {
continue
}
if len(slugs) > 1 {
slog.Error("ignoring slug: variants of one bundle declare different ones",
"key", key, "slugs", sorted(slugs))
continue
}
route := path.Join(path.Dir(key), sorted(slugs)[0])
if _, taken := s.byKeyLang[route+"\x00"+DefaultLang]; taken || s.keyByRoute[route] != "" {
slog.Error("ignoring slug: another bundle already answers there", "key", key, "route", route)
continue
}
s.keyByRoute[route] = key
s.renamed[key] = true
for _, lang := range s.Variants(key) {
b := s.byKeyLang[key+"\x00"+lang]
b.Route = route
s.byKeyLang[key+"\x00"+lang] = b
}
}
}
// KeyFor is the identity served at a request path, and false when nothing is.
//
// A path is a key unless a slug moved something there — and a key a slug moved *away* from is nothing, so
// the old address stops working the moment the new one starts (ADR-0035).
func (s *Site) KeyFor(route string) (string, bool) {
if key, ok := s.keyByRoute[route]; ok {
return key, true
}
if s.renamed[route] {
return "", false
}
return route, true
}
// RouteOf is the path a key is served at.
func (s *Site) RouteOf(key string) string {
if b, _, ok := s.Lookup(key, DefaultLang); ok {
return b.Route
}
return key
}
// sorted lists a set's members in a stable order, so a log line reads the same twice.
func sorted(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
// indexAliases maps each alias to the key it redirects to.
//
// An alias that names a real bundle, or that two bundles both claim, is ambiguous: it is logged and
// dropped rather than picking a winner, and the real bundle keeps its URL (ADR-0029).
func (s *Site) indexAliases(bundles []Bundle) {
claimed := map[string][]string{}
// A set of keys, not a list: an alias belongs to the bundle, so every variant of it declares the same
// one, and counting those as rival claimants would drop exactly the aliases a translated bundle needs.
claimed := map[string]map[string]bool{}
for _, b := range bundles {
for _, a := range b.Aliases {
claimed[a] = append(claimed[a], b.Key)
if claimed[a] == nil {
claimed[a] = map[string]bool{}
}
claimed[a][b.Key] = true
}
}
for alias, keys := range claimed {
if _, isReal := s.byKeyLang[alias+"\x00"+DefaultLang]; isReal {
slog.Error("ignoring alias that names a real bundle", "alias", alias, "claimed_by", keys)
continue
for alias, claimants := range claimed {
keys := sorted(claimants)
// "Real" means *served there*, not merely a key. A key a slug renamed away from is nothing now, and
// aliasing it is precisely how a rename keeps its old URL working (ADR-0008, ADR-0035) — so this must
// ask the same question a request does.
if key, live := s.KeyFor(alias); live {
if _, isReal := s.byKeyLang[key+"\x00"+DefaultLang]; isReal {
slog.Error("ignoring alias that names a real bundle", "alias", alias, "claimed_by", keys)
continue
}
}
if len(keys) > 1 {
slog.Error("ignoring alias claimed by more than one bundle", "alias", alias, "claimed_by", keys)
+35
View File
@@ -240,3 +240,38 @@ func TestQueryFiltersByTagAndSection(t *testing.T) {
t.Errorf("unknown tag = %v, want none", titles)
}
}
func TestEveryVariantMayDeclareTheSameAlias(t *testing.T) {
// An alias belongs to the bundle, so a translated bundle repeats it in each variant. Counting those as
// rival claimants dropped exactly the aliases a multilingual rename needs — found by serving it, since
// every fixture until now declared an alias in one variant only.
site := NewSite(mustScan(t, fstest.MapFS{
"content/posts/new.md": {Data: []byte("---\ntitle: New\naliases: [posts/old]\n---\n")},
"content/posts/new.bn.md": {Data: []byte("---\ntitle: নতুন\naliases: [posts/old]\n---\n")},
}))
if got, ok := site.Alias("posts/old"); !ok || got != "posts/new" {
t.Errorf("Alias(posts/old) = %q %v, want posts/new", got, ok)
}
}
func TestASlugMovesTheRouteAndLeavesTheKeyAlone(t *testing.T) {
site := NewSite(mustScan(t, fstest.MapFS{
"content/posts/hello-world.md": {Data: []byte("---\ntitle: Hello\nslug: ekti-post\n---\n")},
"content/posts/hello-world.bn.md": {Data: []byte("---\ntitle: একটি\n---\n")},
}))
for _, lang := range []string{"en", "bn"} {
b, _, ok := site.Lookup("posts/hello-world", lang)
if !ok {
t.Fatalf("the key is still the identity, in %s", lang)
}
if b.Route != "posts/ekti-post" {
t.Errorf("%s route = %q, want posts/ekti-post — a slug renames every variant (ADR-0035)", lang, b.Route)
}
}
if key, live := site.KeyFor("posts/ekti-post"); !live || key != "posts/hello-world" {
t.Errorf("KeyFor(route) = %q %v, want the key", key, live)
}
if _, live := site.KeyFor("posts/hello-world"); live {
t.Error("the derived path must stop answering once a slug moves the bundle")
}
}
+4 -4
View File
@@ -299,14 +299,14 @@ func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, se
title = b.Key
}
p := Page{
head: r.head(title, served, content.URL(b.Key, served)),
head: r.head(title, served, content.URL(b.Route, served)),
Key: b.Key,
HTML: template.HTML(body.String()),
Extra: b.Extra,
Sequence: r.sequence(seq, served),
}
for _, l := range variants {
p.Alternates = append(p.Alternates, Alternate{Lang: l, URL: r.absolute(content.URL(b.Key, l))})
p.Alternates = append(p.Alternates, Alternate{Lang: l, URL: r.absolute(content.URL(b.Route, l))})
}
return r.execute(r.page, p, b.Key)
}
@@ -352,7 +352,7 @@ func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence {
}
out := &Sequence{
Title: seq.Series.Title,
URL: content.URL(seq.Series.Key, lang),
URL: content.URL(seq.Series.Route, lang),
Index: seq.Index,
Count: len(seq.Members),
}
@@ -374,7 +374,7 @@ func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence {
// item is one listing entry.
func (r *Renderer) item(b content.Bundle, lang string) Item {
return Item{Title: b.Title, Key: b.Key, URL: content.URL(b.Key, lang), Date: b.Date}
return Item{Title: b.Title, Key: b.Key, URL: content.URL(b.Route, lang), Date: b.Date}
}
// paginate builds the shell of a listing page and returns the slice of entries it shows.
+1 -1
View File
@@ -52,7 +52,7 @@ func serveSitemap(w http.ResponseWriter, req *http.Request, site *content.Site,
out.WriteString(`<?xml version="1.0" encoding="utf-8"?>` + "\n")
out.WriteString(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">` + "\n")
for _, entry := range site.Everything() {
fmt.Fprintf(&out, "<url><loc>%s</loc>", xmlEscape(content.Absolute(base, content.URL(entry.Key, entry.Lang))))
fmt.Fprintf(&out, "<url><loc>%s</loc>", xmlEscape(content.Absolute(base, content.URL(entry.Route, entry.Lang))))
if !entry.Date.IsZero() {
fmt.Fprintf(&out, "<lastmod>%s</lastmod>", entry.Date.Format("2006-01-02"))
}
+156
View File
@@ -0,0 +1,156 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"khosra/internal/content"
"khosra/internal/render"
)
func slugHandler(t *testing.T, fsys fstest.MapFS) http.Handler {
t.Helper()
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
r, err := render.New(nil, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
return Handler(content.NewSite(bundles), r, fsys, content.Settings{})
}
func TestASlugRenamesTheAddressInEveryLanguage(t *testing.T) {
h := slugHandler(t, fstest.MapFS{
"content/posts/hello-world.md": {Data: []byte("---\ntitle: Hello\nslug: ekti-post\n---\nEnglish.\n")},
"content/posts/hello-world.bn.md": {Data: []byte("---\ntitle: একটি পোস্ট\n---\nবাংলা।\n")},
})
for _, path := range []string{"/posts/ekti-post/", "/bn/posts/ekti-post/"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Errorf("GET %s = %d, want 200 — a slug renames every language (ADR-0035)", path, rec.Code)
}
}
// The old path is gone: the engine serves the new one only, and an author who wants both writes an alias.
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/hello-world/", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("the derived path = %d, want 404", rec.Code)
}
// Canonical and hreflang must name the new address, not the key.
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/bn/posts/ekti-post/", nil))
body := rec.Body.String()
if !strings.Contains(body, `rel="canonical" href="/bn/posts/ekti-post/"`) {
t.Errorf("canonical should name the served address:\n%s", body)
}
if strings.Contains(body, "hello-world") {
t.Errorf("nothing the engine emits should still say hello-world:\n%s", body)
}
}
func TestAnAliasCanKeepTheOldPathWorking(t *testing.T) {
h := slugHandler(t, fstest.MapFS{
"content/posts/hello-world.md": {Data: []byte("---\ntitle: Hello\nslug: ekti-post\naliases: [posts/hello-world]\n---\nx\n")},
})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/hello-world/", nil))
if rec.Code != http.StatusMovedPermanently {
t.Fatalf("got %d, want 301 — this is how a rename keeps its promise (ADR-0008)", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/posts/ekti-post/" {
t.Errorf("Location = %q, want the new address", loc)
}
}
func TestListingsAndSitemapsUseTheSluggedAddress(t *testing.T) {
fsys := fstest.MapFS{
"content/posts/hello-world.md": {Data: []byte("---\ntitle: Hello\ndate: 2026-03-01\nslug: ekti-post\n---\nx\n")},
"content/posts/plain.md": {Data: []byte("---\ntitle: Plain\ndate: 2026-02-01\n---\nx\n")},
}
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
settings := content.Settings{Base: "https://khosra.example"}
r, err := render.New(nil, settings, nil)
if err != nil {
t.Fatal(err)
}
h := Handler(content.NewSite(bundles), r, fsys, settings)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/", nil))
if body := rec.Body.String(); !strings.Contains(body, `href="/posts/ekti-post/"`) || strings.Contains(body, "hello-world") {
t.Errorf("a listing must link the address, not the key:\n%s", body)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/sitemap.xml", nil))
if body := rec.Body.String(); !strings.Contains(body, "/posts/ekti-post/") || strings.Contains(body, "hello-world") {
t.Errorf("a sitemap that disagrees with what is served is worse than none:\n%s", body)
}
}
func TestAmbiguousOrCollidingSlugsAreDropped(t *testing.T) {
// Both cases keep the derived path rather than picking a winner, the same rule aliases follow.
h := slugHandler(t, fstest.MapFS{
// Variants disagree.
"content/posts/a.md": {Data: []byte("---\ntitle: A\nslug: one\n---\nx\n")},
"content/posts/a.bn.md": {Data: []byte("---\ntitle: ক\nslug: two\n---\nx\n")},
// Slug lands on a bundle that already exists.
"content/posts/b.md": {Data: []byte("---\ntitle: B\nslug: taken\n---\nx\n")},
"content/posts/taken.md": {Data: []byte("---\ntitle: Taken\n---\nx\n")},
})
for path, want := range map[string]int{
"/posts/a/": http.StatusOK, // kept its derived path
"/posts/one/": http.StatusNotFound, // neither declared slug won
"/posts/two/": http.StatusNotFound,
"/posts/b/": http.StatusOK,
"/posts/taken/": http.StatusOK, // the bundle already there keeps its URL
} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != want {
t.Errorf("GET %s = %d, want %d", path, rec.Code, want)
}
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/taken/", nil))
if body := rec.Body.String(); !strings.Contains(body, "<h1>Taken</h1>") {
t.Errorf("the bundle already there must still be the one served:\n%s", body)
}
}
func TestASeriesSurvivesItsLandingBeingSlugged(t *testing.T) {
// The reason a slug does not touch the key: membership is the directory (ADR-0033), so renaming the
// landing page's address must not orphan its chapters.
h := slugHandler(t, fstest.MapFS{
"content/comics/the-long-monsoon/_index.md": {Data: []byte("---\ntitle: The Long Monsoon\nslug: monsoon\n---\nx\n")},
"content/comics/the-long-monsoon/first-rain.md": {Data: []byte("---\ntitle: First Rain\norder: 10\n---\nx\n")},
"content/comics/the-long-monsoon/the-flood.md": {Data: []byte("---\ntitle: The Flood\norder: 20\n---\nx\n")},
})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/comics/monsoon/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("the landing page = %d, want 200 at its new address", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"First Rain", "The Flood"} {
if !strings.Contains(body, want) {
t.Errorf("the archive lost %q — a slug must not orphan a series:\n%s", want, body)
}
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/comics/the-long-monsoon/the-flood/", nil))
if rec.Code != http.StatusOK {
t.Errorf("a chapter = %d, want 200: chapters keep their own addresses", rec.Code)
}
if body := rec.Body.String(); !strings.Contains(body, `href="/comics/monsoon/"`) {
t.Errorf("the chapter should link its series at the slugged address:\n%s", body)
}
}
+6 -3
View File
@@ -136,7 +136,10 @@ func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *rend
}
// 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)
// A request path is a route: a slug may have moved a bundle there, and moved another away (ADR-0035).
key, live := site.KeyFor(res.key)
b, served, found := site.Lookup(key, res.lang)
found = found && live
if res.redirect != "" && (found || res.key == "") {
http.Redirect(w, req, res.redirect, http.StatusMovedPermanently)
return
@@ -145,7 +148,7 @@ func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *rend
// An alias is a promise that an old URL keeps working, so it answers a permanent redirect to the
// canonical one — and only for an alias that exists, so nothing can be probed by 301.
if canonical, isAlias := site.Alias(res.key); isAlias {
http.Redirect(w, req, content.URL(canonical, res.lang), http.StatusMovedPermanently)
http.Redirect(w, req, content.URL(site.RouteOf(canonical), res.lang), http.StatusMovedPermanently)
return
}
if serveListing(w, req, site, r, res) {
@@ -160,7 +163,7 @@ func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *rend
if series, inSeries := site.Sequence(b.Key, served); inSeries {
seq = &series
}
out, err := r.Bundle(b, served, site.Variants(res.key), seq)
out, err := r.Bundle(b, served, site.Variants(key), seq)
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.