forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
Monorepo for Tangled
fork
Configure Feed
Select the types of activity you want to include in your feed.
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}