Files
khosra/internal/ext/links/links_test.go
T
bdeshiandClaude Opus 5 30c26bd1ac resolve relative links against the disk, serve them as addresses
Item 2 of the order of work. An author writes `../day-01.en.md` — the path an
editor preview resolves — and the engine emits `/posts/day-01/`.

The larger effect is durability. Resolution goes through key → route, and a slug
moves the route while never moving the key (ADR-0035), so a relative link survives
a rename that a hand-written /posts/a-better-name/ does not. The demo proves it:
`../renamed-thing.en.md` renders as href="/posts/a-better-name/" — the author wrote
the filename and got the slugged address.

This is the engine altering authored markup, which ADR-0045 polices, so the test
that matters is what it declines to touch. Fourteen cases must survive exactly as
written: an absolute URL, a scheme-relative URL, mailto:, tel:, a root-relative
path, a bare fragment, a bare query, a name climbing out of content/, and every
relative path whose extension is not .md. That last line is what keeps cover.jpg
working — a bundle's assets already resolve because its URL mirrors its directory,
so rewriting them would break what works. Nine rewrite cases sit beside them.

Key derivation goes through content.KeyFromName, exported for this: the
language-suffix rule is the part that would drift between two copies, so it lives
in one place while the five lines of joining are duplicated in check.

khosra check now reports a relative .md link resolving to no bundle, as fatal —
verified by mistyping one and watching exit 1. Only the .md form: an extensionless
relative path may be an asset, and a checker that calls a working link broken gets
ignored wholesale.

Two debts this change paid rather than deferred.

render.go reached the file-length advisory, so theme parsing moved to theme.go —
414 and 105 lines, one topic each, since parsing runs per rebuild and rendering
runs per request. Not a _helpers.go shard.

And the demo's coverage test bound its renderer with a *copy* of the rebuilder's
wiring, so it missed this feature entirely while the real binary served it
correctly. Navigation had already drifted the same way. Both now call one bind(),
which is exactly what ADR-0072 was written about — and the test failing is the only
reason the copy was found.

Extensions 7 → 8. Core 3020 → 3049 of 3400: the seam is ~20 lines, the feature is
in ext where it belongs.

18 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:44:48 +06:00

134 lines
5.7 KiB
Go

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, "<code>../day-01.md</code>") {
t.Errorf("a code span must stay literal:\n%s", got)
}
}