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