Create prosemirror structs and HTML conversion logic

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-03-26 18:54:42 +04:00
parent 339cc142b5
commit 79a4e7c781
6 changed files with 1953 additions and 0 deletions

268
pkg/prosemirror/html.go Normal file
View File

@@ -0,0 +1,268 @@
// 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"
"html"
"strconv"
)
// RenderHTML renders a ProseMirror document node tree to an HTML string.
func RenderHTML(node Node) (string, error) {
var buf bytes.Buffer
if err := renderNode(&buf, node); err != nil {
return "", err
}
return buf.String(), nil
}
func renderNode(buf *bytes.Buffer, n Node) error {
switch n.Type {
case NodeDoc:
return renderChildren(buf, n.Content)
case NodeParagraph:
buf.WriteString("<p>")
if err := renderChildren(buf, n.Content); err != nil {
return err
}
buf.WriteString("</p>")
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)
}
level := strconv.Itoa(attrs.Level)
buf.WriteString("<h")
buf.WriteString(level)
buf.WriteByte('>')
if err := renderChildren(buf, n.Content); err != nil {
return err
}
buf.WriteString("</h")
buf.WriteString(level)
buf.WriteByte('>')
case NodeBlockquote:
buf.WriteString("<blockquote>")
if err := renderChildren(buf, n.Content); err != nil {
return err
}
buf.WriteString("</blockquote>")
case NodeCodeBlock:
attrs, err := n.CodeBlockAttrs()
if err != nil {
return fmt.Errorf("cannot render code block node: %w", err)
}
buf.WriteString("<pre><code")
if attrs.Language != nil {
writeAttr(buf, "class", "language-"+*attrs.Language)
}
buf.WriteByte('>')
if err := renderChildren(buf, n.Content); err != nil {
return err
}
buf.WriteString("</code></pre>")
case NodeHorizontalRule:
buf.WriteString("<hr>")
case NodeHardBreak:
buf.WriteString("<br>")
case NodeText:
return renderText(buf, n)
case NodeImage:
attrs, err := n.ImageAttrs()
if err != nil {
return fmt.Errorf("cannot render image node: %w", err)
}
buf.WriteString("<img")
writeAttr(buf, "src", attrs.Src)
if attrs.Alt != nil {
writeAttr(buf, "alt", *attrs.Alt)
}
if attrs.Title != nil {
writeAttr(buf, "title", *attrs.Title)
}
buf.WriteByte('>')
case NodeBulletList:
buf.WriteString("<ul>")
if err := renderChildren(buf, n.Content); err != nil {
return err
}
buf.WriteString("</ul>")
case NodeOrderedList:
attrs, err := n.OrderedListAttrs()
if err != nil {
return fmt.Errorf("cannot render ordered list node: %w", err)
}
buf.WriteString("<ol")
if attrs.Start != 1 {
writeAttr(buf, "start", strconv.Itoa(attrs.Start))
}
if attrs.Type != nil {
writeAttr(buf, "type", *attrs.Type)
}
buf.WriteByte('>')
if err := renderChildren(buf, n.Content); err != nil {
return err
}
buf.WriteString("</ol>")
case NodeListItem:
buf.WriteString("<li>")
if err := renderChildren(buf, n.Content); err != nil {
return err
}
buf.WriteString("</li>")
case NodeTable:
buf.WriteString("<table>")
if err := renderChildren(buf, n.Content); err != nil {
return err
}
buf.WriteString("</table>")
case NodeTableRow:
buf.WriteString("<tr>")
if err := renderChildren(buf, n.Content); err != nil {
return err
}
buf.WriteString("</tr>")
case NodeTableCell:
return renderTableCell(buf, n, "td")
case NodeTableHeader:
return renderTableCell(buf, n, "th")
default:
return fmt.Errorf("cannot render node: unknown type %q", n.Type)
}
return nil
}
func renderChildren(buf *bytes.Buffer, nodes []Node) error {
for _, child := range nodes {
if err := renderNode(buf, child); err != nil {
return err
}
}
return nil
}
func renderText(buf *bytes.Buffer, n Node) error {
if n.Text == nil {
return fmt.Errorf("cannot render text node: text is nil")
}
for _, m := range n.Marks {
if err := openMark(buf, m); err != nil {
return err
}
}
buf.WriteString(html.EscapeString(*n.Text))
for i := len(n.Marks) - 1; i >= 0; i-- {
closeMark(buf, n.Marks[i])
}
return nil
}
func openMark(buf *bytes.Buffer, m Mark) error {
switch m.Type {
case MarkStrong:
buf.WriteString("<strong>")
case MarkEm:
buf.WriteString("<em>")
case MarkUnderline:
buf.WriteString("<u>")
case MarkStrike:
buf.WriteString("<s>")
case MarkCode:
buf.WriteString("<code>")
case MarkLink:
attrs, err := m.LinkAttrs()
if err != nil {
return fmt.Errorf("cannot render link mark: %w", err)
}
buf.WriteString("<a")
writeAttr(buf, "href", attrs.Href)
if attrs.Target != nil {
writeAttr(buf, "target", *attrs.Target)
}
if attrs.Rel != nil {
writeAttr(buf, "rel", *attrs.Rel)
}
if attrs.Class != nil {
writeAttr(buf, "class", *attrs.Class)
}
if attrs.Title != nil {
writeAttr(buf, "title", *attrs.Title)
}
buf.WriteByte('>')
default:
return fmt.Errorf("cannot render mark: unknown type %q", m.Type)
}
return nil
}
func closeMark(buf *bytes.Buffer, m Mark) {
switch m.Type {
case MarkStrong:
buf.WriteString("</strong>")
case MarkEm:
buf.WriteString("</em>")
case MarkUnderline:
buf.WriteString("</u>")
case MarkStrike:
buf.WriteString("</s>")
case MarkCode:
buf.WriteString("</code>")
case MarkLink:
buf.WriteString("</a>")
}
}
func renderTableCell(buf *bytes.Buffer, n Node, tag string) error {
attrs, err := n.TableCellAttrs()
if err != nil {
return fmt.Errorf("cannot render %s node: %w", tag, err)
}
buf.WriteByte('<')
buf.WriteString(tag)
if attrs.Colspan > 1 {
writeAttr(buf, "colspan", strconv.Itoa(attrs.Colspan))
}
if attrs.Rowspan > 1 {
writeAttr(buf, "rowspan", strconv.Itoa(attrs.Rowspan))
}
if len(attrs.Colwidth) > 0 {
total := 0
for _, w := range attrs.Colwidth {
total += w
}
writeAttr(buf, "style", "min-width: "+strconv.Itoa(total)+"px")
}
buf.WriteByte('>')
if err := renderChildren(buf, n.Content); err != nil {
return err
}
buf.WriteString("</")
buf.WriteString(tag)
buf.WriteByte('>')
return nil
}
func writeAttr(buf *bytes.Buffer, name, value string) {
buf.WriteByte(' ')
buf.WriteString(name)
buf.WriteString(`="`)
buf.WriteString(html.EscapeString(value))
buf.WriteByte('"')
}

