This repository has no description
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}