forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
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 extension.NewFootnote( 60 extension.WithFootnoteIDPrefix([]byte("footnote")), 61 ), 62 ), 63 goldmark.WithParserOptions( 64 parser.WithAutoHeadingID(), 65 ), 66 goldmark.WithRendererOptions(html.WithUnsafe()), 67 ) 68 69 if rctx != nil { 70 var transformers []util.PrioritizedValue 71 72 transformers = append(transformers, util.Prioritized(&MarkdownTransformer{rctx: rctx}, 10000)) 73 74 md.Parser().AddOptions( 75 parser.WithASTTransformers(transformers...), 76 ) 77 } 78 79 var buf bytes.Buffer 80 if err := md.Convert([]byte(source), &buf); err != nil { 81 return source 82 } 83 84 var processed strings.Builder 85 if err := postProcess(rctx, strings.NewReader(buf.String()), &processed); err != nil { 86 return source 87 } 88 89 return processed.String() 90} 91 92func postProcess(ctx *RenderContext, input io.Reader, output io.Writer) error { 93 node, err := htmlparse.Parse(io.MultiReader( 94 strings.NewReader("<html><body>"), 95 input, 96 strings.NewReader("</body></html>"), 97 )) 98 if err != nil { 99 return fmt.Errorf("failed to parse html: %w", err) 100 } 101 102 if node.Type == htmlparse.DocumentNode { 103 node = node.FirstChild 104 } 105 106 visitNode(ctx, node) 107 108 newNodes := make([]*htmlparse.Node, 0, 5) 109 110 if node.Data == "html" { 111 node = node.FirstChild 112 for node != nil && node.Data != "body" { 113 node = node.NextSibling 114 } 115 } 116 if node != nil { 117 if node.Data == "body" { 118 child := node.FirstChild 119 for child != nil { 120 newNodes = append(newNodes, child) 121 child = child.NextSibling 122 } 123 } else { 124 newNodes = append(newNodes, node) 125 } 126 } 127 128 for _, node := range newNodes { 129 if err := htmlparse.Render(output, node); err != nil { 130 return fmt.Errorf("failed to render processed html: %w", err) 131 } 132 } 133 134 return nil 135} 136 137func visitNode(ctx *RenderContext, node *htmlparse.Node) { 138 switch node.Type { 139 case htmlparse.ElementNode: 140 if node.Data == "img" || node.Data == "source" { 141 for i, attr := range node.Attr { 142 if attr.Key != "src" { 143 continue 144 } 145 146 camoUrl, _ := url.Parse(ctx.CamoUrl) 147 dstUrl, _ := url.Parse(attr.Val) 148 if dstUrl.Host != camoUrl.Host { 149 attr.Val = ctx.imageFromKnotTransformer(attr.Val) 150 attr.Val = ctx.camoImageLinkTransformer(attr.Val) 151 node.Attr[i] = attr 152 } 153 } 154 } 155 156 for n := node.FirstChild; n != nil; n = n.NextSibling { 157 visitNode(ctx, n) 158 } 159 default: 160 } 161} 162 163func (rctx *RenderContext) SanitizeDefault(html string) string { 164 return rctx.Sanitizer.SanitizeDefault(html) 165} 166 167func (rctx *RenderContext) SanitizeDescription(html string) string { 168 return rctx.Sanitizer.SanitizeDescription(html) 169} 170 171type MarkdownTransformer struct { 172 rctx *RenderContext 173} 174 175func (a *MarkdownTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { 176 _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 177 if !entering { 178 return ast.WalkContinue, nil 179 } 180 181 switch a.rctx.RendererType { 182 case RendererTypeRepoMarkdown: 183 switch n := n.(type) { 184 case *ast.Heading: 185 a.rctx.anchorHeadingTransformer(n) 186 case *ast.Link: 187 a.rctx.relativeLinkTransformer(n) 188 case *ast.Image: 189 a.rctx.imageFromKnotAstTransformer(n) 190 a.rctx.camoImageLinkAstTransformer(n) 191 } 192 case RendererTypeDefault: 193 switch n := n.(type) { 194 case *ast.Heading: 195 a.rctx.anchorHeadingTransformer(n) 196 case *ast.Image: 197 a.rctx.imageFromKnotAstTransformer(n) 198 a.rctx.camoImageLinkAstTransformer(n) 199 } 200 } 201 202 return ast.WalkContinue, nil 203 }) 204} 205 206func (rctx *RenderContext) relativeLinkTransformer(link *ast.Link) { 207 208 dst := string(link.Destination) 209 210 if isAbsoluteUrl(dst) || isFragment(dst) || isMail(dst) { 211 return 212 } 213 214 actualPath := rctx.actualPath(dst) 215 216 newPath := path.Join("/", rctx.RepoInfo.FullName(), "tree", rctx.RepoInfo.Ref, actualPath) 217 link.Destination = []byte(newPath) 218} 219 220func (rctx *RenderContext) imageFromKnotTransformer(dst string) string { 221 if isAbsoluteUrl(dst) { 222 return dst 223 } 224 225 scheme := "https" 226 if rctx.IsDev { 227 scheme = "http" 228 } 229 230 actualPath := rctx.actualPath(dst) 231 232 parsedURL := &url.URL{ 233 Scheme: scheme, 234 Host: rctx.Knot, 235 Path: path.Join("/", 236 rctx.RepoInfo.OwnerDid, 237 rctx.RepoInfo.Name, 238 "raw", 239 url.PathEscape(rctx.RepoInfo.Ref), 240 actualPath), 241 } 242 newPath := parsedURL.String() 243 return newPath 244} 245 246func (rctx *RenderContext) imageFromKnotAstTransformer(img *ast.Image) { 247 dst := string(img.Destination) 248 img.Destination = []byte(rctx.imageFromKnotTransformer(dst)) 249} 250 251func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) { 252 idGeneric, exists := h.AttributeString("id") 253 if !exists { 254 return // no id, nothing to do 255 } 256 id, ok := idGeneric.([]byte) 257 if !ok { 258 return 259 } 260 261 // create anchor link 262 anchor := ast.NewLink() 263 anchor.Destination = fmt.Appendf(nil, "#%s", string(id)) 264 anchor.SetAttribute([]byte("class"), []byte("anchor")) 265 266 // create icon text 267 iconText := ast.NewString([]byte("#")) 268 anchor.AppendChild(anchor, iconText) 269 270 // set class on heading 271 h.SetAttribute([]byte("class"), []byte("heading")) 272 273 // append anchor to heading 274 h.AppendChild(h, anchor) 275} 276 277// actualPath decides when to join the file path with the 278// current repository directory (essentially only when the link 279// destination is relative. if it's absolute then we assume the 280// user knows what they're doing.) 281func (rctx *RenderContext) actualPath(dst string) string { 282 if path.IsAbs(dst) { 283 return dst 284 } 285 286 return path.Join(rctx.CurrentDir, dst) 287} 288 289func isAbsoluteUrl(link string) bool { 290 parsed, err := url.Parse(link) 291 if err != nil { 292 return false 293 } 294 return parsed.IsAbs() 295} 296 297func isFragment(link string) bool { 298 return strings.HasPrefix(link, "#") 299} 300 301func isMail(link string) bool { 302 return strings.HasPrefix(link, "mailto:") 303}