From 79a4e7c7815b166e4d92c7040dec7301f6063cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Thu, 26 Mar 2026 18:54:42 +0400 Subject: [PATCH] Create prosemirror structs and HTML conversion logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- pkg/prosemirror/html.go | 268 +++++++++ pkg/prosemirror/html_test.go | 235 ++++++++ pkg/prosemirror/prosemirror.go | 165 ++++++ pkg/prosemirror/prosemirror_test.go | 436 ++++++++++++++ pkg/prosemirror/testdata/document.html | 95 ++++ pkg/prosemirror/testdata/document.json | 754 +++++++++++++++++++++++++ 6 files changed, 1953 insertions(+) create mode 100644 pkg/prosemirror/html.go create mode 100644 pkg/prosemirror/html_test.go create mode 100644 pkg/prosemirror/prosemirror.go create mode 100644 pkg/prosemirror/prosemirror_test.go create mode 100644 pkg/prosemirror/testdata/document.html create mode 100644 pkg/prosemirror/testdata/document.json diff --git a/pkg/prosemirror/html.go b/pkg/prosemirror/html.go new file mode 100644 index 000000000..39ea95bbb --- /dev/null +++ b/pkg/prosemirror/html.go @@ -0,0 +1,268 @@ +// 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 ( + "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("') + 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, `
    1. 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, `A photo`, 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

      +
    • +
    +
    +
      +
    1. +

      ol list item 1

      +
    2. +
    3. +

      ol list item 2

      +
    4. +
    5. +

      ol list item 3

      +
    6. +
    +
    +
    +

    Blockquote
    Line break

    +
    +
    + + + + + + + + + + + + + + + + + + + +
    +

    th 1

    +
    +

    th 2

    +
    +

    th 3

    +
    +

    th 4

    +
    +

    td 11

    +
    +

    td 12

    +
    +

    td 13

    +
    +
      +
    • +

      1

      +
    • +
    • +

      2

      +
    • +
    +
    +

    td 21

    +
    +

    td 22

    +
    +

    td 23

    +
    +
      +
    1. +

      A

      +
    2. +
    3. +

      B

      +
    4. +
    +
    +

    \ 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" + } + ] +}