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:
+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)
|
||||
|
||||
Reference in New Issue
Block a user