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