Monorepo for Tangled tangled.org
5

Configure Feed

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

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