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