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