split the content package at its seam

content.go had passed FILE_LOC_WARN, which conventions.md treats as the moment to
split rather than a number to ignore: flat until the figure, then split, never
pre-partitioned. content.go now parses bundles and builds permalinks; site.go holds
the indexed site — lookup with language fallback, aliases, Query and Run. The tests
follow the same seam.

No behaviour change, and the test-coupling gate was right to demand the tests move:
its exemption covers comments and whitespace, not code relocated between files,
where an edit could hide.
This commit is contained in:
2026-08-01 02:23:34 +06:00
parent b14658094e
commit e95601d34c
6 changed files with 348 additions and 333 deletions
-189
View File
@@ -1,7 +1,3 @@
// Package content reads a site root into bundles. It knows the disk and nothing about HTTP.
//
// Every read goes through an [os.Root] (ADR-0031), so no path — from a filename or later from a
// request — can escape the site root, even through a symlink.
package content
import (
@@ -11,7 +7,6 @@ import (
"log/slog"
"os"
"path"
"sort"
"strconv"
"strings"
"time"
@@ -299,196 +294,12 @@ func dropCollisions(all []Bundle) []Bundle {
return kept
}
// 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) }
// PerPage is how many entries a listing shows.
//
// Changing it renumbers page URLs, which ADR-0028 calls a URL event; it becomes a setting when the
// cascade exists rather than being one knob early.
const PerPage = 10
// 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 {
seen := map[string]bool{}
var out []Bundle
for kl := range s.byKeyLang {
key, _, found := strings.Cut(kl, "\x00")
if !found || seen[key] {
continue
}
if q.Section != "" && !strings.HasPrefix(key, q.Section+"/") {
continue
}
seen[key] = true
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
}
// 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
}
// URL is the permalink of a variant: /{section}/{slug}/, with a language prefix for anything but the
// default locale (ADR-0008, ADR-0009). Templates never build a path by hand.
func URL(key, lang string) string {