The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
563 lines
14 KiB
Go
563 lines
14 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// 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://probo.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 TestTextLength(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run(
|
|
"empty doc",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
n := Node{Type: NodeDoc}
|
|
assert.Equal(t, 0, n.TextLength())
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"single text node",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
text := "hello"
|
|
n := Node{Type: NodeText, Text: &text}
|
|
assert.Equal(t, 5, n.TextLength())
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"paragraph with text",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello world"}]}]}`
|
|
doc, err := Parse(raw)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 11, doc.TextLength())
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"multiple paragraphs",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"aaa"}]},{"type":"paragraph","content":[{"type":"text","text":"bb"}]}]}`
|
|
doc, err := Parse(raw)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 5, doc.TextLength())
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"formatted text counts only text",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"plain "},{"type":"text","marks":[{"type":"bold"}],"text":"bold"}]}]}`
|
|
doc, err := Parse(raw)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 10, doc.TextLength())
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"nested list structure",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
raw := `{"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item 1"}]}]},{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item 2"}]}]}]}]}`
|
|
doc, err := Parse(raw)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 12, doc.TextLength())
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"multi-byte unicode characters",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
raw := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"café résumé"}]}]}`
|
|
doc, err := Parse(raw)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 11, doc.TextLength())
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"testdata document",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
doc := loadTestDocument(t)
|
|
assert.Greater(t, doc.TextLength(), 0)
|
|
},
|
|
)
|
|
}
|
|
|
|
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)
|
|
}
|