Create prosemirror structs and HTML conversion logic
Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
268
pkg/prosemirror/html.go
Normal file
268
pkg/prosemirror/html.go
Normal 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('"')
|
||||
}
|
||||
235
pkg/prosemirror/html_test.go
Normal file
235
pkg/prosemirror/html_test.go
Normal 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><script>alert("xss")</script> & 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")
|
||||
}
|
||||
165
pkg/prosemirror/prosemirror.go
Normal file
165
pkg/prosemirror/prosemirror.go
Normal 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
|
||||
}
|
||||
436
pkg/prosemirror/prosemirror_test.go
Normal file
436
pkg/prosemirror/prosemirror_test.go
Normal 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
95
pkg/prosemirror/testdata/document.html
vendored
Normal 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'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
754
pkg/prosemirror/testdata/document.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user