Download
264 lines 7.6 KiBGoModified 2026-10-20 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
// Package markdown renders GitHub-flavoured markdown with repo-aware link
// rewriting. Fenced code goes through the highlight package so it matches
// file pages byte for byte; raw HTML is escaped, never passed through.
package markdown

import (
	"bytes"
	"html"
	"html/template"
	"net/url"
	"path"
	"regexp"
	"strings"

	"code.bruce-hill.com/repo-renderer/internal/highlight"
	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/ast"
	"github.com/yuin/goldmark/extension"
	"github.com/yuin/goldmark/parser"
	"github.com/yuin/goldmark/renderer"
	"github.com/yuin/goldmark/text"
	"github.com/yuin/goldmark/util"
)

// Context tells the link rewriter where the document lives. The zero Context
// leaves relative links alone.
type Context struct {
	// Dir is the repo-relative directory of the markdown file ("" for root).
	Dir string
	// PageURL takes a link's resolved repo-relative path ("" for the root)
	// and returns the URL of its file or directory page.
	PageURL func(string) string
	// ImageURL takes an image's resolved repo-relative path and returns the
	// URL to embed.
	ImageURL func(string) string
}

// Renderer converts markdown to HTML. It is safe for concurrent use.
type Renderer struct {
	md goldmark.Markdown
}

// New builds a renderer that highlights through hl.
func New(hl *highlight.Registry) *Renderer {
	return &Renderer{md: goldmark.New(
		goldmark.WithExtensions(extension.GFM, extension.Footnote),
		goldmark.WithParserOptions(
			parser.WithAutoHeadingID(),
			parser.WithASTTransformers(util.Prioritized(linkRewriter{}, 100)),
		),
		goldmark.WithRendererOptions(
			renderer.WithNodeRenderers(util.Prioritized(&nodeRenderer{hl: hl}, 100)),
		),
	)}
}

// contextKey carries each document's Context to the link rewriter.
var contextKey = parser.NewContextKey()

// Render converts src to HTML.
func (r *Renderer) Render(src []byte, ctx Context) (template.HTML, error) {
	pc := parser.NewContext()
	pc.Set(contextKey, ctx)
	var buf bytes.Buffer
	if err := r.md.Convert(StripPandocMeta(src), &buf, parser.WithContext(pc)); err != nil {
		return "", err
	}
	return template.HTML(buf.String()), nil
}

// RenderInline converts a one-paragraph snippet, such as a heading, to HTML
// without the enclosing <p>. Relative links pass through unchanged.
func (r *Renderer) RenderInline(src string) (template.HTML, error) {
	out, err := r.Render([]byte(src), Context{})
	if err != nil {
		return "", err
	}
	s := strings.TrimSpace(string(out))
	if inner, ok := strings.CutPrefix(s, "<p>"); ok && strings.Count(s, "<p>") == 1 {
		if inner, ok = strings.CutSuffix(inner, "</p>"); ok {
			s = inner
		}
	}
	return template.HTML(s), nil
}

// StripPandocMeta removes a pandoc title block: leading lines that start
// with "%" (title, authors, date), each optionally continued by lines that
// start with a space. The block is not interpreted, only dropped, and only
// at the very start of the document.
func StripPandocMeta(src []byte) []byte {
	if len(src) == 0 || src[0] != '%' {
		return src
	}
	rest := src
	for len(rest) > 0 && rest[0] == '%' {
		// The "%" line itself, then any indented continuation lines.
		i := bytes.IndexByte(rest, '\n')
		if i < 0 {
			return nil
		}
		rest = rest[i+1:]
		for len(rest) > 0 && (rest[0] == ' ' || rest[0] == '\t') {
			j := bytes.IndexByte(rest, '\n')
			if j < 0 {
				return nil
			}
			rest = rest[j+1:]
		}
	}
	return rest
}

// ---- link rewriting ----

