diff --git a/docs/state.md b/docs/state.md index 35ebdf0..ad5fe74 100644 --- a/docs/state.md +++ b/docs/state.md @@ -1,6 +1,6 @@ # State -**Verified against:** `499d107` on 2026-07-30 — update this line every change. +**Verified against:** `449850c` 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 @@ -8,7 +8,9 @@ If this file disagrees with the code, the code is right and this file is a bug. | File | Purpose | LOC | |---|---|---| | `go.mod` | module `khosra`; `x/text`, `yaml.v3` direct | 8 | -| `internal/content/content.go` | site root → bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, collision drop, key index, language fallback, alias index, URL building | 358 | +| `internal/content/doc.go` | package comment | 5 | +| `internal/content/content.go` | bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, tag slugs, permalink building | 332 | +| `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections | 191 | | `internal/render/render.go` | goldmark, per-kind template sets with site override, `Page`/`List`/`head` | 214 | | `internal/render/templates/` | reference theme: `base.html`, `page.html`, `list.html`, `theme.css` (ADR-0026) | — | | `internal/web/resolve.go` | URL → (key, lang) or a canonical redirect: language prefix, `/en/…` fork guard, trailing slash | 56 | diff --git a/internal/content/content.go b/internal/content/content.go index 6575cf2..4352130 100644 --- a/internal/content/content.go +++ b/internal/content/content.go @@ -1,7 +1,3 @@ -// Package content reads a site root into bundles. It knows the disk and nothing about HTTP. -// -// Every read goes through an [os.Root] (ADR-0031), so no path — from a filename or later from a -// request — can escape the site root, even through a symlink. package content import ( @@ -11,7 +7,6 @@ import ( "log/slog" "os" "path" - "sort" "strconv" "strings" "time" @@ -299,196 +294,12 @@ func dropCollisions(all []Bundle) []Bundle { return kept } -// Site is a set of bundles indexed for lookup by permalink key. -type Site struct { - byKeyLang map[string]Bundle - aliases map[string]string -} - -// NewSite indexes bundles for lookup. Later variants of a key and language cannot occur, because Scan -// drops ambiguity before this sees it. -func NewSite(bundles []Bundle) *Site { - s := &Site{ - byKeyLang: make(map[string]Bundle, len(bundles)), - aliases: map[string]string{}, - } - for _, b := range bundles { - s.byKeyLang[b.Key+"\x00"+b.Lang] = b - } - s.indexAliases(bundles) - return s -} - -// indexAliases maps each alias to the key it redirects to. -// -// An alias that names a real bundle, or that two bundles both claim, is ambiguous: it is logged and -// dropped rather than picking a winner, and the real bundle keeps its URL (ADR-0029). -func (s *Site) indexAliases(bundles []Bundle) { - claimed := map[string][]string{} - for _, b := range bundles { - for _, a := range b.Aliases { - claimed[a] = append(claimed[a], b.Key) - } - } - for alias, keys := range claimed { - if _, isReal := s.byKeyLang[alias+"\x00"+DefaultLang]; isReal { - slog.Error("ignoring alias that names a real bundle", "alias", alias, "claimed_by", keys) - continue - } - if len(keys) > 1 { - slog.Error("ignoring alias claimed by more than one bundle", "alias", alias, "claimed_by", keys) - continue - } - s.aliases[alias] = keys[0] - } -} - -// Alias returns the key an alias redirects to. -func (s *Site) Alias(alias string) (string, bool) { - key, ok := s.aliases[alias] - return key, ok -} - -// Lookup returns the best variant of a key for a requested language, and the language actually served. -// -// The fallback chain is requested → default → any (ADR-0009); "any" is resolved in sorted order so the -// same request always answers the same way. A key with no variants at all reports false. -func (s *Site) Lookup(key, lang string) (b Bundle, served string, ok bool) { - for _, try := range []string{lang, DefaultLang} { - if try == "" { - continue - } - if b, ok = s.byKeyLang[key+"\x00"+try]; ok { - return b, try, true - } - } - for _, l := range s.Variants(key) { - b = s.byKeyLang[key+"\x00"+l] - return b, l, true - } - return Bundle{}, "", false -} - -// Variants lists the languages a key exists in, sorted. -func (s *Site) Variants(key string) []string { - var langs []string - for kl := range s.byKeyLang { - k, l, found := strings.Cut(kl, "\x00") - if found && k == key { - langs = append(langs, l) - } - } - sort.Strings(langs) - return langs -} - -// HasLang reports whether any bundle is written in lang. The resolver needs this to tell a language -// prefix from a section that happens to share its name. -func (s *Site) HasLang(lang string) bool { - for kl := range s.byKeyLang { - if _, l, found := strings.Cut(kl, "\x00"); found && l == lang { - return true - } - } - return false -} - -// Len reports how many bundles the site holds. -func (s *Site) Len() int { return len(s.byKeyLang) } - // PerPage is how many entries a listing shows. // // Changing it renumbers page URLs, which ADR-0028 calls a URL event; it becomes a setting when the // cascade exists rather than being one knob early. const PerPage = 10 -// Query selects bundles into an ordered list. -// -// Every grouping in the engine is a Query — sections now, tags and series later. It carries no cache -// signature yet: nothing caches, and a signature with no consumer is speculation (architecture.md). -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 -} - -// Run applies q, newest first, with undated bundles after dated ones and ties broken by key so the same -// query always answers in the same order. -func (s *Site) Run(q Query) []Bundle { - seen := map[string]bool{} - var out []Bundle - for kl := range s.byKeyLang { - key, _, found := strings.Cut(kl, "\x00") - if !found || seen[key] { - continue - } - if q.Section != "" && !strings.HasPrefix(key, q.Section+"/") { - continue - } - seen[key] = true - 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] - switch { - case !a.Date.Equal(b.Date): - return a.Date.After(b.Date) - default: - return a.Key < b.Key - } - }) - 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{} - for kl := range s.byKeyLang { - key, _, found := strings.Cut(kl, "\x00") - if !found { - continue - } - if sec, _, nested := strings.Cut(key, "/"); nested { - seen[sec] = true - } - } - out := make([]string, 0, len(seen)) - for sec := range seen { - out = append(out, sec) - } - sort.Strings(out) - return out -} - // URL is the permalink of a variant: /{section}/{slug}/, with a language prefix for anything but the // default locale (ADR-0008, ADR-0009). Templates never build a path by hand. func URL(key, lang string) string { diff --git a/internal/content/content_test.go b/internal/content/content_test.go index 5e23897..1397f4d 100644 --- a/internal/content/content_test.go +++ b/internal/content/content_test.go @@ -160,122 +160,6 @@ func TestOpenSiteRefusesSymlinkEscape(t *testing.T) { } } -func TestSiteLookupFindsDefaultVariant(t *testing.T) { - fsys := fstest.MapFS{ - "content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nhi\n")}, - "content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\nহাই\n")}, - } - bundles, err := Scan(fsys) - if err != nil { - t.Fatal(err) - } - site := NewSite(bundles) - if site.Len() != 2 { - t.Fatalf("indexed %d bundles, want 2", site.Len()) - } - b, served, ok := site.Lookup("pages/about", "en") - if !ok || b.Title != "About" || served != "en" { - t.Fatalf("Lookup gave %+v %q %v, want the English variant", b, served, ok) - } - if _, _, ok := site.Lookup("pages/missing", "en"); ok { - t.Error("Lookup invented a bundle") - } -} - -func TestLookupFallsBackThroughLanguages(t *testing.T) { - fsys := fstest.MapFS{ - "content/pages/about.md": {Data: []byte("---\ntitle: About\n---\n")}, - "content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\n")}, - "content/posts/only.bn.md": {Data: []byte("---\ntitle: শুধু\n---\n")}, - "content/posts/plain.md": {Data: []byte("---\ntitle: Plain\n---\n")}, - } - bundles, err := Scan(fsys) - if err != nil { - t.Fatal(err) - } - site := NewSite(bundles) - cases := []struct{ key, want, served string }{ - {"pages/about", "পরিচিতি", "bn"}, // the requested language exists - {"posts/plain", "Plain", "en"}, // falls back to the default - {"posts/only", "শুধু", "bn"}, // no default either: any variant beats a 404 - } - for _, c := range cases { - b, served, ok := site.Lookup(c.key, "bn") - if !ok || b.Title != c.want || served != c.served { - t.Errorf("Lookup(%q, bn) = %q/%q ok=%v, want %q/%q", c.key, b.Title, served, ok, c.want, c.served) - } - } - if got := site.Variants("pages/about"); len(got) != 2 || got[0] != "bn" || got[1] != "en" { - t.Errorf("Variants = %v, want [bn en]", got) - } - if !site.HasLang("bn") || site.HasLang("fr") { - t.Error("HasLang misreported") - } -} - -func TestURLPrefixesOnlyNonDefaultLanguages(t *testing.T) { - cases := map[string]string{"en": "/pages/about/", "": "/pages/about/", "bn": "/bn/pages/about/"} - for lang, want := range cases { - if got := URL("pages/about", lang); got != want { - t.Errorf("URL(pages/about, %q) = %q, want %q", lang, got, want) - } - } -} - -func TestAliasesAreParsedAndIndexed(t *testing.T) { - fsys := fstest.MapFS{ - "content/posts/new-name.md": {Data: []byte("---\ntitle: New\naliases: [/posts/old-name/, posts/older]\n---\n")}, - "content/pages/single.md": {Data: []byte("---\ntitle: Single\naliases: pages/one\n---\n")}, - } - bundles, err := Scan(fsys) - if err != nil { - t.Fatal(err) - } - site := NewSite(bundles) - for alias, want := range map[string]string{ - "posts/old-name": "posts/new-name", - "posts/older": "posts/new-name", - "pages/one": "pages/single", - } { - got, ok := site.Alias(alias) - if !ok || got != want { - t.Errorf("Alias(%q) = %q %v, want %q", alias, got, ok, want) - } - } - for _, b := range bundles { - if _, leaked := b.Extra["aliases"]; leaked { - t.Error("aliases should be lifted out of Extra, not duplicated") - } - } -} - -func TestAmbiguousAliasesAreDropped(t *testing.T) { - fsys := fstest.MapFS{ - "content/pages/real.md": {Data: []byte("---\ntitle: Real\n---\n")}, - "content/pages/a.md": {Data: []byte("---\ntitle: A\naliases: [pages/real, pages/shared]\n---\n")}, - "content/pages/b.md": {Data: []byte("---\ntitle: B\naliases: [pages/shared]\n---\n")}, - } - site := NewSite(mustScan(t, fsys)) - if _, ok := site.Alias("pages/real"); ok { - t.Error("an alias naming a real bundle must be dropped, not shadow it") - } - if _, ok := site.Alias("pages/shared"); ok { - t.Error("an alias claimed by two bundles must be dropped, not picked arbitrarily") - } - if _, _, ok := site.Lookup("pages/real", "en"); !ok { - t.Error("the real bundle must keep its URL") - } -} - -func mustScan(t *testing.T, fsys fstest.MapFS) []Bundle { - t.Helper() - b, err := Scan(fsys) - if err != nil { - t.Fatal(err) - } - return b -} - func TestTagSlugPreservesScriptAndFoldsCase(t *testing.T) { cases := map[string]string{ "Long Monsoon": "long-monsoon", @@ -289,29 +173,3 @@ func TestTagSlugPreservesScriptAndFoldsCase(t *testing.T) { } } } - -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) - } -} diff --git a/internal/content/doc.go b/internal/content/doc.go new file mode 100644 index 0000000..3926d87 --- /dev/null +++ b/internal/content/doc.go @@ -0,0 +1,5 @@ +// Package content reads a site root into bundles. It knows the disk and nothing about HTTP. +// +// Every read goes through an [os.Root] (ADR-0031), so no path — from a filename or later from a +// request — can escape the site root, even through a symlink. +package content diff --git a/internal/content/site.go b/internal/content/site.go new file mode 100644 index 0000000..bd5d582 --- /dev/null +++ b/internal/content/site.go @@ -0,0 +1,191 @@ +package content + +import ( + "log/slog" + "sort" + "strings" +) + +// Site is a set of bundles indexed for lookup by permalink key. +type Site struct { + byKeyLang map[string]Bundle + aliases map[string]string +} + +// NewSite indexes bundles for lookup. Later variants of a key and language cannot occur, because Scan +// drops ambiguity before this sees it. +func NewSite(bundles []Bundle) *Site { + s := &Site{ + byKeyLang: make(map[string]Bundle, len(bundles)), + aliases: map[string]string{}, + } + for _, b := range bundles { + s.byKeyLang[b.Key+"\x00"+b.Lang] = b + } + s.indexAliases(bundles) + return s +} + +// indexAliases maps each alias to the key it redirects to. +// +// An alias that names a real bundle, or that two bundles both claim, is ambiguous: it is logged and +// dropped rather than picking a winner, and the real bundle keeps its URL (ADR-0029). +func (s *Site) indexAliases(bundles []Bundle) { + claimed := map[string][]string{} + for _, b := range bundles { + for _, a := range b.Aliases { + claimed[a] = append(claimed[a], b.Key) + } + } + for alias, keys := range claimed { + if _, isReal := s.byKeyLang[alias+"\x00"+DefaultLang]; isReal { + slog.Error("ignoring alias that names a real bundle", "alias", alias, "claimed_by", keys) + continue + } + if len(keys) > 1 { + slog.Error("ignoring alias claimed by more than one bundle", "alias", alias, "claimed_by", keys) + continue + } + s.aliases[alias] = keys[0] + } +} + +// Alias returns the key an alias redirects to. +func (s *Site) Alias(alias string) (string, bool) { + key, ok := s.aliases[alias] + return key, ok +} + +// Lookup returns the best variant of a key for a requested language, and the language actually served. +// +// The fallback chain is requested → default → any (ADR-0009); "any" is resolved in sorted order so the +// same request always answers the same way. A key with no variants at all reports false. +func (s *Site) Lookup(key, lang string) (b Bundle, served string, ok bool) { + for _, try := range []string{lang, DefaultLang} { + if try == "" { + continue + } + if b, ok = s.byKeyLang[key+"\x00"+try]; ok { + return b, try, true + } + } + for _, l := range s.Variants(key) { + b = s.byKeyLang[key+"\x00"+l] + return b, l, true + } + return Bundle{}, "", false +} + +// Variants lists the languages a key exists in, sorted. +func (s *Site) Variants(key string) []string { + var langs []string + for kl := range s.byKeyLang { + k, l, found := strings.Cut(kl, "\x00") + if found && k == key { + langs = append(langs, l) + } + } + sort.Strings(langs) + return langs +} + +// HasLang reports whether any bundle is written in lang. The resolver needs this to tell a language +// prefix from a section that happens to share its name. +func (s *Site) HasLang(lang string) bool { + for kl := range s.byKeyLang { + if _, l, found := strings.Cut(kl, "\x00"); found && l == lang { + return true + } + } + return false +} + +// Len reports how many bundles the site holds. +func (s *Site) Len() int { return len(s.byKeyLang) } + +// Query selects bundles into an ordered list. +// +// Every grouping in the engine is a Query — sections now, tags and series later. It carries no cache +// signature yet: nothing caches, and a signature with no consumer is speculation (architecture.md). +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 +} + +// Run applies q, newest first, with undated bundles after dated ones and ties broken by key so the same +// query always answers in the same order. +func (s *Site) Run(q Query) []Bundle { + seen := map[string]bool{} + var out []Bundle + for kl := range s.byKeyLang { + key, _, found := strings.Cut(kl, "\x00") + if !found || seen[key] { + continue + } + if q.Section != "" && !strings.HasPrefix(key, q.Section+"/") { + continue + } + seen[key] = true + 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] + switch { + case !a.Date.Equal(b.Date): + return a.Date.After(b.Date) + default: + return a.Key < b.Key + } + }) + 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{} + for kl := range s.byKeyLang { + key, _, found := strings.Cut(kl, "\x00") + if !found { + continue + } + if sec, _, nested := strings.Cut(key, "/"); nested { + seen[sec] = true + } + } + out := make([]string, 0, len(seen)) + for sec := range seen { + out = append(out, sec) + } + sort.Strings(out) + return out +} diff --git a/internal/content/site_test.go b/internal/content/site_test.go new file mode 100644 index 0000000..546d066 --- /dev/null +++ b/internal/content/site_test.go @@ -0,0 +1,148 @@ +package content + +import ( + "testing" + "testing/fstest" +) + +func TestSiteLookupFindsDefaultVariant(t *testing.T) { + fsys := fstest.MapFS{ + "content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nhi\n")}, + "content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\nহাই\n")}, + } + bundles, err := Scan(fsys) + if err != nil { + t.Fatal(err) + } + site := NewSite(bundles) + if site.Len() != 2 { + t.Fatalf("indexed %d bundles, want 2", site.Len()) + } + b, served, ok := site.Lookup("pages/about", "en") + if !ok || b.Title != "About" || served != "en" { + t.Fatalf("Lookup gave %+v %q %v, want the English variant", b, served, ok) + } + if _, _, ok := site.Lookup("pages/missing", "en"); ok { + t.Error("Lookup invented a bundle") + } +} + +func TestLookupFallsBackThroughLanguages(t *testing.T) { + fsys := fstest.MapFS{ + "content/pages/about.md": {Data: []byte("---\ntitle: About\n---\n")}, + "content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\n")}, + "content/posts/only.bn.md": {Data: []byte("---\ntitle: শুধু\n---\n")}, + "content/posts/plain.md": {Data: []byte("---\ntitle: Plain\n---\n")}, + } + bundles, err := Scan(fsys) + if err != nil { + t.Fatal(err) + } + site := NewSite(bundles) + cases := []struct{ key, want, served string }{ + {"pages/about", "পরিচিতি", "bn"}, // the requested language exists + {"posts/plain", "Plain", "en"}, // falls back to the default + {"posts/only", "শুধু", "bn"}, // no default either: any variant beats a 404 + } + for _, c := range cases { + b, served, ok := site.Lookup(c.key, "bn") + if !ok || b.Title != c.want || served != c.served { + t.Errorf("Lookup(%q, bn) = %q/%q ok=%v, want %q/%q", c.key, b.Title, served, ok, c.want, c.served) + } + } + if got := site.Variants("pages/about"); len(got) != 2 || got[0] != "bn" || got[1] != "en" { + t.Errorf("Variants = %v, want [bn en]", got) + } + if !site.HasLang("bn") || site.HasLang("fr") { + t.Error("HasLang misreported") + } +} + +func TestURLPrefixesOnlyNonDefaultLanguages(t *testing.T) { + cases := map[string]string{"en": "/pages/about/", "": "/pages/about/", "bn": "/bn/pages/about/"} + for lang, want := range cases { + if got := URL("pages/about", lang); got != want { + t.Errorf("URL(pages/about, %q) = %q, want %q", lang, got, want) + } + } +} + +func TestAliasesAreParsedAndIndexed(t *testing.T) { + fsys := fstest.MapFS{ + "content/posts/new-name.md": {Data: []byte("---\ntitle: New\naliases: [/posts/old-name/, posts/older]\n---\n")}, + "content/pages/single.md": {Data: []byte("---\ntitle: Single\naliases: pages/one\n---\n")}, + } + bundles, err := Scan(fsys) + if err != nil { + t.Fatal(err) + } + site := NewSite(bundles) + for alias, want := range map[string]string{ + "posts/old-name": "posts/new-name", + "posts/older": "posts/new-name", + "pages/one": "pages/single", + } { + got, ok := site.Alias(alias) + if !ok || got != want { + t.Errorf("Alias(%q) = %q %v, want %q", alias, got, ok, want) + } + } + for _, b := range bundles { + if _, leaked := b.Extra["aliases"]; leaked { + t.Error("aliases should be lifted out of Extra, not duplicated") + } + } +} + +func TestAmbiguousAliasesAreDropped(t *testing.T) { + fsys := fstest.MapFS{ + "content/pages/real.md": {Data: []byte("---\ntitle: Real\n---\n")}, + "content/pages/a.md": {Data: []byte("---\ntitle: A\naliases: [pages/real, pages/shared]\n---\n")}, + "content/pages/b.md": {Data: []byte("---\ntitle: B\naliases: [pages/shared]\n---\n")}, + } + site := NewSite(mustScan(t, fsys)) + if _, ok := site.Alias("pages/real"); ok { + t.Error("an alias naming a real bundle must be dropped, not shadow it") + } + if _, ok := site.Alias("pages/shared"); ok { + t.Error("an alias claimed by two bundles must be dropped, not picked arbitrarily") + } + if _, _, ok := site.Lookup("pages/real", "en"); !ok { + t.Error("the real bundle must keep its URL") + } +} + +func mustScan(t *testing.T, fsys fstest.MapFS) []Bundle { + t.Helper() + b, err := Scan(fsys) + if err != nil { + t.Fatal(err) + } + return b +} + +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) + } +}