Membership is a publication date, not a type declaration (ADR-0043). The parked
feed shape said "every type declared primary", which would have made feeds wait on
declared types a third time — but the thing that distinguishes a feed item is
already on disk. Pages and section landings drop out because they have no date,
which is the right reason. The parked idea stays parked with a sharper trigger:
someone wanting a *dated* bundle kept out.
Built with encoding/xml from typed structs, never a template: XML in html/template
is escaping for the wrong grammar, and that is a correctness trap rather than a
matter of taste.
A bug the evidence found, older than feeds: content.URL("", lang) built "//", so a
whole-site feed's id and alternate link were https://khosra.example// — every
entry identity wrong in every reader. The root is "/" now, with a test, and the
hand-built "/" the resolver carried for the same reason can follow later.
Two counters re-scoped rather than incremented, the same way transforms was:
Views now counts *per-bundle selection* — the thing architecture.md means by the
View layer, still at zero consumers. Output formats are not it: HTML, sitemap XML
and Atom are three functions with nothing to share, so an interface over them
would have one member and no leverage.
Effects stays at 1. A feed is generated per request like the sitemap, so it is not
a second Effect and the runner is not yet due — the next thing that writes files
off the request path is.
288 lines
11 KiB
Go
288 lines
11 KiB
Go
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)
|
|
}
|
|
}
|
|
// The site root is "/", not "//" — what a whole-site feed asks for, and what a doubled slash would put in
|
|
// front of every reader as a broken identity.
|
|
for lang, want := range map[string]string{"en": "/", "": "/", "bn": "/bn/"} {
|
|
if got := URL("", lang); got != want {
|
|
t.Errorf("URL(\"\", %q) = %q, want %q", lang, got, want)
|
|
}
|
|
}
|
|
if got := PageURL("", "en", 2); got != "/page/2/" {
|
|
t.Errorf("PageURL at the root = %q", got)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// seriesFS is a series joined structurally: the landing bundle plus four chapters nested under it, two
|
|
// carrying order and two not, and one outsider that must never join.
|
|
func seriesFS() fstest.MapFS {
|
|
return fstest.MapFS{
|
|
"content/comics/the-long-monsoon/_index.md": {Data: []byte("---\ntitle: The Long Monsoon\n---\n")},
|
|
"content/comics/the-long-monsoon/first-rain.md": {Data: []byte("---\ntitle: First Rain\norder: 10\n---\n")},
|
|
"content/comics/the-long-monsoon/the-flood.md": {Data: []byte("---\ntitle: The Flood\norder: 20\n---\n")},
|
|
"content/comics/the-long-monsoon/appendix-a.md": {Data: []byte("---\ntitle: Appendix A\n---\n")},
|
|
"content/comics/the-long-monsoon/appendix-b.bn.md": {Data: []byte("---\ntitle: পরিশিষ্ট খ\n---\n")},
|
|
"content/comics/the-long-monsoon/appendix-b.md": {Data: []byte("---\ntitle: Appendix B\n---\n")},
|
|
"content/comics/elsewhere.md": {Data: []byte("---\ntitle: Elsewhere\n---\n")},
|
|
}
|
|
}
|
|
|
|
func TestSequenceMembershipIsStructural(t *testing.T) {
|
|
site := NewSite(mustScan(t, seriesFS()))
|
|
seq, ok := site.Sequence("comics/the-long-monsoon/the-flood", "en")
|
|
if !ok {
|
|
t.Fatal("a bundle nested under a landing bundle is a member (ADR-0033)")
|
|
}
|
|
if seq.Series.Title != "The Long Monsoon" {
|
|
t.Errorf("series = %q, want the enclosing landing bundle", seq.Series.Title)
|
|
}
|
|
var titles []string
|
|
for _, m := range seq.Members {
|
|
titles = append(titles, m.Title)
|
|
}
|
|
want := []string{"First Rain", "The Flood", "Appendix A", "Appendix B"}
|
|
if len(titles) != len(want) {
|
|
t.Fatalf("members = %v, want %v", titles, want)
|
|
}
|
|
for i := range want {
|
|
if titles[i] != want[i] {
|
|
t.Fatalf("members = %v, want %v — ordered first, then by name", titles, want)
|
|
}
|
|
}
|
|
if seq.Index != 2 {
|
|
t.Errorf("index = %d, want 2", seq.Index)
|
|
}
|
|
if _, ok := site.Sequence("comics/elsewhere", "en"); ok {
|
|
t.Error("a bundle beside the series is not in it")
|
|
}
|
|
}
|
|
|
|
func TestSequenceOnTheLandingPageListsItsMembers(t *testing.T) {
|
|
site := NewSite(mustScan(t, seriesFS()))
|
|
seq, ok := site.Sequence("comics/the-long-monsoon", "en")
|
|
if !ok {
|
|
t.Fatal("a bundle with bundles nested under it is a series landing page")
|
|
}
|
|
if seq.Index != 0 {
|
|
t.Errorf("index = %d, want 0: the landing page holds no position in its own series", seq.Index)
|
|
}
|
|
if len(seq.Members) != 4 {
|
|
t.Errorf("members = %d, want 4", len(seq.Members))
|
|
}
|
|
}
|
|
|
|
func TestSequenceMembersFollowTheLanguageFallback(t *testing.T) {
|
|
site := NewSite(mustScan(t, seriesFS()))
|
|
seq, ok := site.Sequence("comics/the-long-monsoon/the-flood", "bn")
|
|
if !ok {
|
|
t.Fatal("the series must resolve in every language")
|
|
}
|
|
if len(seq.Members) != 4 {
|
|
t.Fatalf("members = %d, want 4: a chapter missing in Bengali still holds its place", len(seq.Members))
|
|
}
|
|
if got := seq.Members[3].Title; got != "পরিশিষ্ট খ" {
|
|
t.Errorf("last member = %q, want the Bengali variant where one exists", got)
|
|
}
|
|
if got := seq.Members[1].Title; got != "The Flood" {
|
|
t.Errorf("member without a Bengali variant = %q, want the English fallback", got)
|
|
}
|
|
}
|
|
|
|
func TestNestedSeriesReportsItsOwnMembers(t *testing.T) {
|
|
fsys := seriesFS()
|
|
fsys["content/comics/_index.md"] = &fstest.MapFile{Data: []byte("---\ntitle: Comics\n---\n")}
|
|
site := NewSite(mustScan(t, fsys))
|
|
seq, ok := site.Sequence("comics/the-long-monsoon", "en")
|
|
if !ok {
|
|
t.Fatal("still a series")
|
|
}
|
|
if len(seq.Members) != 4 || seq.Series.Title != "The Long Monsoon" {
|
|
t.Errorf("a landing page inside another series reports its own members, got %d under %q",
|
|
len(seq.Members), seq.Series.Title)
|
|
}
|
|
// The outsider's nearest bundle ancestor is now the section landing, so it joins that sequence.
|
|
seq, ok = site.Sequence("comics/elsewhere", "en")
|
|
if !ok || seq.Series.Title != "Comics" {
|
|
t.Errorf("nearest ancestor should win, got %q ok=%v", seq.Series.Title, ok)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestEveryVariantMayDeclareTheSameAlias(t *testing.T) {
|
|
// An alias belongs to the bundle, so a translated bundle repeats it in each variant. Counting those as
|
|
// rival claimants dropped exactly the aliases a multilingual rename needs — found by serving it, since
|
|
// every fixture until now declared an alias in one variant only.
|
|
site := NewSite(mustScan(t, fstest.MapFS{
|
|
"content/posts/new.md": {Data: []byte("---\ntitle: New\naliases: [posts/old]\n---\n")},
|
|
"content/posts/new.bn.md": {Data: []byte("---\ntitle: নতুন\naliases: [posts/old]\n---\n")},
|
|
}))
|
|
if got, ok := site.Alias("posts/old"); !ok || got != "posts/new" {
|
|
t.Errorf("Alias(posts/old) = %q %v, want posts/new", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestASlugMovesTheRouteAndLeavesTheKeyAlone(t *testing.T) {
|
|
site := NewSite(mustScan(t, fstest.MapFS{
|
|
"content/posts/hello-world.md": {Data: []byte("---\ntitle: Hello\nslug: ekti-post\n---\n")},
|
|
"content/posts/hello-world.bn.md": {Data: []byte("---\ntitle: একটি\n---\n")},
|
|
}))
|
|
for _, lang := range []string{"en", "bn"} {
|
|
b, _, ok := site.Lookup("posts/hello-world", lang)
|
|
if !ok {
|
|
t.Fatalf("the key is still the identity, in %s", lang)
|
|
}
|
|
if b.Route != "posts/ekti-post" {
|
|
t.Errorf("%s route = %q, want posts/ekti-post — a slug renames every variant (ADR-0035)", lang, b.Route)
|
|
}
|
|
}
|
|
if key, live := site.KeyFor("posts/ekti-post"); !live || key != "posts/hello-world" {
|
|
t.Errorf("KeyFor(route) = %q %v, want the key", key, live)
|
|
}
|
|
if _, live := site.KeyFor("posts/hello-world"); live {
|
|
t.Error("the derived path must stop answering once a slug moves the bundle")
|
|
}
|
|
}
|