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