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