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:
2026-08-01 02:23:34 +06:00
parent 61af326474
commit b14658094e
10 changed files with 320 additions and 28 deletions
+49 -12
View File
@@ -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.
+11 -1
View File
@@ -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>