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:
2026-08-01 02:23:36 +06:00
parent 7a08fccc42
commit 98a973db83
10 changed files with 330 additions and 26 deletions
+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)