A draft is now not served at all, and neither is a bundle whose date has not arrived. The filter sits in `Site.Lookup` and `Site.Run`, which is every path to a bundle — so the files inside an unpublished bundle inherit its status for free, which is what ADR-0024 asks for and what the asset route was written to allow. A test asserts the 404 for the bundle *and* its picture, and that nothing leaks into a listing, a feed or a sitemap. The clock is read per request rather than at startup, so a scheduled post appears exactly when its date arrives with nothing to restart and nothing to invalidate. That first clock read created internal/content/clock.go, which is the only place `verify.sh` allows `time.Now` — a render that depends on the time is worth being able to find. `-dev on` reveals both and reparses the theme before each render. Deliberately not a bare boolean flag: turning unpublished work into public work should not be one fumbled argument away. A reload that fails to parse leaves the working template set in place, so a typo shows an error rather than replacing a good set with a broken one.
425 lines
14 KiB
Go
425 lines
14 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
|
|
// reveal serves drafts and future-dated bundles, for `-dev` only.
|
|
reveal bool
|
|
// 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
|
|
}
|
|
|
|
// Reveal makes drafts and future-dated bundles visible. Only `-dev` calls it: in a serving build an
|
|
// unpublished bundle answers 404 for itself and for every file inside it (ADR-0024).
|
|
func (s *Site) Reveal() { s.reveal = true }
|
|
|
|
// visible reports whether a bundle may be served now.
|
|
//
|
|
// Asked in Lookup and in Run, which is every path to a bundle — so hiding a draft is one rule in one place
|
|
// rather than a filter each caller must remember. The clock is read here rather than at startup, so a
|
|
// future-dated bundle appears the moment its date arrives without anything being restarted or invalidated.
|
|
func (s *Site) visible(b Bundle) bool {
|
|
return s.reveal || b.Published(now())
|
|
}
|
|
|
|
// 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 && s.visible(b) {
|
|
return b, try, true
|
|
}
|
|
}
|
|
for _, l := range s.Variants(key) {
|
|
if b = s.byKeyLang[key+"\x00"+l]; s.visible(b) {
|
|
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) {
|
|
if b := s.byKeyLang[key+"\x00"+lang]; s.visible(b) {
|
|
out = append(out, b)
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|