Handle mardown html elements parsing with sanitization

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-03-30 16:58:01 +04:00
parent 4bb009f25b
commit 9781c7534f
6 changed files with 876 additions and 17 deletions

View File

@@ -18,6 +18,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
@@ -70,6 +71,62 @@ func (c *converter) convertChildren(n ast.Node) ([]Node, error) {
return nodes, nil
}
// convertInlineChildren walks inline content and merges adjacent RawHTML + Text
// segments so tags split by goldmark (e.g. <strong>, b, </strong>) form one HTML
// fragment for sanitization and conversion.
func (c *converter) convertInlineChildren(n ast.Node) ([]Node, error) {
var nodes []Node
for ch := n.FirstChild(); ch != nil; {
if ch.Kind() == ast.KindRawHTML {
run, next := c.collectRawHTMLRun(ch)
inodes, err := convertProseMirrorFromInlineHTML(run)
if err != nil {
return nil, err
}
nodes = append(nodes, prependOuterMarks(copyMarks(c.marks), inodes)...)
ch = next
continue
}
converted, err := c.convertNode(ch)
if err != nil {
return nil, err
}
nodes = append(nodes, converted...)
ch = ch.NextSibling()
}
return nodes, nil
}
// collectRawHTMLRun concatenates a leading RawHTML node and following Text/String
// and RawHTML siblings until a different node kind is seen. next is the first
// sibling not consumed (or nil).
func (c *converter) collectRawHTMLRun(start ast.Node) (run string, next ast.Node) {
var buf bytes.Buffer
ch := start
for ch != nil {
switch ch.Kind() {
case ast.KindRawHTML:
raw := ch.(*ast.RawHTML)
for i := 0; i < raw.Segments.Len(); i++ {
seg := raw.Segments.At(i)
buf.Write(seg.Value(c.source))
}
case ast.KindText:
t := ch.(*ast.Text)
buf.Write(t.Segment.Value(c.source))
case ast.KindString:
buf.Write(ch.(*ast.String).Value)
default:
return buf.String(), ch
}
ch = ch.NextSibling()
}
return buf.String(), nil
}
func (c *converter) convertNode(n ast.Node) ([]Node, error) {
switch n.Kind() {
case ast.KindHeading:
@@ -107,7 +164,7 @@ func (c *converter) convertNode(n ast.Node) ([]Node, error) {
case ast.KindRawHTML:
return c.convertRawHTML(n)
case ast.KindHTMLBlock:
return nil, nil
return c.convertHTMLBlock(n.(*ast.HTMLBlock))
default:
if n.Kind() == goldmarkast.KindStrikethrough {
return c.convertStrikethrough(n)
@@ -117,7 +174,7 @@ func (c *converter) convertNode(n ast.Node) ([]Node, error) {
}
func (c *converter) convertHeading(n *ast.Heading) ([]Node, error) {
children, err := c.convertChildren(n)
children, err := c.convertInlineChildren(n)
if err != nil {
return nil, err
}
@@ -135,7 +192,7 @@ func (c *converter) convertHeading(n *ast.Heading) ([]Node, error) {
}
func (c *converter) convertParagraph(n ast.Node) ([]Node, error) {
children, err := c.convertChildren(n)
children, err := c.convertInlineChildren(n)
if err != nil {
return nil, err
}
@@ -334,7 +391,7 @@ func (c *converter) convertEmphasis(n *ast.Emphasis) ([]Node, error) {
}
c.marks = append(c.marks, mark)
children, err := c.convertChildren(n)
children, err := c.convertInlineChildren(n)
c.marks = c.marks[:len(c.marks)-1]
if err != nil {
return nil, err
@@ -383,7 +440,7 @@ func (c *converter) convertLink(n *ast.Link) ([]Node, error) {
}
c.marks = append(c.marks, Mark{Type: MarkLink, Attrs: attrs})
children, err := c.convertChildren(n)
children, err := c.convertInlineChildren(n)
c.marks = c.marks[:len(c.marks)-1]
if err != nil {
return nil, err
@@ -411,28 +468,48 @@ func (c *converter) convertAutoLink(n *ast.AutoLink) ([]Node, error) {
}
func (c *converter) convertRawHTML(n ast.Node) ([]Node, error) {
raw, ok := n.(*ast.RawHTML)
if !ok {
return nil, fmt.Errorf("cannot convert raw html: unexpected node type %T", n)
}
var buf bytes.Buffer
for i := 0; i < raw.Segments.Len(); i++ {
seg := raw.Segments.At(i)
buf.Write(seg.Value(c.source))
}
run := buf.String()
if run == "" {
return nil, nil
}
nodes, err := convertProseMirrorFromInlineHTML(run)
if err != nil {
return nil, err
}
return prependOuterMarks(copyMarks(c.marks), nodes), nil
}
func (c *converter) convertHTMLBlock(n *ast.HTMLBlock) ([]Node, error) {
var buf bytes.Buffer
for i := 0; i < n.Lines().Len(); i++ {
line := n.Lines().At(i)
buf.Write(line.Value(c.source))
}
if n.HasClosure() {
buf.Write(n.ClosureLine.Value(c.source))
}
content := buf.String()
if content == "" {
raw := buf.String()
if strings.TrimSpace(raw) == "" {
return nil, nil
}
return []Node{{
Type: NodeText,
Text: &content,
Marks: copyMarks(c.marks),
}}, nil
return convertProseMirrorFromHTMLBlock(raw)
}
func (c *converter) convertStrikethrough(n ast.Node) ([]Node, error) {
c.marks = append(c.marks, Mark{Type: MarkStrike})
children, err := c.convertChildren(n)
children, err := c.convertInlineChildren(n)
c.marks = c.marks[:len(c.marks)-1]
if err != nil {
return nil, err
@@ -451,3 +528,18 @@ func copyMarks(marks []Mark) []Mark {
return cp
}
// prependOuterMarks applies markdown inline context marks (e.g. emphasis around
// raw HTML) to nodes produced from sanitized HTML. Images are left unchanged.
func prependOuterMarks(outer []Mark, nodes []Node) []Node {
if len(outer) == 0 {
return nodes
}
for i := range nodes {
if nodes[i].Type == NodeImage {
continue
}
nodes[i].Marks = append(copyMarks(outer), nodes[i].Marks...)
}
return nodes
}