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