markdown.go
⎇
Raw
1// Package markdown renders user markdown to sanitized HTML.
2package markdown
3
4import (
5 "bytes"
6 "net/url"
7 "regexp"
8 "strings"
9
10 "github.com/microcosm-cc/bluemonday"
11
12 "github.com/yuin/goldmark"
13 "github.com/yuin/goldmark/ast"
14 "github.com/yuin/goldmark/extension"
15 east "github.com/yuin/goldmark/extension/ast"
16 "github.com/yuin/goldmark/parser"
17 "github.com/yuin/goldmark/renderer"
18 ghtml "github.com/yuin/goldmark/renderer/html"
19 "github.com/yuin/goldmark/text"
20 "github.com/yuin/goldmark/util"
21
22 "hearthforge/internal/highlight"
23 hfutil "hearthforge/internal/util"
24)
25
26const maxMDCache = 50
27
28// Context points relative links and images at a repo's blob and raw routes.
29type Context struct {
30 Repo string
31 Ref string
32 // Dir is the markdown file's directory relative to the repo root,
33 // e.g. "" or "docs/subdir".
34 Dir string
35}
36
37// Renderer holds the parser, the sanitizer policy and the bounded cache.
38type Renderer struct {
39 md goldmark.Markdown
40 policy *bluemonday.Policy
41 cache *hfutil.Cache[string, string]
42}
43
44// New returns a Renderer. It is safe for concurrent use.
45func New() *Renderer {
46 md := goldmark.New(
47 goldmark.WithExtensions(
48 // GFM, but tables use the align attribute instead of an inline
49 // style, because the sanitizer drops style attributes.
50 extension.NewTable(extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute)),
51 extension.Strikethrough,
52 extension.Linkify,
53 extension.TaskList,
54 ),
55 goldmark.WithRendererOptions(
56 // Raw HTML passes through so <details> works. bluemonday cleans it.
57 ghtml.WithUnsafe(),
58 renderer.WithNodeRenderers(util.Prioritized(codeRenderer{}, 100)),
59 ),
60 goldmark.WithParserOptions(
61 parser.WithASTTransformers(util.Prioritized(taskListTransformer{}, 100)),
62 ),
63 )
64 return &Renderer{md: md, policy: newPolicy(), cache: hfutil.NewCache[string, string](maxMDCache, 0)}
65}
66
67// newPolicy allows what the markdown renderer emits and nothing else.
68func newPolicy() *bluemonday.Policy {
69 p := bluemonday.UGCPolicy()
70 // Links keep the href the author wrote, with no injected rel attribute.
71 p.RequireNoFollowOnLinks(false)
72 p.AllowElements("details", "summary", "span")
73 // Task-list checkboxes. They are always disabled, so no state can be set.
74 p.AllowElements("input")
75 p.AllowAttrs("type", "checked", "disabled").OnElements("input")
76 // Table cell alignment from GFM tables.
77 p.AllowAttrs("align").OnElements("td", "th")
78 // Syntax highlighting and task-list class hooks.
79 p.AllowAttrs("class").Globally()
80 return p
81}
82
83// Render converts markdown to sanitized HTML.
84// cacheKey may be empty to skip caching. ctx may be nil.
85func (r *Renderer) Render(md, cacheKey string, ctx *Context) string {
86 if cacheKey != "" && ctx != nil {
87 // Every Context field changes the rewritten links, so all of them
88 // belong in the key. Without the ref a README rendered for a branch
89 // is served for a tag at the same commit.
90 cacheKey += "\x00" + ctx.Repo + "\x00" + ctx.Ref + "\x00" + ctx.Dir
91 }
92 if cached, ok := r.cache.Get(cacheKey); ok {
93 return cached
94 }
95
96 src := []byte(md)
97 doc := r.md.Parser().Parse(text.NewReader(src))
98 if ctx != nil {
99 rewriteRepoURLs(doc, ctx)
100 }
101 var buf bytes.Buffer
102 if err := r.md.Renderer().Render(&buf, src, doc); err != nil {
103 return ""
104 }
105 result := r.policy.Sanitize(buf.String())
106
107 if cacheKey != "" {
108 r.cache.Set(cacheKey, result)
109 }
110 return result
111}
112
113var schemeRE = regexp.MustCompile(`^[a-zA-Z][a-zA-Z\d+\-.]*:`)
114
115// ResolveHref turns a markdown href into a repo-root-relative path.
116// ok is false when the href must stay as written: protocol-absolute or anchor.
117//
118// - "/subdir/img.png" loses its leading slash
119// - "./img.png", "../img.png" and "subdir/img.png" resolve against dir
120func ResolveHref(dir, href string) (path string, ok bool) {
121 if schemeRE.MatchString(href) || strings.HasPrefix(href, "#") {
122 return "", false
123 }
124 if strings.HasPrefix(href, "/") {
125 return href[1:], true
126 }
127 ref, err := url.Parse(href)
128 if err != nil {
129 return "", false
130 }
131 base := &url.URL{Scheme: "http", Host: "x", Path: "/"}
132 if dir != "" {
133 base.Path = "/" + dir + "/"
134 }
135 // ResolveReference clamps "../" at the root, like the JS URL API.
136 return strings.TrimPrefix(base.ResolveReference(ref).Path, "/"), true
137}
138
139// rewriteRepoURLs points relative links at /repo/blob/ref/... and relative
140// images at /repo/raw/ref/....
141func rewriteRepoURLs(doc ast.Node, ctx *Context) {
142 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
143 if !entering {
144 return ast.WalkContinue, nil
145 }
146 route := ""
147 var dest *[]byte
148 switch node := n.(type) {
149 case *ast.Image:
150 route, dest = "raw", &node.Destination
151 case *ast.Link:
152 route, dest = "blob", &node.Destination
153 default:
154 return ast.WalkContinue, nil
155 }
156 resolved, ok := ResolveHref(ctx.Dir, string(*dest))
157 if !ok {
158 return ast.WalkContinue, nil
159 }
160 *dest = []byte("/" + ctx.Repo + "/" + route + "/" + ctx.Ref + "/" + resolved)
161 return ast.WalkContinue, nil
162 })
163}
164
165// codeRenderer highlights fenced code with chroma and keeps the
166// language-* class the stylesheet expects.
167type codeRenderer struct{}
168
169func (codeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
170 reg.Register(ast.KindFencedCodeBlock, renderFencedCode)
171 reg.Register(east.KindTaskCheckBox, renderTaskCheckBox)
172}
173
174// taskListTransformer tags every list item that starts with a checkbox, so it
175// renders as <li class="task-list-item">. The stylesheet selects the list
176// layout by that class.
177type taskListTransformer struct{}
178
179func (taskListTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
180 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
181 if !entering {
182 return ast.WalkContinue, nil
183 }
184 item, ok := n.(*ast.ListItem)
185 if !ok {
186 return ast.WalkContinue, nil
187 }
188 // The checkbox is always the first inline of the item's first block.
189 if first := item.FirstChild(); first != nil && first.FirstChild() != nil {
190 if _, isBox := first.FirstChild().(*east.TaskCheckBox); isBox {
191 item.SetAttributeString("class", []byte("task-list-item"))
192 }
193 }
194 return ast.WalkContinue, nil
195 })
196}
197
198// renderTaskCheckBox writes the checkbox markup the stylesheet needs: the
199// class hook, and no space between the input and the label.
200func renderTaskCheckBox(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
201 if !entering {
202 return ast.WalkContinue, nil
203 }
204 w.WriteString(`<input type="checkbox" class="task-list-item-checkbox" disabled=""`)
205 if node.(*east.TaskCheckBox).IsChecked {
206 w.WriteString(` checked=""`)
207 }
208 w.WriteString(">")
209 return ast.WalkContinue, nil
210}
211
212func renderFencedCode(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
213 if !entering {
214 return ast.WalkContinue, nil
215 }
216 n := node.(*ast.FencedCodeBlock)
217 lang := string(n.Language(source))
218
219 var code bytes.Buffer
220 for i := 0; i < n.Lines().Len(); i++ {
221 line := n.Lines().At(i)
222 code.Write(line.Value(source))
223 }
224
225 w.WriteString("<pre><code")
226 if lang != "" {
227 w.WriteString(` class="language-`)
228 w.WriteString(string(util.EscapeHTML([]byte(lang))))
229 w.WriteString(`"`)
230 }
231 w.WriteString(">")
232 w.WriteString(highlight.Code(code.String(), lang))
233 w.WriteString("</code></pre>\n")
234 return ast.WalkSkipChildren, nil
235}
236