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:
+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