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:
Claude Opus 5
2026-07-30 03:03:20 +06:00
committed by bdeshi
parent 486bc7c3d3
commit fbaf077f74
10 changed files with 427 additions and 41 deletions
+17 -22
View File
@@ -1,27 +1,28 @@
# State
**Verified against:** `fbf24f7` on 2026-07-30 — update this line every change.
**Verified against:** `919d6fc` on 2026-07-30 — update this line every change.
If this file disagrees with the code, the code is right and this file is a bug.
## Inventory
| File | Purpose | LOC |
|---|---|---|
| `go.mod` | module `khosra`; `x/text`, `yaml.v3` direct | 8 |
| `go.mod` | module `khosra`; `goldmark`, `x/text`, `yaml.v3` direct | 10 |
| `internal/content/doc.go` | package comment | 5 |
| `internal/content/content.go` | bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, tag slugs, permalink building | 332 |
| `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections | 191 |
| `internal/render/render.go` | goldmark, per-kind template sets with site override, `Page`/`List`/`head` | 214 |
| `internal/content/content.go` | bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, tag slugs, permalink building | 352 |
| `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections, `Sequence` | 286 |
| `internal/render/render.go` | goldmark, per-kind template sets with site override, `Page`/`List`/`Sequence`/`head` | 297 |
| `internal/render/templates/` | reference theme: `base.html`, `page.html`, `list.html`, `theme.css` (ADR-0026) | — |
| `internal/web/resolve.go` | URL → (key, lang) or a canonical redirect: language prefix, `/en/…` fork guard, trailing slash | 56 |
| `internal/web/web.go` | handler: resolve, look up with fallback, listings, `/static/`, degrade on failure | 62 |
| `cmd/khosra/main.go` | flags, wiring, startup — the only place things are assembled | 50 |
| `*_test.go` | table-driven; symlink escape, permalink, redirect, 404 | 245 |
| `internal/web/resolve.go` | URL → (key, lang, page, tag) or a canonical redirect: language prefix, `/en/…` fork guard, pagination, tags, trailing slash | 112 |
| `internal/web/web.go` | handler: resolve, look up with fallback, section and tag listings, sequence, `/static/`, degrade on failure | 156 |
| `cmd/khosra/main.go` | flags, wiring, startup — the only place things are assembled | 53 |
| `*_test.go` | table-driven; symlink escape, permalink, language fallback, aliases, pagination, tags, sequences, 404 | 1011 |
Serves a bundle at `/{section}/{slug}/`. This repo holds engine source only — the site root is external
Serves a bundle at `/{section}/{slug}/`, a paginated listing per section, tag listings global and
section-narrowed, sequence navigation and a series archive on any nested bundle, and `static/` verbatim. This repo holds engine source only — the site root is external
and passed with `-site` (ADR-0011).
Dependencies: none.
Dependencies: three, all allowlisted — `goldmark`, `golang.org/x/text`, `gopkg.in/yaml.v3`.
## Counters — the earn-it authority
@@ -32,14 +33,14 @@ this change*.
|---|---|---|---|
| Render transforms | 0 | **3** | Stage pipeline (ordered `func(ctx,*Page) error`) |
| Routing cases | 5 | **2** — done | Resolver at `internal/web/resolve.go`: bundle, language prefix, pagination, tag, section-narrowed tag |
| Collection pages | 3 | **1** — done | Query primitive: `content.Query{Section, Tag, Lang}` + `Site.Run` |
| Collection pages | 4 | **1** — done | Query primitive: `content.Query{Section, Tag, Lang}` + `Site.Run`. The fourth — a series archive — resolves through `Site.Sequence` instead: membership is structural and the sort ascends, so it shares the index but not the Query |
| Views / output formats | 2 | **2** — due | Two template sets exist (bundle, listing); the View layer is Arc 2's third item |
| Effects | 0 | **2** | Effect runner + trigger wiring (change / schedule / demand) |
| Extensions | 0 | **3** | Extension registry + wire file (`extensions.md`) |
| Interface implementations | — | **2** | The interface itself |
| Non-stdlib dependencies | 3 direct | budget in `scripts/budgets.env` | — |
Allowlisted, in use: `goldmark` is not yet imported. Allowlist: `goldmark` (markdown), `golang.org/x/text` (NFC, ADR-0015),
Allowlist, all three imported: `goldmark` (markdown), `golang.org/x/text` (NFC, ADR-0015),
`gopkg.in/yaml.v3` (frontmatter, ADR-0020).
## Latent items — known, deliberately unfixed
@@ -52,19 +53,13 @@ with a stated reason. A list nothing drains is a graveyard of known defects.
|---|---|---|
| No mechanical check that the counters are *correct* | Accepted at the Arc 1 boundary: the coupling gate makes forgetting them impossible, which is the real failure mode, and checking the values needs code to count | 3rd transform (queue 12) |
| No mechanical gate on the untrusted boundary (ADR-0003) | Scheduled to Arc 3: nothing untrusted is read yet | The comment path — a test that untrusted input reaches no shortcode or template evaluation |
| `date` stays in `Extra` after being lifted onto `Bundle.Date`, unlike `title`, `aliases`, `tags` and `order`, which are deleted | Spotted while adding `order`; the theme contract says `Extra` holds what the parser does not name, so one of the two is wrong. Harmless today — a template reading `.Extra.date` gets the raw YAML value | Whatever next reads `Extra` generically: feeds (queue 14) or `check` (17) |
| Sequence resolution rescans the index on every bundle request — two passes over every key, each doing a `Lookup` | No cache exists anywhere yet, and a site of this size resolves in microseconds. Measuring first is the rule (queue 16) | The page cache (queue 16), which is the thing that makes the cost visible |
| Raw HTML in Markdown is currently omitted only because goldmark's default omits it | Verified at the Arc 1 boundary, and it is what keeps invariant 2 intact for authored content. `html.WithUnsafe()` is the obvious move when a shortcode needs to emit HTML, and it silently turns authored Markdown into an injection path | Shortcodes (queue 12) — if unsafe rendering is enabled, the trusted/untrusted split must be real code, not a default |
## Open questions
**Blocking sequences (queue entry 10): how a series is joined.** ADR-0016 says membership is the `series`
frontmatter field plus `order`. Building it revealed a simpler option that contradicts that: membership is
**structural** — a chapter is a bundle nested under its series landing page, `comics/the-long-monsoon/first-rain`
under `comics/the-long-monsoon` — so no `series` field is needed and `order` alone stays. Structural needs
no new field and cannot disagree with the directory; `series` allows a series to span directories and is
what ADR-0016 already recorded. Reversing a recorded decision needs a decision, not a silent change
(`CLAUDE.md` rule 9), so this waits.
Nothing else blocks Arc 1 or the first deploy.
None. Nothing blocks Arc 1 or the first deploy.
Every ADR in `decisions.md` is accepted; none is open or proposed.
+15
View File
@@ -21,6 +21,21 @@ A bundle page receives:
| `.Style` | the reference theme's stylesheet, inlined so a bare site root needs no asset route |
| `.Canonical` | the permalink of the variant actually served — not the URL requested, which differs when the fallback chain supplied another language |
| `.Alternates` | every language this key exists in, as `.Lang` and `.URL`, for `hreflang` |
| `.Sequence` | the series this page sits in, absent when it sits in none (ADR-0033) |
`.Sequence` carries the reading order and this page's place in it:
| Field | Contents |
|---|---|
| `.Sequence.Title`, `.Sequence.URL` | the series' title (may be empty) and its permalink |
| `.Sequence.Members` | every entry in reading order, each as an `.Items` entry — *ascending*, unlike a dated listing |
| `.Sequence.Index`, `.Sequence.Count` | this page's 1-based position and the total; `Index` is 0 when this page is the series landing itself |
| `.Sequence.Prev`, `.Sequence.Next` | the neighbours, absent at the ends and on a landing page. `Prev` is the **earlier** entry — the opposite sense of a listing's `.PrevURL` |
| `.Sequence.First`, `.Sequence.Last` | the ends of the series, present whenever it has members |
A landing page therefore renders an archive from `.Members` and a chapter renders navigation from
`.Prev`/`.Next`, both from one field. Membership and order are the engine's business
(`content-model.md`); a theme never sorts.
Two named templates: `base` is executed for every page; `main` is the block each kind of page defines and
a theme redefines. There is one parsed set per kind — bundle and listing today — so two kinds may both
+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")},
+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}}
+7 -1
View File
@@ -138,7 +138,13 @@ func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *rend
http.NotFound(w, req)
return
}
out, err := r.Bundle(b, served, site.Variants(res.key))
// A bundle nested under another, or holding others, is part of a series; anything else renders with no
// sequence at all (ADR-0033).
var seq *content.Sequence
if series, inSeries := site.Sequence(b.Key, served); inSeries {
seq = &series
}
out, err := r.Bundle(b, served, site.Variants(res.key), seq)
if err != nil {
// A render failure degrades: log it and say nothing more to the client than that it failed
// (conventions.md). It must never leak a template or filesystem detail.
+92
View File
@@ -320,6 +320,98 @@ func TestStaticFilesAreServedAndDirectoriesAreNot(t *testing.T) {
}
}
func seriesHandler(t *testing.T) http.Handler {
t.Helper()
fsys := fstest.MapFS{
"content/comics/the-long-monsoon/_index.md": {Data: []byte("---\ntitle: The Long Monsoon\n---\nA series.\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/aftermath.md": {Data: []byte("---\ntitle: Aftermath\norder: 30\n---\n")},
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nx\n")},
}
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
r, err := render.New(nil)
if err != nil {
t.Fatal(err)
}
return Handler(content.NewSite(bundles), r, nil)
}
func TestSequenceNavigationLinksNeighbours(t *testing.T) {
h := seriesHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/comics/the-long-monsoon/the-flood/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`rel="prev" href="/comics/the-long-monsoon/first-rain/"`,
`rel="next" href="/comics/the-long-monsoon/aftermath/"`,
`href="/comics/the-long-monsoon/">The Long Monsoon</a> 2 of 3`,
} {
if !strings.Contains(body, want) {
t.Errorf("missing %q — prev is the *earlier* chapter:\n%s", want, body)
}
}
nav := body[strings.Index(body, `<nav class="sequence">`):]
if strings.Contains(nav[:strings.Index(nav, "</nav>")], "the-flood") {
t.Error("the nav should link its neighbours and the series, never the chapter it is on")
}
}
func TestSequenceEndsHaveNoNeighbourBeyondThem(t *testing.T) {
h := seriesHandler(t)
for path, absent := range map[string]string{
"/comics/the-long-monsoon/first-rain/": `rel="prev"`,
"/comics/the-long-monsoon/aftermath/": `rel="next"`,
} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d, want 200", path, rec.Code)
}
if strings.Contains(rec.Body.String(), absent) {
t.Errorf("GET %s should not carry %s at the end of a series", path, absent)
}
}
}
func TestSeriesLandingPageListsChaptersInOrder(t *testing.T) {
h := seriesHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/comics/the-long-monsoon/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "A series.") {
t.Error("the landing page is still a bundle and renders its own body")
}
first, second, third := strings.Index(body, "First Rain"), strings.Index(body, "The Flood"), strings.Index(body, "Aftermath")
if first < 0 || second < first || third < second {
t.Errorf("the archive must read in sequence order, not newest first:\n%s", body)
}
if strings.Contains(body, `rel="prev"`) || strings.Contains(body, `rel="next"`) {
t.Error("a landing page holds no position of its own, so it has no neighbours")
}
}
func TestPagesOutsideASeriesGetNoSequence(t *testing.T) {
h := seriesHandler(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/pages/about/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if body := rec.Body.String(); strings.Contains(body, "sequence") {
t.Errorf("an unrelated page must render no sequence nav:\n%s", body)
}
}
func tagHandler(t *testing.T) http.Handler {
t.Helper()
fsys := fstest.MapFS{