Implement prosemirror markdown renderer

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-06 11:20:40 +04:00
parent ab33285c9c
commit 5280898eb9
3 changed files with 1172 additions and 0 deletions

View File

@@ -0,0 +1,505 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package prosemirror
import (
"bytes"
"fmt"
"strconv"
"strings"
)
// RenderMarkdown renders a ProseMirror document node tree to a Markdown string.
func RenderMarkdown(node Node) (string, error) {
r := &mdRenderer{
buf: &bytes.Buffer{},
atLineStart: true,
}
if err := r.renderNode(node); err != nil {
return "", err
}
out := strings.TrimRight(r.buf.String(), "\n")
if out != "" {
out += "\n"
}
return out, nil
}
type mdRenderer struct {
buf *bytes.Buffer
prefix string
tight bool
atLineStart bool
}
func (r *mdRenderer) ensurePrefix() {
if r.atLineStart {
r.buf.WriteString(r.prefix)
r.atLineStart = false
}
}
func (r *mdRenderer) newLine() {
r.buf.WriteByte('\n')
r.atLineStart = true
}
func (r *mdRenderer) renderNode(n Node) error {
switch n.Type {
case NodeDoc:
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.Split(code, "\n") {
r.ensurePrefix()
r.buf.WriteString(line)
r.newLine()
}
r.ensurePrefix()
r.buf.WriteString(fence)
r.newLine()
case NodeHorizontalRule:
r.ensurePrefix()
r.buf.WriteString("---")
r.newLine()
case NodeHardBreak:
r.buf.WriteByte('\\')
r.newLine()
case NodeText:
return r.renderText(n)
case NodeImage:
attrs, err := n.ImageAttrs()
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)
case NodeOrderedList:
return r.renderOrderedList(n)
case NodeListItem:
return fmt.Errorf("cannot render list item outside of list context")
case NodeTable:
return r.renderTable(n)
case NodeTableRow, NodeTableCell, NodeTableHeader:
return fmt.Errorf("cannot render %s outside of table context", n.Type)
default:
return fmt.Errorf("cannot render node: unknown type %q", n.Type)
}
return nil
}
func (r *mdRenderer) renderBlocks(nodes []Node) error {
for i, n := range nodes {
if i > 0 && !r.tight {
r.ensurePrefix()
r.newLine()
}
if err := r.renderNode(n); err != nil {
return err
}
}
return nil
}
func (r *mdRenderer) renderInline(nodes []Node) error {
for _, n := range nodes {
if err := r.renderNode(n); err != nil {
return err
}
}
return nil
}
func (r *mdRenderer) renderText(n Node) error {
if n.Text == nil {
return fmt.Errorf("cannot render text node: text is nil")
}
r.ensurePrefix()
if len(n.Marks) == 0 {
r.buf.WriteString(escapeMarkdown(*n.Text))
return nil
}
var hasCode bool
for _, m := range n.Marks {
if m.Type == MarkCode {
hasCode = true
break
}
}
if hasCode {
return r.renderCodeText(n)
}
text := *n.Text
var needsTrim bool
for _, m := range n.Marks {
switch m.Type {
case MarkStrong, MarkEm, MarkStrike:
needsTrim = true
}
}
var leading, trailing string
if needsTrim {
origLen := len(text)
text = strings.TrimLeft(text, " ")
leading = strings.Repeat(" ", origLen-len(text))
origLen = len(text)
text = strings.TrimRight(text, " ")
trailing = strings.Repeat(" ", origLen-len(text))
}
if text == "" {
r.buf.WriteString(leading)
r.buf.WriteString(trailing)
return nil
}
r.buf.WriteString(leading)
for _, m := range n.Marks {
if err := r.openMark(m); err != nil {
return err
}
}
r.buf.WriteString(escapeMarkdown(text))
for i := len(n.Marks) - 1; i >= 0; i-- {
if err := r.closeMark(n.Marks[i]); err != nil {
return err
}
}
r.buf.WriteString(trailing)
return nil
}
func (r *mdRenderer) renderCodeText(n Node) error {
text := *n.Text
backtick := "`"
if strings.Contains(text, "`") {
backtick = "``"
}
var otherMarks []Mark
for _, m := range n.Marks {
if m.Type != MarkCode {
otherMarks = append(otherMarks, m)
}
}
for _, m := range otherMarks {
if err := r.openMark(m); err != nil {
return err
}
}
r.buf.WriteString(backtick)
if backtick == "``" {
r.buf.WriteByte(' ')
}
r.buf.WriteString(text)
if backtick == "``" {
r.buf.WriteByte(' ')
}
r.buf.WriteString(backtick)
for i := len(otherMarks) - 1; i >= 0; i-- {
if err := r.closeMark(otherMarks[i]); err != nil {
return err
}
}
return nil
}
func (r *mdRenderer) openMark(m Mark) error {
switch m.Type {
case MarkStrong:
r.buf.WriteString("**")
case MarkEm:
r.buf.WriteByte('*')
case MarkUnderline:
r.buf.WriteString("<u>")
case MarkStrike:
r.buf.WriteString("~~")
case MarkLink:
r.buf.WriteByte('[')
default:
return fmt.Errorf("cannot render mark: unknown type %q", m.Type)
}
return nil
}
func (r *mdRenderer) closeMark(m Mark) error {
switch m.Type {
case MarkStrong:
r.buf.WriteString("**")
case MarkEm:
r.buf.WriteByte('*')
case MarkUnderline:
r.buf.WriteString("</u>")
case MarkStrike:
r.buf.WriteString("~~")
case MarkLink:
attrs, err := m.LinkAttrs()
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
}
func (r *mdRenderer) renderBulletList(n Node) error {
tight := isTightList(n)
for i, item := range n.Content {
if item.Type != NodeListItem {
continue
}
if i > 0 && !tight {
r.ensurePrefix()
r.newLine()
}
r.ensurePrefix()
r.buf.WriteString("- ")
r.atLineStart = false
oldPrefix := r.prefix
oldTight := r.tight
r.prefix += " "
r.tight = tight
if err := r.renderBlocks(item.Content); err != nil {
r.prefix = oldPrefix
r.tight = oldTight
return err
}
r.prefix = oldPrefix
r.tight = oldTight
}
return nil
}
func (r *mdRenderer) renderOrderedList(n Node) error {
attrs, err := n.OrderedListAttrs()
if err != nil {
return fmt.Errorf("cannot render ordered list node: %w", err)
}
tight := isTightList(n)
start := max(attrs.Start, 1)
for i, item := range n.Content {
if item.Type != NodeListItem {
continue
}
if i > 0 && !tight {
r.ensurePrefix()
r.newLine()
}
r.ensurePrefix()
num := strconv.Itoa(start + i)
r.buf.WriteString(num)
r.buf.WriteString(". ")
r.atLineStart = false
indent := strings.Repeat(" ", len(num)+2)
oldPrefix := r.prefix
oldTight := r.tight
r.prefix += indent
r.tight = tight
if err := r.renderBlocks(item.Content); err != nil {
r.prefix = oldPrefix
r.tight = oldTight
return err
}
r.prefix = oldPrefix
r.tight = oldTight
}
return nil
}
func (r *mdRenderer) renderTable(n Node) error {
return r.renderGFMTable(n)
}
func (r *mdRenderer) renderGFMTable(n Node) error {
if len(n.Content) == 0 {
return nil
}
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()
}
return nil
}
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
}
func isTightList(n Node) bool {
for _, item := range n.Content {
if item.Type != NodeListItem {
continue
}
if len(item.Content) != 1 {
return false
}
}
return true
}
func collectText(nodes []Node) string {
var buf strings.Builder
for _, n := range nodes {
if n.Text != nil {
buf.WriteString(*n.Text)
}
}
return buf.String()
}
func chooseFence(code string) string {
fence := "```"
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

@@ -0,0 +1,630 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package prosemirror
import (
"encoding/json"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRenderMarkdown_Document(t *testing.T) {
t.Parallel()
expected, err := os.ReadFile("testdata/document.md")
require.NoError(t, err)
doc := loadTestDocument(t)
got, err := RenderMarkdown(doc)
require.NoError(t, err)
assert.Equal(t, string(expected), got)
}
func TestRenderMarkdown_EmptyDoc(t *testing.T) {
t.Parallel()
got, err := RenderMarkdown(Node{Type: NodeDoc})
require.NoError(t, err)
assert.Equal(t, "", got)
}
func TestRenderMarkdown_EmptyParagraph(t *testing.T) {
t.Parallel()
got, err := RenderMarkdown(Node{
Type: NodeDoc,
Content: []Node{{Type: NodeParagraph}},
})
require.NoError(t, err)
assert.Equal(t, "", got)
}
func TestRenderMarkdown_Paragraph(t *testing.T) {
t.Parallel()
text := "Hello world"
got, err := RenderMarkdown(Node{
Type: NodeDoc,
Content: []Node{
{
Type: NodeParagraph,
Content: []Node{
{Type: NodeText, Text: &text},
},
},
},
})
require.NoError(t, err)
assert.Equal(t, "Hello world\n", got)
}
func TestRenderMarkdown_HeadingLevels(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
level int
want string
}{
{1, "# X\n"},
{2, "## X\n"},
{3, "### X\n"},
{4, "#### X\n"},
{5, "##### X\n"},
{6, "###### X\n"},
} {
t.Run(
"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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, tc.want, got)
},
)
}
}
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))
_, err := RenderMarkdown(n)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid level")
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "**bold**\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "*italic*\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "*italic* rest\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "~~deleted~~\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "<u>underlined</u>\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "`code`\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "`` a ` b ``\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "[click](https://example.com)\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "[click](https://example.com \"My Title\")\n", got)
}
func TestRenderMarkdown_LinkSanitizesDangerousHrefs(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
href string
wantHref string
}{
{name: "javascript scheme", href: `javascript:alert(1)`, wantHref: `#`},
{name: "data html", href: `data:text/html,<script>alert(1)</script>`, wantHref: `#`},
{name: "protocol-relative", href: `//evil.example/phish`, wantHref: `#`},
{name: "empty href", href: ``, wantHref: `#`},
{name: "https preserved", href: `https://example.com/x`, wantHref: `https://example.com/x`},
{name: "mailto", href: `mailto:user@example.com`, wantHref: `mailto:user@example.com`},
} {
t.Run(
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "[x]("+tc.wantHref+")\n", got)
},
)
}
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "***hello***\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "```go\nfmt.Println()\n```\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "```\nhello\n```\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Contains(t, got, "````")
assert.Contains(t, got, "```\nsome code")
}
func TestRenderMarkdown_HorizontalRule(t *testing.T) {
t.Parallel()
got, err := RenderMarkdown(Node{
Type: NodeDoc,
Content: []Node{
{Type: NodeHorizontalRule},
},
})
require.NoError(t, err)
assert.Equal(t, "---\n", got)
}
func TestRenderMarkdown_HardBreak(t *testing.T) {
t.Parallel()
line1 := "line one"
line2 := "line two"
got, err := RenderMarkdown(Node{
Type: NodeDoc,
Content: []Node{
{
Type: NodeParagraph,
Content: []Node{
{Type: NodeText, Text: &line1},
{Type: NodeHardBreak},
{Type: NodeText, Text: &line2},
},
},
},
})
require.NoError(t, err)
assert.Equal(t, "line one\\\nline two\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "![A photo](https://example.com/img.png \"My image\")\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "![A photo](https://example.com/img.png)\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "![]()\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "- one\n- two\n- three\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "1. first\n2. second\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "5. item\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "- parent\n \n - child\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "> quoted\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "> first\n> \n> second\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "| Name | Age |\n| --- | --- |\n| Alice | 30 |\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "| Header |\n| --- |\n| <ul><li><p>item</p></li></ul> |\n", got)
}
func TestRenderMarkdown_MarkdownEscaping(t *testing.T) {
t.Parallel()
text := `*bold* _italic_ ` + "`code`" + ` [link] ~strike~ |pipe| <html>`
got, err := RenderMarkdown(Node{
Type: NodeDoc,
Content: []Node{
{
Type: NodeParagraph,
Content: []Node{
{Type: NodeText, Text: &text},
},
},
},
})
require.NoError(t, err)
assert.NotContains(t, got, "*bold*")
assert.Contains(t, got, `\*bold\*`)
assert.Contains(t, got, `\_italic\_`)
assert.Contains(t, got, "\\`code\\`")
assert.Contains(t, got, `\[link\]`)
assert.Contains(t, got, `\~strike\~`)
assert.Contains(t, got, `\|pipe\|`)
assert.Contains(t, got, `\<html>`)
}
func TestRenderMarkdown_UnknownNodeType(t *testing.T) {
t.Parallel()
node := Node{Type: NodeType("unknownWidget")}
_, err := RenderMarkdown(node)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown type")
}
func TestRenderMarkdown_UnknownMarkType(t *testing.T) {
t.Parallel()
text := "hello"
node := Node{
Type: NodeDoc,
Content: []Node{
{
Type: NodeParagraph,
Content: []Node{
{
Type: NodeText,
Text: &text,
Marks: []Mark{
{Type: MarkType("superscript")},
},
},
},
},
},
}
_, err := RenderMarkdown(node)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown type")
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "Normal **bold** and *italic* text\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "> line one\\\n> line two\n", got)
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "| Header |\n| --- |\n| **bold** and *italic* |\n", got)
}
func TestRenderMarkdown_TextNil(t *testing.T) {
t.Parallel()
node := Node{
Type: NodeDoc,
Content: []Node{
{
Type: NodeParagraph,
Content: []Node{
{Type: NodeText, Text: nil},
},
},
},
}
_, err := RenderMarkdown(node)
assert.Error(t, err)
assert.Contains(t, err.Error(), "text is nil")
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Equal(t, "> ```go\n> fmt.Println()\n> ```\n", got)
}
func TestRenderMarkdown_ListItemOutsideList(t *testing.T) {
t.Parallel()
_, err := RenderMarkdown(Node{Type: NodeListItem})
assert.Error(t, err)
assert.Contains(t, err.Error(), "list item outside of list context")
}
func TestRenderMarkdown_TableRowOutsideTable(t *testing.T) {
t.Parallel()
_, err := RenderMarkdown(Node{Type: NodeTableRow})
assert.Error(t, err)
assert.Contains(t, err.Error(), "outside of table context")
}
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))
got, err := RenderMarkdown(n)
require.NoError(t, err)
assert.Contains(t, got, `a \| b`)
assert.NotContains(t, got, "<table>")
}

37
pkg/prosemirror/testdata/document.md vendored Normal file
View File

@@ -0,0 +1,37 @@
# Heading 1
This is a paragraph **with some bold** and *some italic* and some <u>underlined text</u>.\
It contains a line break, and some ~~strikethrough.~~\
There's some `inline code`. And a [link](https://getprobo.com)
## Heading 2
A simple paragraph.
```
code block
```
### Heading 3
- ul **list** item 1
- ul *list* item 2
- ul [list](https://google.com) item 3
---
1. ol list <u>item</u> 1
2. ol list `item` 2
3. ol list ~~item~~ 3
---
> Blockquote\
> Line **break**
---
| th 1 | th 2 | th 3 | th 4 |
| --- | --- | --- | --- |
| td *11* | td **12** | td ~~13~~ | <ul><li><p>1</p></li><li><p>2</p></li></ul> |
| td <u>21</u> | td [22](https://example.org) | td `23` | <ol><li><p>A</p></li><li><p>B</p></li></ol> |