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:
+17
-22
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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")},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user