Something went wrong. Try again.
forked from tangled.org/core
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358// Package markup is an umbrella package for all markups and their renderers.package markup
import ( "bytes" "fmt" "io" "net/url" "path" "strings"
"github.com/alecthomas/chroma/v2/styles" "github.com/microcosm-cc/bluemonday" "github.com/yuin/goldmark" "github.com/yuin/goldmark-highlighting/v2" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer/html" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" htmlparse "golang.org/x/net/html" "golang.org/x/net/html/atom"
"tangled.sh/tangled.sh/core/appview/pages/repoinfo")
// RendererType defines the type of renderer to use based on contexttype RendererType int
const ( // RendererTypeRepoMarkdown is for repository documentation markdown files RendererTypeRepoMarkdown RendererType = iota // RendererTypeDefault is non-repo markdown, like issues/pulls/comments. RendererTypeDefault)
// RenderContext holds the contextual data for rendering markdown.// It can be initialized empty, and that'll skip any transformations.type RenderContext struct { CamoUrl string CamoSecret string repoinfo.RepoInfo IsDev bool RendererType RendererType}
// RenderMarkdown renders the given markdown source into sanitized HTML. sanitizer is used to sanitize the HTML output.func (rctx *RenderContext) RenderMarkdown(source string, sanitizer func(string) string) string { md := goldmark.New( goldmark.WithExtensions(extension.GFM, highlighting.NewHighlighting( highlighting.WithCustomStyle( styles.Get("catppuccin-latte"), ), ), ), goldmark.WithParserOptions( parser.WithAutoHeadingID(), ), goldmark.WithRendererOptions(html.WithUnsafe()), )
if rctx != nil { var transformers []util.PrioritizedValue
transformers = append(transformers, util.Prioritized(&MarkdownTransformer{rctx: rctx}, 10000))
md.Parser().AddOptions( parser.WithASTTransformers(transformers...), ) }
var buf bytes.Buffer if err := md.Convert([]byte(source), &buf); err != nil { return source }
sanitizedHtml := sanitizer(buf.String())
var processed strings.Builder if err := postProcessSanitizedHtml(rctx, strings.NewReader(sanitizedHtml), &processed); err != nil { return source }
return processed.String()}
// postProcessSanitizedHtml processes the HTML output from the markdown renderer.// WARNING: Do not insert raw HTML from user-controlled input. Sanitization already happened beforehand at this point.func postProcessSanitizedHtml(ctx *RenderContext, input io.Reader, output io.Writer) error { node, err := htmlparse.Parse(io.MultiReader( strings.NewReader("<html><body>"), input, strings.NewReader("</body></html>"), )) if err != nil { return fmt.Errorf("failed to parse html: %w", err) }
if node.Type == htmlparse.DocumentNode { node = node.FirstChild }
visitNode(ctx, node)
newNodes := make([]*htmlparse.Node, 0, 5)
if node.Data == "html" { node = node.FirstChild for node != nil && node.Data != "body" { node = node.NextSibling } } if node != nil { if node.Data == "body" { child := node.FirstChild for child != nil { newNodes = append(newNodes, child) child = child.NextSibling } } else { newNodes = append(newNodes, node) } }
for _, node := range newNodes { if err := htmlparse.Render(output, node); err != nil { return fmt.Errorf("failed to render processed html: %w", err) } }
return nil}
// visitNode is called on every node of a SANITIZED html document.// WARNING: Do not insert raw HTML from user-controlled input. Sanitization already happened beforehand at this point.func visitNode(ctx *RenderContext, node *htmlparse.Node) { switch node.Type { case htmlparse.ElementNode: if node.Data == "img" || node.Data == "source" { for i, attr := range node.Attr { if attr.Key != "src" { continue }
camoUrl, _ := url.Parse(ctx.CamoUrl) dstUrl, _ := url.Parse(attr.Val) if dstUrl.Host != camoUrl.Host { attr.Val = ctx.imageFromKnotTransformer(attr.Val) attr.Val = ctx.camoImageLinkTransformer(attr.Val) node.Attr[i] = attr } } }
if node.Data == "pre" { // TODO only show when :hover or :focus on the pre element button := &htmlparse.Node{ Type: htmlparse.ElementNode, DataAtom: atom.Button, Data: "button", Attr: []htmlparse.Attribute{ { Key: "class", Val: "absolute top-2 right-2 btn", }, { Key: "style", // FIXME .#watch-tailwind doesnt seem to catch top-2 and right-2, probably cuz it's not used anywhere inside of templates/ ? Val: "top: 0.5rem; right: 0.5rem;", }, { Key: "onclick", Val: ` navigator.clipboard.writeText(this.closest('pre').querySelector('code').innerText); this.innerText = 'Copied!'; setTimeout(() => { this.innerText = 'Copy' }, 1500); `, }, // FIXME: onload does not fire :/ // { // Key: "onload", // Val: "this.removeAttribute('aria-hidden')", // }, // { // Key: "aria-hidden", // Val: "true", // }, { Key: "title", Val: "Copy to clipboard", }, }, }
// TODO // if copyIcon, err := icons.IconNode("copy", "h-4", "w-4"); err != nil { // button.AppendChild(copyIcon) // } else { button.AppendChild(&htmlparse.Node{ Type: htmlparse.TextNode, Data: "Copy", })
var classWasSetOnNode bool for i, attr := range node.Attr { if attr.Key == "class" { node.Attr[i].Val += " relative" classWasSetOnNode = true break } }
if !classWasSetOnNode { node.Attr = append(node.Attr, htmlparse.Attribute{ Key: "class", Val: "relative", }) }
node.AppendChild(button) }
for n := node.FirstChild; n != nil; n = n.NextSibling { visitNode(ctx, n) } default: }}
func (rctx *RenderContext) Sanitize(html string) string { policy := bluemonday.UGCPolicy()
// video policy.AllowElements("video") policy.AllowAttrs("controls").OnElements("video") policy.AllowElements("source") policy.AllowAttrs("src", "type").OnElements("source")
// centering content policy.AllowElements("center")
policy.AllowAttrs("align", "style", "width", "height").Globally() policy.AllowStyles( "margin", "padding", "text-align", "font-weight", "text-decoration", "padding-left", "padding-right", "padding-top", "padding-bottom", "margin-left", "margin-right", "margin-top", "margin-bottom", ) return policy.Sanitize(html)}
type MarkdownTransformer struct { rctx *RenderContext}
func (a *MarkdownTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil }
switch a.rctx.RendererType { case RendererTypeRepoMarkdown: switch n := n.(type) { case *ast.Link: a.rctx.relativeLinkTransformer(n) case *ast.Image: a.rctx.imageFromKnotAstTransformer(n) a.rctx.camoImageLinkAstTransformer(n) } case RendererTypeDefault: switch n := n.(type) { case *ast.Image: a.rctx.imageFromKnotAstTransformer(n) a.rctx.camoImageLinkAstTransformer(n) } }
return ast.WalkContinue, nil })}
func (rctx *RenderContext) relativeLinkTransformer(link *ast.Link) {
dst := string(link.Destination)
if isAbsoluteUrl(dst) { return }
actualPath := rctx.actualPath(dst)
newPath := path.Join("/", rctx.RepoInfo.FullName(), "tree", rctx.RepoInfo.Ref, actualPath) link.Destination = []byte(newPath)}
func (rctx *RenderContext) imageFromKnotTransformer(dst string) string { if isAbsoluteUrl(dst) { return dst }
scheme := "https" if rctx.IsDev { scheme = "http" }
actualPath := rctx.actualPath(dst)
parsedURL := &url.URL{ Scheme: scheme, Host: rctx.Knot, Path: path.Join("/", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name, "raw", url.PathEscape(rctx.RepoInfo.Ref), actualPath), } newPath := parsedURL.String() return newPath}
func (rctx *RenderContext) imageFromKnotAstTransformer(img *ast.Image) { dst := string(img.Destination) img.Destination = []byte(rctx.imageFromKnotTransformer(dst))}
// actualPath decides when to join the file path with the// current repository directory (essentially only when the link// destination is relative. if it's absolute then we assume the// user knows what they're doing.)func (rctx *RenderContext) actualPath(dst string) string { if path.IsAbs(dst) { return dst }
return path.Join(rctx.CurrentDir, dst)}
func isAbsoluteUrl(link string) bool { parsed, err := url.Parse(link) if err != nil { return false } return parsed.IsAbs()}