diff --git a/docs/content-model.md b/docs/content-model.md index 77cb6c3..915969d 100644 --- a/docs/content-model.md +++ b/docs/content-model.md @@ -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 diff --git a/docs/state.md b/docs/state.md index 5aea51c..3705a63 100644 --- a/docs/state.md +++ b/docs/state.md @@ -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`. diff --git a/docs/theme-contract.md b/docs/theme-contract.md index ef65672..7f557f6 100644 --- a/docs/theme-contract.md +++ b/docs/theme-contract.md @@ -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 | diff --git a/internal/content/content.go b/internal/content/content.go index 7481872..06bc96c 100644 --- a/internal/content/content.go +++ b/internal/content/content.go @@ -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 } diff --git a/internal/content/site.go b/internal/content/site.go index 6ec6616..2426ebc 100644 --- a/internal/content/site.go +++ b/internal/content/site.go @@ -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) diff --git a/internal/content/site_test.go b/internal/content/site_test.go index 3796b3e..e73a410 100644 --- a/internal/content/site_test.go +++ b/internal/content/site_test.go @@ -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") + } +} diff --git a/internal/render/render.go b/internal/render/render.go index b298f67..ad135bb 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -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. diff --git a/internal/web/discover.go b/internal/web/discover.go index ed6ca02..0f90968 100644 --- a/internal/web/discover.go +++ b/internal/web/discover.go @@ -52,7 +52,7 @@ func serveSitemap(w http.ResponseWriter, req *http.Request, site *content.Site, out.WriteString(`` + "\n") out.WriteString(`` + "\n") for _, entry := range site.Everything() { - fmt.Fprintf(&out, "%s", xmlEscape(content.Absolute(base, content.URL(entry.Key, entry.Lang)))) + fmt.Fprintf(&out, "%s", xmlEscape(content.Absolute(base, content.URL(entry.Route, entry.Lang)))) if !entry.Date.IsZero() { fmt.Fprintf(&out, "%s", entry.Date.Format("2006-01-02")) } diff --git a/internal/web/slug_test.go b/internal/web/slug_test.go new file mode 100644 index 0000000..e02f3d6 --- /dev/null +++ b/internal/web/slug_test.go @@ -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, "

Taken

") { + 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) + } +} diff --git a/internal/web/web.go b/internal/web/web.go index c85b3fa..938059d 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -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.