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
+57 -6
View File
@@ -46,6 +46,25 @@ type Page struct {
HTML template.HTML
// Extra carries every frontmatter key the parser does not name (ADR-0002).
Extra map[string]any
// Sequence is the series this page sits in, nil when it sits in none.
Sequence *Sequence
}
// Sequence is a series as a page sees it: its members in reading order, and where this page is in them
// (ADR-0033).
type Sequence struct {
// Title is the series' title, empty when the landing page omits one; URL is its permalink.
Title, URL string
// Members are every entry in reading order — ascending, unlike a dated listing.
Members []Item
// Index is this page's 1-based position, zero when this page is the series landing itself. Count is
// how many members there are.
Index, Count int
// Prev and Next are the neighbours in reading order, nil at the ends and on the landing page. Prev is
// the *earlier* entry, the opposite sense of a listing's PrevURL.
Prev, Next *Item
// First and Last are the ends of the series, set whenever it has members.
First, Last *Item
}
// List is a collection page: the result of a Query, one page of it.
@@ -156,8 +175,9 @@ func readStyle(siteFS fs.FS) (template.CSS, error) {
// Bundle renders one bundle into a complete page.
//
// served is the language actually chosen by the fallback chain, and variants every language the key
// exists in; both feed canonical and hreflang, which a theme must not construct itself.
func (r *Renderer) Bundle(b content.Bundle, served string, variants []string) ([]byte, error) {
// exists in; both feed canonical and hreflang, which a theme must not construct itself. seq is the series
// the bundle sits in, or nil.
func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error) {
var body bytes.Buffer
if err := r.md.Convert(b.Body, &body); err != nil {
return nil, fmt.Errorf("markdown %s: %w", b.Path, err)
@@ -167,10 +187,11 @@ func (r *Renderer) Bundle(b content.Bundle, served string, variants []string) ([
title = b.Key
}
p := Page{
head: head{Title: title, Lang: served, Canonical: content.URL(b.Key, served), Style: r.style},
Key: b.Key,
HTML: template.HTML(body.String()),
Extra: b.Extra,
head: head{Title: title, Lang: served, Canonical: content.URL(b.Key, served), Style: r.style},
Key: b.Key,
HTML: template.HTML(body.String()),
Extra: b.Extra,
Sequence: r.sequence(seq, served),
}
for _, l := range variants {
p.Alternates = append(p.Alternates, Alternate{Lang: l, URL: content.URL(b.Key, l)})
@@ -209,6 +230,36 @@ func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page in
return r.execute(r.list, l, "tag "+slug)
}
// sequence builds the series view for a page: its members, and the neighbours around this page.
//
// Neighbours are pointers into Members, so a theme reads them with `with` and gets nothing at the ends
// rather than an empty entry that looks like a link.
func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence {
if seq == nil {
return nil
}
out := &Sequence{
Title: seq.Series.Title,
URL: content.URL(seq.Series.Key, lang),
Index: seq.Index,
Count: len(seq.Members),
}
for _, m := range seq.Members {
out.Members = append(out.Members, r.item(m, lang))
}
if len(out.Members) == 0 {
return out
}
out.First, out.Last = &out.Members[0], &out.Members[len(out.Members)-1]
if seq.Index > 1 {
out.Prev = &out.Members[seq.Index-2]
}
if seq.Index > 0 && seq.Index < len(out.Members) {
out.Next = &out.Members[seq.Index]
}
return out
}
// item is one listing entry.
func (r *Renderer) item(b content.Bundle, lang string) Item {
return Item{Title: b.Title, Key: b.Key, URL: content.URL(b.Key, lang), Date: b.Date}
+5 -5
View File
@@ -17,7 +17,7 @@ func TestBundleRendersMarkdownIntoTheTheme(t *testing.T) {
if err != nil {
t.Fatal(err)
}
out, err := r.Bundle(b, "en", []string{"en"})
out, err := r.Bundle(b, "en", []string{"en"}, nil)
if err != nil {
t.Fatal(err)
}
@@ -41,7 +41,7 @@ func TestBundleWithoutTitleFallsBackToKey(t *testing.T) {
if err != nil {
t.Fatal(err)
}
out, err := r.Bundle(b, "en", []string{"en"})
out, err := r.Bundle(b, "en", []string{"en"}, nil)
if err != nil {
t.Fatal(err)
}
@@ -62,7 +62,7 @@ func TestSiteOverridesOneBlockAndInheritsTheRest(t *testing.T) {
if err != nil {
t.Fatal(err)
}
out, err := r.Bundle(b, "en", []string{"en"})
out, err := r.Bundle(b, "en", []string{"en"}, nil)
if err != nil {
t.Fatal(err)
}
@@ -90,7 +90,7 @@ func TestAListingOverrideDoesNotLeakIntoBundlePages(t *testing.T) {
if err != nil {
t.Fatal(err)
}
out, err := r.Bundle(b, "en", nil)
out, err := r.Bundle(b, "en", nil, nil)
if err != nil {
t.Fatal(err)
}
@@ -106,7 +106,7 @@ func TestSiteStylesheetReplacesTheReferenceOne(t *testing.T) {
t.Fatal(err)
}
b, _ := content.Parse("pages/x.md", []byte("hi\n"))
out, err := r.Bundle(b, "en", nil)
out, err := r.Bundle(b, "en", nil, nil)
if err != nil {
t.Fatal(err)
}
+18
View File
@@ -3,4 +3,22 @@
{{if .Title}}<h1>{{.Title}}</h1>{{end}}
{{.HTML}}
</article>
{{- with .Sequence}}
{{- if .Index}}
<nav class="sequence">
{{- with .Prev}}<a rel="prev" href="{{.URL}}">{{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}}</a>{{end}}
<span><a href="{{.URL}}">{{if .Title}}{{.Title}}{{else}}{{.URL}}{{end}}</a> {{.Index}} of {{.Count}}</span>
{{- with .Next}}<a rel="next" href="{{.URL}}">{{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}}</a>{{end}}
</nav>
{{- else if .Members}}
<nav class="sequence">
<ol class="archive">
{{- range .Members}}
<li><a href="{{.URL}}">{{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}}</a>
{{- if not .Date.IsZero}} <time datetime="{{.Date.Format "2006-01-02"}}">{{.Date.Format "2 January 2006"}}</time>{{end}}</li>
{{- end}}
</ol>
</nav>
{{- end}}
{{- end}}
{{- end}}