Monorepo for Tangled tangled.org
10

Configure Feed

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

1package markup 2 3import ( 4 "bytes" 5 "context" 6 "fmt" 7 "io" 8 "net/url" 9 "strings" 10 11 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 12 "github.com/alecthomas/chroma/v2/styles" 13 "github.com/bluesky-social/indigo/atproto/syntax" 14 "github.com/ipfs/go-cid" 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/extension" 19 "github.com/yuin/goldmark/parser" 20 "github.com/yuin/goldmark/renderer/html" 21 callout "gitlab.com/staticnoise/goldmark-callout" 22 "go.abhg.dev/goldmark/mermaid" 23 htmlparse "golang.org/x/net/html" 24 "tangled.org/core/appview/config" 25 textension "tangled.org/core/appview/pages/markup/extension" 26 "tangled.org/core/blobstore" 27) 28 29// make more pure markdown renderer. 30 31type RenderGitBlobContext struct { 32 RepoDid syntax.DID 33 CurrentRev string 34 CurrentDir string 35} 36 37// MarkdownRenderer can render user-owned markdown bodies like issue title/body. 38type MarkdownRenderer struct { 39 CamoUrl string 40 CamoSecret string 41 Hostname string 42 blobStore blobstore.BlobStore // to generate blob url 43 markdown goldmark.Markdown 44} 45 46func NewMarkdownRenderer(cfg *config.Config, blobStore blobstore.BlobStore) *MarkdownRenderer { 47 markdown := goldmark.New( 48 goldmark.WithExtensions( 49 extension.GFM, 50 &mermaid.Extender{ 51 RenderMode: mermaid.RenderModeClient, 52 NoScript: true, 53 }, 54 highlighting.NewHighlighting( 55 highlighting.WithFormatOptions( 56 chromahtml.Standalone(false), 57 chromahtml.WithClasses(true), 58 ), 59 highlighting.WithCustomStyle(styles.Get("catppuccin-latte")), 60 ), 61 extension.NewFootnote( 62 extension.WithFootnoteIDPrefix([]byte("footnote")), 63 ), 64 callout.CalloutExtention, 65 textension.AtExt, 66 textension.NewTangledLinkExt(cfg.Core.AppviewHost), 67 emoji.Emoji, 68 ), 69 goldmark.WithParserOptions( 70 parser.WithAutoHeadingID(), 71 ), 72 goldmark.WithRendererOptions(html.WithUnsafe()), 73 ) 74 return &MarkdownRenderer{ 75 CamoUrl: cfg.Camo.Host, 76 CamoSecret: cfg.Camo.SharedSecret, 77 Hostname: cfg.Core.AppviewHost, 78 blobStore: blobStore, 79 markdown: markdown, 80 } 81} 82 83type RenderMarkdownBodyParams struct { 84 OwnerDid syntax.DID // Content owner DID. Used to make blob links 85 IsPreview bool 86} 87 88func (m *MarkdownRenderer) RenderMarkdownBody(params *RenderMarkdownBodyParams, source string) string { 89 var buf bytes.Buffer 90 if err := m.markdown.Convert([]byte(source), &buf); err != nil { 91 return source 92 } 93 94 var processed strings.Builder 95 if err := postProcessHTML( 96 strings.NewReader(buf.String()), 97 &processed, 98 func(node *htmlparse.Node) { m.visitNode(params, node) }, 99 ); err != nil { 100 return source 101 } 102 103 return processed.String() 104} 105 106func (m *MarkdownRenderer) visitNode(params *RenderMarkdownBodyParams, node *htmlparse.Node) { 107 switch node.Type { 108 case htmlparse.ElementNode: 109 switch node.Data { 110 case "img", "source": 111 isBlob := false 112 for i, attr := range node.Attr { 113 if attr.Key != "src" { 114 continue 115 } 116 117 if rawCid, found := strings.CutPrefix(attr.Val, "blob://"); found { 118 isBlob = true 119 cid, err := cid.Parse(rawCid) 120 if err != nil { 121 continue // skip invalid cid 122 } 123 blobUrl, err := m.blobStore.MakeBlobUrl(context.TODO(), params.OwnerDid, cid) 124 if err != nil { 125 continue 126 } 127 attr.Val = blobUrl 128 node.Attr[i] = attr 129 continue 130 } 131 132 src, err := url.Parse(attr.Val) 133 if err != nil { 134 continue // skip invalid url 135 } 136 137 if src.IsAbs() { 138 camoUrl, _ := url.Parse(m.CamoUrl) 139 if camoUrl != nil && src.Host != m.Hostname && src.Host != camoUrl.Host { 140 attr.Val = GenerateCamoURL(m.CamoUrl, m.CamoSecret, attr.Val) 141 node.Attr[i] = attr 142 } 143 } 144 } 145 if params.IsPreview && isBlob { 146 setAttr(node, "onerror", fmt.Sprintf("this.onerror=null;this.src=%q", "/static/blobpreviewplaceholder.png")) 147 } 148 } 149 150 for n := node.FirstChild; n != nil; n = n.NextSibling { 151 m.visitNode(params, n) 152 } 153 default: 154 } 155} 156 157func setAttr(node *htmlparse.Node, key, val string) { 158 for i, attr := range node.Attr { 159 if attr.Key != key { 160 continue 161 } 162 attr.Val = val 163 node.Attr[i] = attr 164 return 165 } 166 node.Attr = append(node.Attr, htmlparse.Attribute{ 167 Key: key, 168 Val: val, 169 }) 170} 171 172func postProcessHTML(input io.Reader, output io.Writer, processFn func(*htmlparse.Node)) error { 173 node, err := htmlparse.Parse(io.MultiReader( 174 strings.NewReader("<html><body>"), 175 input, 176 strings.NewReader("</body></html>"), 177 )) 178 if err != nil { 179 return fmt.Errorf("failed to parse html: %w", err) 180 } 181 182 if node.Type == htmlparse.DocumentNode { 183 node = node.FirstChild 184 } 185 186 processFn(node) 187 188 newNodes := make([]*htmlparse.Node, 0, 5) 189 190 if node.Data == "html" { 191 node = node.FirstChild 192 for node != nil && node.Data != "body" { 193 node = node.NextSibling 194 } 195 } 196 if node != nil { 197 if node.Data == "body" { 198 child := node.FirstChild 199 for child != nil { 200 newNodes = append(newNodes, child) 201 child = child.NextSibling 202 } 203 } else { 204 newNodes = append(newNodes, node) 205 } 206 } 207 208 for _, node := range newNodes { 209 if err := htmlparse.Render(output, node); err != nil { 210 return fmt.Errorf("failed to render processed html: %w", err) 211 } 212 } 213 214 return nil 215}