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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user