Implement prosemirror markdown renderer
Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
505
pkg/prosemirror/markdown_renderer.go
Normal file
505
pkg/prosemirror/markdown_renderer.go
Normal 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("
|
||||
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()
|
||||
}
|
||||
630
pkg/prosemirror/markdown_renderer_test.go
Normal file
630
pkg/prosemirror/markdown_renderer_test.go
Normal 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, "\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, "\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
37
pkg/prosemirror/testdata/document.md
vendored
Normal 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> |
|
||||
Reference in New Issue
Block a user