diff --git a/docs/state.md b/docs/state.md index 6ed8b30..b142b67 100644 --- a/docs/state.md +++ b/docs/state.md @@ -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. diff --git a/docs/theme-contract.md b/docs/theme-contract.md index 91e9bd1..d442d10 100644 --- a/docs/theme-contract.md +++ b/docs/theme-contract.md @@ -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 diff --git a/internal/content/content.go b/internal/content/content.go index 4352130..036cb7c 100644 --- a/internal/content/content.go +++ b/internal/content/content.go @@ -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 diff --git a/internal/content/site.go b/internal/content/site.go index bd5d582..1ffc484 100644 --- a/internal/content/site.go +++ b/internal/content/site.go @@ -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 == "" { diff --git a/internal/content/site_test.go b/internal/content/site_test.go index 546d066..3796b3e 100644 --- a/internal/content/site_test.go +++ b/internal/content/site_test.go @@ -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")}, diff --git a/internal/render/render.go b/internal/render/render.go index 025b796..f102920 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -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} diff --git a/internal/render/render_test.go b/internal/render/render_test.go index ef9829a..fef5600 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -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) } diff --git a/internal/render/templates/page.html b/internal/render/templates/page.html index ed2e820..3023c5c 100644 --- a/internal/render/templates/page.html +++ b/internal/render/templates/page.html @@ -3,4 +3,22 @@ {{if .Title}}

{{.Title}}

{{end}} {{.HTML}} +{{- with .Sequence}} +{{- if .Index}} + +{{- else if .Members}} + +{{- end}} +{{- end}} {{- end}} diff --git a/internal/web/web.go b/internal/web/web.go index d2f74a3..d5334bd 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -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. diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 7576088..878f534 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -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 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, `")], "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{