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
+74 -2
View File
@@ -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 {
+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)
}
}