diff --git a/appview/pages/funcmap.go b/appview/pages/funcmap.go index 8232deb4..9b7f32ed 100644 --- a/appview/pages/funcmap.go +++ b/appview/pages/funcmap.go @@ -334,6 +334,25 @@ func (p *Pages) funcMap() template.FuncMap { sanitized := rctx.SanitizeDefault(htmlString) return template.HTML(sanitized) }, + // render user-owned markdown content which can include blob attachments + "markdownBody": func(owner syntax.DID, text string) template.HTML { + rctx := markup.RenderBodyContext{ + OwnerDid: owner, + IsPreview: false, + } + htmlString := p.markdown.RenderMarkdownBody(rctx, text) + sanitized := p.rctx.SanitizeDefault(htmlString) + return template.HTML(sanitized) + }, + "markdownBodyPreview": func(owner syntax.DID, text string) template.HTML { + rctx := markup.RenderBodyContext{ + OwnerDid: owner, + IsPreview: true, + } + htmlString := p.markdown.RenderMarkdownBody(rctx, text) + sanitized := p.rctx.SanitizeDefault(htmlString) + return template.HTML(sanitized) + }, "code": func(content, path string) string { var style *chroma.Style = styles.Get("catpuccin-latte") formatter := chromahtml.New( diff --git a/appview/pages/markup/markdown2.go b/appview/pages/markup/markdown2.go new file mode 100644 index 00000000..96a5a46b --- /dev/null +++ b/appview/pages/markup/markdown2.go @@ -0,0 +1,199 @@ +package markup + +import ( + "bytes" + "context" + "fmt" + "io" + "net/url" + "strings" + + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/styles" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/ipfs/go-cid" + "github.com/yuin/goldmark" + emoji "github.com/yuin/goldmark-emoji" + highlighting "github.com/yuin/goldmark-highlighting/v2" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer/html" + callout "gitlab.com/staticnoise/goldmark-callout" + "go.abhg.dev/goldmark/mermaid" + htmlparse "golang.org/x/net/html" + "tangled.org/core/appview/config" + textension "tangled.org/core/appview/pages/markup/extension" + "tangled.org/core/blobstore" +) + +// make more pure markdown renderer. + +type MarkdownRenderer struct { + CamoUrl string + CamoSecret string + Hostname string + blobStore blobstore.BlobStore + markdown goldmark.Markdown +} + +func NewMarkdownRenderer(cfg *config.Config, blobStore blobstore.BlobStore) *MarkdownRenderer { + markdown := goldmark.New( + goldmark.WithExtensions( + 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.AtExt, + textension.NewTangledLinkExt(cfg.Core.AppviewHost), + emoji.Emoji, + ), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + ), + goldmark.WithRendererOptions(html.WithUnsafe()), + ) + return &MarkdownRenderer{ + CamoUrl: cfg.Camo.Host, + CamoSecret: cfg.Camo.SharedSecret, + Hostname: cfg.Core.AppviewHost, + blobStore: blobStore, + markdown: markdown, + } +} + +type RenderBodyContext struct { + OwnerDid syntax.DID // Content owner DID. Used to make blob links + IsPreview bool +} + +type RenderGitBlobContext struct { + RepoDid syntax.DID + CurrentRev string + CurrentDir string +} + +func (m *MarkdownRenderer) RenderMarkdownBody(rctx RenderBodyContext, source string) string { + var buf bytes.Buffer + if err := m.markdown.Convert([]byte(source), &buf); err != nil { + return source + } + + var processed strings.Builder + if err := m.postProcess( + strings.NewReader(buf.String()), + &processed, + func(n *htmlparse.Node) { m.visitNode(&rctx, n) }, + ); err != nil { + return source + } + + return processed.String() +} + +func (m *MarkdownRenderer) postProcess(input io.Reader, output io.Writer, processFn func(*htmlparse.Node)) error { + node, err := htmlparse.Parse(io.MultiReader( + strings.NewReader("
"), + input, + strings.NewReader(""), + )) + if err != nil { + return fmt.Errorf("failed to parse html: %w", err) + } + + if node.Type == htmlparse.DocumentNode { + node = node.FirstChild + } + + processFn(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 (m *MarkdownRenderer) visitNode(rctx *RenderBodyContext, node *htmlparse.Node) { + switch node.Type { + case htmlparse.ElementNode: + switch node.Data { + case "img", "source": + isBlob := false + for i, attr := range node.Attr { + if attr.Key != "src" { + continue + } + + if rawCid, found := strings.CutPrefix(attr.Val, "blob://"); found { + isBlob = true + cid, err := cid.Parse(rawCid) + if err != nil { + continue // skip invalid cid + } + blobUrl, err := m.blobStore.GetBlobUrl(context.TODO(), rctx.OwnerDid, cid) + if err != nil { + continue + } + attr.Val = blobUrl + node.Attr[i] = attr + continue + } + + src, err := url.Parse(attr.Val) + if err != nil { + continue // skip invalid url + } + + if src.IsAbs() { + camoUrl, _ := url.Parse(m.CamoUrl) + if camoUrl != nil && src.Host != m.Hostname && src.Host != camoUrl.Host { + attr.Val = GenerateCamoURL(m.CamoUrl, m.CamoSecret, attr.Val) + node.Attr[i] = attr + } + } + } + if isBlob { + // TODO: set `onerror=""` to fallback on preview + } + } + + for n := node.FirstChild; n != nil; n = n.NextSibling { + m.visitNode(rctx, n) + } + default: + } +} diff --git a/appview/pages/pages.go b/appview/pages/pages.go index e817c995..3a9abb2d 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -27,6 +27,7 @@ import ( "tangled.org/core/appview/pages/markup" "tangled.org/core/appview/pages/repoinfo" "tangled.org/core/appview/pagination" + "tangled.org/core/blobstore" "tangled.org/core/idresolver" "tangled.org/core/types" @@ -79,9 +80,11 @@ type Pages struct { templateDir string // Path to templates on disk for dev mode rctx *markup.RenderContext logger *slog.Logger + + markdown *markup.MarkdownRenderer } -func NewPages(config *config.Config, res *idresolver.Resolver, database *db.DB, rdb *cache.Cache, logger *slog.Logger) *Pages { +func NewPages(config *config.Config, res *idresolver.Resolver, database *db.DB, rdb *cache.Cache, blobStore blobstore.BlobStore, logger *slog.Logger) *Pages { // initialized with safe defaults, can be overridden per use rctx := &markup.RenderContext{ IsDev: config.Core.Dev, @@ -89,7 +92,6 @@ func NewPages(config *config.Config, res *idresolver.Resolver, database *db.DB, CamoUrl: config.Camo.Host, CamoSecret: config.Camo.SharedSecret, Sanitizer: markup.NewSanitizer(), - Files: Files, } p := &Pages{ @@ -104,6 +106,7 @@ func NewPages(config *config.Config, res *idresolver.Resolver, database *db.DB, rdb: rdb, templateDir: "appview/pages", logger: logger, + markdown: markup.NewMarkdownRenderer(config, blobStore), } if p.dev { @@ -1379,8 +1382,13 @@ func (p *Pages) PullComposeHostFragment(w io.Writer, params RepoNewPullParams) e return p.executePlain("repo/pulls/fragments/pullComposeHost", w, params) } -func (p *Pages) MarkdownPreviewFragment(w io.Writer, body string) error { - return p.executePlain("fragments/markdownPreview", w, body) +type MarkdownPreviewFragmentParams struct { + LoggedInUser *oauth.MultiAccountUser + Content string +} + +func (p *Pages) MarkdownPreviewFragment(w io.Writer, params MarkdownPreviewFragmentParams) error { + return p.executePlain("fragments/markdownPreview", w, params) } type EditPullParams struct { diff --git a/appview/pages/templates/fragments/comment/commentBody.html b/appview/pages/templates/fragments/comment/commentBody.html index 3bd7b8c1..2e398da6 100644 --- a/appview/pages/templates/fragments/comment/commentBody.html +++ b/appview/pages/templates/fragments/comment/commentBody.html @@ -1,7 +1,7 @@ {{ define "fragments/comment/commentBody" }}