diff --git a/docs/architecture.md b/docs/architecture.md index 06eee0a..ba7db66 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/content-model.md b/docs/content-model.md index 5a7fed7..f0e24a4 100644 --- a/docs/content-model.md +++ b/docs/content-model.md @@ -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 diff --git a/docs/state.md b/docs/state.md index 6b9a08a..867c590 100644 --- a/docs/state.md +++ b/docs/state.md @@ -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 | diff --git a/docs/theme-contract.md b/docs/theme-contract.md index b881fb5..e3d42cf 100644 --- a/docs/theme-contract.md +++ b/docs/theme-contract.md @@ -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 diff --git a/internal/content/content.go b/internal/content/content.go index b1aa7b5..049794d 100644 --- a/internal/content/content.go +++ b/internal/content/content.go @@ -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) + "/" +} diff --git a/internal/render/render.go b/internal/render/render.go index 016fa61..8400ba8 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -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 } diff --git a/internal/render/templates/base.html b/internal/render/templates/base.html index dea5e79..7f6a9b9 100644 --- a/internal/render/templates/base.html +++ b/internal/render/templates/base.html @@ -4,7 +4,7 @@ -{{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}} +{{.Title}} {{- range .Alternates}} @@ -13,12 +13,7 @@
-{{block "main" . -}} -
-{{if .Title}}

{{.Title}}

{{end}} -{{.HTML}} -
-{{- end}} +{{- template "main" .}}
diff --git a/internal/render/templates/list.html b/internal/render/templates/list.html new file mode 100644 index 0000000..b5e7a1f --- /dev/null +++ b/internal/render/templates/list.html @@ -0,0 +1,20 @@ +{{define "main" -}} +

{{.Title}}

+{{if .Items -}} + +{{- else}} +

Nothing here yet.

+{{- end}} +{{- if or .PrevURL .NextURL}} + +{{- end}} +{{- end}} diff --git a/internal/render/templates/page.html b/internal/render/templates/page.html new file mode 100644 index 0000000..ed2e820 --- /dev/null +++ b/internal/render/templates/page.html @@ -0,0 +1,6 @@ +{{define "main" -}} +
+{{if .Title}}

{{.Title}}

{{end}} +{{.HTML}} +
+{{- end}} diff --git a/internal/web/resolve.go b/internal/web/resolve.go index 7fb1ea9..ddd5f76 100644 --- a/internal/web/resolve.go +++ b/internal/web/resolve.go @@ -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 } diff --git a/internal/web/web.go b/internal/web/web.go index fc51dab..04d7edd 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -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 } diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 2ec350d..be9e114 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -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, "
  • ") != content.PerPage { + t.Errorf("page one holds %d entries, want %d", strings.Count(body, "
  • "), 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, "
  • ") != 2 { + t.Errorf("page two holds %d entries, want 2", strings.Count(body, "
  • ")) + } + 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) + } +}