Files
khosra/internal/ext/links/links.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

135 lines
4.7 KiB
Go

package links
import (
"log/slog"
"net/url"
"path"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
"khosra/internal/content"
"khosra/internal/render"
)
// contentDir is the one directory a relative link may resolve inside.
const contentDir = "content"
// New returns the Markdown extension.
func New() goldmark.Extender { return extension{} }
type extension struct{}
// Extend registers the rewrite as a transformer, after the include expander so a merged fragment's links are
// in the tree by the time this walks it. An `include: embed` fragment is converted by this same pipeline with
// the same Origin, so its links are rewritten on that nested parse rather than being missed.
func (extension) Extend(md goldmark.Markdown) {
md.Parser().AddOptions(parser.WithASTTransformers(util.Prioritized(rewrite{}, 200)))
}
type rewrite struct{}
func (rewrite) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
origin, ok := render.OriginFrom(pc)
if !ok || origin.Resolve == nil {
return
}
// The error is always nil: the callback below never returns one, and swallowing a value that cannot exist
// reads better than a branch that cannot run.
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
link, is := n.(*ast.Link)
if !entering || !is {
return ast.WalkContinue, nil
}
if to, changed := resolve(string(link.Destination), origin); changed {
link.Destination = []byte(to)
}
return ast.WalkContinue, nil
})
}
// resolve turns one destination into a served URL, reporting whether it should be replaced at all.
//
// Everything it declines to touch it must leave *exactly* as written, which is most of what this function is
// for: an absolute URL, a root-relative path, a fragment, a mail address, and — the case that matters most —
// a relative path naming something that is not a bundle. A bundle's own assets already resolve correctly by
// accident, because a bundle's URL mirrors its directory, so rewriting them would break what works.
func resolve(dest string, origin render.Origin) (string, bool) {
ref, ok := relative(dest)
if !ok {
return "", false
}
// path.Join cleans, so ".." is collapsed here rather than reaching the filesystem. A name that climbs out
// of content/ is refused, the rule an include and a code block's file= already enforce (ADR-0038).
joined := path.Join(origin.Dir, ref.Path)
if joined != contentDir && !strings.HasPrefix(joined, contentDir+"/") {
slog.Warn("a relative link climbs out of the content directory and is left as written",
"link", dest, "from", origin.Dir)
return "", false
}
key, ok := keyOf(strings.TrimPrefix(joined, contentDir+"/"))
if !ok {
return "", false
}
// The language of the page being rendered, so a Bengali page links the Bengali variant and falls back the
// way every other lookup does (ADR-0009).
to, ok := origin.Resolve(key, origin.Lang)
if !ok {
return "", false
}
return to + suffix(ref), true
}
// relative reports whether a destination is a path this package may resolve, and parses it.
//
// Declined: anything with a scheme (`https:`, `mailto:`, `tel:`), a host (`//example.com`), an absolute path,
// and a destination with no path at all, which is a bare fragment or query.
func relative(dest string) (*url.URL, bool) {
if dest == "" || strings.HasPrefix(dest, "/") {
return nil, false
}
ref, err := url.Parse(dest)
if err != nil || ref.Scheme != "" || ref.Host != "" || ref.Path == "" {
return nil, false
}
return ref, true
}
// keyOf derives a bundle key from a name relative to content/, or reports that this is not one.
//
// Three cases and no more: a name ending in `.md` is a bundle file, a name with no extension at all is a
// bundle directory or the same file written without its suffix, and a name with any other extension is an
// asset and is never touched. That last line is what keeps `cover.jpg` working.
func keyOf(name string) (string, bool) {
name = strings.TrimSuffix(name, "/")
switch path.Ext(name) {
case ".md":
key, _, ok := content.KeyFromName(name)
return key, ok
case "":
// Derived through the same function rather than used as-is, so `index` naming its directory and the
// language-suffix rule stay in one place (ADR-0021).
key, _, ok := content.KeyFromName(name + ".md")
return key, ok
default:
return "", false
}
}
// suffix is the query and fragment the author wrote, preserved so `../post/#section` still lands on the
// section it names.
func suffix(ref *url.URL) string {
var out strings.Builder
if ref.RawQuery != "" {
out.WriteString("?" + ref.RawQuery)
}
if ref.Fragment != "" {
out.WriteString("#" + ref.Fragment)
}
return out.String()
}