.
+//
+// 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("")
+ if err := renderChildren(buf, n.Content); err != nil {
+ return err
+ }
+ buf.WriteString("
")
+ 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("')
+ if err := renderChildren(buf, n.Content); err != nil {
+ return err
+ }
+ buf.WriteString("')
+ case NodeBlockquote:
+ buf.WriteString("")
+ if err := renderChildren(buf, n.Content); err != nil {
+ return err
+ }
+ buf.WriteString("
")
+ case NodeCodeBlock:
+ attrs, err := n.CodeBlockAttrs()
+ if err != nil {
+ return fmt.Errorf("cannot render code block node: %w", err)
+ }
+ buf.WriteString("')
+ if err := renderChildren(buf, n.Content); err != nil {
+ return err
+ }
+ buf.WriteString("
")
+ case NodeHorizontalRule:
+ buf.WriteString("
")
+ case NodeHardBreak:
+ buf.WriteString("
")
+ 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("
')
+ case NodeBulletList:
+ buf.WriteString("")
+ if err := renderChildren(buf, n.Content); err != nil {
+ return err
+ }
+ buf.WriteString("
")
+ case NodeOrderedList:
+ attrs, err := n.OrderedListAttrs()
+ if err != nil {
+ return fmt.Errorf("cannot render ordered list node: %w", err)
+ }
+ buf.WriteString("')
+ if err := renderChildren(buf, n.Content); err != nil {
+ return err
+ }
+ buf.WriteString("
")
+ case NodeListItem:
+ buf.WriteString("")
+ if err := renderChildren(buf, n.Content); err != nil {
+ return err
+ }
+ buf.WriteString("")
+ case NodeTable:
+ buf.WriteString("")
+ if err := renderChildren(buf, n.Content); err != nil {
+ return err
+ }
+ buf.WriteString("
")
+ case NodeTableRow:
+ buf.WriteString("")
+ if err := renderChildren(buf, n.Content); err != nil {
+ return err
+ }
+ buf.WriteString("
")
+ 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("")
+ case MarkEm:
+ buf.WriteString("")
+ case MarkUnderline:
+ buf.WriteString("")
+ case MarkStrike:
+ buf.WriteString("")
+ case MarkCode:
+ buf.WriteString("")
+ case MarkLink:
+ attrs, err := m.LinkAttrs()
+ if err != nil {
+ return fmt.Errorf("cannot render link mark: %w", err)
+ }
+ buf.WriteString("')
+ 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("")
+ case MarkEm:
+ buf.WriteString("")
+ case MarkUnderline:
+ buf.WriteString("")
+ case MarkStrike:
+ buf.WriteString("")
+ case MarkCode:
+ buf.WriteString("")
+ case MarkLink:
+ buf.WriteString("")
+ }
+}
+
+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('"')
+}
diff --git a/pkg/prosemirror/html_test.go b/pkg/prosemirror/html_test.go
new file mode 100644
index 000000000..3c4b21286
--- /dev/null
+++ b/pkg/prosemirror/html_test.go
@@ -0,0 +1,235 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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, "", got)
+}
+
+func TestRenderHTML_HeadingLevels(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ level int
+ expected string
+ }{
+ {1, "X
"},
+ {2, "X
"},
+ {3, "X
"},
+ {4, "X
"},
+ {5, "X
"},
+ {6, "X
"},
+ } {
+ 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, `fmt.Println()
`, 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, "hello
", 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, `item
`, 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, `wide | `, 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, `X | `, got)
+}
+
+func TestRenderHTML_HTMLEscaping(t *testing.T) {
+ t.Parallel()
+
+ text := ` & 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, `<script>alert("xss")</script> & more
`, 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, `hi`, 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, `hi`, 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, `
`, 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, "hello", 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")
+}
diff --git a/pkg/prosemirror/prosemirror.go b/pkg/prosemirror/prosemirror.go
new file mode 100644
index 000000000..061ba4966
--- /dev/null
+++ b/pkg/prosemirror/prosemirror.go
@@ -0,0 +1,165 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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
+}
diff --git a/pkg/prosemirror/prosemirror_test.go b/pkg/prosemirror/prosemirror_test.go
new file mode 100644
index 000000000..a85233a19
--- /dev/null
+++ b/pkg/prosemirror/prosemirror_test.go
@@ -0,0 +1,436 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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)
+}
diff --git a/pkg/prosemirror/testdata/document.html b/pkg/prosemirror/testdata/document.html
new file mode 100644
index 000000000..9ebfd9241
--- /dev/null
+++ b/pkg/prosemirror/testdata/document.html
@@ -0,0 +1,95 @@
+Heading 1
+This is a paragraph with some bold and some italic and some underlined text.
It
+ contains a line break, and some strikethrough.
There's some inline code. And a link
+Heading 2
+A simple paragraph.
+code block
+Heading 3
+
+ -
+
ul list item 1
+
+ -
+
ul list item 2
+
+ -
+
ul list item 3
+
+
+
+
+ -
+
ol list item 1
+
+ -
+
ol list item 2
+
+ -
+
ol list item 3
+
+
+
+
+ Blockquote
Line break
+
+
+
+
+ |
+ th 1
+ |
+
+ th 2
+ |
+
+ th 3
+ |
+
+ th 4
+ |
+
+
+ |
+ td 11
+ |
+
+ td 12
+ |
+
+ td 13
+ |
+
+
+ |
+
+
+ |
+ td 21
+ |
+
+ td 22
+ |
+
+ td 23
+ |
+
+
+ -
+
A
+
+ -
+
B
+
+
+ |
+
+
+
\ No newline at end of file
diff --git a/pkg/prosemirror/testdata/document.json b/pkg/prosemirror/testdata/document.json
new file mode 100644
index 000000000..c00338f1f
--- /dev/null
+++ b/pkg/prosemirror/testdata/document.json
@@ -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"
+ }
+ ]
+}