Fix html conversion of soft line breaks + rel attr for _blank link targets

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-03-30 18:37:50 +04:00
parent a461410fed
commit dda6e41102
5 changed files with 91 additions and 6 deletions

View File

@@ -198,8 +198,9 @@ func openMark(buf *bytes.Buffer, m Mark) error {
if attrs.Target != nil {
writeAttr(buf, "target", *attrs.Target)
}
if attrs.Rel != nil {
writeAttr(buf, "rel", *attrs.Rel)
rel := linkRelToEmit(attrs)
if rel != "" {
writeAttr(buf, "rel", rel)
}
if attrs.Class != nil {
writeAttr(buf, "class", *attrs.Class)
@@ -261,6 +262,27 @@ func renderTableCell(buf *bytes.Buffer, n Node, tag string) error {
return nil
}
const linkRelBlankTargetDefault = "noopener noreferrer"
// linkRelToEmit returns the rel attribute value for a link mark, or empty when
// the attribute should be omitted. When target opens a new browsing context
// (_blank) and the document provides no rel, browsers would grant the opened
// page access to window.opener unless noopener is set.
func linkRelToEmit(attrs LinkAttrs) string {
if attrs.Rel != nil {
if s := strings.TrimSpace(*attrs.Rel); s != "" {
return s
}
}
if attrs.Target == nil {
return ""
}
if !strings.EqualFold(strings.TrimSpace(*attrs.Target), "_blank") {
return ""
}
return linkRelBlankTargetDefault
}
func writeAttr(buf *bytes.Buffer, name, value string) {
buf.WriteByte(' ')
buf.WriteString(name)

View File

@@ -26,12 +26,10 @@ import (
// htmlBlockSanitizePolicy matches tags and attributes we can represent as
// ProseMirror/Tiptap JSON and strips scripts, event handlers, and unsafe URLs.
func htmlBlockSanitizePolicy() *bluemonday.Policy {
return bluemonday.UGCPolicy()
}
var htmlBlockSanitizePolicy = bluemonday.UGCPolicy()
func sanitizeHTMLBlockContent(s string) string {
return htmlBlockSanitizePolicy().Sanitize(s)
return htmlBlockSanitizePolicy.Sanitize(s)
}
// convertProseMirrorFromInlineHTML sanitizes inline raw HTML and maps it to

View File

@@ -187,6 +187,45 @@ func TestRenderHTML_LinkMinimalAttrs(t *testing.T) {
assert.Equal(t, `<a href="https://example.com">hi</a>`, 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: `<a href="https://example.com" target="_blank" rel="noopener noreferrer">hi</a>`,
},
{
name: "case insensitive target",
raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":"_BLANK"}}],"text":"hi"}`,
want: `<a href="https://example.com" target="_BLANK" rel="noopener noreferrer">hi</a>`,
},
{
name: "whitespace rel treated as absent",
raw: `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://example.com","target":" _blank ","rel":" "}}],"text":"hi"}`,
want: `<a href="https://example.com" target=" _blank " rel="noopener noreferrer">hi</a>`,
},
} {
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()

View File

@@ -117,6 +117,9 @@ func (c *converter) collectRawHTMLRun(start ast.Node) (run string, next ast.Node
case ast.KindText:
t := ch.(*ast.Text)
buf.Write(t.Segment.Value(c.source))
if t.SoftLineBreak() {
buf.WriteByte(' ')
}
case ast.KindString:
buf.Write(ch.(*ast.String).Value)
default:
@@ -352,6 +355,9 @@ func (c *converter) convertImage(n *ast.Image) ([]Node, error) {
func (c *converter) convertText(n *ast.Text) ([]Node, error) {
content := string(n.Segment.Value(c.source))
if n.SoftLineBreak() {
content += " "
}
if content == "" {
return nil, nil
}

View File

@@ -16,6 +16,7 @@ package prosemirror
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -351,6 +352,25 @@ func TestParseMarkdown_HardBreak(t *testing.T) {
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()