Add promisemirror markdown capabilities

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-03-27 17:55:19 +04:00
parent 7dcfa90672
commit 745c52537d
12 changed files with 1178 additions and 0 deletions

453
pkg/prosemirror/markdown.go Normal file
View File

@@ -0,0 +1,453 @@
// 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"
"encoding/json"
"fmt"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
goldmarkext "github.com/yuin/goldmark/extension"
goldmarkast "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
)
// ParseMarkdown converts a markdown string into a ProseMirror Node tree.
func ParseMarkdown(markdown string) (Node, error) {
source := []byte(markdown)
md := goldmark.New(
goldmark.WithExtensions(goldmarkext.Strikethrough),
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
)
doc := md.Parser().Parse(text.NewReader(source))
c := &converter{source: source}
nodes, err := c.convertChildren(doc)
if err != nil {
return Node{}, fmt.Errorf("cannot convert markdown to prosemirror: %w", err)
}
return Node{
Type: NodeDoc,
Content: nodes,
}, nil
}
type converter struct {
source []byte
marks []Mark
}
func (c *converter) convertChildren(n ast.Node) ([]Node, error) {
var nodes []Node
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
converted, err := c.convertNode(child)
if err != nil {
return nil, err
}
nodes = append(nodes, converted...)
}
return nodes, nil
}
func (c *converter) convertNode(n ast.Node) ([]Node, error) {
switch n.Kind() {
case ast.KindHeading:
return c.convertHeading(n.(*ast.Heading))
case ast.KindParagraph:
return c.convertParagraph(n)
case ast.KindBlockquote:
return c.convertBlockquote(n)
case ast.KindFencedCodeBlock:
return c.convertFencedCodeBlock(n.(*ast.FencedCodeBlock))
case ast.KindCodeBlock:
return c.convertCodeBlock(n.(*ast.CodeBlock))
case ast.KindList:
return c.convertList(n.(*ast.List))
case ast.KindListItem:
return c.convertListItem(n)
case ast.KindThematicBreak:
return []Node{{Type: NodeHorizontalRule}}, nil
case ast.KindImage:
return c.convertImage(n.(*ast.Image))
case ast.KindTextBlock:
return c.convertParagraph(n)
case ast.KindText:
return c.convertText(n.(*ast.Text))
case ast.KindString:
return c.convertString(n.(*ast.String))
case ast.KindEmphasis:
return c.convertEmphasis(n.(*ast.Emphasis))
case ast.KindCodeSpan:
return c.convertCodeSpan(n)
case ast.KindLink:
return c.convertLink(n.(*ast.Link))
case ast.KindAutoLink:
return c.convertAutoLink(n.(*ast.AutoLink))
case ast.KindRawHTML:
return c.convertRawHTML(n)
case ast.KindHTMLBlock:
return nil, nil
default:
if n.Kind() == goldmarkast.KindStrikethrough {
return c.convertStrikethrough(n)
}
return nil, fmt.Errorf("cannot convert markdown node of kind %s", n.Kind())
}
}
func (c *converter) convertHeading(n *ast.Heading) ([]Node, error) {
children, err := c.convertChildren(n)
if err != nil {
return nil, err
}
attrs, err := json.Marshal(HeadingAttrs{Level: n.Level})
if err != nil {
return nil, fmt.Errorf("cannot marshal heading attrs: %w", err)
}
return []Node{{
Type: NodeHeading,
Content: children,
Attrs: attrs,
}}, nil
}
func (c *converter) convertParagraph(n ast.Node) ([]Node, error) {
children, err := c.convertChildren(n)
if err != nil {
return nil, err
}
return []Node{{
Type: NodeParagraph,
Content: children,
}}, nil
}
func (c *converter) convertBlockquote(n ast.Node) ([]Node, error) {
children, err := c.convertChildren(n)
if err != nil {
return nil, err
}
return []Node{{
Type: NodeBlockquote,
Content: children,
}}, nil
}
func (c *converter) convertFencedCodeBlock(n *ast.FencedCodeBlock) ([]Node, error) {
var buf bytes.Buffer
for i := 0; i < n.Lines().Len(); i++ {
line := n.Lines().At(i)
buf.Write(line.Value(c.source))
}
content := buf.String()
var lang *string
if n.Language(c.source) != nil {
l := string(n.Language(c.source))
lang = &l
}
attrs, err := json.Marshal(CodeBlockAttrs{Language: lang})
if err != nil {
return nil, fmt.Errorf("cannot marshal code block attrs: %w", err)
}
var textNodes []Node
if content != "" {
textNodes = []Node{{
Type: NodeText,
Text: &content,
}}
}
return []Node{{
Type: NodeCodeBlock,
Content: textNodes,
Attrs: attrs,
}}, nil
}
func (c *converter) convertCodeBlock(n *ast.CodeBlock) ([]Node, error) {
var buf bytes.Buffer
for i := 0; i < n.Lines().Len(); i++ {
line := n.Lines().At(i)
buf.Write(line.Value(c.source))
}
content := buf.String()
attrs, err := json.Marshal(CodeBlockAttrs{Language: nil})
if err != nil {
return nil, fmt.Errorf("cannot marshal code block attrs: %w", err)
}
var textNodes []Node
if content != "" {
textNodes = []Node{{
Type: NodeText,
Text: &content,
}}
}
return []Node{{
Type: NodeCodeBlock,
Content: textNodes,
Attrs: attrs,
}}, nil
}
func (c *converter) convertList(n *ast.List) ([]Node, error) {
children, err := c.convertChildren(n)
if err != nil {
return nil, err
}
if n.IsOrdered() {
attrs, err := json.Marshal(OrderedListAttrs{Start: n.Start})
if err != nil {
return nil, fmt.Errorf("cannot marshal ordered list attrs: %w", err)
}
return []Node{{
Type: NodeOrderedList,
Content: children,
Attrs: attrs,
}}, nil
}
return []Node{{
Type: NodeBulletList,
Content: children,
}}, nil
}
func (c *converter) convertListItem(n ast.Node) ([]Node, error) {
children, err := c.convertChildren(n)
if err != nil {
return nil, err
}
return []Node{{
Type: NodeListItem,
Content: children,
}}, nil
}
func (c *converter) convertImage(n *ast.Image) ([]Node, error) {
imgAttrs := ImageAttrs{
Src: string(n.Destination),
}
if n.Title != nil {
t := string(n.Title)
imgAttrs.Title = &t
}
// Collect alt text from child text nodes.
var altBuf bytes.Buffer
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
if child.Kind() == ast.KindText {
altBuf.Write(child.(*ast.Text).Segment.Value(c.source))
}
}
if altBuf.Len() > 0 {
alt := altBuf.String()
imgAttrs.Alt = &alt
}
attrs, err := json.Marshal(imgAttrs)
if err != nil {
return nil, fmt.Errorf("cannot marshal image attrs: %w", err)
}
return []Node{{
Type: NodeImage,
Attrs: attrs,
}}, nil
}
func (c *converter) convertText(n *ast.Text) ([]Node, error) {
content := string(n.Segment.Value(c.source))
if content == "" {
return nil, nil
}
nodes := []Node{{
Type: NodeText,
Text: &content,
Marks: copyMarks(c.marks),
}}
if n.HardLineBreak() {
nodes = append(nodes, Node{Type: NodeHardBreak})
}
return nodes, nil
}
func (c *converter) convertString(n *ast.String) ([]Node, error) {
content := string(n.Value)
if content == "" {
return nil, nil
}
return []Node{{
Type: NodeText,
Text: &content,
Marks: copyMarks(c.marks),
}}, nil
}
func (c *converter) convertEmphasis(n *ast.Emphasis) ([]Node, error) {
var mark Mark
if n.Level == 2 {
mark = Mark{Type: MarkStrong}
} else {
mark = Mark{Type: MarkEm}
}
c.marks = append(c.marks, mark)
children, err := c.convertChildren(n)
c.marks = c.marks[:len(c.marks)-1]
if err != nil {
return nil, err
}
return children, nil
}
func (c *converter) convertCodeSpan(n ast.Node) ([]Node, error) {
var buf bytes.Buffer
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
if t, ok := child.(*ast.Text); ok {
buf.Write(t.Segment.Value(c.source))
}
}
content := buf.String()
if content == "" {
return nil, nil
}
marks := copyMarks(c.marks)
marks = append(marks, Mark{Type: MarkCode})
return []Node{{
Type: NodeText,
Text: &content,
Marks: marks,
}}, nil
}
func (c *converter) convertLink(n *ast.Link) ([]Node, error) {
linkAttrs := LinkAttrs{
Href: string(n.Destination),
}
if n.Title != nil {
t := string(n.Title)
linkAttrs.Title = &t
}
attrs, err := json.Marshal(linkAttrs)
if err != nil {
return nil, fmt.Errorf("cannot marshal link attrs: %w", err)
}
c.marks = append(c.marks, Mark{Type: MarkLink, Attrs: attrs})
children, err := c.convertChildren(n)
c.marks = c.marks[:len(c.marks)-1]
if err != nil {
return nil, err
}
return children, nil
}
func (c *converter) convertAutoLink(n *ast.AutoLink) ([]Node, error) {
url := string(n.URL(c.source))
linkAttrs := LinkAttrs{Href: url}
attrs, err := json.Marshal(linkAttrs)
if err != nil {
return nil, fmt.Errorf("cannot marshal link attrs: %w", err)
}
label := string(n.Label(c.source))
return []Node{{
Type: NodeText,
Text: &label,
Marks: append(copyMarks(c.marks), Mark{Type: MarkLink, Attrs: attrs}),
}}, nil
}
func (c *converter) convertRawHTML(n ast.Node) ([]Node, error) {
var buf bytes.Buffer
for i := 0; i < n.Lines().Len(); i++ {
line := n.Lines().At(i)
buf.Write(line.Value(c.source))
}
content := buf.String()
if content == "" {
return nil, nil
}
return []Node{{
Type: NodeText,
Text: &content,
Marks: copyMarks(c.marks),
}}, nil
}
func (c *converter) convertStrikethrough(n ast.Node) ([]Node, error) {
c.marks = append(c.marks, Mark{Type: MarkStrike})
children, err := c.convertChildren(n)
c.marks = c.marks[:len(c.marks)-1]
if err != nil {
return nil, err
}
return children, nil
}
func copyMarks(marks []Mark) []Mark {
if len(marks) == 0 {
return nil
}
cp := make([]Mark, len(marks))
copy(cp, marks)
return cp
}

