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