This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

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