diff --git a/docs/content-model.md b/docs/content-model.md index f0e24a4..bc71e3a 100644 --- a/docs/content-model.md +++ b/docs/content-model.md @@ -77,7 +77,7 @@ readable by templates (ADR-0002). Never add a required field. | `draft` | bool | Excluded from queries and feeds | | `nocache` | bool | Never cache this bundle's render. Named so absence means cacheable, per ADR-0002 | | `summary` | string | Explicit summary; otherwise derived | -| `tags` | []string | Flat, case-preserved, Unicode | +| `tags` | []string | Flat, case- and script-preserved as written. A scalar or a list. The URL form is lowercased with spaces hyphenated, so `Long Monsoon` and `long monsoon` are one term; scripts without case pass through unchanged (ADR-0018) | | `series` / `order` | string / int | Series membership and position. Sparse by convention (10, 20, 30) so insertion is one edit; never appears in a URL (ADR-0016) | | `cover` | string | Relative path to the lead image | | `view` | string | Per-bundle View override (Arc 2) | @@ -128,6 +128,8 @@ Overrides are normalised like everything else: writing a slug by hand does not e ## Permalinks +`tags` is reserved at the top level and inside every section, so no bundle may be slugged `tags`. + 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. diff --git a/docs/state.md b/docs/state.md index 838f4a6..35ebdf0 100644 --- a/docs/state.md +++ b/docs/state.md @@ -1,6 +1,6 @@ # State -**Verified against:** `85a4906` on 2026-07-30 — update this line every change. +**Verified against:** `499d107` 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,8 +29,8 @@ this change*. | Counter | Now | Extraction due at | What it buys | |---|---|---|---| | Render transforms | 0 | **3** | Stage pipeline (ordered `func(ctx,*Page) error`) | -| 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` | +| Routing cases | 5 | **2** — done | Resolver at `internal/web/resolve.go`: bundle, language prefix, pagination, tag, section-narrowed tag | +| Collection pages | 3 | **1** — done | Query primitive: `content.Query{Section, Tag, Lang}` + `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`) | diff --git a/docs/theme-contract.md b/docs/theme-contract.md index ba39cb6..91e9bd1 100644 --- a/docs/theme-contract.md +++ b/docs/theme-contract.md @@ -33,6 +33,7 @@ A listing page receives `.Title`, `.Lang`, `.Canonical`, `.Style` as above, plus | `.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 | +| `.Groups` | set instead of `.Items` when entries are grouped — a tag listing groups by section, each `.Name` and `.Items` | ## The stability rule diff --git a/internal/content/content.go b/internal/content/content.go index 049794d..6575cf2 100644 --- a/internal/content/content.go +++ b/internal/content/content.go @@ -42,6 +42,9 @@ type Bundle struct { Title string // Date is publication time, zero when frontmatter omits it. Undated bundles sort after dated ones. Date time.Time + // Tags are free-form terms, case and script preserved as the author wrote them. The URL form is + // TagSlug of each (ADR-0018). + Tags []string // 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 @@ -123,6 +126,8 @@ func Parse(name string, data []byte) (Bundle, error) { b.Aliases = stringList(b.Extra["aliases"]) delete(b.Extra, "aliases") b.Date = asTime(b.Extra["date"]) + b.Tags = terms(b.Extra["tags"]) + delete(b.Extra, "tags") return b, nil } @@ -164,6 +169,35 @@ func asTime(v any) time.Time { return time.Time{} } +// terms reads a scalar or sequence of tag names, preserving case and script. +func terms(v any) []string { + var out []string + add := func(x any) { + if str, ok := x.(string); ok { + if t := strings.TrimSpace(Normalise(str)); t != "" { + out = append(out, t) + } + } + } + switch t := v.(type) { + case string: + add(t) + case []any: + for _, x := range t { + add(x) + } + } + return out +} + +// TagSlug is the URL form of a tag: normalised, lowercased, spaces joined by hyphens. +// +// Lowercasing is a no-op for scripts without case, so Bengali terms pass through unchanged. A hand-chosen +// slug per term waits for the type declaration that owns term overrides (ADR-0015). +func TagSlug(tag string) string { + return strings.Join(strings.Fields(strings.ToLower(Normalise(tag))), "-") +} + // Normalise puts s into NFC. // // Every identifier goes through this: Bengali conjuncts have several byte encodings for text that looks @@ -375,6 +409,8 @@ const PerPage = 10 type Query struct { // Section is the first path segment of a key. Empty matches every section. Section string + // Tag is a tag slug. Empty matches every bundle; set, it matches those carrying the term. + Tag string // Lang is the language to serve, with the usual fallback per key (ADR-0009). Lang string } @@ -393,9 +429,11 @@ func (s *Site) Run(q Query) []Bundle { continue } seen[key] = true - if b, _, ok := s.Lookup(key, q.Lang); ok { - out = append(out, b) + b, _, ok := s.Lookup(key, q.Lang) + if !ok || !b.hasTag(q.Tag) { + continue } + out = append(out, b) } sort.Slice(out, func(i, j int) bool { a, b := out[i], out[j] @@ -409,6 +447,28 @@ func (s *Site) Run(q Query) []Bundle { return out } +// hasTag reports whether the bundle carries a tag slug. An empty slug matches everything. +func (b Bundle) hasTag(slug string) bool { + if slug == "" { + return true + } + for _, t := range b.Tags { + if TagSlug(t) == slug { + return true + } + } + return false +} + +// Section is the first path segment of a bundle's key: its content type by default. +func (b Bundle) Section() string { + sec, _, nested := strings.Cut(b.Key, "/") + if !nested { + return "" + } + return sec +} + // Sections lists every section that holds at least one bundle, sorted. func (s *Site) Sections() []string { seen := map[string]bool{} @@ -438,6 +498,18 @@ func URL(key, lang string) string { return "/" + lang + "/" + key + "/" } +// TagURL is the permalink of a tag listing, optionally narrowed to a section (ADR-0018). +func TagURL(section, slug, lang string, page int) string { + key := TagsSegment + "/" + slug + if section != "" { + key = section + "/" + key + } + return PageURL(key, lang, page) +} + +// TagsSegment is reserved at the top level and inside every section, so no bundle may be slugged with it. +const TagsSegment = "tags" + // 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 { diff --git a/internal/content/content_test.go b/internal/content/content_test.go index 8eae65b..5e23897 100644 --- a/internal/content/content_test.go +++ b/internal/content/content_test.go @@ -35,7 +35,7 @@ func TestSplitNameDerivesKeyAndLang(t *testing.T) { } func TestParseSplitsFrontmatterAndKeepsUnknownKeys(t *testing.T) { - b, err := Parse("posts/hello.md", []byte("---\ntitle: Hello\ntags: [a, b]\n---\n\nBody text.\n")) + b, err := Parse("posts/hello.md", []byte("---\ntitle: Hello\nmood: cheerful\ntags: [a, b]\n---\n\nBody text.\n")) if err != nil { t.Fatal(err) } @@ -45,8 +45,14 @@ func TestParseSplitsFrontmatterAndKeepsUnknownKeys(t *testing.T) { if got := string(b.Body); got != "Body text.\n" { t.Errorf("body = %q", got) } - if _, ok := b.Extra["tags"]; !ok { - t.Error("tags did not land in Extra") + if b.Extra["mood"] != "cheerful" { + t.Error("an unnamed frontmatter key did not land in Extra") + } + if len(b.Tags) != 2 || b.Tags[0] != "a" { + t.Errorf("tags = %v, want [a b] lifted into the named field", b.Tags) + } + if _, leaked := b.Extra["tags"]; leaked { + t.Error("tags should be lifted out of Extra, not duplicated") } if _, ok := b.Extra["title"]; ok { t.Error("title should be lifted out of Extra, not duplicated") @@ -269,3 +275,43 @@ func mustScan(t *testing.T, fsys fstest.MapFS) []Bundle { } return b } + +func TestTagSlugPreservesScriptAndFoldsCase(t *testing.T) { + cases := map[string]string{ + "Long Monsoon": "long-monsoon", + "WATERCOLOUR": "watercolour", + "জলরঙ": "জলরঙ", + " spaced out ": "spaced-out", + } + for in, want := range cases { + if got := TagSlug(in); got != want { + t.Errorf("TagSlug(%q) = %q, want %q", in, got, want) + } + } +} + +func TestQueryFiltersByTagAndSection(t *testing.T) { + fsys := fstest.MapFS{ + "content/posts/a.md": {Data: []byte("---\ntitle: A\ndate: 2026-01-03\ntags: [Monsoon, prose]\n---\n")}, + "content/posts/b.md": {Data: []byte("---\ntitle: B\ndate: 2026-01-02\ntags: [prose]\n---\n")}, + "content/comics/c.md": {Data: []byte("---\ntitle: C\ndate: 2026-01-01\ntags: [monsoon]\n---\n")}, + "content/writing/d.md": {Data: []byte("---\ntitle: D\n---\n")}, + } + site := NewSite(mustScan(t, fsys)) + got := func(q Query) []string { + var titles []string + for _, b := range site.Run(q) { + titles = append(titles, b.Title) + } + return titles + } + if titles := got(Query{Tag: "monsoon", Lang: "en"}); len(titles) != 2 || titles[0] != "A" || titles[1] != "C" { + t.Errorf("global tag query = %v, want [A C] — a tag spans sections and case does not matter", titles) + } + if titles := got(Query{Section: "posts", Tag: "monsoon", Lang: "en"}); len(titles) != 1 || titles[0] != "A" { + t.Errorf("section-narrowed tag query = %v, want [A]", titles) + } + if titles := got(Query{Tag: "nothing", Lang: "en"}); titles != nil { + t.Errorf("unknown tag = %v, want none", titles) + } +} diff --git a/internal/render/render.go b/internal/render/render.go index 99e219a..025b796 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -57,6 +57,15 @@ type List struct { Page, Pages int // PrevURL and NextURL are empty at the ends. Newer is "prev" because the order is newest first. PrevURL, NextURL string + // Groups is set instead of Items when entries are grouped — a tag listing groups by section, so one + // busy term stays readable (ADR-0018). + Groups []Group +} + +// Group is a named run of entries within a listing. +type Group struct { + Name string + Items []Item } // Item is one entry in a listing. @@ -171,6 +180,42 @@ func (r *Renderer) Bundle(b content.Bundle, served string, variants []string) ([ // 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) { + l, window := r.paginate(section, lang, content.PageURL(section, lang, page), all, page, + func(p int) string { return content.PageURL(section, lang, p) }) + for _, b := range window { + l.Items = append(l.Items, r.item(b, lang)) + } + return r.execute(r.list, l, section) +} + +// Tag renders one page of a tag listing, grouped by section. +// +// section narrows the listing to one section and is empty for the global one. +func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error) { + title := "#" + slug + if section != "" { + title = section + " · #" + slug + } + l, window := r.paginate(title, lang, content.TagURL(section, slug, lang, page), all, page, + func(p int) string { return content.TagURL(section, slug, lang, p) }) + for _, b := range window { + sec := b.Section() + if n := len(l.Groups); n > 0 && l.Groups[n-1].Name == sec { + l.Groups[n-1].Items = append(l.Groups[n-1].Items, r.item(b, lang)) + continue + } + l.Groups = append(l.Groups, Group{Name: sec, Items: []Item{r.item(b, lang)}}) + } + return r.execute(r.list, l, "tag "+slug) +} + +// item is one listing entry. +func (r *Renderer) item(b content.Bundle, lang string) Item { + return Item{Title: b.Title, Key: b.Key, URL: content.URL(b.Key, lang), Date: b.Date} +} + +// paginate builds the shell of a listing page and returns the slice of entries it shows. +func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle) { pages := (len(all) + content.PerPage - 1) / content.PerPage if pages < 1 { pages = 1 @@ -178,25 +223,17 @@ func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) 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, - }, + head: head{Title: title, Lang: lang, Canonical: canonical, 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) + l.PrevURL = url(page - 1) } if page < pages { - l.NextURL = content.PageURL(section, lang, page+1) + l.NextURL = url(page + 1) } - return r.execute(r.list, l, section) + return l, all[start:end] } // execute runs a template set and wraps a failure with what was being rendered. diff --git a/internal/render/templates/list.html b/internal/render/templates/list.html index b5e7a1f..a014280 100644 --- a/internal/render/templates/list.html +++ b/internal/render/templates/list.html @@ -1,6 +1,16 @@ {{define "main" -}}