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