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:
+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}}
|
||||
Reference in New Issue
Block a user