@@ -46,14 +46,18 @@ func convertProseMirrorFromInlineHTML(raw string) ([]Node, error) {
|
||||
}
|
||||
|
||||
c := &htmlBlockConverter{}
|
||||
|
||||
var out []Node
|
||||
|
||||
for _, root := range roots {
|
||||
nodes, err := c.convertInlineNode(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out = append(out, nodes...)
|
||||
}
|
||||
|
||||
if len(out) > 0 {
|
||||
return out, nil
|
||||
}
|
||||
@@ -62,6 +66,7 @@ func convertProseMirrorFromInlineHTML(raw string) ([]Node, error) {
|
||||
if plain == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []Node{{Type: NodeText, Text: &plain}}, nil
|
||||
}
|
||||
|
||||
@@ -75,6 +80,7 @@ func convertProseMirrorFromHTMLBlock(raw string) ([]Node, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot convert html block to prosemirror: %w", err)
|
||||
}
|
||||
|
||||
if len(nodes) > 0 {
|
||||
return nodes, nil
|
||||
}
|
||||
@@ -83,6 +89,7 @@ func convertProseMirrorFromHTMLBlock(raw string) ([]Node, error) {
|
||||
if plain == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []Node{paragraphWithPlainText(plain)}, nil
|
||||
}
|
||||
|
||||
@@ -109,12 +116,17 @@ func plainTextFromHTMLFragment(htmlStr string) string {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
var walk func(*html.Node)
|
||||
|
||||
var (
|
||||
b strings.Builder
|
||||
walk func(*html.Node)
|
||||
)
|
||||
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.TextNode {
|
||||
b.WriteString(n.Data)
|
||||
}
|
||||
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
@@ -122,6 +134,7 @@ func plainTextFromHTMLFragment(htmlStr string) string {
|
||||
for _, root := range roots {
|
||||
walk(root)
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -132,14 +145,18 @@ func htmlFragmentToProseMirrorBlocks(htmlStr string) ([]Node, error) {
|
||||
}
|
||||
|
||||
c := &htmlBlockConverter{}
|
||||
|
||||
var out []Node
|
||||
|
||||
for _, root := range roots {
|
||||
nodes, err := c.convertTopLevel(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out = append(out, nodes...)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -154,6 +171,7 @@ func (c *htmlBlockConverter) convertTopLevel(n *html.Node) ([]Node, error) {
|
||||
if t == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []Node{paragraphWithPlainText(t)}, nil
|
||||
case html.ElementNode:
|
||||
return c.convertBlockElement(n)
|
||||
@@ -169,17 +187,21 @@ func (c *htmlBlockConverter) convertBlockElement(n *html.Node) ([]Node, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []Node{{Type: NodeParagraph, Content: inlines}}, nil
|
||||
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||
level := int(n.Data[1] - '0')
|
||||
|
||||
inlines, err := c.convertInlineFragments(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attrs, err := json.Marshal(HeadingAttrs{Level: level})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot marshal heading attrs: %w", err)
|
||||
}
|
||||
|
||||
return []Node{{
|
||||
Type: NodeHeading,
|
||||
Attrs: attrs,
|
||||
@@ -207,9 +229,11 @@ func (c *htmlBlockConverter) convertBlockElement(n *html.Node) ([]Node, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if img == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []Node{{
|
||||
Type: NodeParagraph,
|
||||
Content: []Node{*img},
|
||||
@@ -228,23 +252,29 @@ func (c *htmlBlockConverter) unwrapBlockElement(n *html.Node) ([]Node, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(inlines) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []Node{{Type: NodeParagraph, Content: inlines}}, nil
|
||||
}
|
||||
|
||||
return c.convertBlockChildren(n)
|
||||
}
|
||||
|
||||
func (c *htmlBlockConverter) convertBlockChildren(n *html.Node) ([]Node, error) {
|
||||
var out []Node
|
||||
|
||||
for ch := n.FirstChild; ch != nil; ch = ch.NextSibling {
|
||||
nodes, err := c.convertTopLevel(ch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out = append(out, nodes...)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -254,44 +284,58 @@ func (c *htmlBlockConverter) convertBlockquote(n *html.Node) ([]Node, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []Node{{Type: NodeBlockquote, Content: inner}}, nil
|
||||
}
|
||||
|
||||
inlines, err := c.convertInlineFragments(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var content []Node
|
||||
if len(inlines) > 0 {
|
||||
content = []Node{{Type: NodeParagraph, Content: inlines}}
|
||||
}
|
||||
|
||||
return []Node{{Type: NodeBlockquote, Content: content}}, nil
|
||||
}
|
||||
|
||||
func (c *htmlBlockConverter) convertPre(n *html.Node) ([]Node, error) {
|
||||
var lang *string
|
||||
var textBuf strings.Builder
|
||||
var (
|
||||
lang *string
|
||||
textBuf strings.Builder
|
||||
)
|
||||
|
||||
for ch := n.FirstChild; ch != nil; ch = ch.NextSibling {
|
||||
if ch.Type == html.ElementNode && ch.Data == "code" {
|
||||
lang = codeLanguageFromClass(attrVal(ch, "class"))
|
||||
|
||||
var walkText func(*html.Node)
|
||||
|
||||
walkText = func(x *html.Node) {
|
||||
if x.Type == html.TextNode {
|
||||
textBuf.WriteString(x.Data)
|
||||
}
|
||||
|
||||
for cc := x.FirstChild; cc != nil; cc = cc.NextSibling {
|
||||
walkText(cc)
|
||||
}
|
||||
}
|
||||
walkText(ch)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if textBuf.Len() == 0 {
|
||||
var walkText func(*html.Node)
|
||||
|
||||
walkText = func(x *html.Node) {
|
||||
if x.Type == html.TextNode {
|
||||
textBuf.WriteString(x.Data)
|
||||
}
|
||||
|
||||
for cc := x.FirstChild; cc != nil; cc = cc.NextSibling {
|
||||
walkText(cc)
|
||||
}
|
||||
@@ -300,6 +344,7 @@ func (c *htmlBlockConverter) convertPre(n *html.Node) ([]Node, error) {
|
||||
}
|
||||
|
||||
content := textBuf.String()
|
||||
|
||||
attrs, err := json.Marshal(CodeBlockAttrs{Language: lang})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot marshal code block attrs: %w", err)
|
||||
@@ -309,6 +354,7 @@ func (c *htmlBlockConverter) convertPre(n *html.Node) ([]Node, error) {
|
||||
if content != "" {
|
||||
textNodes = []Node{{Type: NodeText, Text: &content}}
|
||||
}
|
||||
|
||||
return []Node{{
|
||||
Type: NodeCodeBlock,
|
||||
Attrs: attrs,
|
||||
@@ -326,39 +372,49 @@ func codeLanguageFromClass(class string) *string {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *htmlBlockConverter) convertList(n *html.Node, ordered bool) ([]Node, error) {
|
||||
var items []Node
|
||||
|
||||
for li := n.FirstChild; li != nil; li = li.NextSibling {
|
||||
if li.Type != html.ElementNode || li.Data != "li" {
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := c.convertListItem(li)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, Node{Type: NodeListItem, Content: body})
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if ordered {
|
||||
start := parseOlStart(n)
|
||||
|
||||
attrs, err := json.Marshal(OrderedListAttrs{Start: start})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot marshal ordered list attrs: %w", err)
|
||||
}
|
||||
|
||||
return []Node{{
|
||||
Type: NodeOrderedList,
|
||||
Attrs: attrs,
|
||||
Content: items,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
return []Node{{
|
||||
Type: NodeBulletList,
|
||||
Content: items,
|
||||
@@ -370,10 +426,12 @@ func parseOlStart(n *html.Node) int {
|
||||
if s == "" {
|
||||
return 1
|
||||
}
|
||||
|
||||
v, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || v < 1 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -381,13 +439,16 @@ func (c *htmlBlockConverter) convertListItem(li *html.Node) ([]Node, error) {
|
||||
if hasBlockElementChild(li) {
|
||||
return c.convertBlockChildren(li)
|
||||
}
|
||||
|
||||
inlines, err := c.convertInlineFragments(li)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(inlines) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []Node{{Type: NodeParagraph, Content: inlines}}, nil
|
||||
}
|
||||
|
||||
@@ -397,6 +458,7 @@ func hasBlockElementChild(n *html.Node) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -419,10 +481,12 @@ func blockTagName(name string) bool {
|
||||
// tables cannot contribute rows to the outer table.
|
||||
func collectTableRows(table *html.Node) []*html.Node {
|
||||
var rows []*html.Node
|
||||
|
||||
for ch := table.FirstChild; ch != nil; ch = ch.NextSibling {
|
||||
if ch.Type != html.ElementNode {
|
||||
continue
|
||||
}
|
||||
|
||||
switch ch.Data {
|
||||
case "thead", "tbody", "tfoot":
|
||||
for tr := ch.FirstChild; tr != nil; tr = tr.NextSibling {
|
||||
@@ -438,6 +502,7 @@ func collectTableRows(table *html.Node) []*html.Node {
|
||||
// Ignore other direct children (e.g. invalid markup).
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -445,29 +510,38 @@ func (c *htmlBlockConverter) convertTable(n *html.Node) ([]Node, error) {
|
||||
rows := collectTableRows(n)
|
||||
|
||||
var rowNodes []Node
|
||||
|
||||
for _, tr := range rows {
|
||||
row, err := c.convertTableRow(tr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if row != nil {
|
||||
rowNodes = append(rowNodes, *row)
|
||||
}
|
||||
}
|
||||
|
||||
if len(rowNodes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []Node{{Type: NodeTable, Content: rowNodes}}, nil
|
||||
}
|
||||
|
||||
func (c *htmlBlockConverter) convertTableRow(tr *html.Node) (*Node, error) {
|
||||
var cells []Node
|
||||
|
||||
for ch := tr.FirstChild; ch != nil; ch = ch.NextSibling {
|
||||
if ch.Type != html.ElementNode {
|
||||
continue
|
||||
}
|
||||
var cell *Node
|
||||
var err error
|
||||
|
||||
var (
|
||||
cell *Node
|
||||
err error
|
||||
)
|
||||
|
||||
switch ch.Data {
|
||||
case "th":
|
||||
cell, err = c.convertTableCell(ch, NodeTableHeader)
|
||||
@@ -476,16 +550,20 @@ func (c *htmlBlockConverter) convertTableRow(tr *html.Node) (*Node, error) {
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cell != nil {
|
||||
cells = append(cells, *cell)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cells) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &Node{Type: NodeTableRow, Content: cells}, nil
|
||||
}
|
||||
|
||||
@@ -494,27 +572,34 @@ func (c *htmlBlockConverter) convertTableCell(n *html.Node, typ NodeType) (*Node
|
||||
Colspan: tableSpanFromHTML(n, "colspan"),
|
||||
Rowspan: tableSpanFromHTML(n, "rowspan"),
|
||||
}
|
||||
|
||||
attrs, err := json.Marshal(cellAttrs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot marshal table cell attrs: %w", err)
|
||||
}
|
||||
|
||||
inlines, err := c.convertInlineFragments(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content := []Node{{Type: NodeParagraph, Content: inlines}}
|
||||
|
||||
return &Node{Type: typ, Attrs: attrs, Content: content}, nil
|
||||
}
|
||||
|
||||
func (c *htmlBlockConverter) convertInlineFragments(parent *html.Node) ([]Node, error) {
|
||||
var out []Node
|
||||
|
||||
for ch := parent.FirstChild; ch != nil; ch = ch.NextSibling {
|
||||
nodes, err := c.convertInlineNode(ch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out = append(out, nodes...)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -524,7 +609,9 @@ func (c *htmlBlockConverter) convertInlineNode(n *html.Node) ([]Node, error) {
|
||||
if n.Data == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
t := n.Data
|
||||
|
||||
return []Node{{
|
||||
Type: NodeText,
|
||||
Text: &t,
|
||||
@@ -562,6 +649,7 @@ func (c *htmlBlockConverter) convertInlineElement(n *html.Node) ([]Node, error)
|
||||
if err != nil || img == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []Node{*img}, nil
|
||||
case "span":
|
||||
return c.convertInlineFragments(n)
|
||||
@@ -574,9 +662,11 @@ func (c *htmlBlockConverter) withMark(m Mark, n *html.Node) ([]Node, error) {
|
||||
c.marks = append(c.marks, m)
|
||||
nodes, err := c.convertInlineFragments(n)
|
||||
c.marks = c.marks[:len(c.marks)-1]
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
@@ -585,21 +675,26 @@ func (c *htmlBlockConverter) convertAnchor(n *html.Node) ([]Node, error) {
|
||||
if href == "" {
|
||||
return c.convertInlineFragments(n)
|
||||
}
|
||||
|
||||
var title *string
|
||||
if t := attrVal(n, "title"); t != "" {
|
||||
title = &t
|
||||
}
|
||||
|
||||
attrs, err := json.Marshal(LinkAttrs{Href: safeLinkHref(href), Title: title})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot marshal link attrs: %w", err)
|
||||
}
|
||||
|
||||
m := Mark{Type: MarkLink, Attrs: attrs}
|
||||
c.marks = append(c.marks, m)
|
||||
nodes, err := c.convertInlineFragments(n)
|
||||
c.marks = c.marks[:len(c.marks)-1]
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
@@ -608,17 +703,21 @@ func (c *htmlBlockConverter) convertImageElement(n *html.Node) (*Node, error) {
|
||||
if src == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var alt, title *string
|
||||
if a := attrVal(n, "alt"); a != "" {
|
||||
alt = &a
|
||||
}
|
||||
|
||||
if t := attrVal(n, "title"); t != "" {
|
||||
title = &t
|
||||
}
|
||||
|
||||
attrs, err := json.Marshal(ImageAttrs{Src: src, Alt: alt, Title: title})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot marshal image attrs: %w", err)
|
||||
}
|
||||
|
||||
return &Node{Type: NodeImage, Attrs: attrs}, nil
|
||||
}
|
||||
|
||||
@@ -628,6 +727,7 @@ func attrVal(n *html.Node, key string) string {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -639,9 +739,11 @@ func tableSpanFromHTML(n *html.Node, key string) int {
|
||||
if s == "" {
|
||||
return 1
|
||||
}
|
||||
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil || v < 1 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -45,17 +45,22 @@ func TestParseMarkdown_BlockHTMLDivPreservesInlineMarks(t *testing.T) {
|
||||
require.Equal(t, NodeParagraph, p.Type)
|
||||
|
||||
var foundStrong bool
|
||||
|
||||
for _, ch := range p.Content {
|
||||
if ch.Type != NodeText || ch.Text == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if *ch.Text != "world" {
|
||||
continue
|
||||
}
|
||||
|
||||
require.Len(t, ch.Marks, 1)
|
||||
assert.Equal(t, MarkStrong, ch.Marks[0].Type)
|
||||
|
||||
foundStrong = true
|
||||
}
|
||||
|
||||
assert.True(t, foundStrong, "expected bold mark on 'world' inside a single paragraph")
|
||||
}
|
||||
|
||||
@@ -230,11 +235,13 @@ func TestParseMarkdown_InlineRawHTML(t *testing.T) {
|
||||
require.Equal(t, NodeParagraph, p.Type)
|
||||
|
||||
var joined strings.Builder
|
||||
|
||||
for _, ch := range p.Content {
|
||||
require.Equal(t, NodeText, ch.Type)
|
||||
require.NotNil(t, ch.Text)
|
||||
joined.WriteString(*ch.Text)
|
||||
}
|
||||
|
||||
// Sanitized HTML: span is unwrapped to plain text content.
|
||||
assert.Equal(t, "before x after", joined.String())
|
||||
}
|
||||
@@ -261,12 +268,15 @@ func TestParseMarkdown_InlineRawHTMLScriptStripped(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, doc.Content, 1)
|
||||
p := doc.Content[0]
|
||||
|
||||
var joined strings.Builder
|
||||
|
||||
for _, ch := range p.Content {
|
||||
if ch.Type == NodeText && ch.Text != nil {
|
||||
joined.WriteString(*ch.Text)
|
||||
}
|
||||
}
|
||||
|
||||
assert.NotContains(t, joined.String(), "script")
|
||||
assert.NotContains(t, joined.String(), "evil")
|
||||
assert.Contains(t, joined.String(), "hi")
|
||||
@@ -283,20 +293,24 @@ func TestParseMarkdown_InlineRawHTMLWithOuterBold(t *testing.T) {
|
||||
require.GreaterOrEqual(t, len(p.Content), 3)
|
||||
|
||||
var joined strings.Builder
|
||||
|
||||
for _, ch := range p.Content {
|
||||
require.Equal(t, NodeText, ch.Type)
|
||||
require.NotNil(t, ch.Text)
|
||||
joined.WriteString(*ch.Text)
|
||||
}
|
||||
|
||||
assert.Equal(t, "a b c", joined.String())
|
||||
|
||||
var mid *Node
|
||||
|
||||
for i := range p.Content {
|
||||
if p.Content[i].Text != nil && *p.Content[i].Text == "b" {
|
||||
mid = &p.Content[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, mid, "expected inner <em> as text node b")
|
||||
require.GreaterOrEqual(t, len(mid.Marks), 2)
|
||||
assert.Equal(t, MarkStrong, mid.Marks[0].Type)
|
||||
|
||||
@@ -29,6 +29,7 @@ func RenderHTML(node Node) (string, error) {
|
||||
if err := renderNode(&buf, node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
@@ -38,54 +39,70 @@ func renderNode(buf *bytes.Buffer, n Node) error {
|
||||
return renderChildren(buf, n.Content)
|
||||
case NodeParagraph:
|
||||
buf.WriteString("<p>")
|
||||
|
||||
if err := renderChildren(buf, n.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf.WriteString("</p>")
|
||||
case NodeHeading:
|
||||
attrs, err := n.HeadingAttrs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render heading node: %w", err)
|
||||
}
|
||||
|
||||
if attrs.Level < 1 || attrs.Level > 6 {
|
||||
return fmt.Errorf("cannot render heading node: invalid level %d", attrs.Level)
|
||||
}
|
||||
|
||||
level := strconv.Itoa(attrs.Level)
|
||||
|
||||
buf.WriteString("<h")
|
||||
buf.WriteString(level)
|
||||
buf.WriteByte('>')
|
||||
|
||||
if err := renderChildren(buf, n.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf.WriteString("</h")
|
||||
buf.WriteString(level)
|
||||
buf.WriteByte('>')
|
||||
case NodeBlockquote:
|
||||
buf.WriteString("<blockquote>")
|
||||
|
||||
if err := renderChildren(buf, n.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf.WriteString("</blockquote>")
|
||||
case NodeCodeBlock:
|
||||
attrs, err := n.CodeBlockAttrs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render code block node: %w", err)
|
||||
}
|
||||
|
||||
if attrs.Language != nil && *attrs.Language == "mermaid" {
|
||||
buf.WriteString(`<pre class="mermaid">`)
|
||||
|
||||
if err := renderChildren(buf, n.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf.WriteString("</pre>")
|
||||
} else {
|
||||
buf.WriteString("<pre><code")
|
||||
|
||||
if attrs.Language != nil {
|
||||
writeAttr(buf, "class", "language-"+*attrs.Language)
|
||||
}
|
||||
|
||||
buf.WriteByte('>')
|
||||
|
||||
if err := renderChildren(buf, n.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf.WriteString("</code></pre>")
|
||||
}
|
||||
case NodeHorizontalRule:
|
||||
@@ -99,55 +116,73 @@ func renderNode(buf *bytes.Buffer, n Node) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render image node: %w", err)
|
||||
}
|
||||
|
||||
buf.WriteString("<img")
|
||||
writeAttr(buf, "src", safeImageSrc(attrs.Src))
|
||||
|
||||
if attrs.Alt != nil {
|
||||
writeAttr(buf, "alt", *attrs.Alt)
|
||||
}
|
||||
|
||||
if attrs.Title != nil {
|
||||
writeAttr(buf, "title", *attrs.Title)
|
||||
}
|
||||
|
||||
buf.WriteByte('>')
|
||||
case NodeBulletList:
|
||||
buf.WriteString("<ul>")
|
||||
|
||||
if err := renderChildren(buf, n.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf.WriteString("</ul>")
|
||||
case NodeOrderedList:
|
||||
attrs, err := n.OrderedListAttrs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render ordered list node: %w", err)
|
||||
}
|
||||
|
||||
buf.WriteString("<ol")
|
||||
|
||||
if attrs.Start != 1 {
|
||||
writeAttr(buf, "start", strconv.Itoa(attrs.Start))
|
||||
}
|
||||
|
||||
if attrs.Type != nil {
|
||||
writeAttr(buf, "type", *attrs.Type)
|
||||
}
|
||||
|
||||
buf.WriteByte('>')
|
||||
|
||||
if err := renderChildren(buf, n.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf.WriteString("</ol>")
|
||||
case NodeListItem:
|
||||
buf.WriteString("<li>")
|
||||
|
||||
if err := renderChildren(buf, n.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf.WriteString("</li>")
|
||||
case NodeTable:
|
||||
buf.WriteString("<table>")
|
||||
|
||||
if err := renderChildren(buf, n.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf.WriteString("</table>")
|
||||
case NodeTableRow:
|
||||
buf.WriteString("<tr>")
|
||||
|
||||
if err := renderChildren(buf, n.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf.WriteString("</tr>")
|
||||
case NodeTableCell:
|
||||
return renderTableCell(buf, n, "td")
|
||||
@@ -156,6 +191,7 @@ func renderNode(buf *bytes.Buffer, n Node) error {
|
||||
default:
|
||||
return fmt.Errorf("cannot render node: unknown type %q", n.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -165,6 +201,7 @@ func renderChildren(buf *bytes.Buffer, nodes []Node) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -172,15 +209,19 @@ func renderText(buf *bytes.Buffer, n Node) error {
|
||||
if n.Text == nil {
|
||||
return fmt.Errorf("cannot render text node: text is nil")
|
||||
}
|
||||
|
||||
for _, m := range n.Marks {
|
||||
if err := openMark(buf, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
buf.WriteString(html.EscapeString(*n.Text))
|
||||
|
||||
for i := len(n.Marks) - 1; i >= 0; i-- {
|
||||
closeMark(buf, n.Marks[i])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -201,25 +242,32 @@ func openMark(buf *bytes.Buffer, m Mark) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render link mark: %w", err)
|
||||
}
|
||||
|
||||
buf.WriteString("<a")
|
||||
writeAttr(buf, "href", safeLinkHref(attrs.Href))
|
||||
|
||||
if attrs.Target != nil {
|
||||
writeAttr(buf, "target", *attrs.Target)
|
||||
}
|
||||
|
||||
rel := linkRelToEmit(attrs)
|
||||
if rel != "" {
|
||||
writeAttr(buf, "rel", rel)
|
||||
}
|
||||
|
||||
if attrs.Class != nil {
|
||||
writeAttr(buf, "class", *attrs.Class)
|
||||
}
|
||||
|
||||
if attrs.Title != nil {
|
||||
writeAttr(buf, "title", *attrs.Title)
|
||||
}
|
||||
|
||||
buf.WriteByte('>')
|
||||
default:
|
||||
return fmt.Errorf("cannot render mark: unknown type %q", m.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -245,28 +293,37 @@ func renderTableCell(buf *bytes.Buffer, n Node, tag string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render %s node: %w", tag, err)
|
||||
}
|
||||
|
||||
buf.WriteByte('<')
|
||||
buf.WriteString(tag)
|
||||
|
||||
if attrs.Colspan > 1 {
|
||||
writeAttr(buf, "colspan", strconv.Itoa(attrs.Colspan))
|
||||
}
|
||||
|
||||
if attrs.Rowspan > 1 {
|
||||
writeAttr(buf, "rowspan", strconv.Itoa(attrs.Rowspan))
|
||||
}
|
||||
|
||||
if len(attrs.Colwidth) > 0 {
|
||||
total := 0
|
||||
for _, w := range attrs.Colwidth {
|
||||
total += w
|
||||
}
|
||||
|
||||
writeAttr(buf, "style", "min-width: "+strconv.Itoa(total)+"px")
|
||||
}
|
||||
|
||||
buf.WriteByte('>')
|
||||
|
||||
if err := renderChildren(buf, n.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf.WriteString("</")
|
||||
buf.WriteString(tag)
|
||||
buf.WriteByte('>')
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -285,6 +342,7 @@ func linkRelToEmit(attrs LinkAttrs) string {
|
||||
if blanksTarget {
|
||||
return ensureNoopener(s)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -292,6 +350,7 @@ func linkRelToEmit(attrs LinkAttrs) string {
|
||||
if blanksTarget {
|
||||
return linkRelBlankTargetDefault
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -303,6 +362,7 @@ func ensureNoopener(rel string) string {
|
||||
return rel
|
||||
}
|
||||
}
|
||||
|
||||
return rel + " noopener"
|
||||
}
|
||||
|
||||
@@ -323,19 +383,24 @@ func safeLinkHref(href string) string {
|
||||
if href == "" {
|
||||
return "#"
|
||||
}
|
||||
|
||||
if href[0] == '#' {
|
||||
return href
|
||||
}
|
||||
|
||||
if strings.HasPrefix(href, "/") {
|
||||
if len(href) > 1 && (href[1] == '/' || href[1] == '\\') {
|
||||
return "#"
|
||||
}
|
||||
|
||||
return href
|
||||
}
|
||||
|
||||
u, err := url.Parse(href)
|
||||
if err != nil {
|
||||
return "#"
|
||||
}
|
||||
|
||||
if u.Scheme != "" {
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http", "https", "mailto", "tel":
|
||||
@@ -344,9 +409,11 @@ func safeLinkHref(href string) string {
|
||||
return "#"
|
||||
}
|
||||
}
|
||||
|
||||
if u.Host != "" {
|
||||
return "#"
|
||||
}
|
||||
|
||||
return href
|
||||
}
|
||||
|
||||
@@ -359,16 +426,20 @@ func safeImageSrc(src string) string {
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.HasPrefix(src, "/") {
|
||||
if len(src) > 1 && (src[1] == '/' || src[1] == '\\') {
|
||||
return ""
|
||||
}
|
||||
|
||||
return src
|
||||
}
|
||||
|
||||
u, err := url.Parse(src)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if u.Scheme != "" {
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http", "https", "data":
|
||||
@@ -377,8 +448,10 @@ func safeImageSrc(src string) string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
if u.Host != "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return src
|
||||
}
|
||||
|
||||
@@ -64,7 +64,9 @@ func TestRenderHTML_HeadingLevels(t *testing.T) {
|
||||
"level "+string(rune('0'+tc.level)),
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"heading","attrs":{"level":` + string(rune('0'+tc.level)) + `},"content":[{"type":"text","text":"X"}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -80,6 +82,7 @@ func TestRenderHTML_HeadingInvalidLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"heading","attrs":{"level":7},"content":[{"type":"text","text":"X"}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -92,6 +95,7 @@ func TestRenderHTML_CodeBlockWithLanguage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"codeBlock","attrs":{"language":"go"},"content":[{"type":"text","text":"fmt.Println()"}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -104,6 +108,7 @@ func TestRenderHTML_CodeBlockMermaid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"codeBlock","attrs":{"language":"mermaid"},"content":[{"type":"text","text":"graph TD\n A-->B"}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -116,6 +121,7 @@ func TestRenderHTML_CodeBlockWithoutLanguage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"codeBlock","attrs":{"language":null},"content":[{"type":"text","text":"hello"}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -128,6 +134,7 @@ func TestRenderHTML_OrderedListWithStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"orderedList","attrs":{"start":5,"type":null},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item"}]}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -140,6 +147,7 @@ func TestRenderHTML_TableCellColspan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"tableCell","attrs":{"colspan":2,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"wide"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -152,6 +160,7 @@ func TestRenderHTML_TableCellColwidth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":[100]},"content":[{"type":"paragraph","content":[{"type":"text","text":"X"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -179,6 +188,7 @@ func TestRenderHTML_LinkAllAttrs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":"_blank","rel":"noopener","class":"btn","title":"Click"}}],"text":"hi"}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -191,6 +201,7 @@ func TestRenderHTML_LinkMinimalAttrs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":null,"rel":null,"class":null,"title":null}}],"text":"hi"}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -247,6 +258,7 @@ func TestRenderHTML_LinkBlankTargetDefaultRel(t *testing.T) {
|
||||
tc.name,
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(tc.raw), &n))
|
||||
|
||||
@@ -283,17 +295,21 @@ func TestRenderHTML_LinkSanitizesDangerousHrefs(t *testing.T) {
|
||||
tc.name,
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hrefJSON, err := json.Marshal(tc.href)
|
||||
require.NoError(t, err)
|
||||
|
||||
raw := fmt.Sprintf(
|
||||
`{"type":"text","marks":[{"type":"link","attrs":{"href":%s,"target":null,"rel":null,"class":null,"title":null}}],"text":"x"}`,
|
||||
string(hrefJSON),
|
||||
)
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
got, err := RenderHTML(n)
|
||||
require.NoError(t, err)
|
||||
|
||||
want := fmt.Sprintf(`<a href="%s">x</a>`, html.EscapeString(tc.wantHref))
|
||||
assert.Equal(t, want, got)
|
||||
},
|
||||
@@ -305,6 +321,7 @@ func TestRenderHTML_Image(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"image","attrs":{"src":"https://example.com/img.png","alt":"A photo","title":"My image"}}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -336,17 +353,21 @@ func TestRenderHTML_ImageSanitizesDangerousSrc(t *testing.T) {
|
||||
tc.name,
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srcJSON, err := json.Marshal(tc.src)
|
||||
require.NoError(t, err)
|
||||
|
||||
raw := fmt.Sprintf(
|
||||
`{"type":"image","attrs":{"src":%s}}`,
|
||||
string(srcJSON),
|
||||
)
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
got, err := RenderHTML(n)
|
||||
require.NoError(t, err)
|
||||
|
||||
want := fmt.Sprintf(`<img src="%s">`, html.EscapeString(tc.wantSrc))
|
||||
assert.Equal(t, want, got)
|
||||
},
|
||||
@@ -358,6 +379,7 @@ func TestRenderHTML_MultipleMarks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"text","marks":[{"type":"bold"},{"type":"italic"}],"text":"hello"}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
|
||||
@@ -63,9 +63,11 @@ func normalizeCodeBlockContent(content string) string {
|
||||
if content == "" {
|
||||
return content
|
||||
}
|
||||
|
||||
if strings.HasSuffix(content, "\n") && !strings.HasSuffix(content, "\n\n") {
|
||||
return strings.TrimSuffix(content, "\n")
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
@@ -82,6 +84,7 @@ func (c *converter) convertChildren(n ast.Node) ([]Node, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodes = append(nodes, converted...)
|
||||
}
|
||||
|
||||
@@ -97,12 +100,15 @@ func (c *converter) convertInlineChildren(n ast.Node) ([]Node, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -110,6 +116,7 @@ func (c *converter) convertInlineChildren(n ast.Node) ([]Node, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodes = append(nodes, converted...)
|
||||
ch = ch.NextSibling()
|
||||
}
|
||||
@@ -122,6 +129,7 @@ func (c *converter) convertInlineChildren(n ast.Node) ([]Node, error) {
|
||||
// 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() {
|
||||
@@ -134,6 +142,7 @@ func (c *converter) collectRawHTMLRun(start ast.Node) (run string, next ast.Node
|
||||
case ast.KindText:
|
||||
t := ch.(*ast.Text)
|
||||
buf.Write(t.Segment.Value(c.source))
|
||||
|
||||
if t.SoftLineBreak() {
|
||||
buf.WriteByte(' ')
|
||||
}
|
||||
@@ -142,8 +151,10 @@ func (c *converter) collectRawHTMLRun(start ast.Node) (run string, next ast.Node
|
||||
default:
|
||||
return buf.String(), ch
|
||||
}
|
||||
|
||||
ch = ch.NextSibling()
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
@@ -198,6 +209,7 @@ func (c *converter) convertNode(n ast.Node) ([]Node, error) {
|
||||
case goldmarkast.KindTableCell:
|
||||
return nil, fmt.Errorf("cannot convert table cell outside of a table row")
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot convert markdown node of kind %s", n.Kind())
|
||||
}
|
||||
}
|
||||
@@ -255,6 +267,7 @@ func (c *converter) convertFencedCodeBlock(n *ast.FencedCodeBlock) ([]Node, erro
|
||||
content := normalizeCodeBlockContent(buf.String())
|
||||
|
||||
var lang *string
|
||||
|
||||
if n.Language(c.source) != nil {
|
||||
l := string(n.Language(c.source))
|
||||
lang = &l
|
||||
@@ -321,6 +334,7 @@ func (c *converter) convertList(n *ast.List) ([]Node, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot marshal ordered list attrs: %w", err)
|
||||
}
|
||||
|
||||
return []Node{{
|
||||
Type: NodeOrderedList,
|
||||
Content: children,
|
||||
@@ -376,6 +390,7 @@ func (c *converter) convertText(n *ast.Text) ([]Node, error) {
|
||||
if n.SoftLineBreak() {
|
||||
content += " "
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -417,6 +432,7 @@ func (c *converter) convertEmphasis(n *ast.Emphasis) ([]Node, error) {
|
||||
c.marks = append(c.marks, mark)
|
||||
children, err := c.convertInlineChildren(n)
|
||||
c.marks = c.marks[:len(c.marks)-1]
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -466,6 +482,7 @@ func (c *converter) convertLink(n *ast.Link) ([]Node, error) {
|
||||
c.marks = append(c.marks, Mark{Type: MarkLink, Attrs: attrs})
|
||||
children, err := c.convertInlineChildren(n)
|
||||
c.marks = c.marks[:len(c.marks)-1]
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -477,6 +494,7 @@ func (c *converter) convertAutoLink(n *ast.AutoLink) ([]Node, error) {
|
||||
url := string(n.URL(c.source))
|
||||
|
||||
linkAttrs := LinkAttrs{Href: safeLinkHref(url)}
|
||||
|
||||
attrs, err := json.Marshal(linkAttrs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot marshal link attrs: %w", err)
|
||||
@@ -496,19 +514,24 @@ func (c *converter) convertRawHTML(n ast.Node) ([]Node, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -519,6 +542,7 @@ func (c *converter) convertHTMLBlock(n *ast.HTMLBlock) ([]Node, error) {
|
||||
line := n.Lines().At(i)
|
||||
buf.Write(line.Value(c.source))
|
||||
}
|
||||
|
||||
if n.HasClosure() {
|
||||
buf.Write(n.ClosureLine.Value(c.source))
|
||||
}
|
||||
@@ -535,6 +559,7 @@ func (c *converter) convertStrikethrough(n ast.Node) ([]Node, error) {
|
||||
c.marks = append(c.marks, Mark{Type: MarkStrike})
|
||||
children, err := c.convertInlineChildren(n)
|
||||
c.marks = c.marks[:len(c.marks)-1]
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -550,6 +575,7 @@ func (c *converter) convertTable(n ast.Node) ([]Node, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows = append(rows, converted...)
|
||||
}
|
||||
|
||||
@@ -622,10 +648,12 @@ func (c *converter) convertTableCells(row ast.Node, cellType NodeType) ([]Node,
|
||||
func (c *converter) convertTableCellContent(cell ast.Node) ([]Node, error) {
|
||||
if c.cellHasBlockHTML(cell) {
|
||||
raw := c.collectCellRawContent(cell)
|
||||
|
||||
nodes, err := convertProseMirrorFromHTMLBlock(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(nodes) > 0 {
|
||||
return nodes, nil
|
||||
}
|
||||
@@ -635,6 +663,7 @@ func (c *converter) convertTableCellContent(cell ast.Node) ([]Node, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []Node{{Type: NodeParagraph, Content: inlineContent}}, nil
|
||||
}
|
||||
|
||||
@@ -643,15 +672,18 @@ func (c *converter) cellHasBlockHTML(cell ast.Node) bool {
|
||||
if ch.Kind() != ast.KindRawHTML {
|
||||
continue
|
||||
}
|
||||
|
||||
raw := ch.(*ast.RawHTML)
|
||||
for i := 0; i < raw.Segments.Len(); i++ {
|
||||
seg := raw.Segments.At(i)
|
||||
|
||||
val := strings.ToLower(string(seg.Value(c.source)))
|
||||
if containsBlockOpenTag(val) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -664,11 +696,13 @@ func containsBlockOpenTag(s string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *converter) collectCellRawContent(cell ast.Node) string {
|
||||
var buf bytes.Buffer
|
||||
|
||||
for ch := cell.FirstChild(); ch != nil; ch = ch.NextSibling() {
|
||||
switch ch.Kind() {
|
||||
case ast.KindRawHTML:
|
||||
@@ -680,6 +714,7 @@ func (c *converter) collectCellRawContent(cell ast.Node) string {
|
||||
case ast.KindText:
|
||||
t := ch.(*ast.Text)
|
||||
buf.Write(t.Segment.Value(c.source))
|
||||
|
||||
if t.SoftLineBreak() {
|
||||
buf.WriteByte(' ')
|
||||
}
|
||||
@@ -689,12 +724,14 @@ func (c *converter) collectCellRawContent(cell ast.Node) string {
|
||||
buf.WriteString(c.extractText(ch))
|
||||
}
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// extractText recursively collects the text content of all descendant nodes.
|
||||
func (c *converter) extractText(n ast.Node) string {
|
||||
var buf bytes.Buffer
|
||||
|
||||
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
switch child.Kind() {
|
||||
case ast.KindText:
|
||||
@@ -705,6 +742,7 @@ func (c *converter) extractText(n ast.Node) string {
|
||||
buf.WriteString(c.extractText(child))
|
||||
}
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
@@ -725,11 +763,14 @@ 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
|
||||
}
|
||||
|
||||
@@ -240,6 +240,7 @@ func TestParseMarkdown_LinkSanitizesDangerousHrefs(t *testing.T) {
|
||||
|
||||
doc, err := ParseMarkdown(tt.markdown)
|
||||
require.NoError(t, err)
|
||||
|
||||
txt := doc.Content[0].Content[0]
|
||||
linkAttrs, err := txt.Marks[0].LinkAttrs()
|
||||
require.NoError(t, err)
|
||||
@@ -378,11 +379,13 @@ func TestParseMarkdown_HardBreak(t *testing.T) {
|
||||
|
||||
// Should contain: text("line one"), hardBreak, text("line two")
|
||||
var hasHardBreak bool
|
||||
|
||||
for _, child := range p.Content {
|
||||
if child.Type == NodeHardBreak {
|
||||
hasHardBreak = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, hasHardBreak, "expected hard break node")
|
||||
}
|
||||
|
||||
@@ -397,11 +400,13 @@ func TestParseMarkdown_SoftLineBreak(t *testing.T) {
|
||||
require.Equal(t, NodeParagraph, p.Type)
|
||||
|
||||
var joined strings.Builder
|
||||
|
||||
for _, child := range p.Content {
|
||||
if child.Type == NodeText && child.Text != nil {
|
||||
joined.WriteString(*child.Text)
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, "line one and line two", joined.String())
|
||||
}
|
||||
|
||||
@@ -423,6 +428,7 @@ func TestParseMarkdown_NestedMarks(t *testing.T) {
|
||||
for _, m := range txt.Marks {
|
||||
markTypes[m.Type] = true
|
||||
}
|
||||
|
||||
assert.True(t, markTypes[MarkStrong])
|
||||
assert.True(t, markTypes[MarkEm])
|
||||
}
|
||||
|
||||
@@ -30,10 +30,12 @@ func RenderMarkdown(node Node) (string, error) {
|
||||
if err := r.renderNode(node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
out := strings.TrimRight(r.buf.String(), "\n")
|
||||
if out != "" {
|
||||
out += "\n"
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -62,53 +64,69 @@ func (r *mdRenderer) renderNode(n Node) error {
|
||||
return r.renderBlocks(n.Content)
|
||||
case NodeParagraph:
|
||||
r.ensurePrefix()
|
||||
|
||||
if err := r.renderInline(n.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.newLine()
|
||||
case NodeHeading:
|
||||
attrs, err := n.HeadingAttrs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render heading node: %w", err)
|
||||
}
|
||||
|
||||
if attrs.Level < 1 || attrs.Level > 6 {
|
||||
return fmt.Errorf("cannot render heading node: invalid level %d", attrs.Level)
|
||||
}
|
||||
|
||||
r.ensurePrefix()
|
||||
|
||||
for i := 0; i < attrs.Level; i++ {
|
||||
r.buf.WriteByte('#')
|
||||
}
|
||||
|
||||
r.buf.WriteByte(' ')
|
||||
|
||||
if err := r.renderInline(n.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.newLine()
|
||||
case NodeBlockquote:
|
||||
oldPrefix := r.prefix
|
||||
|
||||
r.prefix += "> "
|
||||
if err := r.renderBlocks(n.Content); err != nil {
|
||||
r.prefix = oldPrefix
|
||||
return err
|
||||
}
|
||||
|
||||
r.prefix = oldPrefix
|
||||
case NodeCodeBlock:
|
||||
attrs, err := n.CodeBlockAttrs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render code block node: %w", err)
|
||||
}
|
||||
|
||||
code := collectText(n.Content)
|
||||
fence := chooseFence(code)
|
||||
|
||||
r.ensurePrefix()
|
||||
r.buf.WriteString(fence)
|
||||
|
||||
if attrs.Language != nil {
|
||||
r.buf.WriteString(*attrs.Language)
|
||||
}
|
||||
|
||||
r.newLine()
|
||||
|
||||
for line := range strings.SplitSeq(code, "\n") {
|
||||
r.ensurePrefix()
|
||||
r.buf.WriteString(line)
|
||||
r.newLine()
|
||||
}
|
||||
|
||||
r.ensurePrefix()
|
||||
r.buf.WriteString(fence)
|
||||
r.newLine()
|
||||
@@ -126,18 +144,23 @@ func (r *mdRenderer) renderNode(n Node) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render image node: %w", err)
|
||||
}
|
||||
|
||||
r.ensurePrefix()
|
||||
r.buf.WriteString("
|
||||
r.buf.WriteString(safeImageSrc(attrs.Src))
|
||||
|
||||
if attrs.Title != nil {
|
||||
r.buf.WriteString(` "`)
|
||||
r.buf.WriteString(strings.ReplaceAll(*attrs.Title, `"`, `\"`))
|
||||
r.buf.WriteByte('"')
|
||||
}
|
||||
|
||||
r.buf.WriteByte(')')
|
||||
case NodeBulletList:
|
||||
return r.renderBulletList(n)
|
||||
@@ -152,6 +175,7 @@ func (r *mdRenderer) renderNode(n Node) error {
|
||||
default:
|
||||
return fmt.Errorf("cannot render node: unknown type %q", n.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -161,10 +185,12 @@ func (r *mdRenderer) renderBlocks(nodes []Node) error {
|
||||
r.ensurePrefix()
|
||||
r.newLine()
|
||||
}
|
||||
|
||||
if err := r.renderNode(n); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -174,6 +200,7 @@ func (r *mdRenderer) renderInline(nodes []Node) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -190,12 +217,14 @@ func (r *mdRenderer) renderText(n Node) error {
|
||||
}
|
||||
|
||||
var hasCode bool
|
||||
|
||||
for _, m := range n.Marks {
|
||||
if m.Type == MarkCode {
|
||||
hasCode = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasCode {
|
||||
return r.renderCodeText(n)
|
||||
}
|
||||
@@ -203,6 +232,7 @@ func (r *mdRenderer) renderText(n Node) error {
|
||||
text := *n.Text
|
||||
|
||||
var needsTrim bool
|
||||
|
||||
for _, m := range n.Marks {
|
||||
switch m.Type {
|
||||
case MarkStrong, MarkEm, MarkStrike:
|
||||
@@ -211,6 +241,7 @@ func (r *mdRenderer) renderText(n Node) error {
|
||||
}
|
||||
|
||||
var leading, trailing string
|
||||
|
||||
if needsTrim {
|
||||
origLen := len(text)
|
||||
text = strings.TrimLeft(text, " ")
|
||||
@@ -223,6 +254,7 @@ func (r *mdRenderer) renderText(n Node) error {
|
||||
if text == "" {
|
||||
r.buf.WriteString(leading)
|
||||
r.buf.WriteString(trailing)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -251,6 +283,7 @@ func (r *mdRenderer) renderText(n Node) error {
|
||||
// Inline code fences must be longer than this value (CommonMark).
|
||||
func maxConsecutiveBackticks(s string) int {
|
||||
max, cur := 0, 0
|
||||
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '`' {
|
||||
cur++
|
||||
@@ -261,6 +294,7 @@ func maxConsecutiveBackticks(s string) int {
|
||||
cur = 0
|
||||
}
|
||||
}
|
||||
|
||||
return max
|
||||
}
|
||||
|
||||
@@ -269,6 +303,7 @@ func (r *mdRenderer) renderCodeText(n Node) error {
|
||||
fence := strings.Repeat("`", maxConsecutiveBackticks(text)+1)
|
||||
|
||||
var otherMarks []Mark
|
||||
|
||||
for _, m := range n.Marks {
|
||||
if m.Type != MarkCode {
|
||||
otherMarks = append(otherMarks, m)
|
||||
@@ -282,13 +317,17 @@ func (r *mdRenderer) renderCodeText(n Node) error {
|
||||
}
|
||||
|
||||
r.buf.WriteString(fence)
|
||||
|
||||
if len(fence) > 1 {
|
||||
r.buf.WriteByte(' ')
|
||||
}
|
||||
|
||||
r.buf.WriteString(text)
|
||||
|
||||
if len(fence) > 1 {
|
||||
r.buf.WriteByte(' ')
|
||||
}
|
||||
|
||||
r.buf.WriteString(fence)
|
||||
|
||||
for i := len(otherMarks) - 1; i >= 0; i-- {
|
||||
@@ -315,6 +354,7 @@ func (r *mdRenderer) openMark(m Mark) error {
|
||||
default:
|
||||
return fmt.Errorf("cannot render mark: unknown type %q", m.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -333,17 +373,21 @@ func (r *mdRenderer) closeMark(m Mark) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render link mark: %w", err)
|
||||
}
|
||||
|
||||
r.buf.WriteString("](")
|
||||
r.buf.WriteString(safeLinkHref(attrs.Href))
|
||||
|
||||
if attrs.Title != nil {
|
||||
r.buf.WriteString(` "`)
|
||||
r.buf.WriteString(strings.ReplaceAll(*attrs.Title, `"`, `\"`))
|
||||
r.buf.WriteByte('"')
|
||||
}
|
||||
|
||||
r.buf.WriteByte(')')
|
||||
default:
|
||||
return fmt.Errorf("cannot render mark: unknown type %q", m.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -352,11 +396,13 @@ func (r *mdRenderer) renderBulletList(n Node) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render bullet list: %w", err)
|
||||
}
|
||||
|
||||
for i, item := range n.Content {
|
||||
if i > 0 && !tight {
|
||||
r.ensurePrefix()
|
||||
r.newLine()
|
||||
}
|
||||
|
||||
r.ensurePrefix()
|
||||
r.buf.WriteString("- ")
|
||||
r.atLineStart = false
|
||||
@@ -369,12 +415,14 @@ func (r *mdRenderer) renderBulletList(n Node) error {
|
||||
if err := r.renderBlocks(item.Content); err != nil {
|
||||
r.prefix = oldPrefix
|
||||
r.tight = oldTight
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
r.prefix = oldPrefix
|
||||
r.tight = oldTight
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -383,17 +431,22 @@ func (r *mdRenderer) renderOrderedList(n Node) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render ordered list node: %w", err)
|
||||
}
|
||||
|
||||
tight, err := listTightness(n)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render ordered list: %w", err)
|
||||
}
|
||||
|
||||
start := max(attrs.Start, 1)
|
||||
|
||||
for i, item := range n.Content {
|
||||
if i > 0 && !tight {
|
||||
r.ensurePrefix()
|
||||
r.newLine()
|
||||
}
|
||||
|
||||
r.ensurePrefix()
|
||||
|
||||
num := strconv.Itoa(start + i)
|
||||
r.buf.WriteString(num)
|
||||
r.buf.WriteString(". ")
|
||||
@@ -408,12 +461,14 @@ func (r *mdRenderer) renderOrderedList(n Node) error {
|
||||
if err := r.renderBlocks(item.Content); err != nil {
|
||||
r.prefix = oldPrefix
|
||||
r.tight = oldTight
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
r.prefix = oldPrefix
|
||||
r.tight = oldTight
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -427,34 +482,45 @@ func (r *mdRenderer) renderGFMTable(n Node) error {
|
||||
}
|
||||
|
||||
headerRow := n.Content[0]
|
||||
|
||||
r.ensurePrefix()
|
||||
r.buf.WriteByte('|')
|
||||
|
||||
for _, cell := range headerRow.Content {
|
||||
r.buf.WriteByte(' ')
|
||||
|
||||
if err := r.renderCellInline(cell); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.buf.WriteString(" |")
|
||||
}
|
||||
|
||||
r.newLine()
|
||||
|
||||
r.ensurePrefix()
|
||||
r.buf.WriteByte('|')
|
||||
|
||||
for range headerRow.Content {
|
||||
r.buf.WriteString(" --- |")
|
||||
}
|
||||
|
||||
r.newLine()
|
||||
|
||||
for _, row := range n.Content[1:] {
|
||||
r.ensurePrefix()
|
||||
r.buf.WriteByte('|')
|
||||
|
||||
for _, cell := range row.Content {
|
||||
r.buf.WriteByte(' ')
|
||||
|
||||
if err := r.renderCellInline(cell); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.buf.WriteString(" |")
|
||||
}
|
||||
|
||||
r.newLine()
|
||||
}
|
||||
|
||||
@@ -465,13 +531,16 @@ func (r *mdRenderer) renderCellInline(cell Node) error {
|
||||
if len(cell.Content) == 1 && cell.Content[0].Type == NodeParagraph {
|
||||
return r.renderInline(cell.Content[0].Content)
|
||||
}
|
||||
|
||||
for _, child := range cell.Content {
|
||||
h, err := RenderHTML(child)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render table cell content: %w", err)
|
||||
}
|
||||
|
||||
r.buf.WriteString(strings.ReplaceAll(h, "|", `\|`))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -479,6 +548,7 @@ func (r *mdRenderer) renderCellInline(cell Node) error {
|
||||
// Every direct child of n must be a listItem; otherwise listTightness returns an error.
|
||||
func listTightness(n Node) (tight bool, err error) {
|
||||
tight = true
|
||||
|
||||
for _, item := range n.Content {
|
||||
if item.Type != NodeListItem {
|
||||
return false, fmt.Errorf(
|
||||
@@ -487,20 +557,24 @@ func listTightness(n Node) (tight bool, err error) {
|
||||
NodeListItem,
|
||||
)
|
||||
}
|
||||
|
||||
if len(item.Content) != 1 {
|
||||
tight = false
|
||||
}
|
||||
}
|
||||
|
||||
return tight, nil
|
||||
}
|
||||
|
||||
func collectText(nodes []Node) string {
|
||||
var buf strings.Builder
|
||||
|
||||
for _, n := range nodes {
|
||||
if n.Text != nil {
|
||||
buf.WriteString(*n.Text)
|
||||
}
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
@@ -509,18 +583,22 @@ func chooseFence(code string) string {
|
||||
for strings.Contains(code, fence) {
|
||||
fence += "`"
|
||||
}
|
||||
|
||||
return fence
|
||||
}
|
||||
|
||||
func escapeMarkdown(s string) string {
|
||||
var buf strings.Builder
|
||||
buf.Grow(len(s))
|
||||
|
||||
for _, c := range s {
|
||||
switch c {
|
||||
case '\\', '*', '_', '`', '[', ']', '~', '|', '<':
|
||||
buf.WriteByte('\\')
|
||||
}
|
||||
|
||||
buf.WriteRune(c)
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
@@ -91,7 +91,9 @@ func TestRenderMarkdown_HeadingLevels(t *testing.T) {
|
||||
"level "+string(rune('0'+tc.level)),
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"heading","attrs":{"level":` + string(rune('0'+tc.level)) + `},"content":[{"type":"text","text":"X"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -107,6 +109,7 @@ func TestRenderMarkdown_HeadingInvalidLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"heading","attrs":{"level":7},"content":[{"type":"text","text":"X"}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -119,6 +122,7 @@ func TestRenderMarkdown_Bold(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold"}],"text":"bold"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -131,6 +135,7 @@ func TestRenderMarkdown_Italic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"italic"}],"text":"italic"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -143,6 +148,7 @@ func TestRenderMarkdown_ItalicTrailingSpace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"italic"}],"text":"italic "},{"type":"text","text":"rest"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -155,6 +161,7 @@ func TestRenderMarkdown_Strikethrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"strike"}],"text":"deleted"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -167,6 +174,7 @@ func TestRenderMarkdown_Underline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"underline"}],"text":"underlined"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -179,6 +187,7 @@ func TestRenderMarkdown_InlineCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"code"}],"text":"code"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -191,6 +200,7 @@ func TestRenderMarkdown_InlineCodeWithBacktick(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"code"}],"text":"a ` + "`" + ` b"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -204,6 +214,7 @@ func TestRenderMarkdown_InlineCodeWithDoubleBacktickRun(t *testing.T) {
|
||||
|
||||
// Two consecutive backticks in content need a 3+ backtick fence.
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"code"}],"text":"` + "``" + `x"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -216,6 +227,7 @@ func TestRenderMarkdown_InlineCodeWithTripleBacktickRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"code"}],"text":"` + "```" + `"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -228,6 +240,7 @@ func TestRenderMarkdown_Link(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":null,"rel":null,"class":null,"title":null}}],"text":"click"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -240,6 +253,7 @@ func TestRenderMarkdown_LinkWithTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":null,"rel":null,"class":null,"title":"My Title"}}],"text":"click"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -267,9 +281,12 @@ func TestRenderMarkdown_LinkSanitizesDangerousHrefs(t *testing.T) {
|
||||
tc.name,
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hrefJSON, err := json.Marshal(tc.href)
|
||||
require.NoError(t, err)
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"link","attrs":{"href":` + string(hrefJSON) + `,"target":null,"rel":null,"class":null,"title":null}}],"text":"x"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -285,6 +302,7 @@ func TestRenderMarkdown_MultipleMarks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold"},{"type":"italic"}],"text":"hello"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -297,6 +315,7 @@ func TestRenderMarkdown_CodeBlockWithLanguage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"codeBlock","attrs":{"language":"go"},"content":[{"type":"text","text":"fmt.Println()"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -309,6 +328,7 @@ func TestRenderMarkdown_CodeBlockWithoutLanguage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"codeBlock","attrs":{"language":null},"content":[{"type":"text","text":"hello"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -321,6 +341,7 @@ func TestRenderMarkdown_CodeBlockWithTripleBackticks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"codeBlock","attrs":{"language":null},"content":[{"type":"text","text":"` + "```" + `\nsome code"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -369,6 +390,7 @@ func TestRenderMarkdown_Image(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"image","attrs":{"src":"https://example.com/img.png","alt":"A photo","title":"My image"}}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -381,6 +403,7 @@ func TestRenderMarkdown_ImageWithoutTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"image","attrs":{"src":"https://example.com/img.png","alt":"A photo","title":null}}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -393,6 +416,7 @@ func TestRenderMarkdown_ImageSanitizesDangerousSrc(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"image","attrs":{"src":"javascript:alert(1)","alt":null,"title":null}}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -405,6 +429,7 @@ func TestRenderMarkdown_BulletList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"one"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"two"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"three"}]}]}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -417,6 +442,7 @@ func TestRenderMarkdown_OrderedList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"orderedList","attrs":{"start":1,"type":null},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"second"}]}]}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -429,6 +455,7 @@ func TestRenderMarkdown_OrderedListWithStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"orderedList","attrs":{"start":5,"type":null},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item"}]}]}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -441,6 +468,7 @@ func TestRenderMarkdown_NestedList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"parent"}]},{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"child"}]}]}]}]}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -453,6 +481,7 @@ func TestRenderMarkdown_Blockquote(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"quoted"}]}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -465,6 +494,7 @@ func TestRenderMarkdown_BlockquoteMultipleParagraphs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]},{"type":"paragraph","content":[{"type":"text","text":"second"}]}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -477,6 +507,7 @@ func TestRenderMarkdown_GFMTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"Name"}]}]},{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"Age"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"Alice"}]}]},{"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"30"}]}]}]}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -489,6 +520,7 @@ func TestRenderMarkdown_TableWithBlockContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"Header"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item"}]}]}]}]}]}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -562,6 +594,7 @@ func TestRenderMarkdown_MixedContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Normal "},{"type":"text","marks":[{"type":"bold"}],"text":"bold"},{"type":"text","text":" and "},{"type":"text","marks":[{"type":"italic"}],"text":"italic"},{"type":"text","text":" text"}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -574,6 +607,7 @@ func TestRenderMarkdown_BlockquoteWithHardBreak(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -586,6 +620,7 @@ func TestRenderMarkdown_GFMTableWithMarks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"Header"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","marks":[{"type":"bold"}],"text":"bold"},{"type":"text","text":" and "},{"type":"text","marks":[{"type":"italic"}],"text":"italic"}]}]}]}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -617,6 +652,7 @@ func TestRenderMarkdown_CodeBlockInBlockquote(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"blockquote","content":[{"type":"codeBlock","attrs":{"language":"go"},"content":[{"type":"text","text":"fmt.Println()"}]}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -645,6 +681,7 @@ func TestRenderMarkdown_TableBlockCellEscapesPipes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"H"}]}]}]},{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a | b"}]}]}]}]}]}]}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ func Parse(s string) (Node, error) {
|
||||
if err := json.Unmarshal([]byte(s), &n); err != nil {
|
||||
return Node{}, fmt.Errorf("cannot parse prosemirror node: %w", err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -127,6 +128,7 @@ func (n Node) HeadingAttrs() (HeadingAttrs, error) {
|
||||
if err := json.Unmarshal(n.Attrs, &a); err != nil {
|
||||
return a, fmt.Errorf("cannot parse heading attrs: %w", err)
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -135,10 +137,12 @@ func (n Node) CodeBlockAttrs() (CodeBlockAttrs, error) {
|
||||
if len(n.Attrs) == 0 {
|
||||
return CodeBlockAttrs{}, nil
|
||||
}
|
||||
|
||||
var a CodeBlockAttrs
|
||||
if err := json.Unmarshal(n.Attrs, &a); err != nil {
|
||||
return a, fmt.Errorf("cannot parse code block attrs: %w", err)
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -148,6 +152,7 @@ func (n Node) OrderedListAttrs() (OrderedListAttrs, error) {
|
||||
if err := json.Unmarshal(n.Attrs, &a); err != nil {
|
||||
return a, fmt.Errorf("cannot parse ordered list attrs: %w", err)
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -157,6 +162,7 @@ func (n Node) ImageAttrs() (ImageAttrs, error) {
|
||||
if err := json.Unmarshal(n.Attrs, &a); err != nil {
|
||||
return a, fmt.Errorf("cannot parse image attrs: %w", err)
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -166,6 +172,7 @@ func (n Node) TableCellAttrs() (TableCellAttrs, error) {
|
||||
if err := json.Unmarshal(n.Attrs, &a); err != nil {
|
||||
return a, fmt.Errorf("cannot parse table cell attrs: %w", err)
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -176,9 +183,11 @@ func (n Node) TextLength() int {
|
||||
if n.Text != nil {
|
||||
length += utf8.RuneCountInString(*n.Text)
|
||||
}
|
||||
|
||||
for _, child := range n.Content {
|
||||
length += child.TextLength()
|
||||
}
|
||||
|
||||
return length
|
||||
}
|
||||
|
||||
@@ -188,5 +197,6 @@ func (m Mark) LinkAttrs() (LinkAttrs, error) {
|
||||
if err := json.Unmarshal(m.Attrs, &a); err != nil {
|
||||
return a, fmt.Errorf("cannot parse link attrs: %w", err)
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ func loadTestDocument(t *testing.T) Node {
|
||||
|
||||
var doc Node
|
||||
require.NoError(t, json.Unmarshal(data, &doc))
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
@@ -46,6 +47,7 @@ func TestUnmarshalDocument(t *testing.T) {
|
||||
"heading level 1",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h1 := doc.Content[0]
|
||||
assert.Equal(t, NodeHeading, h1.Type)
|
||||
|
||||
@@ -64,6 +66,7 @@ func TestUnmarshalDocument(t *testing.T) {
|
||||
"paragraph with mixed marks",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := doc.Content[1]
|
||||
assert.Equal(t, NodeParagraph, p.Type)
|
||||
require.True(t, len(p.Content) > 5)
|
||||
@@ -119,6 +122,7 @@ func TestUnmarshalDocument(t *testing.T) {
|
||||
"heading level 2",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h2 := doc.Content[2]
|
||||
assert.Equal(t, NodeHeading, h2.Type)
|
||||
|
||||
@@ -132,6 +136,7 @@ func TestUnmarshalDocument(t *testing.T) {
|
||||
"code block",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cb := doc.Content[4]
|
||||
assert.Equal(t, NodeCodeBlock, cb.Type)
|
||||
|
||||
@@ -149,6 +154,7 @@ func TestUnmarshalDocument(t *testing.T) {
|
||||
"heading level 3",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h3 := doc.Content[5]
|
||||
attrs, err := h3.HeadingAttrs()
|
||||
require.NoError(t, err)
|
||||
@@ -160,6 +166,7 @@ func TestUnmarshalDocument(t *testing.T) {
|
||||
"bullet list",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bl := doc.Content[6]
|
||||
assert.Equal(t, NodeBulletList, bl.Type)
|
||||
require.Len(t, bl.Content, 3)
|
||||
@@ -186,6 +193,7 @@ func TestUnmarshalDocument(t *testing.T) {
|
||||
"ordered list",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ol := doc.Content[8]
|
||||
assert.Equal(t, NodeOrderedList, ol.Type)
|
||||
require.Len(t, ol.Content, 3)
|
||||
@@ -201,6 +209,7 @@ func TestUnmarshalDocument(t *testing.T) {
|
||||
"blockquote",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bq := doc.Content[10]
|
||||
assert.Equal(t, NodeBlockquote, bq.Type)
|
||||
require.Len(t, bq.Content, 1)
|
||||
@@ -217,6 +226,7 @@ func TestUnmarshalDocument(t *testing.T) {
|
||||
"table",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
table := doc.Content[12]
|
||||
assert.Equal(t, NodeTable, table.Type)
|
||||
require.Len(t, table.Content, 3)
|
||||
@@ -225,6 +235,7 @@ func TestUnmarshalDocument(t *testing.T) {
|
||||
headerRow := table.Content[0]
|
||||
assert.Equal(t, NodeTableRow, headerRow.Type)
|
||||
require.Len(t, headerRow.Content, 4)
|
||||
|
||||
for _, cell := range headerRow.Content {
|
||||
assert.Equal(t, NodeTableHeader, cell.Type)
|
||||
}
|
||||
@@ -247,6 +258,7 @@ func TestUnmarshalDocument(t *testing.T) {
|
||||
for _, row := range table.Content[1:] {
|
||||
assert.Equal(t, NodeTableRow, row.Type)
|
||||
require.Len(t, row.Content, 4)
|
||||
|
||||
for _, cell := range row.Content {
|
||||
assert.Equal(t, NodeTableCell, cell.Type)
|
||||
}
|
||||
@@ -268,6 +280,7 @@ func TestUnmarshalDocument(t *testing.T) {
|
||||
"trailing empty paragraph",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
emptyP := doc.Content[13]
|
||||
assert.Equal(t, NodeParagraph, emptyP.Type)
|
||||
assert.Empty(t, emptyP.Content)
|
||||
@@ -297,6 +310,7 @@ func TestHeadingAttrs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"heading","attrs":{"level":3},"content":[{"type":"text","text":"Hello"}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -312,7 +326,9 @@ func TestCodeBlockAttrs(t *testing.T) {
|
||||
"with language",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"codeBlock","attrs":{"language":"go"},"content":[{"type":"text","text":"fmt.Println()"}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -327,7 +343,9 @@ func TestCodeBlockAttrs(t *testing.T) {
|
||||
"with null language",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"codeBlock","attrs":{"language":null}}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -342,6 +360,7 @@ func TestOrderedListAttrs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"orderedList","attrs":{"start":5,"type":null}}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -355,6 +374,7 @@ func TestImageAttrs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"image","attrs":{"src":"https://example.com/img.png","alt":"An image","title":null}}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -373,7 +393,9 @@ func TestTableCellAttrs(t *testing.T) {
|
||||
"with colwidth",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"tableCell","attrs":{"colspan":2,"rowspan":1,"colwidth":[100,200]}}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -389,7 +411,9 @@ func TestTableCellAttrs(t *testing.T) {
|
||||
"with null colwidth",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null}}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
@@ -406,6 +430,7 @@ func TestLinkAttrs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"link","attrs":{"href":"https://example.com","target":"_blank","rel":"noopener","class":null,"title":"Example"}}`
|
||||
|
||||
var m Mark
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &m))
|
||||
|
||||
@@ -428,6 +453,7 @@ func TestTextLength(t *testing.T) {
|
||||
"empty doc",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
n := Node{Type: NodeDoc}
|
||||
assert.Equal(t, 0, n.TextLength())
|
||||
},
|
||||
@@ -437,6 +463,7 @@ func TestTextLength(t *testing.T) {
|
||||
"single text node",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
text := "hello"
|
||||
n := Node{Type: NodeText, Text: &text}
|
||||
assert.Equal(t, 5, n.TextLength())
|
||||
@@ -447,6 +474,7 @@ func TestTextLength(t *testing.T) {
|
||||
"paragraph with text",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello world"}]}]}`
|
||||
doc, err := Parse(raw)
|
||||
require.NoError(t, err)
|
||||
@@ -458,6 +486,7 @@ func TestTextLength(t *testing.T) {
|
||||
"multiple paragraphs",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"aaa"}]},{"type":"paragraph","content":[{"type":"text","text":"bb"}]}]}`
|
||||
doc, err := Parse(raw)
|
||||
require.NoError(t, err)
|
||||
@@ -469,6 +498,7 @@ func TestTextLength(t *testing.T) {
|
||||
"formatted text counts only text",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"plain "},{"type":"text","marks":[{"type":"bold"}],"text":"bold"}]}]}`
|
||||
doc, err := Parse(raw)
|
||||
require.NoError(t, err)
|
||||
@@ -480,6 +510,7 @@ func TestTextLength(t *testing.T) {
|
||||
"nested list structure",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item 1"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item 2"}]}]}]}]}`
|
||||
doc, err := Parse(raw)
|
||||
require.NoError(t, err)
|
||||
@@ -491,6 +522,7 @@ func TestTextLength(t *testing.T) {
|
||||
"multi-byte unicode characters",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"café résumé"}]}]}`
|
||||
doc, err := Parse(raw)
|
||||
require.NoError(t, err)
|
||||
@@ -512,6 +544,7 @@ func TestNodeWithNoAttrs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := `{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}`
|
||||
|
||||
var n Node
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &n))
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ func ValidateDocumentContentJSON(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := parseDocRoot(s)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -35,9 +37,11 @@ func parseDocRoot(s string) (Node, error) {
|
||||
if err != nil {
|
||||
return Node{}, fmt.Errorf("cannot parse document content as ProseMirror JSON: %w", err)
|
||||
}
|
||||
|
||||
if n.Type != NodeDoc {
|
||||
return Node{}, fmt.Errorf("document content root must be type %q", NodeDoc)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -69,9 +73,11 @@ func sanitizeNode(n *Node) {
|
||||
if n.Type == NodeImage {
|
||||
sanitizeImageNode(n)
|
||||
}
|
||||
|
||||
for i := range n.Marks {
|
||||
sanitizeLinkMark(&n.Marks[i])
|
||||
}
|
||||
|
||||
for i := range n.Content {
|
||||
sanitizeNode(&n.Content[i])
|
||||
}
|
||||
@@ -85,6 +91,7 @@ func sanitizeImageNode(n *Node) {
|
||||
}
|
||||
|
||||
attrs.Src = safeImageSrc(attrs.Src)
|
||||
|
||||
raw, err := json.Marshal(attrs)
|
||||
if err != nil {
|
||||
n.Attrs = []byte(`{"src":""}`)
|
||||
@@ -106,6 +113,7 @@ func sanitizeLinkMark(m *Mark) {
|
||||
}
|
||||
|
||||
attrs.Href = safeLinkHref(attrs.Href)
|
||||
|
||||
raw, err := json.Marshal(attrs)
|
||||
if err != nil {
|
||||
m.Attrs = []byte(`{"href":"#"}`)
|
||||
|
||||
Reference in New Issue
Block a user