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