Monorepo for Tangled
tangled.org
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}