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