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