var schemeRE = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*:`)

type linkRewriter struct{}

func (linkRewriter) Transform(doc *ast.Document, _ text.Reader, pc parser.Context) {
	ctx := pc.Get(contextKey).(Context)
	_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
		if !entering {
			return ast.WalkContinue, nil
		}
		switch v := n.(type) {
		case *ast.Link:
			v.Destination = []byte(Resolve(string(v.Destination), ctx, false))
		case *ast.Image:
			v.Destination = []byte(Resolve(string(v.Destination), ctx, true))
		}
		return ast.WalkContinue, nil
	})
}

// Resolve rewrites a relative markdown destination to a site URL. A leading
// "/" means the repository root, as it does on GitHub. Absolute URLs,
// scheme-relative URLs and pure fragments pass through unchanged, as does
// anything that would escape the repository.
func Resolve(dest string, ctx Context, image bool) string {
	if dest == "" || dest[0] == '#' || strings.HasPrefix(dest, "//") || schemeRE.MatchString(dest) {
		return dest
	}
	u, err := url.Parse(dest)
	if err != nil || u.Host != "" {
		return dest
	}
	p := u.Path
	if p == "" {
		return dest
	}
	// Decode once so "a%20b.md" resolves against real tree paths.
	if dp, err := url.PathUnescape(p); err == nil {
		p = dp
	}
	// Join without anchoring at "/" so an escape shows up as a leading "..".
	dir := ctx.Dir
	if strings.HasPrefix(p, "/") {
		dir, p = "", strings.TrimLeft(p, "/")
	}
	rel := path.Join(dir, p)
	if rel == ".." || strings.HasPrefix(rel, "../") {
		return dest // would escape the repository root; leave it alone
	}
	if rel == "." {
		rel = ""
	}
	urlOf := ctx.PageURL
	if image {
		urlOf = ctx.ImageURL
	}
	if urlOf == nil {
		return dest
	}
	out := urlOf(rel)
	if u.RawQuery != "" {
		out += "?" + u.RawQuery
	}
	if u.Fragment != "" {
		out += "#" + u.EscapedFragment()
	}
	return out
}

// ---- custom node rendering ----

type nodeRenderer struct {
	hl *highlight.Registry
}

func (nr *nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
	reg.Register(ast.KindFencedCodeBlock, nr.renderFenced)
	reg.Register(ast.KindCodeBlock, nr.renderIndented)
	reg.Register(ast.KindHTMLBlock, nr.renderHTMLBlock)
	reg.Register(ast.KindRawHTML, nr.renderRawHTML)
}

func blockText(n ast.Node, source []byte) []byte {
	var buf bytes.Buffer
	lines := n.Lines()
	for i := range lines.Len() {
		seg := lines.At(i)
		buf.Write(seg.Value(source))
	}
	return buf.Bytes()
}

func (nr *nodeRenderer) renderFenced(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if !entering {
		return ast.WalkContinue, nil
	}
	n := node.(*ast.FencedCodeBlock)
	lang := string(n.Language(source))
	lx := nr.hl.ByName(lang)
	_, _ = w.WriteString(`<pre class="chroma"`)
	if lang != "" {
		_, _ = w.WriteString(` data-lang="` + html.EscapeString(lang) + `"`)
	}
	_, _ = w.WriteString(`>`)
	_, _ = w.WriteString(string(nr.hl.HTML(blockText(n, source), lx)))
	_, _ = w.WriteString("</pre>\n")
	return ast.WalkSkipChildren, nil
}

func (nr *nodeRenderer) renderIndented(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if !entering {
		return ast.WalkContinue, nil
	}
	_, _ = w.WriteString(`<pre class="chroma">`)
	_, _ = w.WriteString(string(nr.hl.HTML(blockText(node, source), nr.hl.ByName(""))))
	_, _ = w.WriteString("</pre>\n")
	return ast.WalkSkipChildren, nil
}

// Raw HTML is shown escaped, as literal text, so a README can never inject
// markup into the page.
func (nr *nodeRenderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if !entering {
		return ast.WalkContinue, nil
	}
	n := node.(*ast.HTMLBlock)
	txt := blockText(n, source)
	if n.HasClosure() {
		txt = append(txt, n.ClosureLine.Value(source)...)
	}
	_, _ = w.WriteString(`<pre class="raw-html">`)
	_, _ = w.WriteString(html.EscapeString(string(txt)))
	_, _ = w.WriteString("</pre>\n")
	return ast.WalkSkipChildren, nil
}

func (nr *nodeRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if !entering {
		return ast.WalkContinue, nil
	}
	n := node.(*ast.RawHTML)
	var buf bytes.Buffer
	for i := range n.Segments.Len() {
		seg := n.Segments.At(i)
		buf.Write(seg.Value(source))
	}
	_, _ = w.WriteString(html.EscapeString(buf.String()))
	return ast.WalkSkipChildren, nil
}