package links import ( "bytes" "strings" "testing" "github.com/yuin/goldmark" "github.com/yuin/goldmark/parser" "khosra/internal/render" ) // site is the pretend index every case resolves against: three bundles, one of them slugged, one with a // Bengali variant. Keys in, URLs out — exactly the shape the real resolver has. func site(key, lang string) (string, bool) { routes := map[string]string{ "posts/day-01": "/posts/day-01/", "posts/first-light": "/posts/first-light/", "writing/notes-on-water": "/writing/notes-on-water/", "posts/renamed": "/posts/a-better-name/", // a slug moved the address, never the key } url, ok := routes[key] if !ok { return "", false } if lang == "bn" { return "/bn" + url, true } return url, true } // convert renders body as a bundle at dir would be, and returns the HTML. func convert(t *testing.T, dir, lang, body string) string { t.Helper() md := goldmark.New(goldmark.WithExtensions(New())) pc := parser.NewContext() render.WithOrigin(pc, render.Origin{Dir: dir, Lang: lang, Resolve: site}) var out bytes.Buffer if err := md.Convert([]byte(body), &out, parser.WithContext(pc)); err != nil { t.Fatal(err) } return out.String() } // What must be rewritten: a path on disk becomes the address that path is served at. func TestARelativeLinkBecomesTheServedAddress(t *testing.T) { for _, c := range []struct { what, from, link, want string }{ {"a sibling single-file bundle, written with its filename", "content/posts/first-light", "../day-01.en.md", `href="/posts/day-01/"`}, {"the same, written without the suffix", "content/posts/first-light", "../day-01", `href="/posts/day-01/"`}, {"the same, written with only .md", "content/posts/first-light", "../day-01.md", `href="/posts/day-01/"`}, {"a directory bundle in another section", "content/posts/first-light", "../../writing/notes-on-water/", `href="/writing/notes-on-water/"`}, {"a directory bundle by its index file", "content/posts/first-light", "../../writing/notes-on-water/index.en.md", `href="/writing/notes-on-water/"`}, {"from a single-file bundle, whose directory is the section", "content/posts", "./first-light/", `href="/posts/first-light/"`}, {"a slug moves the address and never the key", "content/posts/first-light", "../renamed.md", `href="/posts/a-better-name/"`}, {"a fragment survives", "content/posts/first-light", "../day-01.md#the-part", `href="/posts/day-01/#the-part"`}, {"a query survives", "content/posts/first-light", "../day-01.md?raw", `href="/posts/day-01/?raw"`}, } { got := convert(t, c.from, "en", "["+c.what+"]("+c.link+")") if !strings.Contains(got, c.want) { t.Errorf("%s: %q from %s\n got %s\n want %s", c.what, c.link, c.from, strings.TrimSpace(got), c.want) } } } // What prose it eats — the table that decides whether this ships. Every destination here must survive // **exactly** as written, because rewriting any of them would break something that works today. func TestWhatItLeavesAlone(t *testing.T) { for _, c := range []struct{ what, link string }{ {"an absolute URL", "https://example.com/posts/day-01/"}, {"a scheme-relative URL", "//example.com/x"}, {"a mail address", "mailto:someone@example.com"}, {"a telephone link", "tel:+8801000000"}, {"a root-relative path, which is already an address", "/posts/day-01/"}, {"a bare fragment", "#a-heading"}, {"a bare query", "?raw"}, {"a bundle's own asset, which already resolves because the URL mirrors the directory", "cover.jpg"}, {"the same, written explicitly relative", "./cover.jpg"}, {"an asset in a subdirectory", "./scans/page-01.png"}, {"a name climbing out of the content directory", "../../../etc/passwd"}, {"a relative path naming no bundle at all", "../nothing-here.md"}, {"a relative path naming no bundle, without a suffix", "../nothing-here"}, {"an asset one level up, which is not this bundle's to link but is still not a bundle", "../shared.css"}, } { got := convert(t, "content/posts/first-light", "en", "["+c.what+"]("+c.link+")") if !strings.Contains(got, `href="`+c.link+`"`) { t.Errorf("%s: %q was altered\n got %s", c.what, c.link, strings.TrimSpace(got)) } } } // A Bengali page links the Bengali variant, through the same fallback every lookup uses (ADR-0009). func TestTheLinkFollowsTheLanguageBeingRendered(t *testing.T) { got := convert(t, "content/posts/first-light", "bn", "[চলুন](../day-01.md)") if !strings.Contains(got, `href="/bn/posts/day-01/"`) { t.Errorf("a Bengali page should link the Bengali variant:\n%s", got) } } // Without a resolver the feature does nothing at all, rather than guessing an address. That is the state a // renderer built with no site root is in, and a guess there would emit links to pages that do not exist. func TestWithoutAResolverNothingIsTouched(t *testing.T) { md := goldmark.New(goldmark.WithExtensions(New())) pc := parser.NewContext() render.WithOrigin(pc, render.Origin{Dir: "content/posts/first-light", Lang: "en"}) var out bytes.Buffer if err := md.Convert([]byte("[x](../day-01.md)"), &out, parser.WithContext(pc)); err != nil { t.Fatal(err) } if !strings.Contains(out.String(), `href="../day-01.md"`) { t.Errorf("with no resolver the destination must survive:\n%s", out.String()) } } // Code spans are literal, so a path inside one is text and not a link. Worth asserting rather than assuming: // every syntax defect this engine has shipped was something eating ordinary prose. func TestAPathInACodeSpanIsNotALink(t *testing.T) { got := convert(t, "content/posts/first-light", "en", "Write `../day-01.md` to link it.") if !strings.Contains(got, "../day-01.md") { t.Errorf("a code span must stay literal:\n%s", got) } }