this repo has no description
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
131 camoUrl, _ := url.Parse(ctx.CamoUrl)
132 dstUrl, _ := url.Parse(attr.Val)
133 if dstUrl.Host != camoUrl.Host {
134 attr.Val = ctx.imageFromKnotTransformer(attr.Val)
135 attr.Val = ctx.camoImageLinkTransformer(attr.Val)
136 node.Attr[i] = attr
137 }
138 }
139 }
140
141 for n := node.FirstChild; n != nil; n = n.NextSibling {
142 visitNode(ctx, n)
143 }
144 default:
145 }
146}
147
148func (rctx *RenderContext) Sanitize(html string) string {
149 policy := bluemonday.UGCPolicy()
150
151 // video
152 policy.AllowElements("video")
153 policy.AllowAttrs("controls").OnElements("video")
154 policy.AllowElements("source")
155 policy.AllowAttrs("src", "type").OnElements("source")
156
157 policy.AllowAttrs("align", "style", "width", "height").Globally()
158 policy.AllowStyles(
159 "margin",
160 "padding",
161 "text-align",
162 "font-weight",
163 "text-decoration",
164 "padding-left",
165 "padding-right",
166 "padding-top",
167 "padding-bottom",
168 "margin-left",
169 "margin-right",
170 "margin-top",
171 "margin-bottom",
172 )
173 return policy.Sanitize(html)
174}
175
176type MarkdownTransformer struct {
177 rctx *RenderContext
178}
179
180func (a *MarkdownTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
181 _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
182 if !entering {
183 return ast.WalkContinue, nil
184 }
185
186 switch a.rctx.RendererType {
187 case RendererTypeRepoMarkdown:
188 switch n := n.(type) {
189 case *ast.Link:
190 a.rctx.relativeLinkTransformer(n)
191 case *ast.Image:
192 a.rctx.imageFromKnotAstTransformer(n)
193 a.rctx.camoImageLinkAstTransformer(n)
194 }
195 case RendererTypeDefault:
196 switch n := n.(type) {
197 case *ast.Image:
198 a.rctx.imageFromKnotAstTransformer(n)
199 a.rctx.camoImageLinkAstTransformer(n)
200 }
201 }
202
203 return ast.WalkContinue, nil
204 })
205}
206
207func (rctx *RenderContext) relativeLinkTransformer(link *ast.Link) {
208
209 dst := string(link.Destination)
210
211 if isAbsoluteUrl(dst) {
212 return
213 }
214
215 actualPath := rctx.actualPath(dst)
216
217 newPath := path.Join("/", rctx.RepoInfo.FullName(), "tree", rctx.RepoInfo.Ref, actualPath)
218 link.Destination = []byte(newPath)
219}
220
221func (rctx *RenderContext) imageFromKnotTransformer(dst string) string {
222 if isAbsoluteUrl(dst) {
223 return dst
224 }
225
226 scheme := "https"
227 if rctx.IsDev {
228 scheme = "http"
229 }
230
231 actualPath := rctx.actualPath(dst)
232
233 parsedURL := &url.URL{
234 Scheme: scheme,
235 Host: rctx.Knot,
236 Path: path.Join("/",
237 rctx.RepoInfo.OwnerDid,
238 rctx.RepoInfo.Name,
239 "raw",
240 url.PathEscape(rctx.RepoInfo.Ref),
241 actualPath),
242 }
243 newPath := parsedURL.String()
244 return newPath
245}
246
247func (rctx *RenderContext) imageFromKnotAstTransformer(img *ast.Image) {
248 dst := string(img.Destination)
249 img.Destination = []byte(rctx.imageFromKnotTransformer(dst))
250}
251
252// actualPath decides when to join the file path with the
253// current repository directory (essentially only when the link
254// destination is relative. if it's absolute then we assume the
255// user knows what they're doing.)
256func (rctx *RenderContext) actualPath(dst string) string {
257 if path.IsAbs(dst) {
258 return dst
259 }
260
261 return path.Join(rctx.CurrentDir, dst)
262}
263
264func isAbsoluteUrl(link string) bool {
265 parsed, err := url.Parse(link)
266 if err != nil {
267 return false
268 }
269 return parsed.IsAbs()
270}