From dda6e41102e76028599f82be369d8637218bf9e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Mon, 30 Mar 2026 18:37:50 +0400 Subject: [PATCH] Fix html conversion of soft line breaks + rel attr for _blank link targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- pkg/prosemirror/html.go | 26 +++++++++++++++++++-- pkg/prosemirror/html_block.go | 6 ++--- pkg/prosemirror/html_test.go | 39 ++++++++++++++++++++++++++++++++ pkg/prosemirror/markdown.go | 6 +++++ pkg/prosemirror/markdown_test.go | 20 ++++++++++++++++ 5 files changed, 91 insertions(+), 6 deletions(-) diff --git a/pkg/prosemirror/html.go b/pkg/prosemirror/html.go index 3d5d57ab3..b42ec85a8 100644 --- a/pkg/prosemirror/html.go +++ b/pkg/prosemirror/html.go @@ -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) diff --git a/pkg/prosemirror/html_block.go b/pkg/prosemirror/html_block.go index 3a38284c2..231ba99eb 100644 --- a/pkg/prosemirror/html_block.go +++ b/pkg/prosemirror/html_block.go @@ -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 diff --git a/pkg/prosemirror/html_test.go b/pkg/prosemirror/html_test.go index b6cfb07bb..9b7a5e9c1 100644 --- a/pkg/prosemirror/html_test.go +++ b/pkg/prosemirror/html_test.go @@ -187,6 +187,45 @@ func TestRenderHTML_LinkMinimalAttrs(t *testing.T) { 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`, + }, + } { + 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() diff --git a/pkg/prosemirror/markdown.go b/pkg/prosemirror/markdown.go index b7af49d85..468e71eae 100644 --- a/pkg/prosemirror/markdown.go +++ b/pkg/prosemirror/markdown.go @@ -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 } diff --git a/pkg/prosemirror/markdown_test.go b/pkg/prosemirror/markdown_test.go index 061a45e0b..dfe86b20e 100644 --- a/pkg/prosemirror/markdown_test.go +++ b/pkg/prosemirror/markdown_test.go @@ -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()