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
9.9 kB 389 lines
1// Package markup is an umbrella package for all markups and their renderers. 2package markup 3 4import ( 5 "bytes" 6 "context" 7 "fmt" 8 "io" 9 "net/url" 10 "path" 11 "strings" 12 "time" 13 14 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 15 "github.com/alecthomas/chroma/v2/styles" 16 "github.com/bluesky-social/indigo/atproto/identity" 17 "github.com/bluesky-social/indigo/atproto/syntax" 18 "github.com/yuin/goldmark" 19 emoji "github.com/yuin/goldmark-emoji" 20 highlighting "github.com/yuin/goldmark-highlighting/v2" 21 "github.com/yuin/goldmark/ast" 22 "github.com/yuin/goldmark/extension" 23 "github.com/yuin/goldmark/parser" 24 "github.com/yuin/goldmark/renderer/html" 25 "github.com/yuin/goldmark/text" 26 "github.com/yuin/goldmark/util" 27 callout "gitlab.com/staticnoise/goldmark-callout" 28 "go.abhg.dev/goldmark/mermaid" 29 htmlparse "golang.org/x/net/html" 30 31 textension "tangled.org/core/appview/pages/markup/extension" 32 "tangled.org/core/appview/pages/repoinfo" 33) 34 35// RendererType defines the type of renderer to use based on context 36type RendererType int 37 38const ( 39 // RendererTypeRepoMarkdown is for repository documentation markdown files 40 RendererTypeRepoMarkdown RendererType = iota 41 // RendererTypeDefault is non-repo markdown, like issues/pulls/comments. 42 RendererTypeDefault 43) 44 45// RenderContext holds the contextual data for rendering markdown. 46// It can be initialized empty, and that'll skip any transformations. 47type RenderContext struct { 48 CamoUrl string 49 CamoSecret string 50 repoinfo.RepoInfo 51 IsDev bool 52 Hostname string 53 RendererType RendererType 54 Directory identity.Directory 55} 56 57func NewMarkdown(hostname string, extra ...goldmark.Extender) goldmark.Markdown { 58 exts := []goldmark.Extender{ 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.MathExt, 76 textension.AtExt, 77 textension.NewTangledLinkExt(hostname), 78 emoji.Emoji, 79 } 80 exts = append(exts, extra...) 81 md := goldmark.New( 82 goldmark.WithExtensions(exts...), 83 goldmark.WithParserOptions( 84 parser.WithAutoHeadingID(), 85 ), 86 goldmark.WithRendererOptions(html.WithUnsafe()), 87 ) 88 return md 89} 90 91// clone creates a shallow copy of the RenderContext 92func (rctx *RenderContext) Clone() *RenderContext { 93 if rctx == nil { 94 return nil 95 } 96 clone := *rctx 97 return &clone 98} 99 100// NewMarkdownWith is an alias for NewMarkdown with extra extensions. 101func NewMarkdownWith(hostname string, extra ...goldmark.Extender) goldmark.Markdown { 102 return NewMarkdown(hostname, extra...) 103} 104 105func (rctx *RenderContext) RenderMarkdown(source string) string { 106 return rctx.RenderMarkdownWith(source, NewMarkdown(rctx.Hostname)) 107} 108 109func (rctx *RenderContext) RenderMarkdownWith(source string, md goldmark.Markdown) string { 110 if rctx != nil { 111 var transformers []util.PrioritizedValue 112 113 transformers = append(transformers, util.Prioritized(&MarkdownTransformer{rctx: rctx}, 10000)) 114 115 md.Parser().AddOptions( 116 parser.WithASTTransformers(transformers...), 117 ) 118 } 119 120 var buf bytes.Buffer 121 if err := md.Convert([]byte(source), &buf); err != nil { 122 return source 123 } 124 125 var processed strings.Builder 126 if err := postProcess(rctx, strings.NewReader(buf.String()), &processed); err != nil { 127 return source 128 } 129 130 return processed.String() 131} 132 133func postProcess(ctx *RenderContext, input io.Reader, output io.Writer) error { 134 node, err := htmlparse.Parse(io.MultiReader( 135 strings.NewReader("<html><body>"), 136 input, 137 strings.NewReader("</body></html>"), 138 )) 139 if err != nil { 140 return fmt.Errorf("failed to parse html: %w", err) 141 } 142 143 if node.Type == htmlparse.DocumentNode { 144 node = node.FirstChild 145 } 146 147 visitNode(ctx, node) 148 149 newNodes := make([]*htmlparse.Node, 0, 5) 150 151 if node.Data == "html" { 152 node = node.FirstChild 153 for node != nil && node.Data != "body" { 154 node = node.NextSibling 155 } 156 } 157 if node != nil { 158 if node.Data == "body" { 159 child := node.FirstChild 160 for child != nil { 161 newNodes = append(newNodes, child) 162 child = child.NextSibling 163 } 164 } else { 165 newNodes = append(newNodes, node) 166 } 167 } 168 169 for _, node := range newNodes { 170 if err := htmlparse.Render(output, node); err != nil { 171 return fmt.Errorf("failed to render processed html: %w", err) 172 } 173 } 174 175 return nil 176} 177 178func visitNode(ctx *RenderContext, node *htmlparse.Node) { 179 switch node.Type { 180 case htmlparse.ElementNode: 181 switch node.Data { 182 case "a": 183 // TODO: transform `./` or `/` links to tree link 184 case "img", "source": 185 var blobCid syntax.CID 186 for i, attr := range node.Attr { 187 if attr.Key != "src" { 188 continue 189 } 190 191 if strings.HasPrefix(attr.Val, blobURIScheme) { 192 if _, cid, ok := parseBlobURI(attr.Val); ok { 193 blobCid = cid 194 } 195 if blobUrl, ok := ctx.blobToGetBlobURL(attr.Val); ok { 196 attr.Val = ctx.camoImageLinkTransformer(blobUrl) 197 node.Attr[i] = attr 198 } 199 continue 200 } 201 202 if isAbsoluteUrl(attr.Val) { 203 // apply camo to external links 204 camoUrl, _ := url.Parse(ctx.CamoUrl) 205 dstUrl, _ := url.Parse(attr.Val) 206 if camoUrl != nil && dstUrl != nil && dstUrl.Host != ctx.Hostname && dstUrl.Host != camoUrl.Host { 207 attr.Val = ctx.camoImageLinkTransformer(attr.Val) 208 } 209 } else { 210 attr.Val = ctx.imageToRawTransformer(attr.Val) 211 } 212 node.Attr[i] = attr 213 } 214 // tag with the cid so the editor can preview an uncommitted blob 215 // (appended after the loop to avoid mutating node.Attr mid-range) 216 if blobCid != "" { 217 node.Attr = append(node.Attr, htmlparse.Attribute{Key: "data-blob-cid", Val: blobCid.String()}) 218 } 219 } 220 221 for n := node.FirstChild; n != nil; n = n.NextSibling { 222 visitNode(ctx, n) 223 } 224 default: 225 } 226} 227 228type MarkdownTransformer struct { 229 rctx *RenderContext 230} 231 232func (a *MarkdownTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { 233 _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 234 if !entering { 235 return ast.WalkContinue, nil 236 } 237 238 switch a.rctx.RendererType { 239 case RendererTypeRepoMarkdown: 240 switch n := n.(type) { 241 case *ast.Heading: 242 a.rctx.anchorHeadingTransformer(n) 243 case *ast.Link: 244 // TODO: run this on HTML transformation instead 245 a.rctx.relativeLinkTransformer(n) 246 } 247 case RendererTypeDefault: 248 switch n := n.(type) { 249 case *ast.Heading: 250 a.rctx.anchorHeadingTransformer(n) 251 } 252 } 253 254 return ast.WalkContinue, nil 255 }) 256} 257 258func (rctx *RenderContext) relativeLinkTransformer(link *ast.Link) { 259 260 dst := string(link.Destination) 261 262 if isAbsoluteUrl(dst) || isFragment(dst) || isMail(dst) { 263 return 264 } 265 266 actualPath := rctx.actualPath(dst) 267 268 newPath := path.Join("/", rctx.RepoInfo.FullName(), "tree", rctx.RepoInfo.Ref, actualPath) 269 link.Destination = []byte(newPath) 270} 271 272// blobURIScheme embeds a PDS blob in markdown independent of the PDS hostname: 273// blob+at://<did>/<cid>. 274const blobURIScheme = "blob+at://" 275 276func parseBlobURI(src string) (syntax.DID, syntax.CID, bool) { 277 rest, ok := strings.CutPrefix(src, blobURIScheme) 278 if !ok { 279 return "", "", false 280 } 281 rawDid, rawCid, ok := strings.Cut(rest, "/") 282 if !ok || rawDid == "" || rawCid == "" { 283 return "", "", false 284 } 285 did, err := syntax.ParseDID(rawDid) 286 if err != nil { 287 return "", "", false 288 } 289 cid, err := syntax.ParseCID(rawCid) 290 if err != nil { 291 return "", "", false 292 } 293 return did, cid, true 294} 295 296func (rctx *RenderContext) blobToGetBlobURL(src string) (string, bool) { 297 did, cid, ok := parseBlobURI(src) 298 if !ok { 299 return src, false 300 } 301 if rctx.Directory == nil { 302 return src, false 303 } 304 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 305 defer cancel() 306 ident, err := rctx.Directory.LookupDID(ctx, did) 307 if err != nil { 308 return src, false 309 } 310 pds := ident.PDSEndpoint() 311 if pds == "" { 312 return src, false 313 } 314 // TODO: avoid directly fetching from PDS. use services like porxie instead. 315 u, err := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", pds)) 316 if err != nil { 317 return src, false 318 } 319 q := u.Query() 320 q.Set("did", did.String()) 321 q.Set("cid", cid.String()) 322 u.RawQuery = q.Encode() 323 return u.String(), true 324} 325 326func (rctx *RenderContext) imageToRawTransformer(dst string) string { 327 if isAbsoluteUrl(dst) { 328 return dst 329 } 330 331 actualPath := rctx.actualPath(dst) 332 333 newDest := path.Join("/", rctx.RepoInfo.FullName(), "raw", rctx.RepoInfo.Ref, actualPath) 334 return newDest 335} 336 337func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) { 338 idGeneric, exists := h.AttributeString("id") 339 if !exists { 340 return // no id, nothing to do 341 } 342 id, ok := idGeneric.([]byte) 343 if !ok { 344 return 345 } 346 347 // create anchor link 348 anchor := ast.NewLink() 349 anchor.Destination = fmt.Appendf(nil, "#%s", string(id)) 350 anchor.SetAttribute([]byte("class"), []byte("anchor")) 351 352 // create icon text 353 iconText := ast.NewString([]byte("#")) 354 anchor.AppendChild(anchor, iconText) 355 356 // set class on heading 357 h.SetAttribute([]byte("class"), []byte("heading")) 358 359 // append anchor to heading 360 h.AppendChild(h, anchor) 361} 362 363// actualPath decides when to join the file path with the 364// current repository directory (essentially only when the link 365// destination is relative. if it's absolute then we assume the 366// user knows what they're doing.) 367func (rctx *RenderContext) actualPath(dst string) string { 368 if path.IsAbs(dst) { 369 return dst 370 } 371 372 return path.Join(rctx.CurrentDir, dst) 373} 374 375func isAbsoluteUrl(link string) bool { 376 parsed, err := url.Parse(link) 377 if err != nil { 378 return false 379 } 380 return parsed.IsAbs() 381} 382 383func isFragment(link string) bool { 384 return strings.HasPrefix(link, "#") 385} 386 387func isMail(link string) bool { 388 return strings.HasPrefix(link, "mailto:") 389}