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