View File

@@ -0,0 +1,396 @@
// 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"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseMarkdown_EmptyInput(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("")
require.NoError(t, err)
assert.Equal(t, NodeDoc, doc.Type)
assert.Empty(t, doc.Content)
}
func TestParseMarkdown_Paragraph(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("Hello world")
require.NoError(t, err)
assert.Equal(t, NodeDoc, doc.Type)
require.Len(t, doc.Content, 1)
p := doc.Content[0]
assert.Equal(t, NodeParagraph, p.Type)
require.Len(t, p.Content, 1)
assert.Equal(t, NodeText, p.Content[0].Type)
assert.Equal(t, "Hello world", *p.Content[0].Text)
}
func TestParseMarkdown_Headings(t *testing.T) {
t.Parallel()
tests := []struct {
name string
markdown string
level int
}{
{"h1", "# Heading 1", 1},
{"h2", "## Heading 2", 2},
{"h3", "### Heading 3", 3},
{"h4", "#### Heading 4", 4},
{"h5", "##### Heading 5", 5},
{"h6", "###### Heading 6", 6},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown(tt.markdown)
require.NoError(t, err)
require.Len(t, doc.Content, 1)
h := doc.Content[0]
assert.Equal(t, NodeHeading, h.Type)
attrs, err := h.HeadingAttrs()
require.NoError(t, err)
assert.Equal(t, tt.level, attrs.Level)
require.Len(t, h.Content, 1)
assert.Equal(t, NodeText, h.Content[0].Type)
})
}
}
func TestParseMarkdown_Bold(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("**bold text**")
require.NoError(t, err)
require.Len(t, doc.Content, 1)
p := doc.Content[0]
require.Len(t, p.Content, 1)
txt := p.Content[0]
assert.Equal(t, "bold text", *txt.Text)
require.Len(t, txt.Marks, 1)
assert.Equal(t, MarkStrong, txt.Marks[0].Type)
}
func TestParseMarkdown_Italic(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("*italic text*")
require.NoError(t, err)
require.Len(t, doc.Content, 1)
p := doc.Content[0]
require.Len(t, p.Content, 1)
txt := p.Content[0]
assert.Equal(t, "italic text", *txt.Text)
require.Len(t, txt.Marks, 1)
assert.Equal(t, MarkEm, txt.Marks[0].Type)
}
func TestParseMarkdown_Strikethrough(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("~~deleted~~")
require.NoError(t, err)
require.Len(t, doc.Content, 1)
p := doc.Content[0]
require.Len(t, p.Content, 1)
txt := p.Content[0]
assert.Equal(t, "deleted", *txt.Text)
require.Len(t, txt.Marks, 1)
assert.Equal(t, MarkStrike, txt.Marks[0].Type)
}
func TestParseMarkdown_InlineCode(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("`code`")
require.NoError(t, err)
require.Len(t, doc.Content, 1)
p := doc.Content[0]
require.Len(t, p.Content, 1)
txt := p.Content[0]
assert.Equal(t, "code", *txt.Text)
require.Len(t, txt.Marks, 1)
assert.Equal(t, MarkCode, txt.Marks[0].Type)
}
func TestParseMarkdown_CodeBlock(t *testing.T) {
t.Parallel()
t.Run("with language", func(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("```go\nfmt.Println(\"hello\")\n```")
require.NoError(t, err)
require.Len(t, doc.Content, 1)
cb := doc.Content[0]
assert.Equal(t, NodeCodeBlock, cb.Type)
attrs, err := cb.CodeBlockAttrs()
require.NoError(t, err)
require.NotNil(t, attrs.Language)
assert.Equal(t, "go", *attrs.Language)
require.Len(t, cb.Content, 1)
assert.Equal(t, "fmt.Println(\"hello\")\n", *cb.Content[0].Text)
})
t.Run("without language", func(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("```\nsome code\n```")
require.NoError(t, err)
require.Len(t, doc.Content, 1)
cb := doc.Content[0]
assert.Equal(t, NodeCodeBlock, cb.Type)
attrs, err := cb.CodeBlockAttrs()
require.NoError(t, err)
assert.Nil(t, attrs.Language)
})
}
func TestParseMarkdown_Link(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("[click here](https://example.com)")
require.NoError(t, err)
require.Len(t, doc.Content, 1)
p := doc.Content[0]
require.Len(t, p.Content, 1)
txt := p.Content[0]
assert.Equal(t, "click here", *txt.Text)
require.Len(t, txt.Marks, 1)
assert.Equal(t, MarkLink, txt.Marks[0].Type)
linkAttrs, err := txt.Marks[0].LinkAttrs()
require.NoError(t, err)
assert.Equal(t, "https://example.com", linkAttrs.Href)
}
func TestParseMarkdown_Image(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("![alt text](https://example.com/img.png \"title\")")
require.NoError(t, err)
require.Len(t, doc.Content, 1)
p := doc.Content[0]
require.Len(t, p.Content, 1)
img := p.Content[0]
assert.Equal(t, NodeImage, img.Type)
attrs, err := img.ImageAttrs()
require.NoError(t, err)
assert.Equal(t, "https://example.com/img.png", attrs.Src)
require.NotNil(t, attrs.Alt)
assert.Equal(t, "alt text", *attrs.Alt)
require.NotNil(t, attrs.Title)
assert.Equal(t, "title", *attrs.Title)
}
func TestParseMarkdown_BulletList(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("- item 1\n- item 2\n- item 3")
require.NoError(t, err)
require.Len(t, doc.Content, 1)
list := doc.Content[0]
assert.Equal(t, NodeBulletList, list.Type)
require.Len(t, list.Content, 3)
for i, item := range list.Content {
assert.Equal(t, NodeListItem, item.Type, "item %d", i)
require.Len(t, item.Content, 1)
assert.Equal(t, NodeParagraph, item.Content[0].Type)
}
}
func TestParseMarkdown_OrderedList(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("1. first\n2. second\n3. third")
require.NoError(t, err)
require.Len(t, doc.Content, 1)
list := doc.Content[0]
assert.Equal(t, NodeOrderedList, list.Type)
attrs, err := list.OrderedListAttrs()
require.NoError(t, err)
assert.Equal(t, 1, attrs.Start)
require.Len(t, list.Content, 3)
}
func TestParseMarkdown_NestedList(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("- parent\n - child\n - child 2\n- parent 2")
require.NoError(t, err)
require.Len(t, doc.Content, 1)
list := doc.Content[0]
assert.Equal(t, NodeBulletList, list.Type)
require.Len(t, list.Content, 2)
// First item should have a paragraph and a nested bullet list.
firstItem := list.Content[0]
assert.Equal(t, NodeListItem, firstItem.Type)
require.Len(t, firstItem.Content, 2)
assert.Equal(t, NodeParagraph, firstItem.Content[0].Type)
assert.Equal(t, NodeBulletList, firstItem.Content[1].Type)
require.Len(t, firstItem.Content[1].Content, 2)
}
func TestParseMarkdown_Blockquote(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("> quoted text")
require.NoError(t, err)
require.Len(t, doc.Content, 1)
bq := doc.Content[0]
assert.Equal(t, NodeBlockquote, bq.Type)
require.Len(t, bq.Content, 1)
assert.Equal(t, NodeParagraph, bq.Content[0].Type)
}
func TestParseMarkdown_HorizontalRule(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("---")
require.NoError(t, err)
require.Len(t, doc.Content, 1)
assert.Equal(t, NodeHorizontalRule, doc.Content[0].Type)
}
func TestParseMarkdown_HardBreak(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("line one\\\nline two")
require.NoError(t, err)
require.Len(t, doc.Content, 1)
p := doc.Content[0]
assert.Equal(t, NodeParagraph, p.Type)
// Should contain: text("line one"), hardBreak, text("line two")
var hasHardBreak bool
for _, child := range p.Content {
if child.Type == NodeHardBreak {
hasHardBreak = true
}
}
assert.True(t, hasHardBreak, "expected hard break node")
}
func TestParseMarkdown_NestedMarks(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("***bold and italic***")
require.NoError(t, err)
require.Len(t, doc.Content, 1)
p := doc.Content[0]
require.Len(t, p.Content, 1)
txt := p.Content[0]
assert.Equal(t, "bold and italic", *txt.Text)
require.Len(t, txt.Marks, 2)
markTypes := make(map[MarkType]bool)
for _, m := range txt.Marks {
markTypes[m.Type] = true
}
assert.True(t, markTypes[MarkStrong])
assert.True(t, markTypes[MarkEm])
}
func TestParseMarkdown_MixedContent(t *testing.T) {
t.Parallel()
doc, err := ParseMarkdown("Normal **bold** and *italic* text")
require.NoError(t, err)
require.Len(t, doc.Content, 1)
p := doc.Content[0]
require.True(t, len(p.Content) >= 4, "expected at least 4 inline nodes")
// Verify first text node is plain.
assert.Equal(t, "Normal ", *p.Content[0].Text)
assert.Empty(t, p.Content[0].Marks)
// Verify bold text node.
assert.Equal(t, "bold", *p.Content[1].Text)
require.Len(t, p.Content[1].Marks, 1)
assert.Equal(t, MarkStrong, p.Content[1].Marks[0].Type)
}
func TestParseMarkdown_JSONRoundTrip(t *testing.T) {
t.Parallel()
md := `# Title
A paragraph with **bold**, *italic*, and ` + "`code`" + `.
- item 1
- item 2
> blockquote
---
` + "```go\nfmt.Println()\n```"
doc, err := ParseMarkdown(md)
require.NoError(t, err)
data, err := json.Marshal(doc)
require.NoError(t, err)
// Verify we can parse it back.
parsed, err := Parse(string(data))
require.NoError(t, err)
assert.Equal(t, NodeDoc, parsed.Type)
assert.True(t, len(parsed.Content) > 0)
}