add paginated section index pages
The first collection page earns the Query primitive: content.Query{Section, Lang}
with Site.Run, newest first, undated after dated, ties broken by key so the same
query always answers in the same order. No cache signature — nothing caches, and a
signature with no consumer is speculation.
Pagination lives in the path (ADR-0028): page one is the bare listing URL,
/page/1/ redirects to it, and a page past the end is 404 rather than an empty page,
because an empty page is a URL that means nothing. `page` is therefore a reserved
segment inside a section, now recorded in content-model.md.
Two kinds of page means two parsed template sets already — base plus the block that
kind defines — which is ADR-0019's per-type shape arriving by need rather than by
anticipation. A head struct is embedded in both Page and List so base.html has one
contract, and theme-contract.md gains the listing fields.
Bundle gains Date, accepting an unquoted YAML date or an RFC 3339 string, since
yaml.v3 hands back time.Time for one and a string for the other.
Evidence: 12 posts → /posts/ shows 10 with rel=next to /posts/page/2/,
/posts/page/2/ shows 3 with rel=prev to /posts/, ordering is post-12 11 10,
/posts/page/1/ 301s to /posts/, /posts/page/9/ is 404, /bn/posts/ is 200.
This commit is contained in:
@@ -31,7 +31,9 @@ reaches its threshold. Check it; do not guess.
|
||||
## Query — filter/sort bundles into a list
|
||||
Returns a list plus a signature doubling as a cache key. All grouping is a Query: sections, tags,
|
||||
series, latest, related, pagination, feeds.
|
||||
**STATUS: deferred until the first collection page.** One-off directory reads are fine before then.
|
||||
**STATUS: live.** Earned at the first collection page. `content.Query{Section, Lang}` with `Site.Run`;
|
||||
it carries no cache signature yet, because nothing caches and a signature with no consumer is
|
||||
speculation.
|
||||
|
||||
## View — bundle-or-query → output
|
||||
Overridable per bundle. All presentation. Emits HTML, gemtext, PDF, a program, JSON.
|
||||
|
||||
@@ -70,7 +70,7 @@ readable by templates (ADR-0002). Never add a required field.
|
||||
| Field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `title` | string | Only required field |
|
||||
| `date` / `updated` | date | Publication; `updated` drives feeds and `Last-Modified` |
|
||||
| `date` / `updated` | date | Publication; `updated` drives feeds and `Last-Modified`. An unquoted `2026-07-30` or an RFC 3339 timestamp; undated bundles sort after dated ones |
|
||||
| `type` | string | Post type; defaults from the top-level section |
|
||||
| `slug` | string | Overrides the derived slug. The engine serves the new path only; the old one 404s unless it appears in `aliases` |
|
||||
| `aliases` | []string | Paths the engine redirects permanently to this bundle's canonical URL (ADR-0008). A scalar or a list; surrounding slashes optional. An alias naming a real bundle, or claimed by two bundles, is ambiguous — logged and dropped, and the real bundle keeps its URL |
|
||||
@@ -128,6 +128,9 @@ Overrides are normalised like everything else: writing a slug by hand does not e
|
||||
|
||||
## Permalinks
|
||||
|
||||
Listings paginate at `/{section}/page/N/` (ADR-0028), so `page` is a reserved segment inside a section:
|
||||
no bundle may be slugged `page`. Page one is the bare listing URL and `/page/1/` redirects to it.
|
||||
|
||||
`/{section}/{slug}/`, no exceptions (ADR-0008). Section is the content type — the top-level directory
|
||||
under `content/`, including `pages` — and slug comes from the bundle path or a `slug` override. So
|
||||
`pages/about/` serves at `/pages/about/`, and the root stays engine-owned: emitted files and future
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
# State
|
||||
|
||||
**Verified against:** `8a6857b` on 2026-07-30 — update this line every change.
|
||||
**Verified against:** `2b6cdc7` 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
|
||||
@@ -29,9 +29,9 @@ this change*.
|
||||
| Counter | Now | Extraction due at | What it buys |
|
||||
|---|---|---|---|
|
||||
| Render transforms | 0 | **3** | Stage pipeline (ordered `func(ctx,*Page) error`) |
|
||||
| Routing cases | 2 | **2** — done | Resolver extracted at `internal/web/resolve.go` |
|
||||
| Collection pages | 0 | **1** | Query primitive |
|
||||
| Views / output formats | 1 | **2** | View layer (contract per `theme-contract.md`) |
|
||||
| Routing cases | 3 | **2** — done | Resolver at `internal/web/resolve.go`: bundle, language prefix, pagination |
|
||||
| Collection pages | 1 | **1** — done | Query primitive: `content.Query` + `Site.Run` |
|
||||
| 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 |
|
||||
|
||||
+11
-2
@@ -22,8 +22,17 @@ A bundle page receives:
|
||||
| `.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` |
|
||||
|
||||
Two named templates: `base` is executed for every page; `main` is the block a theme redefines to change
|
||||
the body while inheriting the document. Nothing else is promised yet.
|
||||
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
|
||||
define `main` without colliding (ADR-0019).
|
||||
|
||||
A listing page receives `.Title`, `.Lang`, `.Canonical`, `.Style` as above, plus:
|
||||
|
||||
| Field | Contents |
|
||||
|---|---|
|
||||
| `.Items` | entries on this page: `.Title`, `.Key`, `.URL`, `.Date` |
|
||||
| `.Page`, `.Pages` | 1-based position and total, `Pages` at least 1 |
|
||||
| `.PrevURL`, `.NextURL` | empty at the ends; *newer* is `prev`, because the order is newest first |
|
||||
|
||||
## The stability rule
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -38,6 +40,8 @@ type Bundle struct {
|
||||
// Title is empty when frontmatter omits it. Whether that is legal depends on the type, which
|
||||
// nothing decides yet, so the parser accepts it.
|
||||
Title string
|
||||
// Date is publication time, zero when frontmatter omits it. Undated bundles sort after dated ones.
|
||||
Date time.Time
|
||||
// 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
|
||||
@@ -118,6 +122,7 @@ func Parse(name string, data []byte) (Bundle, error) {
|
||||
delete(b.Extra, "title")
|
||||
b.Aliases = stringList(b.Extra["aliases"])
|
||||
delete(b.Extra, "aliases")
|
||||
b.Date = asTime(b.Extra["date"])
|
||||
return b, nil
|
||||
}
|
||||
|
||||
@@ -143,6 +148,22 @@ func stringList(v any) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// asTime reads a frontmatter date. yaml.v3 hands back a time.Time for an unquoted timestamp and a string
|
||||
// for a quoted one, so both spellings work and anything else is simply absent.
|
||||
func asTime(v any) time.Time {
|
||||
switch t := v.(type) {
|
||||
case time.Time:
|
||||
return t
|
||||
case string:
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
|
||||
if parsed, err := time.Parse(layout, t); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// Normalise puts s into NFC.
|
||||
//
|
||||
// Every identifier goes through this: Bengali conjuncts have several byte encodings for text that looks
|
||||
@@ -341,6 +362,73 @@ func (s *Site) HasLang(lang string) bool {
|
||||
// Len reports how many bundles the site holds.
|
||||
func (s *Site) Len() int { return len(s.byKeyLang) }
|
||||
|
||||
// PerPage is how many entries a listing shows.
|
||||
//
|
||||
// Changing it renumbers page URLs, which ADR-0028 calls a URL event; it becomes a setting when the
|
||||
// cascade exists rather than being one knob early.
|
||||
const PerPage = 10
|
||||
|
||||
// Query selects bundles into an ordered list.
|
||||
//
|
||||
// Every grouping in the engine is a Query — sections now, tags and series later. It carries no cache
|
||||
// signature yet: nothing caches, and a signature with no consumer is speculation (architecture.md).
|
||||
type Query struct {
|
||||
// Section is the first path segment of a key. Empty matches every section.
|
||||
Section string
|
||||
// Lang is the language to serve, with the usual fallback per key (ADR-0009).
|
||||
Lang string
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if q.Section != "" && !strings.HasPrefix(key, q.Section+"/") {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
if b, _, ok := s.Lookup(key, q.Lang); ok {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
a, b := out[i], out[j]
|
||||
switch {
|
||||
case !a.Date.Equal(b.Date):
|
||||
return a.Date.After(b.Date)
|
||||
default:
|
||||
return a.Key < b.Key
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Sections lists every section that holds at least one bundle, sorted.
|
||||
func (s *Site) Sections() []string {
|
||||
seen := map[string]bool{}
|
||||
for kl := range s.byKeyLang {
|
||||
key, _, found := strings.Cut(kl, "\x00")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if sec, _, nested := strings.Cut(key, "/"); nested {
|
||||
seen[sec] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for sec := range seen {
|
||||
out = append(out, sec)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// URL is the permalink of a variant: /{section}/{slug}/, with a language prefix for anything but the
|
||||
// default locale (ADR-0008, ADR-0009). Templates never build a path by hand.
|
||||
func URL(key, lang string) string {
|
||||
@@ -349,3 +437,13 @@ func URL(key, lang string) string {
|
||||
}
|
||||
return "/" + lang + "/" + key + "/"
|
||||
}
|
||||
|
||||
// PageURL is the permalink of a listing page. Page one is the bare listing URL, never /page/1/
|
||||
// (ADR-0028).
|
||||
func PageURL(key, lang string, page int) string {
|
||||
base := URL(key, lang)
|
||||
if page <= 1 {
|
||||
return base
|
||||
}
|
||||
return base + "page/" + strconv.Itoa(page) + "/"
|
||||
}
|
||||
|
||||
+96
-28
@@ -10,6 +10,7 @@ import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
|
||||
@@ -19,26 +20,50 @@ import (
|
||||
//go:embed templates
|
||||
var themeFS embed.FS
|
||||
|
||||
// Page is what a template receives. Absence is the zero value: a template reads what exists and never
|
||||
// fails on a missing field (invariant 1).
|
||||
type Page struct {
|
||||
// Title may be empty; whether that is legal depends on a type, which nothing decides yet.
|
||||
// head is what every kind of page shares: the document shell the base template needs. Absence is the
|
||||
// zero value — a template reads what exists and never fails on a missing field (invariant 1).
|
||||
type head struct {
|
||||
// Title may be empty for a bundle; a listing always has one.
|
||||
Title string
|
||||
// Lang is the locale this variant is written in.
|
||||
// Lang is the locale being served.
|
||||
Lang string
|
||||
// Key is the bundle's identity, useful for building links.
|
||||
Key string
|
||||
// HTML is the rendered body, already escaped by the Markdown renderer.
|
||||
HTML template.HTML
|
||||
// Extra carries every frontmatter key the parser does not name (ADR-0002).
|
||||
Extra map[string]any
|
||||
// Style is the reference theme's stylesheet, inlined so a bare site root needs no asset route.
|
||||
Style template.CSS
|
||||
// Canonical is the permalink of the variant actually served, which differs from the requested URL
|
||||
// when the fallback chain supplied another language (ADR-0009).
|
||||
// Canonical is the permalink of what was actually served, which differs from the URL requested when
|
||||
// the fallback chain supplied another language (ADR-0009).
|
||||
Canonical string
|
||||
// Alternates lists every language this key exists in, for hreflang.
|
||||
Alternates []Alternate
|
||||
// Style is the reference theme's stylesheet, inlined so a bare site root needs no asset route.
|
||||
Style template.CSS
|
||||
}
|
||||
|
||||
// Page is one bundle rendered.
|
||||
type Page struct {
|
||||
head
|
||||
// Key is the bundle's identity, useful for building links.
|
||||
Key string
|
||||
// HTML is the rendered body.
|
||||
HTML template.HTML
|
||||
// Extra carries every frontmatter key the parser does not name (ADR-0002).
|
||||
Extra map[string]any
|
||||
}
|
||||
|
||||
// List is a collection page: the result of a Query, one page of it.
|
||||
type List struct {
|
||||
head
|
||||
// Items are the entries on this page, in the Query's order.
|
||||
Items []Item
|
||||
// Page is 1-based; Pages is the total, at least 1 even when empty.
|
||||
Page, Pages int
|
||||
// PrevURL and NextURL are empty at the ends. Newer is "prev" because the order is newest first.
|
||||
PrevURL, NextURL string
|
||||
}
|
||||
|
||||
// Item is one entry in a listing.
|
||||
type Item struct {
|
||||
Title string
|
||||
Key string
|
||||
URL string
|
||||
Date time.Time
|
||||
}
|
||||
|
||||
// Alternate is one language a bundle exists in.
|
||||
@@ -50,7 +75,10 @@ type Alternate struct {
|
||||
// Renderer holds the parsed template set and the Markdown converter. Templates are parsed once, never
|
||||
// per request (conventions.md).
|
||||
type Renderer struct {
|
||||
tmpl *template.Template
|
||||
// Two sets, not one: base plus the block that kind of page defines. A single set would have two
|
||||
// definitions of "main" fighting, which is why per-type sets are the shape (ADR-0019).
|
||||
page *template.Template
|
||||
list *template.Template
|
||||
md goldmark.Markdown
|
||||
style template.CSS
|
||||
}
|
||||
@@ -60,15 +88,19 @@ type Renderer struct {
|
||||
// A malformed embedded template is a programming error caught at startup, not at request time, so this
|
||||
// returns an error and the caller is expected to treat it as fatal.
|
||||
func New() (*Renderer, error) {
|
||||
tmpl, err := template.ParseFS(themeFS, "templates/*.html")
|
||||
page, err := template.ParseFS(themeFS, "templates/base.html", "templates/page.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse reference theme: %w", err)
|
||||
return nil, fmt.Errorf("parse bundle templates: %w", err)
|
||||
}
|
||||
list, err := template.ParseFS(themeFS, "templates/base.html", "templates/list.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse listing templates: %w", err)
|
||||
}
|
||||
css, err := themeFS.ReadFile("templates/theme.css")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read reference stylesheet: %w", err)
|
||||
}
|
||||
return &Renderer{tmpl: tmpl, md: goldmark.New(), style: template.CSS(css)}, nil
|
||||
return &Renderer{page: page, list: list, md: goldmark.New(), style: template.CSS(css)}, nil
|
||||
}
|
||||
|
||||
// Bundle renders one bundle into a complete page.
|
||||
@@ -80,21 +112,57 @@ func (r *Renderer) Bundle(b content.Bundle, served string, variants []string) ([
|
||||
if err := r.md.Convert(b.Body, &body); err != nil {
|
||||
return nil, fmt.Errorf("markdown %s: %w", b.Path, err)
|
||||
}
|
||||
title := b.Title
|
||||
if title == "" {
|
||||
title = b.Key
|
||||
}
|
||||
p := Page{
|
||||
Title: b.Title,
|
||||
Lang: b.Lang,
|
||||
Key: b.Key,
|
||||
HTML: template.HTML(body.String()),
|
||||
Extra: b.Extra,
|
||||
Style: r.style,
|
||||
Canonical: content.URL(b.Key, served),
|
||||
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,
|
||||
}
|
||||
for _, l := range variants {
|
||||
p.Alternates = append(p.Alternates, Alternate{Lang: l, URL: content.URL(b.Key, l)})
|
||||
}
|
||||
return r.execute(r.page, p, b.Key)
|
||||
}
|
||||
|
||||
// Listing renders one page of a Query result for a section.
|
||||
func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error) {
|
||||
pages := (len(all) + content.PerPage - 1) / content.PerPage
|
||||
if pages < 1 {
|
||||
pages = 1
|
||||
}
|
||||
start := (page - 1) * content.PerPage
|
||||
end := min(start+content.PerPage, len(all))
|
||||
l := List{
|
||||
head: head{
|
||||
Title: section,
|
||||
Lang: lang,
|
||||
Canonical: content.PageURL(section, lang, page),
|
||||
Style: r.style,
|
||||
},
|
||||
Page: page,
|
||||
Pages: pages,
|
||||
}
|
||||
for _, b := range all[start:end] {
|
||||
l.Items = append(l.Items, Item{Title: b.Title, Key: b.Key, URL: content.URL(b.Key, lang), Date: b.Date})
|
||||
}
|
||||
if page > 1 {
|
||||
l.PrevURL = content.PageURL(section, lang, page-1)
|
||||
}
|
||||
if page < pages {
|
||||
l.NextURL = content.PageURL(section, lang, page+1)
|
||||
}
|
||||
return r.execute(r.list, l, section)
|
||||
}
|
||||
|
||||
// execute runs a template set and wraps a failure with what was being rendered.
|
||||
func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
if err := r.tmpl.ExecuteTemplate(&out, "base", p); err != nil {
|
||||
return nil, fmt.Errorf("template %s: %w", b.Key, err)
|
||||
if err := set.ExecuteTemplate(&out, "base", data); err != nil {
|
||||
return nil, fmt.Errorf("template %s: %w", what, err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}}</title>
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="canonical" href="{{.Canonical}}">
|
||||
{{- range .Alternates}}
|
||||
<link rel="alternate" hreflang="{{.Lang}}" href="{{.URL}}">
|
||||
@@ -13,12 +13,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
{{block "main" . -}}
|
||||
<article>
|
||||
{{if .Title}}<h1>{{.Title}}</h1>{{end}}
|
||||
{{.HTML}}
|
||||
</article>
|
||||
{{- end}}
|
||||
{{- template "main" .}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{{define "main" -}}
|
||||
<h1>{{.Title}}</h1>
|
||||
{{if .Items -}}
|
||||
<ul class="listing">
|
||||
{{- range .Items}}
|
||||
<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}}
|
||||
</ul>
|
||||
{{- else}}
|
||||
<p>Nothing here yet.</p>
|
||||
{{- end}}
|
||||
{{- if or .PrevURL .NextURL}}
|
||||
<nav class="pagination">
|
||||
{{- if .PrevURL}}<a rel="prev" href="{{.PrevURL}}">Newer</a>{{end}}
|
||||
<span>Page {{.Page}} of {{.Pages}}</span>
|
||||
{{- if .NextURL}}<a rel="next" href="{{.NextURL}}">Older</a>{{end}}
|
||||
</nav>
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,6 @@
|
||||
{{define "main" -}}
|
||||
<article>
|
||||
{{if .Title}}<h1>{{.Title}}</h1>{{end}}
|
||||
{{.HTML}}
|
||||
</article>
|
||||
{{- end}}
|
||||
+34
-3
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"khosra/internal/content"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
type resolution struct {
|
||||
key string
|
||||
lang string
|
||||
// page is 1 for a bundle or the first listing page, higher for /page/N/.
|
||||
page int
|
||||
// redirect is the canonical path when the request named a non-canonical one. Non-empty means answer
|
||||
// with a permanent redirect and nothing else.
|
||||
redirect string
|
||||
@@ -48,8 +51,36 @@ func resolve(path string, site *content.Site) (resolution, bool) {
|
||||
return resolution{redirect: "/"}, true
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(path, "/") {
|
||||
return resolution{key: key, lang: lang, redirect: content.URL(key, lang)}, true
|
||||
page := 1
|
||||
// A trailing /page/N/ is pagination, not part of the key (ADR-0028). Page one is the bare listing
|
||||
// URL, so /page/1/ is a second spelling and redirects.
|
||||
if rest, n, isPaged := cutPage(key); isPaged {
|
||||
if n == 1 {
|
||||
return resolution{redirect: content.PageURL(rest, lang, 1)}, true
|
||||
}
|
||||
key, page = rest, n
|
||||
}
|
||||
return resolution{key: key, lang: lang}, true
|
||||
if !strings.HasSuffix(path, "/") {
|
||||
return resolution{key: key, lang: lang, page: page, redirect: content.PageURL(key, lang, page)}, true
|
||||
}
|
||||
return resolution{key: key, lang: lang, page: page}, true
|
||||
}
|
||||
|
||||
// cutPage strips a trailing "page/N" off a key, reporting the page number.
|
||||
func cutPage(key string) (rest string, page int, ok bool) {
|
||||
i := strings.LastIndex(key, "/")
|
||||
if i < 0 {
|
||||
return key, 1, false
|
||||
}
|
||||
n, err := strconv.Atoi(key[i+1:])
|
||||
if err != nil || n < 1 {
|
||||
return key, 1, false
|
||||
}
|
||||
switch base := key[:i]; {
|
||||
case base == "page":
|
||||
return "", n, true
|
||||
case strings.HasSuffix(base, "/page"):
|
||||
return strings.TrimSuffix(base, "/page"), n, true
|
||||
}
|
||||
return key, 1, false
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package web
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"khosra/internal/content"
|
||||
"khosra/internal/render"
|
||||
@@ -21,6 +22,34 @@ func Handler(site *content.Site, r *render.Renderer) http.Handler {
|
||||
return mux
|
||||
}
|
||||
|
||||
// serveListing answers a section index, reporting whether it handled the request.
|
||||
//
|
||||
// A section is not a bundle, so this runs only after the bundle lookup misses. A page number past the
|
||||
// end is a 404 rather than an empty page, because an empty page is a URL that means nothing.
|
||||
func serveListing(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, res resolution) bool {
|
||||
if res.key == "" || strings.Contains(res.key, "/") {
|
||||
return false
|
||||
}
|
||||
items := site.Run(content.Query{Section: res.key, Lang: res.lang})
|
||||
if len(items) == 0 {
|
||||
return false
|
||||
}
|
||||
if res.page > 1 && (res.page-1)*content.PerPage >= len(items) {
|
||||
return false
|
||||
}
|
||||
out, err := r.Listing(res.key, res.lang, items, res.page)
|
||||
if err != nil {
|
||||
slog.Error("listing failed", "section", res.key, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if _, err := w.Write(out); err != nil {
|
||||
slog.Warn("write failed", "section", res.key, "err", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// serve resolves one request and writes its bundle.
|
||||
func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer) {
|
||||
res, ok := resolve(req.URL.Path, site)
|
||||
@@ -42,6 +71,9 @@ func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *rend
|
||||
http.Redirect(w, req, content.URL(canonical, res.lang), http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
if serveListing(w, req, site, r, res) {
|
||||
return
|
||||
}
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -205,3 +206,86 @@ func TestUnknownPathIsStill404NotAnAliasProbe(t *testing.T) {
|
||||
t.Errorf("got %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func listingHandler(t *testing.T, n int) http.Handler {
|
||||
t.Helper()
|
||||
fsys := fstest.MapFS{}
|
||||
for i := 1; i <= n; i++ {
|
||||
name := fmt.Sprintf("content/posts/post-%02d.md", i)
|
||||
body := fmt.Sprintf("---\ntitle: Post %02d\ndate: 2026-01-%02d\n---\nBody %d.\n", i, i, i)
|
||||
fsys[name] = &fstest.MapFile{Data: []byte(body)}
|
||||
}
|
||||
fsys["content/pages/about.md"] = &fstest.MapFile{Data: []byte("---\ntitle: About\n---\nx\n")}
|
||||
bundles, err := content.Scan(fsys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := render.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r)
|
||||
}
|
||||
|
||||
func TestSectionIndexListsNewestFirst(t *testing.T) {
|
||||
h := listingHandler(t, 3)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
first, third := strings.Index(body, "Post 03"), strings.Index(body, "Post 01")
|
||||
if first < 0 || third < 0 || first > third {
|
||||
t.Errorf("newest should come first:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "About") {
|
||||
t.Error("a section listing must not leak another section's bundles")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginationSplitsAndLinks(t *testing.T) {
|
||||
h := listingHandler(t, content.PerPage+2)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/", nil))
|
||||
body := rec.Body.String()
|
||||
if strings.Count(body, "<li>") != content.PerPage {
|
||||
t.Errorf("page one holds %d entries, want %d", strings.Count(body, "<li>"), content.PerPage)
|
||||
}
|
||||
if !strings.Contains(body, `rel="next" href="/posts/page/2/"`) || strings.Contains(body, `rel="prev"`) {
|
||||
t.Errorf("page one should link next and not prev:\n%s", body)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/page/2/", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("page two = %d, want 200", rec.Code)
|
||||
}
|
||||
body = rec.Body.String()
|
||||
if strings.Count(body, "<li>") != 2 {
|
||||
t.Errorf("page two holds %d entries, want 2", strings.Count(body, "<li>"))
|
||||
}
|
||||
if !strings.Contains(body, `rel="prev" href="/posts/"`) {
|
||||
t.Errorf("page two should link back to the bare listing URL:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageOneIsNeverItsOwnURL(t *testing.T) {
|
||||
h := listingHandler(t, 3)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/page/1/", nil))
|
||||
if rec.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("got %d, want 301 (ADR-0028)", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/posts/" {
|
||||
t.Errorf("Location = %q, want /posts/", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagePastTheEndIs404(t *testing.T) {
|
||||
h := listingHandler(t, 3)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/page/9/", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("got %d, want 404: an empty page is a URL that means nothing", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user