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