1// Package markup is an umbrella package for all markups and their renderers.
2package markup
3
4import (
5 "bytes"
6 "fmt"
7 "io"
8 "net/url"
9 "path"
10 "strings"
11
12 "github.com/microcosm-cc/bluemonday"
13 "github.com/yuin/goldmark"
14 "github.com/yuin/goldmark/ast"
15 "github.com/yuin/goldmark/extension"
16 "github.com/yuin/goldmark/parser"
17 "github.com/yuin/goldmark/renderer/html"
18 "github.com/yuin/goldmark/text"
19 "github.com/yuin/goldmark/util"
20 htmlparse "golang.org/x/net/html"
21
22 "tangled.sh/tangled.sh/core/appview/pages/repoinfo"
23)
24
25// RendererType defines the type of renderer to use based on context
26type RendererType int
27
28const (
29 // RendererTypeRepoMarkdown is for repository documentation markdown files
30 RendererTypeRepoMarkdown RendererType = iota
31 // RendererTypeDefault is non-repo markdown, like issues/pulls/comments.
32 RendererTypeDefault
33)
34
35// RenderContext holds the contextual data for rendering markdown.
36// It can be initialized empty, and that'll skip any transformations.
37type RenderContext struct {
38 CamoUrl string
39 CamoSecret string
40 repoinfo.RepoInfo
41 IsDev bool
42 RendererType RendererType
43}
44
45func (rctx *RenderContext) RenderMarkdown(source string) string {
46 md := goldmark.New(
47 goldmark.WithExtensions(extension.GFM),
48 goldmark.WithParserOptions(
49 parser.WithAutoHeadingID(),
50 ),
51 goldmark.WithRendererOptions(html.WithUnsafe()),
52 )
53
54 if rctx != nil {
55 var transformers []util.PrioritizedValue
56
57 transformers = append(transformers, util.Prioritized(&MarkdownTransformer{rctx: rctx}, 10000))
58
59 md.Parser().AddOptions(
60 parser.WithASTTransformers(transformers...),
61 )
62 }
63
64 var buf bytes.Buffer
65 if err := md.Convert([]byte(source), &buf); err != nil {
66 return source
67 }
68
69 var processed strings.Builder
70 if err := postProcess(rctx, strings.NewReader(buf.String()), &processed); err != nil {
71 return source
72 }
73
74 return processed.String()
75}
76
77func postProcess(ctx *RenderContext, input io.Reader, output io.Writer) error {
78 node, err := htmlparse.Parse(io.MultiReader(
79 strings.NewReader("<html><body>"),
80 input,
81 strings.NewReader("</body></html>"),
82 ))
83 if err != nil {
84 return fmt.Errorf("failed to parse html: %w", err)
85 }
86
87 if node.Type == htmlparse.DocumentNode {
88 node = node.FirstChild
89 }
90
91 visitNode(ctx, node)
92
93 newNodes := make([]*htmlparse.Node, 0, 5)
94
95 if node.Data == "html" {
96 node = node.FirstChild
97 for node != nil && node.Data != "body" {
98 node = node.NextSibling
99 }
100 }
101 if node != nil {
102 if node.Data == "body" {
103 child := node.FirstChild
104 for child != nil {
105 newNodes = append(newNodes, child)
106 child = child.NextSibling
107 }
108 } else {
109 newNodes = append(newNodes, node)
110 }
111 }
112
113 for _, node := range newNodes {
114 if err := htmlparse.Render(output, node); err != nil {
115 return fmt.Errorf("failed to render processed html: %w", err)
116 }
117 }
118
119 return nil
120}
121
122func visitNode(ctx *RenderContext, node *htmlparse.Node) {
123 switch node.Type {
124 case htmlparse.ElementNode:
125 if node.Data == "img" || node.Data == "source" {
126 for i, attr := range node.Attr {
127 if attr.Key != "src" {
128 continue
129 }
130 attr.Val = ctx.imageFromKnotTransformer(attr.Val)
131 attr.Val = ctx.camoImageLinkTransformer(attr.Val)
132 node.Attr[i] = attr
133 }
134 }
135
136 for n := node.FirstChild; n != nil; n = n.NextSibling {
137 visitNode(ctx, n)
138 }
139 default:
140 }
141}
142
143func (rctx *RenderContext) Sanitize(html string) string {
144 policy := bluemonday.UGCPolicy()
145
146 // video
147 policy.AllowElements("video")
148 policy.AllowAttrs("controls").OnElements("video")
149 policy.AllowElements("source")
150 policy.AllowAttrs("src", "type").OnElements("source")
151
152 policy.AllowAttrs("align", "style", "width", "height").Globally()
153 policy.AllowStyles(
154 "margin",
155 "padding",
156 "text-align",
157 "font-weight",
158 "text-decoration",
159 "padding-left",
160 "padding-right",
161 "padding-top",
162 "padding-bottom",
163 "margin-left",
164 "margin-right",
165 "margin-top",
166 "margin-bottom",
167 )
168 return policy.Sanitize(html)
169}
170
171type MarkdownTransformer struct {
172 rctx *RenderContext
173}
174
175func (a *MarkdownTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
176 _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
177 if !entering {
178 return ast.WalkContinue, nil
179 }
180
181 switch a.rctx.RendererType {
182 case RendererTypeRepoMarkdown:
183 switch n := n.(type) {
184 case *ast.Link:
185 a.rctx.relativeLinkTransformer(n)
186 case *ast.Image:
187 a.rctx.imageFromKnotAstTransformer(n)
188 a.rctx.camoImageLinkAstTransformer(n)
189 }
190 case RendererTypeDefault:
191 switch n := n.(type) {
192 case *ast.Image:
193 a.rctx.imageFromKnotAstTransformer(n)
194 a.rctx.camoImageLinkAstTransformer(n)
195 }
196 }
197
198 return ast.WalkContinue, nil
199 })
200}
201
202func (rctx *RenderContext) relativeLinkTransformer(link *ast.Link) {
203
204 dst := string(link.Destination)
205
206 if isAbsoluteUrl(dst) {
207 return
208 }
209
210 actualPath := rctx.actualPath(dst)
211
212 newPath := path.Join("/", rctx.RepoInfo.FullName(), "tree", rctx.RepoInfo.Ref, actualPath)
213 link.Destination = []byte(newPath)
214}
215
216func (rctx *RenderContext) imageFromKnotTransformer(dst string) string {
217 if isAbsoluteUrl(dst) {
218 return dst
219 }
220
221 scheme := "https"
222 if rctx.IsDev {
223 scheme = "http"
224 }
225
226 actualPath := rctx.actualPath(dst)
227
228 parsedURL := &url.URL{
229 Scheme: scheme,
230 Host: rctx.Knot,
231 Path: path.Join("/",
232 rctx.RepoInfo.OwnerDid,
233 rctx.RepoInfo.Name,
234 "raw",
235 url.PathEscape(rctx.RepoInfo.Ref),
236 actualPath),
237 }
238 newPath := parsedURL.String()
239 return newPath
240}
241
242func (rctx *RenderContext) imageFromKnotAstTransformer(img *ast.Image) {
243 dst := string(img.Destination)
244 img.Destination = []byte(rctx.imageFromKnotTransformer(dst))
245}
246
247// actualPath decides when to join the file path with the
248// current repository directory (essentially only when the link
249// destination is relative. if it's absolute then we assume the
250// user knows what they're doing.)
251func (rctx *RenderContext) actualPath(dst string) string {
252 if path.IsAbs(dst) {
253 return dst
254 }
255
256 return path.Join(rctx.CurrentDir, dst)
257}
258
259func isAbsoluteUrl(link string) bool {
260 parsed, err := url.Parse(link)
261 if err != nil {
262 return false
263 }
264 return parsed.IsAbs()
265}