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 -3
View File
@@ -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)
}
}