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