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