// Copyright (c) 2026 Probo Inc . // // 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" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestParseMarkdown_EmptyInput(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("") require.NoError(t, err) assert.Equal(t, NodeDoc, doc.Type) assert.Empty(t, doc.Content) } func TestParseMarkdown_Paragraph(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("Hello world") require.NoError(t, err) assert.Equal(t, NodeDoc, doc.Type) require.Len(t, doc.Content, 1) p := doc.Content[0] assert.Equal(t, NodeParagraph, p.Type) require.Len(t, p.Content, 1) assert.Equal(t, NodeText, p.Content[0].Type) assert.Equal(t, "Hello world", *p.Content[0].Text) } func TestParseMarkdown_Headings(t *testing.T) { t.Parallel() tests := []struct { name string markdown string level int }{ {"h1", "# Heading 1", 1}, {"h2", "## Heading 2", 2}, {"h3", "### Heading 3", 3}, {"h4", "#### Heading 4", 4}, {"h5", "##### Heading 5", 5}, {"h6", "###### Heading 6", 6}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() doc, err := ParseMarkdown(tt.markdown) require.NoError(t, err) require.Len(t, doc.Content, 1) h := doc.Content[0] assert.Equal(t, NodeHeading, h.Type) attrs, err := h.HeadingAttrs() require.NoError(t, err) assert.Equal(t, tt.level, attrs.Level) require.Len(t, h.Content, 1) assert.Equal(t, NodeText, h.Content[0].Type) }) } } func TestParseMarkdown_Bold(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("**bold text**") require.NoError(t, err) require.Len(t, doc.Content, 1) p := doc.Content[0] require.Len(t, p.Content, 1) txt := p.Content[0] assert.Equal(t, "bold text", *txt.Text) require.Len(t, txt.Marks, 1) assert.Equal(t, MarkStrong, txt.Marks[0].Type) } func TestParseMarkdown_Italic(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("*italic text*") require.NoError(t, err) require.Len(t, doc.Content, 1) p := doc.Content[0] require.Len(t, p.Content, 1) txt := p.Content[0] assert.Equal(t, "italic text", *txt.Text) require.Len(t, txt.Marks, 1) assert.Equal(t, MarkEm, txt.Marks[0].Type) } func TestParseMarkdown_Strikethrough(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("~~deleted~~") require.NoError(t, err) require.Len(t, doc.Content, 1) p := doc.Content[0] require.Len(t, p.Content, 1) txt := p.Content[0] assert.Equal(t, "deleted", *txt.Text) require.Len(t, txt.Marks, 1) assert.Equal(t, MarkStrike, txt.Marks[0].Type) } func TestParseMarkdown_InlineCode(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("`code`") require.NoError(t, err) require.Len(t, doc.Content, 1) p := doc.Content[0] require.Len(t, p.Content, 1) txt := p.Content[0] assert.Equal(t, "code", *txt.Text) require.Len(t, txt.Marks, 1) assert.Equal(t, MarkCode, txt.Marks[0].Type) } func TestParseMarkdown_CodeBlock(t *testing.T) { t.Parallel() t.Run("with language", func(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("```go\nfmt.Println(\"hello\")\n```") require.NoError(t, err) require.Len(t, doc.Content, 1) cb := doc.Content[0] assert.Equal(t, NodeCodeBlock, cb.Type) attrs, err := cb.CodeBlockAttrs() require.NoError(t, err) require.NotNil(t, attrs.Language) assert.Equal(t, "go", *attrs.Language) require.Len(t, cb.Content, 1) assert.Equal(t, "fmt.Println(\"hello\")", *cb.Content[0].Text) }) t.Run("without language", func(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("```\nsome code\n```") require.NoError(t, err) require.Len(t, doc.Content, 1) cb := doc.Content[0] 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) assert.Equal(t, "some code", *cb.Content[0].Text) }) t.Run("trailing blank line preserved", func(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("```\nline\n\n```") require.NoError(t, err) require.Len(t, doc.Content, 1) cb := doc.Content[0] require.Len(t, cb.Content, 1) assert.Equal(t, "line\n\n", *cb.Content[0].Text) }) } func TestParseMarkdown_Link(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("[click here](https://example.com)") require.NoError(t, err) require.Len(t, doc.Content, 1) p := doc.Content[0] require.Len(t, p.Content, 1) txt := p.Content[0] assert.Equal(t, "click here", *txt.Text) require.Len(t, txt.Marks, 1) assert.Equal(t, MarkLink, txt.Marks[0].Type) linkAttrs, err := txt.Marks[0].LinkAttrs() require.NoError(t, err) assert.Equal(t, "https://example.com", linkAttrs.Href) } func TestParseMarkdown_LinkSanitizesDangerousHrefs(t *testing.T) { t.Parallel() tests := []struct { name string markdown string wantHref string }{ {name: "javascript scheme", markdown: `[x](javascript:alert(1))`, wantHref: `#`}, {name: "data html", markdown: `[x](data:text/html,)`, wantHref: `#`}, {name: "protocol-relative", markdown: `[x](//evil.example/phish)`, wantHref: `#`}, {name: "https preserved", markdown: `[x](https://example.com/y)`, wantHref: `https://example.com/y`}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() doc, err := ParseMarkdown(tt.markdown) require.NoError(t, err) txt := doc.Content[0].Content[0] linkAttrs, err := txt.Marks[0].LinkAttrs() require.NoError(t, err) assert.Equal(t, tt.wantHref, linkAttrs.Href) }) } } func TestParseMarkdown_Image(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("![alt text](https://example.com/img.png \"title\")") require.NoError(t, err) require.Len(t, doc.Content, 1) p := doc.Content[0] require.Len(t, p.Content, 1) img := p.Content[0] assert.Equal(t, NodeImage, img.Type) attrs, err := img.ImageAttrs() require.NoError(t, err) assert.Equal(t, "https://example.com/img.png", attrs.Src) require.NotNil(t, attrs.Alt) assert.Equal(t, "alt text", *attrs.Alt) require.NotNil(t, attrs.Title) assert.Equal(t, "title", *attrs.Title) } func TestParseMarkdown_ImageFormattedAltText(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("![**bold** and *italic*](https://example.com/img.png)") require.NoError(t, err) require.Len(t, doc.Content, 1) p := doc.Content[0] require.Len(t, p.Content, 1) img := p.Content[0] assert.Equal(t, NodeImage, img.Type) attrs, err := img.ImageAttrs() require.NoError(t, err) require.NotNil(t, attrs.Alt) assert.Equal(t, "bold and italic", *attrs.Alt) } func TestParseMarkdown_BulletList(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("- item 1\n- item 2\n- item 3") require.NoError(t, err) require.Len(t, doc.Content, 1) list := doc.Content[0] assert.Equal(t, NodeBulletList, list.Type) require.Len(t, list.Content, 3) for i, item := range list.Content { assert.Equal(t, NodeListItem, item.Type, "item %d", i) require.Len(t, item.Content, 1) assert.Equal(t, NodeParagraph, item.Content[0].Type) } } func TestParseMarkdown_OrderedList(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("1. first\n2. second\n3. third") require.NoError(t, err) require.Len(t, doc.Content, 1) list := doc.Content[0] assert.Equal(t, NodeOrderedList, list.Type) attrs, err := list.OrderedListAttrs() require.NoError(t, err) assert.Equal(t, 1, attrs.Start) require.Len(t, list.Content, 3) } func TestParseMarkdown_NestedList(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("- parent\n - child\n - child 2\n- parent 2") require.NoError(t, err) require.Len(t, doc.Content, 1) list := doc.Content[0] assert.Equal(t, NodeBulletList, list.Type) require.Len(t, list.Content, 2) // First item should have a paragraph and a nested bullet list. firstItem := list.Content[0] assert.Equal(t, NodeListItem, firstItem.Type) require.Len(t, firstItem.Content, 2) assert.Equal(t, NodeParagraph, firstItem.Content[0].Type) assert.Equal(t, NodeBulletList, firstItem.Content[1].Type) require.Len(t, firstItem.Content[1].Content, 2) } func TestParseMarkdown_Blockquote(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("> quoted text") require.NoError(t, err) require.Len(t, doc.Content, 1) bq := doc.Content[0] assert.Equal(t, NodeBlockquote, bq.Type) require.Len(t, bq.Content, 1) assert.Equal(t, NodeParagraph, bq.Content[0].Type) } func TestParseMarkdown_HorizontalRule(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("---") require.NoError(t, err) require.Len(t, doc.Content, 1) assert.Equal(t, NodeHorizontalRule, doc.Content[0].Type) } func TestParseMarkdown_HardBreak(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("line one\\\nline two") require.NoError(t, err) require.Len(t, doc.Content, 1) p := doc.Content[0] assert.Equal(t, NodeParagraph, p.Type) // Should contain: text("line one"), hardBreak, text("line two") var hasHardBreak bool for _, child := range p.Content { if child.Type == NodeHardBreak { hasHardBreak = true } } assert.True(t, hasHardBreak, "expected hard break node") } func TestParseMarkdown_SoftLineBreak(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("line one\nand line two") require.NoError(t, err) require.Len(t, doc.Content, 1) p := doc.Content[0] require.Equal(t, NodeParagraph, p.Type) var joined strings.Builder for _, child := range p.Content { if child.Type == NodeText && child.Text != nil { joined.WriteString(*child.Text) } } assert.Equal(t, "line one and line two", joined.String()) } func TestParseMarkdown_NestedMarks(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("***bold and italic***") require.NoError(t, err) require.Len(t, doc.Content, 1) p := doc.Content[0] require.Len(t, p.Content, 1) txt := p.Content[0] assert.Equal(t, "bold and italic", *txt.Text) require.Len(t, txt.Marks, 2) markTypes := make(map[MarkType]bool) for _, m := range txt.Marks { markTypes[m.Type] = true } assert.True(t, markTypes[MarkStrong]) assert.True(t, markTypes[MarkEm]) } func TestParseMarkdown_MixedContent(t *testing.T) { t.Parallel() doc, err := ParseMarkdown("Normal **bold** and *italic* text") require.NoError(t, err) require.Len(t, doc.Content, 1) p := doc.Content[0] require.True(t, len(p.Content) >= 4, "expected at least 4 inline nodes") // Verify first text node is plain. assert.Equal(t, "Normal ", *p.Content[0].Text) assert.Empty(t, p.Content[0].Marks) // Verify bold text node. assert.Equal(t, "bold", *p.Content[1].Text) require.Len(t, p.Content[1].Marks, 1) assert.Equal(t, MarkStrong, p.Content[1].Marks[0].Type) } func TestParseMarkdown_MarkdownTable(t *testing.T) { t.Parallel() md := "| Name | Age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |\n" doc, err := ParseMarkdown(md) require.NoError(t, err) require.Len(t, doc.Content, 1) assert.Equal(t, NodeTable, doc.Content[0].Type) table := doc.Content[0] require.Len(t, table.Content, 3) headerRow := table.Content[0] assert.Equal(t, NodeTableRow, headerRow.Type) require.Len(t, headerRow.Content, 2) assert.Equal(t, NodeTableHeader, headerRow.Content[0].Type) assert.Equal(t, NodeTableHeader, headerRow.Content[1].Type) require.Len(t, headerRow.Content[0].Content, 1) require.Equal(t, NodeParagraph, headerRow.Content[0].Content[0].Type) require.Len(t, headerRow.Content[0].Content[0].Content, 1) assert.Equal(t, "Name", *headerRow.Content[0].Content[0].Content[0].Text) require.Len(t, headerRow.Content[1].Content, 1) require.Equal(t, NodeParagraph, headerRow.Content[1].Content[0].Type) require.Len(t, headerRow.Content[1].Content[0].Content, 1) assert.Equal(t, "Age", *headerRow.Content[1].Content[0].Content[0].Text) for i := 1; i <= 2; i++ { row := table.Content[i] assert.Equal(t, NodeTableRow, row.Type) require.Len(t, row.Content, 2) assert.Equal(t, NodeTableCell, row.Content[0].Type) assert.Equal(t, NodeTableCell, row.Content[1].Type) attrs, err := row.Content[0].TableCellAttrs() require.NoError(t, err) assert.Equal(t, 1, attrs.Colspan) assert.Equal(t, 1, attrs.Rowspan) } assert.Equal(t, "Alice", *table.Content[1].Content[0].Content[0].Content[0].Text) assert.Equal(t, "30", *table.Content[1].Content[1].Content[0].Content[0].Text) assert.Equal(t, "Bob", *table.Content[2].Content[0].Content[0].Content[0].Text) assert.Equal(t, "25", *table.Content[2].Content[1].Content[0].Content[0].Text) } func TestParseMarkdown_MarkdownTableWithInlineMarks(t *testing.T) { t.Parallel() md := "| Header |\n| --- |\n| **bold** and *italic* |\n" doc, err := ParseMarkdown(md) require.NoError(t, err) require.Len(t, doc.Content, 1) assert.Equal(t, NodeTable, doc.Content[0].Type) dataRow := doc.Content[0].Content[1] assert.Equal(t, NodeTableRow, dataRow.Type) cell := dataRow.Content[0] assert.Equal(t, NodeTableCell, cell.Type) p := cell.Content[0] require.Equal(t, NodeParagraph, p.Type) require.GreaterOrEqual(t, len(p.Content), 3) assert.Equal(t, "bold", *p.Content[0].Text) require.Len(t, p.Content[0].Marks, 1) assert.Equal(t, MarkStrong, p.Content[0].Marks[0].Type) assert.Equal(t, "italic", *p.Content[2].Text) require.Len(t, p.Content[2].Marks, 1) assert.Equal(t, MarkEm, p.Content[2].Marks[0].Type) } func TestParseMarkdown_JSONRoundTrip(t *testing.T) { t.Parallel() md := `# Title A paragraph with **bold**, *italic*, and ` + "`code`" + `. - item 1 - item 2 > blockquote --- ` + "```go\nfmt.Println()\n```" doc, err := ParseMarkdown(md) require.NoError(t, err) data, err := json.Marshal(doc) require.NoError(t, err) // Verify we can parse it back. parsed, err := Parse(string(data)) require.NoError(t, err) assert.Equal(t, NodeDoc, parsed.Type) assert.True(t, len(parsed.Content) > 0) }