A vibe coded tangled fork which supports pijul.
at 87ef8daee0ca7e68edd270cf9adb1eea0e565d22 322 lines 7.9 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 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}