// 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" "html" "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_CodeBlockMermaid(t *testing.T) { t.Parallel() raw := `{"type":"codeBlock","attrs":{"language":"mermaid"},"content":[{"type":"text","text":"graph TD\n A-->B"}]}` var n Node require.NoError(t, json.Unmarshal([]byte(raw), &n)) got, err := RenderHTML(n) require.NoError(t, err) assert.Equal(t, "
graph TD\n  A-->B
", 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_LinkBlankTargetDefaultRel(t *testing.T) { t.Parallel() for _, tc := range []struct { name string raw string want string }{ { name: "no rel", raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":"_blank","rel":null}}],"text":"hi"}`, want: `hi`, }, { name: "case insensitive target", raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":"_BLANK"}}],"text":"hi"}`, want: `hi`, }, { name: "whitespace rel treated as absent", raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":" _blank ","rel":" "}}],"text":"hi"}`, want: `hi`, }, { name: "custom rel without noopener gets noopener appended", raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":"_blank","rel":"nofollow"}}],"text":"hi"}`, want: `hi`, }, { name: "custom rel already has noopener", raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":"_blank","rel":"noopener nofollow"}}],"text":"hi"}`, want: `hi`, }, { name: "custom rel with noopener case insensitive", raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":"_blank","rel":"NoOpener"}}],"text":"hi"}`, want: `hi`, }, { name: "custom rel without blank target unchanged", raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":"_self","rel":"nofollow"}}],"text":"hi"}`, want: `hi`, }, } { t.Run( tc.name, func(t *testing.T) { t.Parallel() var n Node require.NoError(t, json.Unmarshal([]byte(tc.raw), &n)) got, err := RenderHTML(n) require.NoError(t, err) assert.Equal(t, tc.want, got) }, ) } } func TestRenderHTML_LinkSanitizesDangerousHrefs(t *testing.T) { t.Parallel() for _, tc := range []struct { name string href string wantHref string }{ {name: "javascript scheme", href: `javascript:alert(1)`, wantHref: `#`}, {name: "javascript scheme case insensitive", href: `javaScript:alert(1)`, wantHref: `#`}, {name: "data html", href: `data:text/html,`, wantHref: `#`}, {name: "protocol-relative", href: `//evil.example/phish`, wantHref: `#`}, {name: "path with leading slash-slash", href: `//not-a-path`, wantHref: `#`}, {name: "empty href", href: ``, wantHref: `#`}, {name: "fragment only", href: `#section`, wantHref: `#section`}, {name: "relative path", href: `docs/page`, wantHref: `docs/page`}, {name: "absolute path", href: `/app/foo`, wantHref: `/app/foo`}, {name: "mailto", href: `mailto:user@example.com`, wantHref: `mailto:user@example.com`}, {name: "tel", href: `tel:+15551212`, wantHref: `tel:+15551212`}, {name: "https preserved", href: `https://example.com/x`, wantHref: `https://example.com/x`}, } { t.Run( tc.name, func(t *testing.T) { t.Parallel() hrefJSON, err := json.Marshal(tc.href) require.NoError(t, err) raw := fmt.Sprintf( `{"type":"text","marks":[{"type":"link","attrs":{"href":%s,"target":null,"rel":null,"class":null,"title":null}}],"text":"x"}`, string(hrefJSON), ) var n Node require.NoError(t, json.Unmarshal([]byte(raw), &n)) got, err := RenderHTML(n) require.NoError(t, err) want := fmt.Sprintf(`x`, html.EscapeString(tc.wantHref)) assert.Equal(t, want, 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_ImageSanitizesDangerousSrc(t *testing.T) { t.Parallel() for _, tc := range []struct { name string src string wantSrc string }{ {name: "javascript scheme", src: `javascript:alert(1)`, wantSrc: ``}, {name: "javascript case insensitive", src: `javaScript:alert(1)`, wantSrc: ``}, {name: "vbscript scheme", src: `vbscript:MsgBox("xss")`, wantSrc: ``}, {name: "protocol-relative", src: `//evil.example/img.png`, wantSrc: ``}, {name: "empty src", src: ``, wantSrc: ``}, {name: "https preserved", src: `https://example.com/img.png`, wantSrc: `https://example.com/img.png`}, {name: "http preserved", src: `http://example.com/img.png`, wantSrc: `http://example.com/img.png`}, {name: "data URI preserved", src: `data:image/png;base64,iVBOR`, wantSrc: `data:image/png;base64,iVBOR`}, {name: "absolute path", src: `/images/photo.png`, wantSrc: `/images/photo.png`}, {name: "relative path", src: `images/photo.png`, wantSrc: `images/photo.png`}, } { t.Run( tc.name, func(t *testing.T) { t.Parallel() srcJSON, err := json.Marshal(tc.src) require.NoError(t, err) raw := fmt.Sprintf( `{"type":"image","attrs":{"src":%s}}`, string(srcJSON), ) var n Node require.NoError(t, json.Unmarshal([]byte(raw), &n)) got, err := RenderHTML(n) require.NoError(t, err) want := fmt.Sprintf(``, html.EscapeString(tc.wantSrc)) assert.Equal(t, want, 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") }