From 5280898eb9a0f296baf5ef3c8f59df22c55c2fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Mon, 6 Apr 2026 11:20:40 +0400 Subject: [PATCH] Implement prosemirror markdown renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- pkg/prosemirror/markdown_renderer.go | 505 +++++++++++++++++ pkg/prosemirror/markdown_renderer_test.go | 630 ++++++++++++++++++++++ pkg/prosemirror/testdata/document.md | 37 ++ 3 files changed, 1172 insertions(+) create mode 100644 pkg/prosemirror/markdown_renderer.go create mode 100644 pkg/prosemirror/markdown_renderer_test.go create mode 100644 pkg/prosemirror/testdata/document.md diff --git a/pkg/prosemirror/markdown_renderer.go b/pkg/prosemirror/markdown_renderer.go new file mode 100644 index 000000000..b4527dda4 --- /dev/null +++ b/pkg/prosemirror/markdown_renderer.go @@ -0,0 +1,505 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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("") + 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("") + 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() +} diff --git a/pkg/prosemirror/markdown_renderer_test.go b/pkg/prosemirror/markdown_renderer_test.go new file mode 100644 index 000000000..f1fa20471 --- /dev/null +++ b/pkg/prosemirror/markdown_renderer_test.go @@ -0,0 +1,630 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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, "underlined\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,`, 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|
  • item

|\n", got) +} + +func TestRenderMarkdown_MarkdownEscaping(t *testing.T) { + t.Parallel() + + text := `*bold* _italic_ ` + "`code`" + ` [link] ~strike~ |pipe| ` + 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, `\`) +} + +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, "") +} diff --git a/pkg/prosemirror/testdata/document.md b/pkg/prosemirror/testdata/document.md new file mode 100644 index 000000000..78ff803c0 --- /dev/null +++ b/pkg/prosemirror/testdata/document.md @@ -0,0 +1,37 @@ +# Heading 1 + +This is a paragraph **with some bold** and *some italic* and some underlined text.\ +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 item 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~~ |
  • 1

  • 2

| +| td 21 | td [22](https://example.org) | td `23` |
  1. A

  2. B

|