add tag listings, global and section-narrowed
One global namespace (ADR-0018): /tags/{term}/ spans every section and
/{section}/tags/{term}/ narrows it. Listings group by section so one busy term
stays readable, which needed List.Groups alongside Items — list.html renders
whichever is set.
This is Query's second use, so it gained a Tag field rather than being generalised
on speculation: one filter, two callers. Tag slugs lowercase and hyphenate,
preserving script, so "Long Monsoon" and "long monsoon" are one term while Bengali
passes through unchanged. Hand-chosen slugs per term still wait for the type
declaration that owns overrides.
`tags` is reserved at the top level and inside every section, alongside `page` and
the language prefixes. A tag listing redirects to its canonical URL only once it is
known to exist, matching the rule bundles already followed — otherwise a canonical
URL for nothing confirms what is not there.
One stale test expectation fixed rather than worked around: it asserted tags land
in Extra, which stopped being true when tags became a named field.
Evidence: /tags/monsoon/ lists Hello World under posts and First Rain under comics;
/comics/tags/monsoon/ shows one; /tags/monsoon 301s; /tags/nothing/ and /tags/ 404.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
+3
-3
@@ -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`) |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+49
-12
@@ -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.
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
{{define "main" -}}
|
||||
<h1>{{.Title}}</h1>
|
||||
{{if .Items -}}
|
||||
{{if .Groups -}}
|
||||
{{- range .Groups}}
|
||||
<h2>{{.Name}}</h2>
|
||||
<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>
|
||||
{{- end}}
|
||||
{{- else if .Items -}}
|
||||
<ul class="listing">
|
||||
{{- range .Items}}
|
||||
<li><a href="{{.URL}}">{{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}}</a>
|
||||
|
||||
@@ -14,6 +14,9 @@ type resolution struct {
|
||||
lang string
|
||||
// page is 1 for a bundle or the first listing page, higher for /page/N/.
|
||||
page int
|
||||
// tag is a tag slug when the path named a tag listing; key then holds the section, or "" for the
|
||||
// global listing.
|
||||
tag string
|
||||
// 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
|
||||
@@ -60,12 +63,35 @@ func resolve(path string, site *content.Site) (resolution, bool) {
|
||||
}
|
||||
key, page = rest, n
|
||||
}
|
||||
// tags/<term> at the top level, or <section>/tags/<term> inside one (ADR-0018). Recognised by shape
|
||||
// here, so URL knowledge stays in the resolver.
|
||||
tag, section, isTag := cutTag(key)
|
||||
|
||||
if !strings.HasSuffix(path, "/") {
|
||||
if isTag {
|
||||
return resolution{key: section, tag: tag, lang: lang, page: page,
|
||||
redirect: content.TagURL(section, tag, lang, page)}, true
|
||||
}
|
||||
return resolution{key: key, lang: lang, page: page, redirect: content.PageURL(key, lang, page)}, true
|
||||
}
|
||||
if isTag {
|
||||
return resolution{key: section, tag: tag, lang: lang, page: page}, true
|
||||
}
|
||||
return resolution{key: key, lang: lang, page: page}, true
|
||||
}
|
||||
|
||||
// cutTag splits a tag listing key into its term and the section it is narrowed to.
|
||||
func cutTag(key string) (tag, section string, ok bool) {
|
||||
if rest, found := strings.CutPrefix(key, content.TagsSegment+"/"); found {
|
||||
return rest, "", rest != ""
|
||||
}
|
||||
if i := strings.Index(key, "/"+content.TagsSegment+"/"); i >= 0 {
|
||||
term := key[i+len(content.TagsSegment)+2:]
|
||||
return term, key[:i], term != ""
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// 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, "/")
|
||||
|
||||
+39
-6
@@ -68,11 +68,41 @@ func serveListing(w http.ResponseWriter, req *http.Request, site *content.Site,
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
write(w, out, res.key)
|
||||
return true
|
||||
}
|
||||
|
||||
// serveTags answers a tag listing, grouped by section so a busy term stays readable (ADR-0018).
|
||||
func serveTags(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, res resolution) bool {
|
||||
items := site.Run(content.Query{Section: res.key, Tag: res.tag, Lang: res.lang})
|
||||
if len(items) == 0 {
|
||||
return false
|
||||
}
|
||||
if res.page > 1 && (res.page-1)*content.PerPage >= len(items) {
|
||||
return false
|
||||
}
|
||||
// Redirect only once the listing is known to exist, the same rule bundles follow: a canonical URL for
|
||||
// nothing would confirm what is not there.
|
||||
if res.redirect != "" {
|
||||
http.Redirect(w, req, res.redirect, http.StatusMovedPermanently)
|
||||
return true
|
||||
}
|
||||
out, err := r.Tag(res.key, res.tag, res.lang, items, res.page)
|
||||
if err != nil {
|
||||
slog.Error("tag listing failed", "tag", res.tag, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return true
|
||||
}
|
||||
write(w, out, res.tag)
|
||||
return true
|
||||
}
|
||||
|
||||
// write sends a rendered page, logging a failed write rather than pretending it succeeded.
|
||||
func write(w http.ResponseWriter, out []byte, what string) {
|
||||
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)
|
||||
slog.Warn("write failed", "what", what, "err", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// serve resolves one request and writes its bundle.
|
||||
@@ -82,6 +112,12 @@ func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *rend
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
if res.tag != "" {
|
||||
if !serveTags(w, req, site, r, res) {
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
return
|
||||
}
|
||||
// A redirect target only exists for a path that resolves, so check the bundle before sending one:
|
||||
// otherwise a nonexistent page answers 301 and confirms nothing.
|
||||
b, served, found := site.Lookup(res.key, res.lang)
|
||||
@@ -110,8 +146,5 @@ func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *rend
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if _, err := w.Write(out); err != nil {
|
||||
slog.Warn("write failed", "key", b.Key, "err", err)
|
||||
}
|
||||
write(w, out, b.Key)
|
||||
}
|
||||
|
||||
@@ -319,3 +319,68 @@ func TestStaticFilesAreServedAndDirectoriesAreNot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tagHandler(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
fsys := fstest.MapFS{
|
||||
"content/posts/essay.md": {Data: []byte("---\ntitle: Essay\ndate: 2026-01-03\ntags: [Monsoon]\n---\n")},
|
||||
"content/comics/rain.md": {Data: []byte("---\ntitle: Rain\ndate: 2026-01-02\ntags: [monsoon]\n---\n")},
|
||||
"content/posts/other.md": {Data: []byte("---\ntitle: Other\ndate: 2026-01-01\ntags: [prose]\n---\n")},
|
||||
}
|
||||
bundles, err := content.Scan(fsys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := render.New(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, nil)
|
||||
}
|
||||
|
||||
func TestGlobalTagListingSpansSectionsGroupedByOne(t *testing.T) {
|
||||
h := tagHandler(t)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/tags/monsoon/", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"<h2>posts</h2>", "<h2>comics</h2>", "Essay", "Rain"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("missing %q — a tag spans sections and groups by one:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "Other") {
|
||||
t.Error("a different tag leaked in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSectionNarrowedTagListing(t *testing.T) {
|
||||
h := tagHandler(t)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/comics/tags/monsoon/", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Rain") || strings.Contains(body, "Essay") {
|
||||
t.Errorf("narrowing to comics should drop the posts entry:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagPathsCanonicaliseAndMiss(t *testing.T) {
|
||||
h := tagHandler(t)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/tags/monsoon", nil))
|
||||
if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != "/tags/monsoon/" {
|
||||
t.Errorf("slashless tag path = %d %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
for _, path := range []string{"/tags/nothing/", "/tags/", "/posts/tags/nothing/"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("GET %s = %d, want 404", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user