View File

@@ -0,0 +1,235 @@
// 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 TestRenderHTML_Document(t *testing.T) {
t.Parallel()
expected, err := os.ReadFile("testdata/document.html")
require.NoError(t, err)
doc := loadTestDocument(t)
got, err := RenderHTML(doc)
require.NoError(t, err)
assert.Equal(t, string(expected), got)
}
func TestRenderHTML_EmptyParagraph(t *testing.T) {
t.Parallel()
node := Node{Type: NodeParagraph}
got, err := RenderHTML(Node{Type: NodeDoc, Content: []Node{node}})
require.NoError(t, err)
assert.Equal(t, "<p></p>", got)
}
func TestRenderHTML_HeadingLevels(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
level int
expected string
}{
{1, "<h1>X</h1>"},
{2, "<h2>X</h2>"},
{3, "<h3>X</h3>"},
{4, "<h4>X</h4>"},
{5, "<h5>X</h5>"},
{6, "<h6>X</h6>"},
} {
t.Run(
"level "+string(rune('0'+tc.level)),
func(t *testing.T) {
t.Parallel()
raw := `{"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 := RenderHTML(n)
require.NoError(t, err)
assert.Equal(t, tc.expected, got)
},
)
}
}
func TestRenderHTML_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 := RenderHTML(n)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid level")
}
func TestRenderHTML_CodeBlockWithLanguage(t *testing.T) {
t.Parallel()
raw := `{"type":"codeBlock","attrs":{"language":"go"},"content":[{"type":"text","text":"fmt.Println()"}]}`
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
got, err := RenderHTML(n)
require.NoError(t, err)
assert.Equal(t, `<pre><code class="language-go">fmt.Println()</code></pre>`, got)
}
func TestRenderHTML_CodeBlockWithoutLanguage(t *testing.T) {
t.Parallel()
raw := `{"type":"codeBlock","attrs":{"language":null},"content":[{"type":"text","text":"hello"}]}`
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
got, err := RenderHTML(n)
require.NoError(t, err)
assert.Equal(t, "<pre><code>hello</code></pre>", got)
}
func TestRenderHTML_OrderedListWithStart(t *testing.T) {
t.Parallel()
raw := `{"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 := RenderHTML(n)
require.NoError(t, err)
assert.Equal(t, `<ol start="5"><li><p>item</p></li></ol>`, got)
}
func TestRenderHTML_TableCellColspan(t *testing.T) {
t.Parallel()
raw := `{"type":"tableCell","attrs":{"colspan":2,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"wide"}]}]}`
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
got, err := RenderHTML(n)
require.NoError(t, err)
assert.Equal(t, `<td colspan="2"><p>wide</p></td>`, got)
}
func TestRenderHTML_TableCellColwidth(t *testing.T) {
t.Parallel()
raw := `{"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":[100]},"content":[{"type":"paragraph","content":[{"type":"text","text":"X"}]}]}`
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
got, err := RenderHTML(n)
require.NoError(t, err)
assert.Equal(t, `<td style="min-width: 100px"><p>X</p></td>`, got)
}
func TestRenderHTML_HTMLEscaping(t *testing.T) {
t.Parallel()
text := `<script>alert("xss")</script> & more`
node := Node{
Type: NodeParagraph,
Content: []Node{
{Type: NodeText, Text: &text},
},
}
got, err := RenderHTML(Node{Type: NodeDoc, Content: []Node{node}})
require.NoError(t, err)
assert.Equal(t, `<p>&lt;script&gt;alert(&#34;xss&#34;)&lt;/script&gt; &amp; more</p>`, got)
}
func TestRenderHTML_LinkAllAttrs(t *testing.T) {
t.Parallel()
raw := `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":"_blank","rel":"noopener","class":"btn","title":"Click"}}],"text":"hi"}`
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
got, err := RenderHTML(n)
require.NoError(t, err)
assert.Equal(t, `<a href="https://example.com" target="_blank" rel="noopener" class="btn" title="Click">hi</a>`, got)
}
func TestRenderHTML_LinkMinimalAttrs(t *testing.T) {
t.Parallel()
raw := `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":null,"rel":null,"class":null,"title":null}}],"text":"hi"}`
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
got, err := RenderHTML(n)
require.NoError(t, err)
assert.Equal(t, `<a href="https://example.com">hi</a>`, got)
}
func TestRenderHTML_Image(t *testing.T) {
t.Parallel()
raw := `{"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 := RenderHTML(n)
require.NoError(t, err)
assert.Equal(t, `<img src="https://example.com/img.png" alt="A photo" title="My image">`, got)
}
func TestRenderHTML_MultipleMarks(t *testing.T) {
t.Parallel()
raw := `{"type":"text","marks":[{"type":"bold"},{"type":"italic"}],"text":"hello"}`
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
got, err := RenderHTML(n)
require.NoError(t, err)
assert.Equal(t, "<strong><em>hello</em></strong>", got)
}
func TestRenderHTML_UnknownNodeType(t *testing.T) {
t.Parallel()
node := Node{Type: NodeType("unknownWidget")}
_, err := RenderHTML(node)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown type")
}
func TestRenderHTML_UnknownMarkType(t *testing.T) {
t.Parallel()
text := "hello"
node := Node{
Type: NodeText,
Text: &text,
Marks: []Mark{
{Type: MarkType("superscript")},
},
}
_, err := RenderHTML(node)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown type")
}

View File

@@ -0,0 +1,165 @@
// 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"
"fmt"
)
type (
// NodeType identifies the type of a ProseMirror/Tiptap document node.
NodeType string
// MarkType identifies the type of an inline mark.
MarkType string
// Node is a ProseMirror/Tiptap document node.
Node struct {
Type NodeType `json:"type"`
Content []Node `json:"content,omitempty"`
Marks []Mark `json:"marks,omitempty"`
Text *string `json:"text,omitempty"`
Attrs json.RawMessage `json:"attrs,omitempty"`
}
// Mark represents inline formatting applied to a text node.
Mark struct {
Type MarkType `json:"type"`
Attrs json.RawMessage `json:"attrs,omitempty"`
}
// HeadingAttrs contains attributes for heading nodes.
HeadingAttrs struct {
Level int `json:"level"`
}
// CodeBlockAttrs contains attributes for code block nodes.
CodeBlockAttrs struct {
Language *string `json:"language"`
}
// OrderedListAttrs contains attributes for ordered list nodes.
OrderedListAttrs struct {
Start int `json:"start"`
Type *string `json:"type"`
}
// ImageAttrs contains attributes for image nodes.
ImageAttrs struct {
Src string `json:"src"`
Alt *string `json:"alt"`
Title *string `json:"title"`
}
// TableCellAttrs contains attributes for table cell and table header nodes.
TableCellAttrs struct {
Colspan int `json:"colspan"`
Rowspan int `json:"rowspan"`
Colwidth []int `json:"colwidth"`
}
// LinkAttrs contains attributes for link marks.
LinkAttrs struct {
Href string `json:"href"`
Target *string `json:"target"`
Rel *string `json:"rel"`
Class *string `json:"class"`
Title *string `json:"title"`
}
)
// Node type constants. String values match the Tiptap JSON the frontend produces.
// Mark type constants. Go names follow ProseMirror conventions (Strong, Em);
// string values match Tiptap JSON (bold, italic).
const (
NodeDoc NodeType = "doc"
NodeParagraph NodeType = "paragraph"
NodeBlockquote NodeType = "blockquote"
NodeHeading NodeType = "heading"
NodeCodeBlock NodeType = "codeBlock"
NodeHorizontalRule NodeType = "horizontalRule"
NodeHardBreak NodeType = "hardBreak"
NodeText NodeType = "text"
NodeImage NodeType = "image"
NodeBulletList NodeType = "bulletList"
NodeOrderedList NodeType = "orderedList"
NodeListItem NodeType = "listItem"
NodeTable NodeType = "table"
NodeTableRow NodeType = "tableRow"
NodeTableCell NodeType = "tableCell"
NodeTableHeader NodeType = "tableHeader"
MarkStrong MarkType = "bold"
MarkEm MarkType = "italic"
MarkUnderline MarkType = "underline"
MarkStrike MarkType = "strike"
MarkCode MarkType = "code"
MarkLink MarkType = "link"
)
// HeadingAttrs parses and returns the heading attributes from a heading node.
func (n Node) HeadingAttrs() (HeadingAttrs, error) {
var a HeadingAttrs
if err := json.Unmarshal(n.Attrs, &a); err != nil {
return a, fmt.Errorf("cannot parse heading attrs: %w", err)
}
return a, nil
}
// CodeBlockAttrs parses and returns the code block attributes.
func (n Node) CodeBlockAttrs() (CodeBlockAttrs, error) {
var a CodeBlockAttrs
if err := json.Unmarshal(n.Attrs, &a); err != nil {
return a, fmt.Errorf("cannot parse code block attrs: %w", err)
}
return a, nil
}
// OrderedListAttrs parses and returns the ordered list attributes.
func (n Node) OrderedListAttrs() (OrderedListAttrs, error) {
var a OrderedListAttrs
if err := json.Unmarshal(n.Attrs, &a); err != nil {
return a, fmt.Errorf("cannot parse ordered list attrs: %w", err)
}
return a, nil
}
// ImageAttrs parses and returns the image attributes.
func (n Node) ImageAttrs() (ImageAttrs, error) {
var a ImageAttrs
if err := json.Unmarshal(n.Attrs, &a); err != nil {
return a, fmt.Errorf("cannot parse image attrs: %w", err)
}
return a, nil
}
// TableCellAttrs parses and returns the table cell/header attributes.
func (n Node) TableCellAttrs() (TableCellAttrs, error) {
var a TableCellAttrs
if err := json.Unmarshal(n.Attrs, &a); err != nil {
return a, fmt.Errorf("cannot parse table cell attrs: %w", err)
}
return a, nil
}
// LinkAttrs parses and returns the link attributes from a link mark.
func (m Mark) LinkAttrs() (LinkAttrs, error) {
var a LinkAttrs
if err := json.Unmarshal(m.Attrs, &a); err != nil {
return a, fmt.Errorf("cannot parse link attrs: %w", err)
}
return a, nil
}

View File

@@ -0,0 +1,436 @@
// 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 loadTestDocument(t *testing.T) Node {
t.Helper()
data, err := os.ReadFile("testdata/document.json")
require.NoError(t, err)
var doc Node
require.NoError(t, json.Unmarshal(data, &doc))
return doc
}
func TestUnmarshalDocument(t *testing.T) {
t.Parallel()
doc := loadTestDocument(t)
assert.Equal(t, NodeDoc, doc.Type)
require.Len(t, doc.Content, 14)
t.Run(
"heading level 1",
func(t *testing.T) {
t.Parallel()
h1 := doc.Content[0]
assert.Equal(t, NodeHeading, h1.Type)
attrs, err := h1.HeadingAttrs()
require.NoError(t, err)
assert.Equal(t, 1, attrs.Level)
require.Len(t, h1.Content, 1)
assert.Equal(t, NodeText, h1.Content[0].Type)
require.NotNil(t, h1.Content[0].Text)
assert.Equal(t, "Heading 1", *h1.Content[0].Text)
},
)
t.Run(
"paragraph with mixed marks",
func(t *testing.T) {
t.Parallel()
p := doc.Content[1]
assert.Equal(t, NodeParagraph, p.Type)
require.True(t, len(p.Content) > 5)
// Bold text
boldNode := p.Content[1]
require.Len(t, boldNode.Marks, 1)
assert.Equal(t, MarkStrong, boldNode.Marks[0].Type)
require.NotNil(t, boldNode.Text)
assert.Equal(t, "with some bold", *boldNode.Text)
// Italic text
italicNode := p.Content[3]
require.Len(t, italicNode.Marks, 1)
assert.Equal(t, MarkEm, italicNode.Marks[0].Type)
// Underline text
underlineNode := p.Content[5]
require.Len(t, underlineNode.Marks, 1)
assert.Equal(t, MarkUnderline, underlineNode.Marks[0].Type)
// Hard break
assert.Equal(t, NodeHardBreak, p.Content[7].Type)
// Strikethrough
strikeNode := p.Content[9]
require.Len(t, strikeNode.Marks, 1)
assert.Equal(t, MarkStrike, strikeNode.Marks[0].Type)
// Inline code
codeNode := p.Content[12]
require.Len(t, codeNode.Marks, 1)
assert.Equal(t, MarkCode, codeNode.Marks[0].Type)
// Link
linkNode := p.Content[14]
require.Len(t, linkNode.Marks, 1)
assert.Equal(t, MarkLink, linkNode.Marks[0].Type)
linkAttrs, err := linkNode.Marks[0].LinkAttrs()
require.NoError(t, err)
assert.Equal(t, "https://getprobo.com", linkAttrs.Href)
require.NotNil(t, linkAttrs.Target)
assert.Equal(t, "_blank", *linkAttrs.Target)
require.NotNil(t, linkAttrs.Rel)
assert.Equal(t, "noopener noreferrer nofollow", *linkAttrs.Rel)
assert.Nil(t, linkAttrs.Class)
assert.Nil(t, linkAttrs.Title)
},
)
t.Run(
"heading level 2",
func(t *testing.T) {
t.Parallel()
h2 := doc.Content[2]
assert.Equal(t, NodeHeading, h2.Type)
attrs, err := h2.HeadingAttrs()
require.NoError(t, err)
assert.Equal(t, 2, attrs.Level)
},
)
t.Run(
"code block",
func(t *testing.T) {
t.Parallel()
cb := doc.Content[4]
assert.Equal(t, NodeCodeBlock, cb.Type)
attrs, err := cb.CodeBlockAttrs()
require.NoError(t, err)
assert.Nil(t, attrs.Language)
require.Len(t, cb.Content, 1)
require.NotNil(t, cb.Content[0].Text)
assert.Equal(t, "code block", *cb.Content[0].Text)
},
)
t.Run(
"heading level 3",
func(t *testing.T) {
t.Parallel()
h3 := doc.Content[5]
attrs, err := h3.HeadingAttrs()
require.NoError(t, err)
assert.Equal(t, 3, attrs.Level)
},
)
t.Run(
"bullet list",
func(t *testing.T) {
t.Parallel()
bl := doc.Content[6]
assert.Equal(t, NodeBulletList, bl.Type)
require.Len(t, bl.Content, 3)
for _, item := range bl.Content {
assert.Equal(t, NodeListItem, item.Type)
require.Len(t, item.Content, 1)
assert.Equal(t, NodeParagraph, item.Content[0].Type)
}
},
)
t.Run(
"horizontal rule",
func(t *testing.T) {
t.Parallel()
assert.Equal(t, NodeHorizontalRule, doc.Content[7].Type)
assert.Equal(t, NodeHorizontalRule, doc.Content[9].Type)
assert.Equal(t, NodeHorizontalRule, doc.Content[11].Type)
},
)
t.Run(
"ordered list",
func(t *testing.T) {
t.Parallel()
ol := doc.Content[8]
assert.Equal(t, NodeOrderedList, ol.Type)
require.Len(t, ol.Content, 3)
attrs, err := ol.OrderedListAttrs()
require.NoError(t, err)
assert.Equal(t, 1, attrs.Start)
assert.Nil(t, attrs.Type)
},
)
t.Run(
"blockquote",
func(t *testing.T) {
t.Parallel()
bq := doc.Content[10]
assert.Equal(t, NodeBlockquote, bq.Type)
require.Len(t, bq.Content, 1)
assert.Equal(t, NodeParagraph, bq.Content[0].Type)
// Verify hard break inside blockquote
bqPara := bq.Content[0]
require.True(t, len(bqPara.Content) >= 3)
assert.Equal(t, NodeHardBreak, bqPara.Content[1].Type)
},
)
t.Run(
"table",
func(t *testing.T) {
t.Parallel()
table := doc.Content[12]
assert.Equal(t, NodeTable, table.Type)
require.Len(t, table.Content, 3)
// Header row
headerRow := table.Content[0]
assert.Equal(t, NodeTableRow, headerRow.Type)
require.Len(t, headerRow.Content, 4)
for _, cell := range headerRow.Content {
assert.Equal(t, NodeTableHeader, cell.Type)
}
// Last header has colwidth
th4 := headerRow.Content[3]
thAttrs, err := th4.TableCellAttrs()
require.NoError(t, err)
assert.Equal(t, 1, thAttrs.Colspan)
assert.Equal(t, 1, thAttrs.Rowspan)
assert.Equal(t, []int{61}, thAttrs.Colwidth)
// First header has null colwidth
th1 := headerRow.Content[0]
th1Attrs, err := th1.TableCellAttrs()
require.NoError(t, err)
assert.Nil(t, th1Attrs.Colwidth)
// Data rows
for _, row := range table.Content[1:] {
assert.Equal(t, NodeTableRow, row.Type)
require.Len(t, row.Content, 4)
for _, cell := range row.Content {
assert.Equal(t, NodeTableCell, cell.Type)
}
}
// Nested bullet list in table cell
nestedBL := table.Content[1].Content[3]
require.Len(t, nestedBL.Content, 1)
assert.Equal(t, NodeBulletList, nestedBL.Content[0].Type)
// Nested ordered list in table cell
nestedOL := table.Content[2].Content[3]
require.Len(t, nestedOL.Content, 1)
assert.Equal(t, NodeOrderedList, nestedOL.Content[0].Type)
},
)
t.Run(
"trailing empty paragraph",
func(t *testing.T) {
t.Parallel()
emptyP := doc.Content[13]
assert.Equal(t, NodeParagraph, emptyP.Type)
assert.Empty(t, emptyP.Content)
},
)
}
func TestMarshalRoundtrip(t *testing.T) {
t.Parallel()
doc := loadTestDocument(t)
marshaled, err := json.Marshal(doc)
require.NoError(t, err)
var roundtripped Node
require.NoError(t, json.Unmarshal(marshaled, &roundtripped))
// Re-marshal both to compact JSON for comparison, since the original
// testdata file is pretty-printed and RawMessage preserves whitespace.
expected, err := json.Marshal(roundtripped)
require.NoError(t, err)
assert.JSONEq(t, string(marshaled), string(expected))
}
func TestHeadingAttrs(t *testing.T) {
t.Parallel()
raw := `{"type":"heading","attrs":{"level":3},"content":[{"type":"text","text":"Hello"}]}`
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
attrs, err := n.HeadingAttrs()
require.NoError(t, err)
assert.Equal(t, 3, attrs.Level)
}
func TestCodeBlockAttrs(t *testing.T) {
t.Parallel()
t.Run(
"with language",
func(t *testing.T) {
t.Parallel()
raw := `{"type":"codeBlock","attrs":{"language":"go"},"content":[{"type":"text","text":"fmt.Println()"}]}`
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
attrs, err := n.CodeBlockAttrs()
require.NoError(t, err)
require.NotNil(t, attrs.Language)
assert.Equal(t, "go", *attrs.Language)
},
)
t.Run(
"with null language",
func(t *testing.T) {
t.Parallel()
raw := `{"type":"codeBlock","attrs":{"language":null}}`
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
attrs, err := n.CodeBlockAttrs()
require.NoError(t, err)
assert.Nil(t, attrs.Language)
},
)
}
func TestOrderedListAttrs(t *testing.T) {
t.Parallel()
raw := `{"type":"orderedList","attrs":{"start":5,"type":null}}`
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
attrs, err := n.OrderedListAttrs()
require.NoError(t, err)
assert.Equal(t, 5, attrs.Start)
assert.Nil(t, attrs.Type)
}
func TestImageAttrs(t *testing.T) {
t.Parallel()
raw := `{"type":"image","attrs":{"src":"https://example.com/img.png","alt":"An image","title":null}}`
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
attrs, err := n.ImageAttrs()
require.NoError(t, err)
assert.Equal(t, "https://example.com/img.png", attrs.Src)
require.NotNil(t, attrs.Alt)
assert.Equal(t, "An image", *attrs.Alt)
assert.Nil(t, attrs.Title)
}
func TestTableCellAttrs(t *testing.T) {
t.Parallel()
t.Run(
"with colwidth",
func(t *testing.T) {
t.Parallel()
raw := `{"type":"tableCell","attrs":{"colspan":2,"rowspan":1,"colwidth":[100,200]}}`
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
attrs, err := n.TableCellAttrs()
require.NoError(t, err)
assert.Equal(t, 2, attrs.Colspan)
assert.Equal(t, 1, attrs.Rowspan)
assert.Equal(t, []int{100, 200}, attrs.Colwidth)
},
)
t.Run(
"with null colwidth",
func(t *testing.T) {
t.Parallel()
raw := `{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null}}`
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
attrs, err := n.TableCellAttrs()
require.NoError(t, err)
assert.Equal(t, 1, attrs.Colspan)
assert.Equal(t, 1, attrs.Rowspan)
assert.Nil(t, attrs.Colwidth)
},
)
}
func TestLinkAttrs(t *testing.T) {
t.Parallel()
raw := `{"type":"link","attrs":{"href":"https://example.com","target":"_blank","rel":"noopener","class":null,"title":"Example"}}`
var m Mark
require.NoError(t, json.Unmarshal([]byte(raw), &m))
attrs, err := m.LinkAttrs()
require.NoError(t, err)
assert.Equal(t, "https://example.com", attrs.Href)
require.NotNil(t, attrs.Target)
assert.Equal(t, "_blank", *attrs.Target)
require.NotNil(t, attrs.Rel)
assert.Equal(t, "noopener", *attrs.Rel)
assert.Nil(t, attrs.Class)
require.NotNil(t, attrs.Title)
assert.Equal(t, "Example", *attrs.Title)
}
func TestNodeWithNoAttrs(t *testing.T) {
t.Parallel()
raw := `{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}`
var n Node
require.NoError(t, json.Unmarshal([]byte(raw), &n))
assert.Equal(t, NodeParagraph, n.Type)
assert.Nil(t, n.Attrs)
require.Len(t, n.Content, 1)
require.NotNil(t, n.Content[0].Text)
assert.Equal(t, "Hello", *n.Content[0].Text)
}

95
pkg/prosemirror/testdata/document.html vendored Normal file
View File

@@ -0,0 +1,95 @@
<h1>Heading 1</h1>
<p>This is a paragraph <strong>with some bold</strong> and <em>some italic </em>and some <u>underlined text</u>.<br>It
contains a line break, and some <s>strikethrough.</s><br>There&#39;s some <code>inline code</code>. And a <a
href="https://getprobo.com" target="_blank" rel="noopener noreferrer nofollow">link</a></p>
<h2>Heading 2</h2>
<p>A simple paragraph.</p>
<pre><code>code block</code></pre>
<h3>Heading 3</h3>
<ul>
<li>
<p>ul <strong>list</strong> item 1</p>
</li>
<li>
<p>ul <em>list</em> item 2</p>
</li>
<li>
<p>ul <a href="https://google.com" target="_blank" rel="noopener noreferrer nofollow">list</a> item 3</p>
</li>
</ul>
<hr>
<ol>
<li>
<p>ol list <u>item</u> 1</p>
</li>
<li>
<p>ol list <code>item</code> 2</p>
</li>
<li>
<p>ol list <s>item</s> 3</p>
</li>
</ol>
<hr>
<blockquote>
<p>Blockquote<br>Line <strong>break</strong></p>
</blockquote>
<hr>
<table>
<tr>
<th>
<p>th 1</p>
</th>
<th>
<p>th 2</p>
</th>
<th>
<p>th 3</p>
</th>
<th style="min-width: 61px">
<p>th 4</p>
</th>
</tr>
<tr>
<td>
<p>td <em>11</em></p>
</td>
<td>
<p>td <strong>12</strong></p>
</td>
<td>
<p>td <s>13</s></p>
</td>
<td style="min-width: 61px">
<ul>
<li>
<p>1</p>
</li>
<li>
<p>2</p>
</li>
</ul>
</td>
</tr>
<tr>
<td>
<p>td <u>21</u></p>
</td>
<td>
<p>td <a href="https://example.org" target="_blank" rel="noopener noreferrer nofollow">22</a></p>
</td>
<td>
<p>td <code>23</code></p>
</td>
<td style="min-width: 61px">
<ol>
<li>
<p>A</p>
</li>
<li>
<p>B</p>
</li>
</ol>
</td>
</tr>
</table>
<p></p>

754
pkg/prosemirror/testdata/document.json vendored Normal file
View File

@@ -0,0 +1,754 @@
{
"type": "doc",
"content": [
{
"type": "heading",
"attrs": {
"level": 1
},
"content": [
{
"type": "text",
"text": "Heading 1"
}
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "This is a paragraph "
},
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "with some bold"
},
{
"type": "text",
"text": " and "
},
{
"type": "text",
"marks": [
{
"type": "italic"
}
],
"text": "some italic "
},
{
"type": "text",
"text": "and some "
},
{
"type": "text",
"marks": [
{
"type": "underline"
}
],
"text": "underlined text"
},
{
"type": "text",
"text": "."
},
{
"type": "hardBreak"
},
{
"type": "text",
"text": "It contains a line break, and some "
},
{
"type": "text",
"marks": [
{
"type": "strike"
}
],
"text": "strikethrough."
},
{
"type": "hardBreak"
},
{
"type": "text",
"text": "There's some "
},
{
"type": "text",
"marks": [
{
"type": "code"
}
],
"text": "inline code"
},
{
"type": "text",
"text": ". And a "
},
{
"type": "text",
"marks": [
{
"type": "link",
"attrs": {
"href": "https://getprobo.com",
"target": "_blank",
"rel": "noopener noreferrer nofollow",
"class": null,
"title": null
}
}
],
"text": "link"
}
]
},
{
"type": "heading",
"attrs": {
"level": 2
},
"content": [
{
"type": "text",
"text": "Heading 2"
}
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "A simple paragraph."
}
]
},
{
"type": "codeBlock",
"attrs": {
"language": null
},
"content": [
{
"type": "text",
"text": "code block"
}
]
},
{
"type": "heading",
"attrs": {
"level": 3
},
"content": [
{
"type": "text",
"text": "Heading 3"
}
]
},
{
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "ul "
},
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "list"
},
{
"type": "text",
"text": " item 1"
}
]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "ul "
},
{
"type": "text",
"marks": [
{
"type": "italic"
}
],
"text": "list"
},
{
"type": "text",
"text": " item 2"
}
]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "ul "
},
{
"type": "text",
"marks": [
{
"type": "link",
"attrs": {
"href": "https://google.com",
"target": "_blank",
"rel": "noopener noreferrer nofollow",
"class": null,
"title": null
}
}
],
"text": "list"
},
{
"type": "text",
"text": " item 3"
}
]
}
]
}
]
},
{
"type": "horizontalRule"
},
{
"type": "orderedList",
"attrs": {
"start": 1,
"type": null
},
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "ol list "
},
{
"type": "text",
"marks": [
{
"type": "underline"
}
],
"text": "item"
},
{
"type": "text",
"text": " 1"
}
]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "ol list "
},
{
"type": "text",
"marks": [
{
"type": "code"
}
],
"text": "item"
},
{
"type": "text",
"text": " 2"
}
]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "ol list "
},
{
"type": "text",
"marks": [
{
"type": "strike"
}
],
"text": "item"
},
{
"type": "text",
"text": " 3"
}
]
}
]
}
]
},
{
"type": "horizontalRule"
},
{
"type": "blockquote",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "Blockquote"
},
{
"type": "hardBreak"
},
{
"type": "text",
"text": "Line "
},
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "break"
}
]
}
]
},
{
"type": "horizontalRule"
},
{
"type": "table",
"content": [
{
"type": "tableRow",
"content": [
{
"type": "tableHeader",
"attrs": {
"colspan": 1,
"rowspan": 1,
"colwidth": null
},
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "th 1"
}
]
}
]
},
{
"type": "tableHeader",
"attrs": {
"colspan": 1,
"rowspan": 1,
"colwidth": null
},
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "th 2"
}
]
}
]
},
{
"type": "tableHeader",
"attrs": {
"colspan": 1,
"rowspan": 1,
"colwidth": null
},
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "th 3"
}
]
}
]
},
{
"type": "tableHeader",
"attrs": {
"colspan": 1,
"rowspan": 1,
"colwidth": [
61
]
},
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "th 4"
}
]
}
]
}
]
},
{
"type": "tableRow",
"content": [
{
"type": "tableCell",
"attrs": {
"colspan": 1,
"rowspan": 1,
"colwidth": null
},
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "td "
},
{
"type": "text",
"marks": [
{
"type": "italic"
}
],
"text": "11"
}
]
}
]
},
{
"type": "tableCell",
"attrs": {
"colspan": 1,
"rowspan": 1,
"colwidth": null
},
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "td "
},
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "12"
}
]
}
]
},
{
"type": "tableCell",
"attrs": {
"colspan": 1,
"rowspan": 1,
"colwidth": null
},
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "td "
},
{
"type": "text",
"marks": [
{
"type": "strike"
}
],
"text": "13"
}
]
}
]
},
{
"type": "tableCell",
"attrs": {
"colspan": 1,
"rowspan": 1,
"colwidth": [
61
]
},
"content": [
{
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "1"
}
]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "2"
}
]
}
]
}
]
}
]
}
]
},
{
"type": "tableRow",
"content": [
{
"type": "tableCell",
"attrs": {
"colspan": 1,
"rowspan": 1,
"colwidth": null
},
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "td "
},
{
"type": "text",
"marks": [
{
"type": "underline"
}
],
"text": "21"
}
]
}
]
},
{
"type": "tableCell",
"attrs": {
"colspan": 1,
"rowspan": 1,
"colwidth": null
},
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "td "
},
{
"type": "text",
"marks": [
{
"type": "link",
"attrs": {
"href": "https://example.org",
"target": "_blank",
"rel": "noopener noreferrer nofollow",
"class": null,
"title": null
}
}
],
"text": "22"
}
]
}
]
},
{
"type": "tableCell",
"attrs": {
"colspan": 1,
"rowspan": 1,
"colwidth": null
},
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "td "
},
{
"type": "text",
"marks": [
{
"type": "code"
}
],
"text": "23"
}
]
}
]
},
{
"type": "tableCell",
"attrs": {
"colspan": 1,
"rowspan": 1,
"colwidth": [
61
]
},
"content": [
{
"type": "orderedList",
"attrs": {
"start": 1,
"type": null
},
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "A"
}
]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "B"
}
]
}
]
}
]
}
]
}
]
}
]
},
{
"type": "paragraph"
}
]
}