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 // 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 } // 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) { // 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 { 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 } // 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 }