Files
khosra/internal/content/site.go
T
bdeshi 7789242185 resolve sequences from the directory tree
A bundle nested under another bundle is a member of that series (ADR-0033), so
`Site.Sequence` walks up to the nearest bundle ancestor and back down to its
members: ordered by `order` where set, then by name. Members resolve through the
language fallback, so a chapter with no Bengali variant still holds its place in
Bengali reading order instead of breaking prev/next.

One `.Sequence` field carries both shapes a theme needs. A landing page renders
`.Members` as an archive; a chapter renders `.Prev`/`.Next`, which are pointers
into `.Members` so `{{with}}` yields nothing at the ends. `Index == 0` is what
tells the two apart.

`Query` was deliberately not extended. A series ascends where `Run` descends, and
an order knob on `Query` is the config knob rule 6 bans; instead `Site.keys()`
came out so both iterate the index one way, deleting `Run`'s own dedupe map.

`draft` is not honoured: no bundle carries the field and nothing else excludes
drafts, so entry 19 adds it in both places at once. Recorded in content-model.md
rather than left implied.

state.md also corrects six inventory rows that had drifted before this change —
three LOC figures, the test total, `go.mod`, and two lines that were flatly wrong
("Dependencies: none", "goldmark is not yet imported"). The coupling gate proves
state.md changed with the code; it cannot prove the numbers are right.
2026-08-01 02:23:35 +06:00

287 lines
8.2 KiB
Go

package content
import (
"log/slog"
"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
}
// 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{},
}
for _, b := range bundles {
s.byKeyLang[b.Key+"\x00"+b.Lang] = b
}
s.indexAliases(bundles)
return s
}
// 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{}
for _, b := range bundles {
for _, a := range b.Aliases {
claimed[a] = append(claimed[a], b.Key)
}
}
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
}
if len(keys) > 1 {
slog.Error("ignoring alias claimed by more than one bundle", "alias", alias, "claimed_by", 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
}
// 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
}