// Package markdown renders user markdown to sanitized HTML. package markdown import ( "bytes" "net/url" "regexp" "strings" "github.com/microcosm-cc/bluemonday" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" east "github.com/yuin/goldmark/extension/ast" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer" ghtml "github.com/yuin/goldmark/renderer/html" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" "hearthforge/internal/highlight" hfutil "hearthforge/internal/util" ) const maxMDCache = 50 // Context points relative links and images at a repo's blob and raw routes. type Context struct { Repo string Ref string // Dir is the markdown file's directory relative to the repo root, // e.g. "" or "docs/subdir". Dir string } // Renderer holds the parser, the sanitizer policy and the bounded cache. type Renderer struct { md goldmark.Markdown policy *bluemonday.Policy cache *hfutil.Cache[string, string] } // New returns a Renderer. It is safe for concurrent use. func New() *Renderer { md := goldmark.New( goldmark.WithExtensions( // GFM, but tables use the align attribute instead of an inline // style, because the sanitizer drops style attributes. extension.NewTable(extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute)), extension.Strikethrough, extension.Linkify, extension.TaskList, ), goldmark.WithRendererOptions( // Raw HTML passes through so
works. bluemonday cleans it. ghtml.WithUnsafe(), renderer.WithNodeRenderers(util.Prioritized(codeRenderer{}, 100)), ), goldmark.WithParserOptions( parser.WithASTTransformers(util.Prioritized(taskListTransformer{}, 100)), ), ) return &Renderer{md: md, policy: newPolicy(), cache: hfutil.NewCache[string, string](maxMDCache, 0)} } // newPolicy allows what the markdown renderer emits and nothing else. func newPolicy() *bluemonday.Policy { p := bluemonday.UGCPolicy() // Links keep the href the author wrote, with no injected rel attribute. p.RequireNoFollowOnLinks(false) p.AllowElements("details", "summary", "span") // Task-list checkboxes. They are always disabled, so no state can be set. p.AllowElements("input") p.AllowAttrs("type", "checked", "disabled").OnElements("input") // Table cell alignment from GFM tables. p.AllowAttrs("align").OnElements("td", "th") // Syntax highlighting and task-list class hooks. p.AllowAttrs("class").Globally() return p } // Render converts markdown to sanitized HTML. // cacheKey may be empty to skip caching. ctx may be nil. func (r *Renderer) Render(md, cacheKey string, ctx *Context) string { if cacheKey != "" && ctx != nil { // Every Context field changes the rewritten links, so all of them // belong in the key. Without the ref a README rendered for a branch // is served for a tag at the same commit. cacheKey += "\x00" + ctx.Repo + "\x00" + ctx.Ref + "\x00" + ctx.Dir } if cached, ok := r.cache.Get(cacheKey); ok { return cached } src := []byte(md) doc := r.md.Parser().Parse(text.NewReader(src)) if ctx != nil { rewriteRepoURLs(doc, ctx) } var buf bytes.Buffer if err := r.md.Renderer().Render(&buf, src, doc); err != nil { return "" } result := r.policy.Sanitize(buf.String()) if cacheKey != "" { r.cache.Set(cacheKey, result) } return result } var schemeRE = regexp.MustCompile(`^[a-zA-Z][a-zA-Z\d+\-.]*:`) // ResolveHref turns a markdown href into a repo-root-relative path. // ok is false when the href must stay as written: protocol-absolute or anchor. // // - "/subdir/img.png" loses its leading slash // - "./img.png", "../img.png" and "subdir/img.png" resolve against dir func ResolveHref(dir, href string) (path string, ok bool) { if schemeRE.MatchString(href) || strings.HasPrefix(href, "#") { return "", false } if strings.HasPrefix(href, "/") { return href[1:], true } ref, err := url.Parse(href) if err != nil { return "", false } base := &url.URL{Scheme: "http", Host: "x", Path: "/"} if dir != "" { base.Path = "/" + dir + "/" } // ResolveReference clamps "../" at the root, like the JS URL API. return strings.TrimPrefix(base.ResolveReference(ref).Path, "/"), true } // rewriteRepoURLs points relative links at /repo/blob/ref/... and relative // images at /repo/raw/ref/.... func rewriteRepoURLs(doc ast.Node, ctx *Context) { _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } route := "" var dest *[]byte switch node := n.(type) { case *ast.Image: route, dest = "raw", &node.Destination case *ast.Link: route, dest = "blob", &node.Destination default: return ast.WalkContinue, nil } resolved, ok := ResolveHref(ctx.Dir, string(*dest)) if !ok { return ast.WalkContinue, nil } *dest = []byte("/" + ctx.Repo + "/" + route + "/" + ctx.Ref + "/" + resolved) return ast.WalkContinue, nil }) } // codeRenderer highlights fenced code with chroma and keeps the // language-* class the stylesheet expects. type codeRenderer struct{} func (codeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { reg.Register(ast.KindFencedCodeBlock, renderFencedCode) reg.Register(east.KindTaskCheckBox, renderTaskCheckBox) } // taskListTransformer tags every list item that starts with a checkbox, so it // renders as
  • . The stylesheet selects the list // layout by that class. type taskListTransformer struct{} func (taskListTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) { _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } item, ok := n.(*ast.ListItem) if !ok { return ast.WalkContinue, nil } // The checkbox is always the first inline of the item's first block. if first := item.FirstChild(); first != nil && first.FirstChild() != nil { if _, isBox := first.FirstChild().(*east.TaskCheckBox); isBox { item.SetAttributeString("class", []byte("task-list-item")) } } return ast.WalkContinue, nil }) } // renderTaskCheckBox writes the checkbox markup the stylesheet needs: the // class hook, and no space between the input and the label. func renderTaskCheckBox(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } w.WriteString(`") return ast.WalkContinue, nil } func renderFencedCode(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)) var code bytes.Buffer for i := 0; i < n.Lines().Len(); i++ { line := n.Lines().At(i) code.Write(line.Value(source)) } w.WriteString("
    ")
    	w.WriteString(highlight.Code(code.String(), lang))
    	w.WriteString("
    \n") return ast.WalkSkipChildren, nil }