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 chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
13 "github.com/alecthomas/chroma/v2/styles"
14 "github.com/yuin/goldmark"
15 highlighting "github.com/yuin/goldmark-highlighting/v2"
16 "github.com/yuin/goldmark/ast"
17 "github.com/yuin/goldmark/extension"
18 "github.com/yuin/goldmark/parser"
19 "github.com/yuin/goldmark/renderer/html"
20 "github.com/yuin/goldmark/text"
21 "github.com/yuin/goldmark/util"
22 htmlparse "golang.org/x/net/html"
23
24 "tangled.sh/tangled.sh/core/appview/pages/repoinfo"
25)
26
27// RendererType defines the type of renderer to use based on context
28type RendererType int
29
30const (
31 // RendererTypeRepoMarkdown is for repository documentation markdown files
32 RendererTypeRepoMarkdown RendererType = iota
33 // RendererTypeDefault is non-repo markdown, like issues/pulls/comments.
34 RendererTypeDefault
35)
36
37// RenderContext holds the contextual data for rendering markdown.
38// It can be initialized empty, and that'll skip any transformations.
39type RenderContext struct {
40 CamoUrl string
41 CamoSecret string
42 repoinfo.RepoInfo
43 IsDev bool
44 RendererType RendererType
45 Sanitizer Sanitizer
46}
47
48func (rctx *RenderContext) RenderMarkdown(source string) string {
49 md := goldmark.New(
50 goldmark.WithExtensions(
51 extension.GFM,
52 highlighting.NewHighlighting(
53 highlighting.WithFormatOptions(
54 chromahtml.Standalone(false),
55 chromahtml.WithClasses(true),
56 ),
57 highlighting.WithCustomStyle(styles.Get("catppuccin-latte")),
58 ),
59 extension.NewFootnote(
60 extension.WithFootnoteIDPrefix([]byte("footnote")),
61 ),
62 ),
63 goldmark.WithParserOptions(
64 parser.WithAutoHeadingID(),
65 ),
66 goldmark.WithRendererOptions(html.WithUnsafe()),
67 )
68
69 if rctx != nil {
70 var transformers []util.PrioritizedValue
71
72 transformers = append(transformers, util.Prioritized(&MarkdownTransformer{rctx: rctx}, 10000))
73
74 md.Parser().AddOptions(
75 parser.WithASTTransformers(transformers...),
76 )
77 }
78
79 var buf bytes.Buffer
80 if err := md.Convert([]byte(source), &buf); err != nil {
81 return source
82 }
83
84 var processed strings.Builder
85 if err := postProcess(rctx, strings.NewReader(buf.String()), &processed); err != nil {
86 return source
87 }
88
89 return processed.String()
90}
91
92func postProcess(ctx *RenderContext, input io.Reader, output io.Writer) error {
93 node, err := htmlparse.Parse(io.MultiReader(
94 strings.NewReader("<html><body>"),
95 input,
96 strings.NewReader("</body></html>"),
97 ))
98 if err != nil {
99 return fmt.Errorf("failed to parse html: %w", err)
100 }
101
102 if node.Type == htmlparse.DocumentNode {
103 node = node.FirstChild
104 }
105
106 visitNode(ctx, node)
107
108 newNodes := make([]*htmlparse.Node, 0, 5)
109
110 if node.Data == "html" {
111 node = node.FirstChild
112 for node != nil && node.Data != "body" {
113 node = node.NextSibling
114 }
115 }
116 if node != nil {
117 if node.Data == "body" {
118 child := node.FirstChild
119 for child != nil {
120 newNodes = append(newNodes, child)
121 child = child.NextSibling
122 }
123 } else {
124 newNodes = append(newNodes, node)
125 }
126 }
127
128 for _, node := range newNodes {
129 if err := htmlparse.Render(output, node); err != nil {
130 return fmt.Errorf("failed to render processed html: %w", err)
131 }
132 }
133
134 return nil
135}
136
137func visitNode(ctx *RenderContext, node *htmlparse.Node) {
138 switch node.Type {
139 case htmlparse.ElementNode:
140 if node.Data == "img" || node.Data == "source" {
141 for i, attr := range node.Attr {
142 if attr.Key != "src" {
143 continue
144 }
145
146 camoUrl, _ := url.Parse(ctx.CamoUrl)
147 dstUrl, _ := url.Parse(attr.Val)
148 if dstUrl.Host != camoUrl.Host {
149 attr.Val = ctx.imageFromKnotTransformer(attr.Val)
150 attr.Val = ctx.camoImageLinkTransformer(attr.Val)
151 node.Attr[i] = attr
152 }
153 }
154 }
155
156 for n := node.FirstChild; n != nil; n = n.NextSibling {
157 visitNode(ctx, n)
158 }
159 default:
160 }
161}
162
163func (rctx *RenderContext) SanitizeDefault(html string) string {
164 return rctx.Sanitizer.SanitizeDefault(html)
165}
166
167func (rctx *RenderContext) SanitizeDescription(html string) string {
168 return rctx.Sanitizer.SanitizeDescription(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.Heading:
185 a.rctx.anchorHeadingTransformer(n)
186 case *ast.Link:
187 a.rctx.relativeLinkTransformer(n)
188 case *ast.Image:
189 a.rctx.imageFromKnotAstTransformer(n)
190 a.rctx.camoImageLinkAstTransformer(n)
191 }
192 case RendererTypeDefault:
193 switch n := n.(type) {
194 case *ast.Heading:
195 a.rctx.anchorHeadingTransformer(n)
196 case *ast.Image:
197 a.rctx.imageFromKnotAstTransformer(n)
198 a.rctx.camoImageLinkAstTransformer(n)
199 }
200 }
201
202 return ast.WalkContinue, nil
203 })
204}
205
206func (rctx *RenderContext) relativeLinkTransformer(link *ast.Link) {
207
208 dst := string(link.Destination)
209
210 if isAbsoluteUrl(dst) || isFragment(dst) || isMail(dst) {
211 return
212 }
213
214 actualPath := rctx.actualPath(dst)
215
216 newPath := path.Join("/", rctx.RepoInfo.FullName(), "tree", rctx.RepoInfo.Ref, actualPath)
217 link.Destination = []byte(newPath)
218}
219
220func (rctx *RenderContext) imageFromKnotTransformer(dst string) string {
221 if isAbsoluteUrl(dst) {
222 return dst
223 }
224
225 scheme := "https"
226 if rctx.IsDev {
227 scheme = "http"
228 }
229
230 actualPath := rctx.actualPath(dst)
231
232 parsedURL := &url.URL{
233 Scheme: scheme,
234 Host: rctx.Knot,
235 Path: path.Join("/",
236 rctx.RepoInfo.OwnerDid,
237 rctx.RepoInfo.Name,
238 "raw",
239 url.PathEscape(rctx.RepoInfo.Ref),
240 actualPath),
241 }
242 newPath := parsedURL.String()
243 return newPath
244}
245
246func (rctx *RenderContext) imageFromKnotAstTransformer(img *ast.Image) {
247 dst := string(img.Destination)
248 img.Destination = []byte(rctx.imageFromKnotTransformer(dst))
249}
250
251func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) {
252 idGeneric, exists := h.AttributeString("id")
253 if !exists {
254 return // no id, nothing to do
255 }
256 id, ok := idGeneric.([]byte)
257 if !ok {
258 return
259 }
260
261 // create anchor link
262 anchor := ast.NewLink()
263 anchor.Destination = fmt.Appendf(nil, "#%s", string(id))
264 anchor.SetAttribute([]byte("class"), []byte("anchor"))
265
266 // create icon text
267 iconText := ast.NewString([]byte("#"))
268 anchor.AppendChild(anchor, iconText)
269
270 // set class on heading
271 h.SetAttribute([]byte("class"), []byte("heading"))
272
273 // append anchor to heading
274 h.AppendChild(h, anchor)
275}
276
277// actualPath decides when to join the file path with the
278// current repository directory (essentially only when the link
279// destination is relative. if it's absolute then we assume the
280// user knows what they're doing.)
281func (rctx *RenderContext) actualPath(dst string) string {
282 if path.IsAbs(dst) {
283 return dst
284 }
285
286 return path.Join(rctx.CurrentDir, dst)
287}
288
289func isAbsoluteUrl(link string) bool {
290 parsed, err := url.Parse(link)
291 if err != nil {
292 return false
293 }
294 return parsed.IsAbs()
295}
296
297func isFragment(link string) bool {
298 return strings.HasPrefix(link, "#")
299}
300
301func isMail(link string) bool {
302 return strings.HasPrefix(link, "mailto:")
303}