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.
This commit is contained in:
2026-08-01 02:23:35 +06:00
parent 50f3bd1942
commit 7789242185
10 changed files with 427 additions and 41 deletions
+20
View File
@@ -43,6 +43,10 @@ type Bundle struct {
// Aliases are paths that must keep resolving to this bundle, each redirecting to its canonical URL
// (ADR-0008). Additive only: an alias is a promise never withdrawn.
Aliases []string
// Order is this bundle's position in the series it is nested under, zero when frontmatter omits it.
// The convention is sparse (10, 20, 30), so zero is not a position: an unordered member sorts by name
// after every ordered one (ADR-0033).
Order int
// Body is everything after the frontmatter, unrendered.
Body []byte
// Extra holds every frontmatter key other than title, exactly as YAML parsed it.
@@ -123,6 +127,8 @@ func Parse(name string, data []byte) (Bundle, error) {
b.Date = asTime(b.Extra["date"])
b.Tags = terms(b.Extra["tags"])
delete(b.Extra, "tags")
b.Order = asInt(b.Extra["order"])
delete(b.Extra, "order")
return b, nil
}
@@ -164,6 +170,20 @@ func asTime(v any) time.Time {
return time.Time{}
}
// asInt reads a frontmatter integer. yaml.v3 hands back an int for an unquoted number and a string for a
// quoted one, so both spellings work and anything else is simply absent.
func asInt(v any) int {
switch t := v.(type) {
case int:
return t
case string:
if n, err := strconv.Atoi(t); err == nil {
return n
}
}
return 0
}
// terms reads a scalar or sequence of tag names, preserving case and script.
func terms(v any) []string {
var out []string
+102 -7
View File
@@ -119,17 +119,11 @@ type Query struct {
// 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
}
for _, key := range s.keys() {
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
@@ -148,6 +142,107 @@ func (s *Site) Run(q Query) []Bundle {
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 == "" {
+94
View File
@@ -121,6 +121,100 @@ func mustScan(t *testing.T, fsys fstest.MapFS) []Bundle {
return b
}
// seriesFS is a series joined structurally: the landing bundle plus four chapters nested under it, two
// carrying order and two not, and one outsider that must never join.
func seriesFS() fstest.MapFS {
return fstest.MapFS{
"content/comics/the-long-monsoon/_index.md": {Data: []byte("---\ntitle: The Long Monsoon\n---\n")},
"content/comics/the-long-monsoon/first-rain.md": {Data: []byte("---\ntitle: First Rain\norder: 10\n---\n")},
"content/comics/the-long-monsoon/the-flood.md": {Data: []byte("---\ntitle: The Flood\norder: 20\n---\n")},
"content/comics/the-long-monsoon/appendix-a.md": {Data: []byte("---\ntitle: Appendix A\n---\n")},
"content/comics/the-long-monsoon/appendix-b.bn.md": {Data: []byte("---\ntitle: পরিশিষ্ট খ\n---\n")},
"content/comics/the-long-monsoon/appendix-b.md": {Data: []byte("---\ntitle: Appendix B\n---\n")},
"content/comics/elsewhere.md": {Data: []byte("---\ntitle: Elsewhere\n---\n")},
}
}
func TestSequenceMembershipIsStructural(t *testing.T) {
site := NewSite(mustScan(t, seriesFS()))
seq, ok := site.Sequence("comics/the-long-monsoon/the-flood", "en")
if !ok {
t.Fatal("a bundle nested under a landing bundle is a member (ADR-0033)")
}
if seq.Series.Title != "The Long Monsoon" {
t.Errorf("series = %q, want the enclosing landing bundle", seq.Series.Title)
}
var titles []string
for _, m := range seq.Members {
titles = append(titles, m.Title)
}
want := []string{"First Rain", "The Flood", "Appendix A", "Appendix B"}
if len(titles) != len(want) {
t.Fatalf("members = %v, want %v", titles, want)
}
for i := range want {
if titles[i] != want[i] {
t.Fatalf("members = %v, want %v — ordered first, then by name", titles, want)
}
}
if seq.Index != 2 {
t.Errorf("index = %d, want 2", seq.Index)
}
if _, ok := site.Sequence("comics/elsewhere", "en"); ok {
t.Error("a bundle beside the series is not in it")
}
}
func TestSequenceOnTheLandingPageListsItsMembers(t *testing.T) {
site := NewSite(mustScan(t, seriesFS()))
seq, ok := site.Sequence("comics/the-long-monsoon", "en")
if !ok {
t.Fatal("a bundle with bundles nested under it is a series landing page")
}
if seq.Index != 0 {
t.Errorf("index = %d, want 0: the landing page holds no position in its own series", seq.Index)
}
if len(seq.Members) != 4 {
t.Errorf("members = %d, want 4", len(seq.Members))
}
}
func TestSequenceMembersFollowTheLanguageFallback(t *testing.T) {
site := NewSite(mustScan(t, seriesFS()))
seq, ok := site.Sequence("comics/the-long-monsoon/the-flood", "bn")
if !ok {
t.Fatal("the series must resolve in every language")
}
if len(seq.Members) != 4 {
t.Fatalf("members = %d, want 4: a chapter missing in Bengali still holds its place", len(seq.Members))
}
if got := seq.Members[3].Title; got != "পরিশিষ্ট খ" {
t.Errorf("last member = %q, want the Bengali variant where one exists", got)
}
if got := seq.Members[1].Title; got != "The Flood" {
t.Errorf("member without a Bengali variant = %q, want the English fallback", got)
}
}
func TestNestedSeriesReportsItsOwnMembers(t *testing.T) {
fsys := seriesFS()
fsys["content/comics/_index.md"] = &fstest.MapFile{Data: []byte("---\ntitle: Comics\n---\n")}
site := NewSite(mustScan(t, fsys))
seq, ok := site.Sequence("comics/the-long-monsoon", "en")
if !ok {
t.Fatal("still a series")
}
if len(seq.Members) != 4 || seq.Series.Title != "The Long Monsoon" {
t.Errorf("a landing page inside another series reports its own members, got %d under %q",
len(seq.Members), seq.Series.Title)
}
// The outsider's nearest bundle ancestor is now the section landing, so it joins that sequence.
seq, ok = site.Sequence("comics/elsewhere", "en")
if !ok || seq.Series.Title != "Comics" {
t.Errorf("nearest ancestor should win, got %q ok=%v", seq.Series.Title, ok)
}
}
func TestQueryFiltersByTagAndSection(t *testing.T) {
fsys := fstest.MapFS{
"content/posts/a.md": {Data: []byte("---\ntitle: A\ndate: 2026-01-03\ntags: [Monsoon, prose]\n---\n")},