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() }