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