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 { if attrs.Target != nil {
writeAttr(buf, "target", *attrs.Target) writeAttr(buf, "target", *attrs.Target)
} }
if attrs.Rel != nil { rel := linkRelToEmit(attrs)
writeAttr(buf, "rel", *attrs.Rel) if rel != "" {
writeAttr(buf, "rel", rel)
} }
if attrs.Class != nil { if attrs.Class != nil {
writeAttr(buf, "class", *attrs.Class) writeAttr(buf, "class", *attrs.Class)
@@ -261,6 +262,27 @@ func renderTableCell(buf *bytes.Buffer, n Node, tag string) error {
return nil 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) { func writeAttr(buf *bytes.Buffer, name, value string) {
buf.WriteByte(' ') buf.WriteByte(' ')
buf.WriteString(name) buf.WriteString(name)

View File

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

View File

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

View File

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