Add wsl linter and fix

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-05-19 14:51:08 +04:00
parent eedfdcecc8
commit 9156d6a16a
882 changed files with 6068 additions and 574 deletions

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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])
}

View File

@@ -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("![")
if attrs.Alt != nil {
r.buf.WriteString(escapeMarkdown(*attrs.Alt))
}
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()
}

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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))

View File

@@ -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":"#"}`)