The server never fails on a bad bundle — it works around it and logs, which is easy to miss (ADR-0029). This is the command that looks on purpose, and it exits non-zero on anything that makes the site wrong rather than merely untidy. Fatal: content the engine had to drop (unparseable frontmatter, a key two files claim, an ignored slug or alias) and links that will 404 for a reader. Warnings: no title, a figure with no alt text, a series where some members declare order and others do not — that last one because unordered members sort last, so adding order to one chapter silently moves every chapter that lacks it. Two things this needed rather than invented. `Scan` and `Site` now *return* what they worked around instead of only logging it: `ScanReport` and `Site.Problems`, with `Scan` staying the logging wrapper so nothing else changed. And required-fields-per-type is deliberately absent — `title` is the only field the engine requires today, so checking more would mean inventing the type declaration that is still parked. Its trigger stays where it was. The link checker asks the same questions the resolver asks — bundle, alias, section listing, file inside a bundle — because a checker that guesses differently from the server is worse than no checker. Engine-owned paths are skipped: they are generated, not authored. It lives in internal/ext/check, so cmd/ stays wiring and the feature stays deletable. Verified against the evidence site: clean before, and five findings across five fault classes after I introduced them on purpose.
407 lines
13 KiB
Go
407 lines
13 KiB
Go
package content
|
|
|
|
import (
|
|
"log/slog"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Site is a set of bundles indexed for lookup by permalink key.
|
|
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
|
|
// problems are what indexing worked around, kept for `check` rather than only logged.
|
|
problems []Problem
|
|
// 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{},
|
|
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
|
|
}
|
|
|
|
// Problems lists what indexing worked around: a contested alias, a slug two variants disagree on, a slug
|
|
// landing where something already answers. Each was also logged.
|
|
func (s *Site) Problems() []Problem { return s.problems }
|
|
|
|
// note records a problem and logs it, so the server says the same thing it always did.
|
|
func (s *Site) note(path, detail string) {
|
|
s.problems = append(s.problems, Problem{path, detail})
|
|
slog.Error("content problem", "path", path, "detail", detail)
|
|
}
|
|
|
|
// 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 {
|
|
s.note(key, "slug ignored: variants declare different ones ("+strings.Join(sorted(slugs), ", ")+")")
|
|
continue
|
|
}
|
|
route := path.Join(path.Dir(key), sorted(slugs)[0])
|
|
if _, taken := s.byKeyLang[route+"\x00"+DefaultLang]; taken || s.keyByRoute[route] != "" {
|
|
s.note(key, "slug ignored: "+route+" is already answered by another bundle")
|
|
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) {
|
|
// 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 {
|
|
if claimed[a] == nil {
|
|
claimed[a] = map[string]bool{}
|
|
}
|
|
claimed[a][b.Key] = true
|
|
}
|
|
}
|
|
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 {
|
|
s.note(alias, "alias ignored: a real bundle answers there, claimed by "+strings.Join(keys, ", "))
|
|
continue
|
|
}
|
|
}
|
|
if len(keys) > 1 {
|
|
s.note(alias, "alias ignored: claimed by more than one bundle ("+strings.Join(keys, ", ")+")")
|
|
continue
|
|
}
|
|
s.aliases[alias] = keys[0]
|
|
}
|
|
}
|
|
|
|
// Alias returns the key an alias redirects to.
|
|
func (s *Site) Alias(alias string) (string, bool) {
|
|
key, ok := s.aliases[alias]
|
|
return key, ok
|
|
}
|
|
|
|
// Lookup returns the best variant of a key for a requested language, and the language actually served.
|
|
//
|
|
// The fallback chain is requested → default → any (ADR-0009); "any" is resolved in sorted order so the
|
|
// same request always answers the same way. A key with no variants at all reports false.
|
|
func (s *Site) Lookup(key, lang string) (b Bundle, served string, ok bool) {
|
|
for _, try := range []string{lang, DefaultLang} {
|
|
if try == "" {
|
|
continue
|
|
}
|
|
if b, ok = s.byKeyLang[key+"\x00"+try]; ok {
|
|
return b, try, true
|
|
}
|
|
}
|
|
for _, l := range s.Variants(key) {
|
|
b = s.byKeyLang[key+"\x00"+l]
|
|
return b, l, true
|
|
}
|
|
return Bundle{}, "", false
|
|
}
|
|
|
|
// Variants lists the languages a key exists in, sorted.
|
|
func (s *Site) Variants(key string) []string {
|
|
var langs []string
|
|
for kl := range s.byKeyLang {
|
|
k, l, found := strings.Cut(kl, "\x00")
|
|
if found && k == key {
|
|
langs = append(langs, l)
|
|
}
|
|
}
|
|
sort.Strings(langs)
|
|
return langs
|
|
}
|
|
|
|
// HasLang reports whether any bundle is written in lang. The resolver needs this to tell a language
|
|
// prefix from a section that happens to share its name.
|
|
func (s *Site) HasLang(lang string) bool {
|
|
for kl := range s.byKeyLang {
|
|
if _, l, found := strings.Cut(kl, "\x00"); found && l == lang {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Len reports how many bundles the site holds.
|
|
func (s *Site) Len() int { return len(s.byKeyLang) }
|
|
|
|
// Query selects bundles into an ordered list.
|
|
//
|
|
// Every grouping in the engine is a Query — sections now, tags and series later. It carries no cache
|
|
// signature yet: nothing caches, and a signature with no consumer is speculation (architecture.md).
|
|
type Query struct {
|
|
// Section is the first path segment of a key. Empty matches every section.
|
|
Section string
|
|
// Tag is a tag slug. Empty matches every bundle; set, it matches those carrying the term.
|
|
Tag string
|
|
// Lang is the language to serve, with the usual fallback per key (ADR-0009).
|
|
Lang string
|
|
}
|
|
|
|
// Run applies q, newest first, with undated bundles after dated ones and ties broken by key so the same
|
|
// query always answers in the same order.
|
|
func (s *Site) Run(q Query) []Bundle {
|
|
var out []Bundle
|
|
for _, key := range s.keys() {
|
|
if q.Section != "" && !strings.HasPrefix(key, q.Section+"/") {
|
|
continue
|
|
}
|
|
b, _, ok := s.Lookup(key, q.Lang)
|
|
if !ok || !b.hasTag(q.Tag) {
|
|
continue
|
|
}
|
|
out = append(out, b)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
a, b := out[i], out[j]
|
|
switch {
|
|
case !a.Date.Equal(b.Date):
|
|
return a.Date.After(b.Date)
|
|
default:
|
|
return a.Key < b.Key
|
|
}
|
|
})
|
|
return out
|
|
}
|
|
|
|
// keys lists every bundle key once, sorted, so nothing iterating the index depends on map order.
|
|
func (s *Site) keys() []string {
|
|
seen := make(map[string]bool, len(s.byKeyLang))
|
|
out := make([]string, 0, len(s.byKeyLang))
|
|
for kl := range s.byKeyLang {
|
|
key, _, found := strings.Cut(kl, "\x00")
|
|
if !found || seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
out = append(out, key)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// Sequence is the reading order a bundle participates in: a series and its members.
|
|
type Sequence struct {
|
|
// Series is the landing bundle — comics/the-long-monsoon for its chapters.
|
|
Series Bundle
|
|
// Members are the series' bundles in reading order.
|
|
Members []Bundle
|
|
// Index is the 1-based position of the bundle asked about, and zero when that bundle is the landing
|
|
// page itself.
|
|
Index int
|
|
}
|
|
|
|
// Sequence resolves the series a bundle participates in, reporting false when it is in none.
|
|
//
|
|
// Membership is structural (ADR-0033): a bundle with bundles nested under it is a series landing page and
|
|
// they are its members, and a bundle nested under another is a member of that one. A landing page inside
|
|
// another series reports its own members rather than its siblings, since the deeper series is what the
|
|
// page is about.
|
|
//
|
|
// Members resolve through the language fallback chain, so a chapter missing in this language still holds
|
|
// its place in the reading order rather than breaking prev/next (ADR-0009). `draft` is not honoured
|
|
// because no bundle carries it yet (content-model.md).
|
|
func (s *Site) Sequence(key, lang string) (Sequence, bool) {
|
|
if members := s.members(key, lang); len(members) > 0 {
|
|
// key names a bundle: members are the bundles nested under it.
|
|
landing, _, _ := s.Lookup(key, lang)
|
|
return Sequence{Series: landing, Members: members}, true
|
|
}
|
|
series, nested := s.parent(key)
|
|
if !nested {
|
|
return Sequence{}, false
|
|
}
|
|
landing, _, _ := s.Lookup(series, lang) // parent only names bundles
|
|
seq := Sequence{Series: landing, Members: s.members(series, lang)}
|
|
for i, m := range seq.Members {
|
|
if m.Key == key {
|
|
seq.Index = i + 1
|
|
}
|
|
}
|
|
return seq, true
|
|
}
|
|
|
|
// parent is the nearest ancestor of key that is itself a bundle, in any language.
|
|
func (s *Site) parent(key string) (string, bool) {
|
|
for i := strings.LastIndex(key, "/"); i > 0; i = strings.LastIndex(key[:i], "/") {
|
|
if ancestor := key[:i]; s.has(ancestor) {
|
|
return ancestor, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// has reports whether a key exists in any language.
|
|
func (s *Site) has(key string) bool {
|
|
_, _, ok := s.Lookup(key, "")
|
|
return ok
|
|
}
|
|
|
|
// members are the bundles whose nearest bundle ancestor is series, in reading order.
|
|
//
|
|
// order ascending where it is set, then by key: a member without order sorts after every member carrying
|
|
// one, mirroring how an undated bundle sorts after dated ones (ADR-0033).
|
|
func (s *Site) members(series, lang string) []Bundle {
|
|
var out []Bundle
|
|
for _, key := range s.keys() {
|
|
if p, nested := s.parent(key); !nested || p != series {
|
|
continue
|
|
}
|
|
if b, _, ok := s.Lookup(key, lang); ok {
|
|
out = append(out, b)
|
|
}
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
a, b := out[i], out[j]
|
|
switch {
|
|
case (a.Order == 0) != (b.Order == 0):
|
|
return b.Order == 0
|
|
case a.Order != b.Order:
|
|
return a.Order < b.Order
|
|
default:
|
|
return a.Key < b.Key
|
|
}
|
|
})
|
|
return out
|
|
}
|
|
|
|
// hasTag reports whether the bundle carries a tag slug. An empty slug matches everything.
|
|
func (b Bundle) hasTag(slug string) bool {
|
|
if slug == "" {
|
|
return true
|
|
}
|
|
for _, t := range b.Tags {
|
|
if TagSlug(t) == slug {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Section is the first path segment of a bundle's key: its content type by default.
|
|
func (b Bundle) Section() string {
|
|
sec, _, nested := strings.Cut(b.Key, "/")
|
|
if !nested {
|
|
return ""
|
|
}
|
|
return sec
|
|
}
|
|
|
|
// Everything lists every variant of every bundle, sorted by key then language.
|
|
//
|
|
// Every *variant*, unlike a Query, which answers with one variant per key: a sitemap lists each language as
|
|
// its own URL, because each is separately reachable.
|
|
func (s *Site) Everything() []Bundle {
|
|
out := make([]Bundle, 0, len(s.byKeyLang))
|
|
for _, key := range s.keys() {
|
|
for _, lang := range s.Variants(key) {
|
|
out = append(out, s.byKeyLang[key+"\x00"+lang])
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Sections lists every section that holds at least one bundle, sorted.
|
|
func (s *Site) Sections() []string {
|
|
seen := map[string]bool{}
|
|
for kl := range s.byKeyLang {
|
|
key, _, found := strings.Cut(kl, "\x00")
|
|
if !found {
|
|
continue
|
|
}
|
|
if sec, _, nested := strings.Cut(key, "/"); nested {
|
|
seen[sec] = true
|
|
}
|
|
}
|
|
out := make([]string, 0, len(seen))
|
|
for sec := range seen {
|
|
out = append(out, sec)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|