this repo has no description
1// Package markup is an umbrella package for all markups and their renderers. 2package markup 3 4import ( 5 "bytes" 6 "fmt" 7 "io" 8 "net/url" 9 "path" 10 "strings" 11 12 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 13 "github.com/alecthomas/chroma/v2/styles" 14 "github.com/yuin/goldmark" 15 highlighting "github.com/yuin/goldmark-highlighting/v2" 16 "github.com/yuin/goldmark/ast" 17 "github.com/yuin/goldmark/extension" 18 "github.com/yuin/goldmark/parser" 19 "github.com/yuin/goldmark/renderer/html" 20 "github.com/yuin/goldmark/text" 21 "github.com/yuin/goldmark/util" 22 htmlparse "golang.org/x/net/html" 23 24 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 25) 26 27// RendererType defines the type of renderer to use based on context 28type RendererType int 29 30const ( 31 // RendererTypeRepoMarkdown is for repository documentation markdown files 32 RendererTypeRepoMarkdown RendererType = iota 33 // RendererTypeDefault is non-repo markdown, like issues/pulls/comments. 34 RendererTypeDefault 35) 36 37// RenderContext holds the contextual data for rendering markdown. 38// It can be initialized empty, and that'll skip any transformations. 39type RenderContext struct { 40 CamoUrl string 41 CamoSecret string 42 repoinfo.RepoInfo 43 IsDev bool 44 RendererType RendererType 45 Sanitizer Sanitizer 46} 47 48func (rctx *RenderContext) RenderMarkdown(source string) string { 49 md := goldmark.New( 50 goldmark.WithExtensions( 51 extension.GFM, 52 highlighting.NewHighlighting( 53 highlighting.WithFormatOptions( 54 chromahtml.Standalone(false), 55 chromahtml.WithClasses(true), 56 ), 57 highlighting.WithCustomStyle(styles.Get("catppuccin-latte")), 58 ), 59 ), 60 goldmark.WithParserOptions( 61 parser.WithAutoHeadingID(), 62 ), 63 goldmark.WithRendererOptions(html.WithUnsafe()), 64 ) 65 66 if rctx != nil { 67 var transformers []util.PrioritizedValue 68 69 transformers = append(transformers, util.Prioritized(&MarkdownTransformer{rctx: rctx}, 10000)) 70 71 md.Parser().AddOptions( 72 parser.WithASTTransformers(transformers...), 73 ) 74 } 75 76 var buf bytes.Buffer 77 if err := md.Convert([]byte(source), &buf); err != nil { 78 return source 79 } 80 81 var processed strings.Builder 82 if err := postProcess(rctx, strings.NewReader(buf.String()), &processed); err != nil { 83 return source 84 } 85 86 return processed.String() 87} 88 89func postProcess(ctx *RenderContext, input io.Reader, output io.Writer) error { 90 node, err := htmlparse.Parse(io.MultiReader( 91 strings.NewReader("<html><body>"), 92 input, 93 strings.NewReader("</body></html>"), 94 )) 95 if err != nil { 96 return fmt.Errorf("failed to parse html: %w", err) 97 } 98 99 if node.Type == htmlparse.DocumentNode { 100 node = node.FirstChild 101 } 102 103 visitNode(ctx, node) 104 105 newNodes := make([]*htmlparse.Node, 0, 5) 106 107 if node.Data == "html" { 108 node = node.FirstChild 109 for node != nil && node.Data != "body" { 110 node = node.NextSibling 111 } 112 } 113 if node != nil { 114 if node.Data == "body" { 115 child := node.FirstChild 116 for child != nil { 117 newNodes = append(newNodes, child) 118 child = child.NextSibling 119 } 120 } else { 121 newNodes = append(newNodes, node) 122 } 123 } 124 125 for _, node := range newNodes { 126 if err := htmlparse.Render(output, node); err != nil { 127 return fmt.Errorf("failed to render processed html: %w", err) 128 } 129 } 130 131 return nil 132} 133 134func visitNode(ctx *RenderContext, node *htmlparse.Node) { 135 switch node.Type { 136 case htmlparse.ElementNode: 137 if node.Data == "img" || node.Data == "source" { 138 for i, attr := range node.Attr { 139 if attr.Key != "src" { 140 continue 141 } 142 143 camoUrl, _ := url.Parse(ctx.CamoUrl) 144 dstUrl, _ := url.Parse(attr.Val) 145 if dstUrl.Host != camoUrl.Host { 146 attr.Val = ctx.imageFromKnotTransformer(attr.Val) 147 attr.Val = ctx.camoImageLinkTransformer(attr.Val) 148 node.Attr[i] = attr 149 } 150 } 151 } 152 153 for n := node.FirstChild; n != nil; n = n.NextSibling { 154 visitNode(ctx, n) 155 } 156 default: 157 } 158} 159 160func (rctx *RenderContext) SanitizeDefault(html string) string { 161 return rctx.Sanitizer.defaultPolicy.Sanitize(html) 162} 163 164type MarkdownTransformer struct { 165 rctx *RenderContext 166} 167 168func (a *MarkdownTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { 169 _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 170 if !entering { 171 return ast.WalkContinue, nil 172 } 173 174 switch a.rctx.RendererType { 175 case RendererTypeRepoMarkdown: 176 switch n := n.(type) { 177 case *ast.Link: 178 a.rctx.relativeLinkTransformer(n) 179 case *ast.Image: 180 a.rctx.imageFromKnotAstTransformer(n) 181 a.rctx.camoImageLinkAstTransformer(n) 182 } 183 case RendererTypeDefault: 184 switch n := n.(type) { 185 case *ast.Image: 186 a.rctx.imageFromKnotAstTransformer(n) 187 a.rctx.camoImageLinkAstTransformer(n) 188 } 189 } 190 191 return ast.WalkContinue, nil 192 }) 193} 194 195func (rctx *RenderContext) relativeLinkTransformer(link *ast.Link) { 196 197 dst := string(link.Destination) 198 199 if isAbsoluteUrl(dst) { 200 return 201 } 202 203 actualPath := rctx.actualPath(dst) 204 205 newPath := path.Join("/", rctx.RepoInfo.FullName(), "tree", rctx.RepoInfo.Ref, actualPath) 206 link.Destination = []byte(newPath) 207} 208 209func (rctx *RenderContext) imageFromKnotTransformer(dst string) string { 210 if isAbsoluteUrl(dst) { 211 return dst 212 } 213 214 scheme := "https" 215 if rctx.IsDev { 216 scheme = "http" 217 } 218 219 actualPath := rctx.actualPath(dst) 220 221 parsedURL := &url.URL{ 222 Scheme: scheme, 223 Host: rctx.Knot, 224 Path: path.Join("/", 225 rctx.RepoInfo.OwnerDid, 226 rctx.RepoInfo.Name, 227 "raw", 228 url.PathEscape(rctx.RepoInfo.Ref), 229 actualPath), 230 } 231 newPath := parsedURL.String() 232 return newPath 233} 234 235func (rctx *RenderContext) imageFromKnotAstTransformer(img *ast.Image) { 236 dst := string(img.Destination) 237 img.Destination = []byte(rctx.imageFromKnotTransformer(dst)) 238} 239 240// actualPath decides when to join the file path with the 241// current repository directory (essentially only when the link 242// destination is relative. if it's absolute then we assume the 243// user knows what they're doing.) 244func (rctx *RenderContext) actualPath(dst string) string { 245 if path.IsAbs(dst) { 246 return dst 247 } 248 249 return path.Join(rctx.CurrentDir, dst) 250} 251 252func isAbsoluteUrl(link string) bool { 253 parsed, err := url.Parse(link) 254 if err != nil { 255 return false 256 } 257 return parsed.IsAbs() 258}