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