Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390// Package markup is an umbrella package for all markups and their renderers.package markup
import ( "bytes" "context" "fmt" "io" "net/url" "path" "strings" "time"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/styles" "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/yuin/goldmark" emoji "github.com/yuin/goldmark-emoji" highlighting "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" callout "gitlab.com/staticnoise/goldmark-callout" "go.abhg.dev/goldmark/mermaid" htmlparse "golang.org/x/net/html"
textension "tangled.org/core/appview/pages/markup/extension" "tangled.org/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 Hostname string RendererType RendererType Directory identity.Directory}
func NewMarkdown(hostname string, extra ...goldmark.Extender) goldmark.Markdown { exts := []goldmark.Extender{ extension.GFM, &mermaid.Extender{ RenderMode: mermaid.RenderModeClient, NoScript: true, }, highlighting.NewHighlighting( highlighting.WithFormatOptions( chromahtml.Standalone(false), chromahtml.WithClasses(true), ), highlighting.WithCustomStyle(styles.Get("catppuccin-latte")), ), extension.NewFootnote( extension.WithFootnoteIDPrefix([]byte("footnote")), ), callout.CalloutExtention, textension.MathExt, textension.AtExt, textension.NewTangledLinkExt(hostname), emoji.Emoji, } exts = append(exts, extra...) md := goldmark.New( goldmark.WithExtensions(exts...), goldmark.WithParserOptions( parser.WithAutoHeadingID(), ), goldmark.WithRendererOptions(html.WithUnsafe()), ) return md}
// clone creates a shallow copy of the RenderContextfunc (rctx *RenderContext) Clone() *RenderContext { if rctx == nil { return nil } clone := *rctx return &clone}
// NewMarkdownWith is an alias for NewMarkdown with extra extensions.func NewMarkdownWith(hostname string, extra ...goldmark.Extender) goldmark.Markdown { return NewMarkdown(hostname, extra...)}
func (rctx *RenderContext) RenderMarkdown(source string) string { return rctx.RenderMarkdownWith(source, NewMarkdown(rctx.Hostname))}
func (rctx *RenderContext) RenderMarkdownWith(source string, md goldmark.Markdown) string { 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 }
var processed strings.Builder if err := postProcess(rctx, strings.NewReader(buf.String()), &processed); err != nil { return source }
return processed.String()}
func postProcess(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}
func visitNode(ctx *RenderContext, node *htmlparse.Node) { switch node.Type { case htmlparse.ElementNode: switch node.Data { case "a": // TODO: transform `./` or `/` links to tree link case "img", "source": var blobCid syntax.CID for i, attr := range node.Attr { if attr.Key != "src" { continue }
if strings.HasPrefix(attr.Val, blobURIScheme) { if _, cid, ok := parseBlobURI(attr.Val); ok { blobCid = cid } if blobUrl, ok := ctx.blobToGetBlobURL(attr.Val); ok { attr.Val = ctx.camoImageLinkTransformer(blobUrl) node.Attr[i] = attr } continue }
if isAbsoluteUrl(attr.Val) { // apply camo to external links camoUrl, _ := url.Parse(ctx.CamoUrl) dstUrl, _ := url.Parse(attr.Val) if camoUrl != nil && dstUrl != nil && dstUrl.Host != ctx.Hostname && dstUrl.Host != camoUrl.Host { attr.Val = ctx.camoImageLinkTransformer(attr.Val) } } else { attr.Val = ctx.imageToRawTransformer(attr.Val) } node.Attr[i] = attr } // tag with the cid so the editor can preview an uncommitted blob // (appended after the loop to avoid mutating node.Attr mid-range) if blobCid != "" { node.Attr = append(node.Attr, htmlparse.Attribute{Key: "data-blob-cid", Val: blobCid.String()}) } }
for n := node.FirstChild; n != nil; n = n.NextSibling { visitNode(ctx, n) } default: }}
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.Heading: a.rctx.anchorHeadingTransformer(n) case *ast.Link: // TODO: run this on HTML transformation instead a.rctx.relativeLinkTransformer(n) } case RendererTypeDefault: switch n := n.(type) { case *ast.Heading: a.rctx.anchorHeadingTransformer(n) } }
return ast.WalkContinue, nil })}
func (rctx *RenderContext) relativeLinkTransformer(link *ast.Link) {
dst := string(link.Destination)
if isAbsoluteUrl(dst) || isFragment(dst) || isMail(dst) { return }
actualPath := rctx.actualPath(dst)
newPath := path.Join("/", rctx.RepoInfo.FullName(), "tree", rctx.RepoInfo.Ref, actualPath) link.Destination = []byte(newPath)}
// blobURIScheme embeds a PDS blob in markdown independent of the PDS hostname:// blob+at://<did>/<cid>.const blobURIScheme = "blob+at://"
func parseBlobURI(src string) (syntax.DID, syntax.CID, bool) { rest, ok := strings.CutPrefix(src, blobURIScheme) if !ok { return "", "", false } rawDid, rawCid, ok := strings.Cut(rest, "/") if !ok || rawDid == "" || rawCid == "" { return "", "", false } did, err := syntax.ParseDID(rawDid) if err != nil { return "", "", false } cid, err := syntax.ParseCID(rawCid) if err != nil { return "", "", false } return did, cid, true}
func (rctx *RenderContext) blobToGetBlobURL(src string) (string, bool) { did, cid, ok := parseBlobURI(src) if !ok { return src, false } if rctx.Directory == nil { return src, false } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() ident, err := rctx.Directory.LookupDID(ctx, did) if err != nil { return src, false } pds := ident.PDSEndpoint() if pds == "" { return src, false } // TODO: avoid directly fetching from PDS. use services like porxie instead. u, err := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", pds)) if err != nil { return src, false } q := u.Query() q.Set("did", did.String()) q.Set("cid", cid.String()) u.RawQuery = q.Encode() return u.String(), true}
func (rctx *RenderContext) imageToRawTransformer(dst string) string { if isAbsoluteUrl(dst) { return dst }
actualPath := rctx.actualPath(dst)
newDest := path.Join("/", rctx.RepoInfo.FullName(), "raw", rctx.RepoInfo.Ref, actualPath) return newDest}
func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) { idGeneric, exists := h.AttributeString("id") if !exists { return // no id, nothing to do } id, ok := idGeneric.([]byte) if !ok { return }
// create anchor link anchor := ast.NewLink() anchor.Destination = fmt.Appendf(nil, "#%s", string(id)) anchor.SetAttribute([]byte("class"), []byte("anchor"))
// create icon text iconText := ast.NewString([]byte("#")) anchor.AppendChild(anchor, iconText)
// set class on heading h.SetAttribute([]byte("class"), []byte("heading"))
// append anchor to heading h.AppendChild(h, anchor)}
// 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()}
func isFragment(link string) bool { return strings.HasPrefix(link, "#")}
func isMail(link string) bool { return strings.HasPrefix(link, "mailto:")}