diff --git a/internal/richtext/richtext.go b/internal/richtext/richtext.go
index b8a9054ba..691c6c271 100644
--- a/internal/richtext/richtext.go
+++ b/internal/richtext/richtext.go
@@ -134,7 +134,15 @@ var mdConverter = goldmark.New(
),
)
-// TrixBreak is a custom block node that renders as
\n for Trix paragraph spacing.
+// paragraphSeparator is the blank line Basecamp's editor itself stores between
+// two blocks. A bare top-level
is not a block in the editor's document
+// model, so it is discarded the first time someone edits the content and the
+// spacing disappears; an empty paragraph survives the round trip.
+const paragraphSeparator = "
A
B
renders squished. The @@ -387,10 +402,12 @@ func MarkdownToHTML(md string) string { // idempotent: a boundary that already carries a separator โ a bare
-// blocks are separated; anything between them (whitespace excepted), such as a
-// heading, list, or attachment, already provides its own break and is left
-// alone.
+// (including Basecamp editor output) is a no-op. Separators the caller supplied
+// are left as they came: this matching is not nesting-aware, and a
the
+// caller put between two paragraphs inside a blockquote is legal inline content
+// that survives editing. Only directly adjacent
blocks are separated;
+// anything else between them, such as a heading, list, or attachment, already
+// provides its own break and is left alone.
func insertParagraphSeparators(s string) string {
locs := reP.FindAllStringIndex(s, -1)
if len(locs) < 2 {
@@ -418,7 +435,7 @@ func insertParagraphSeparators(s string) string {
gap := s[end:nextStart]
if !empty[i] && !empty[i+1] && strings.TrimSpace(gap) == "" {
b.WriteString(gap)
- b.WriteString("
")
+ b.WriteString(paragraphSeparator)
cursor = nextStart
}
}
@@ -430,7 +447,7 @@ func insertParagraphSeparators(s string) string {
// i.e. it is empty or contains only
tags and whitespace, including
// non-breaking-space entities ( , , ) that rich text editors
// commonly use for blank separator lines. Such paragraphs act as separators, so
-// no additional
is inserted adjacent to them.
+// no additional one is inserted adjacent to them.
func isEmptyParagraph(block string) bool {
m := reP.FindStringSubmatch(block)
if m == nil {
diff --git a/internal/richtext/richtext_test.go b/internal/richtext/richtext_test.go
index 8fb3246e3..c213a5731 100644
--- a/internal/richtext/richtext_test.go
+++ b/internal/richtext/richtext_test.go
@@ -85,7 +85,7 @@ func TestMarkdownToHTML(t *testing.T) {
{
name: "list followed by blank line then paragraph",
input: "- Item 1\n- Item 2\n\nFollowing paragraph.",
- expected: "
Following paragraph.
", + expected: "Following paragraph.
", }, { // CommonMark ยง5.4: "After" is a lazy continuation of the second list item. @@ -128,7 +128,7 @@ func TestMarkdownToHTML(t *testing.T) { { name: "mixed formatting", input: "# Title\n\nThis is **bold** and *italic* and `code`.", - expected: "This is bold and italic and code.
This is bold and italic and code.
First paragraph
\nSecond paragraph
", + expected: "First paragraph
\nSecond paragraph
", }, { name: "multiple blank lines collapse to one break", input: "First\n\n\n\nSecond", - expected: "First
\nSecond
", + expected: "First
\nSecond
", }, { name: "consecutive lines join into one paragraph", @@ -158,12 +158,12 @@ func TestMarkdownToHTML(t *testing.T) { { name: "blank line before list", input: "Intro\n\n- Item 1\n- Item 2", - expected: "Intro
\nIntro
\nIntro
\ncode\n",
+ expected: "Intro
\ncode\n",
},
{
name: "leading blank lines ignored",
@@ -173,12 +173,12 @@ func TestMarkdownToHTML(t *testing.T) {
{
name: "blank line before blockquote",
input: "Intro\n\n> A quote",
- expected: "Intro
\nA quote", + expected: "
Intro
\nA quote", }, { name: "blank line before horizontal rule", input: "Intro\n\n---", - expected: "
Intro
\nIntro
\nintro
\n<div>hello</div>\n",
+ expected: "intro
\n<div>hello</div>\n",
},
}
@@ -2223,33 +2223,41 @@ func TestMarkdownToHTMLInsertsParagraphSeparators(t *testing.T) {
{
name: "two contiguous paragraphs get a separator",
input: "Line 1
Line 2
", - expected: "Line 1
Line 2
", + expected: "Line 1
Line 2
", }, { name: "three contiguous paragraphs get separators between each", input: "A
B
C
", - expected: "A
B
C
", + expected: "A
B
C
", }, { name: "paragraphs with attributes are separated", input: `A
B
`, - expected: `A
B
`, + expected: `A
B
`, }, { name: "whitespace-only gap is preserved and separator added", input: "A
\nB
", - expected: "A
\nB
", + expected: "A
\nB
", }, { - name: "existing bare br separator is left untouched (idempotent)", + name: "caller-supplied bare br separator is left untouched", input: "A
B
", expected: "A
B
", }, { - name: "empty separator paragraph is left untouched (Lexxy canonical)", + name: "separator paragraph is left untouched (editor canonical)", input: "A
B
", expected: "A
B
", }, + { + // A", + expected: "A
B
", + }, { name: "empty paragraph separator is left untouched", input: "A
B
A
B
", @@ -2293,7 +2301,7 @@ func TestMarkdownToHTMLInsertsParagraphSeparators(t *testing.T) { { name: "paragraph with inline br is still non-empty and separated", input: "A
C
B
", - expected: "A
C
B
", + expected: "A
C
B
", }, { name: "leading empty paragraph is left alone", @@ -2303,7 +2311,7 @@ func TestMarkdownToHTMLInsertsParagraphSeparators(t *testing.T) { { name: "mix of separated and contiguous only fills the gap that lacks a separator", input: "A
B
C
", - expected: "A
B
C
", + expected: "A
B
C
", }, } @@ -2325,6 +2333,7 @@ func TestMarkdownToHTMLParagraphSeparatorsIdempotent(t *testing.T) { `A
B
`, "A
\nB
", "A
B
", + "A
B
", "A
B
", } @@ -2345,10 +2354,27 @@ func TestMarkdownToHTMLParagraphSeparatorsMatchMarkdownPath(t *testing.T) { fromMarkdown := MarkdownToHTML("Line 1\n\nLine 2") fromHTML := MarkdownToHTML("Line 1
Line 2
") - if !strings.Contains(fromMarkdown, "Line 1
\nLine 2
" { + t.Errorf("markdown path = %q", fromMarkdown) + } + if fromHTML != "Line 1
Line 2
" { + t.Errorf("HTML path = %q, want %q", fromHTML, "Line 1
Line 2
") + } +} + +// Basecamp's editor discards a bare top-level", "
", "", "
"} { + if strings.Contains(html, "
\n"+block) { + t.Errorf("bare
separator before %s in %q", block, html) + } } - if fromHTML != "Line 1
Line 2
" { - t.Errorf("HTML path = %q, want %q", fromHTML, "Line 1
Line 2
") + if want := strings.Count(markdown, "\n\n"); strings.Count(html, paragraphSeparator) != want { + t.Errorf("got %d separator paragraphs, want %d in %q", strings.Count(html, paragraphSeparator), want, html) } }