From d086a888b1c4ae90e38fac11eee506149a11916e Mon Sep 17 00:00:00 2001 From: Nikita Nemirovsky Date: Fri, 24 Apr 2026 13:41:03 +0800 Subject: [PATCH] fix(richtext): emit
paragraphs and

breaks for Trix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trix (the editor behind Basecamp's rich-text fields) stores paragraphs as
...
and blank-line separators as

. goldmark's default output —

...

\n
\n

...

— renders with the block margins of

*plus* the line break of the intervening
, producing double spacing in Basecamp between what the user typed as a single blank line between two paragraphs. MarkdownToHTML changes: - trixRenderer registers renderParagraph, emitting

...
for top-level paragraphs. trixTransformer already converts paragraphs inside lists and blockquotes into TextBlocks, so this only fires where it should. - trixRenderer.renderTrixBreak emits

\n instead of
\n. - trixRenderer.renderHTMLBlock wraps raw HTML blocks in
instead of

for consistency. - The MarkdownToHTML error-path fallback ("

" + escaped + "

") mirrors the new shape. HTMLToMarkdown changes (for round-trip fidelity with Trix-native HTML coming from the API): - Add reDivBr and reDiv patterns running before the existing

/
pipeline:


becomes a blank line,
...
becomes a paragraph. - blockquoteInnerToMarkdown gains
/

handling so multi-paragraph blockquotes round-trip cleanly; without it, the inner

cascades through the outer blockquote line-splitter and produces extra ">" lines. Test updates: - MarkdownToHTML expected strings flip

,

, and the block-level break \n
\n → \n

\n. Applied via a targeted sweep, verified one by one against the rendered output. - ResolveMentions test inputs flip

to stay representative of what MarkdownToHTML now produces and what Trix emits natively. - TestEditLoopRoundTrip/multi-paragraph_blockquote: now passes cleanly because of the new blockquote
handling. - TestCheckinsAnswerCreateDefaultsDateToToday in internal/commands asserted the wrapper shape; updated to
. Inline
inside a paragraph (consecutive lines with single \n between them) is unchanged: trixTransformer still converts soft-break → hard-break inside
, matching Trix's own line-break representation. --- internal/commands/checkins_test.go | 2 +- internal/richtext/richtext.go | 78 +++++++++-- internal/richtext/richtext_test.go | 205 +++++++++++++++-------------- 3 files changed, 174 insertions(+), 111 deletions(-) diff --git a/internal/commands/checkins_test.go b/internal/commands/checkins_test.go index 7194e7a2..73125a31 100644 --- a/internal/commands/checkins_test.go +++ b/internal/commands/checkins_test.go @@ -84,7 +84,7 @@ func TestCheckinsAnswerCreateDefaultsDateToToday(t *testing.T) { require.NoError(t, err) require.NotNil(t, transport.recordedBody) assert.Equal(t, "/99999/questions/456/answers.json", transport.recordedPath) - assert.Equal(t, "

hello world

", transport.recordedBody["content"]) + assert.Equal(t, "
hello world
", transport.recordedBody["content"]) assert.Equal(t, "2026-03-25", transport.recordedBody["group_on"]) } diff --git a/internal/richtext/richtext.go b/internal/richtext/richtext.go index cf7b4474..044b91c8 100644 --- a/internal/richtext/richtext.go +++ b/internal/richtext/richtext.go @@ -47,8 +47,15 @@ var ( // prefix (e.g.

vs

,  vs 
, vs , vs , // vs ", - expected: "

<script>alert(1)</script>

", + expected: "
<script>alert(1)</script>
", }, { name: "multiline script tag", input: "", - expected: "

<script> alert(1) </script>

", + expected: "
<script> alert(1) </script>
", }, } @@ -1332,8 +1332,15 @@ func TestRoundTrip(t *testing.T) { if strings.Contains(back, "\n\n") { t.Errorf("round-trip produced two paragraphs, want one\nhtml: %q\nback: %q", html, back) } - if !strings.Contains(back, "Line 1") || !strings.Contains(back, "Line 2") { - t.Errorf("round-trip lost content\nhtml: %q\nback: %q", html, back) + line1Idx := strings.Index(back, "Line 1") + line2Idx := strings.Index(back, "Line 2") + if line1Idx == -1 || line2Idx == -1 || line1Idx >= line2Idx { + t.Errorf("round-trip did not preserve line order/content\nhtml: %q\nback: %q", html, back) + return + } + between := back[line1Idx+len("Line 1") : line2Idx] + if !strings.Contains(between, "\n") { + t.Errorf("round-trip did not preserve a line break between lines\nhtml: %q\nback: %q", html, back) } }) } @@ -1535,23 +1542,23 @@ func TestResolveMentions(t *testing.T) { }{ { name: "single mention", - input: `

Hey @John, check this

`, - expected: `

Hey ` + MentionToHTML("sgid-john", "John Doe") + `, check this

`, + input: `
Hey @John, check this
`, + expected: `
Hey ` + MentionToHTML("sgid-john", "John Doe") + `, check this
`, }, { name: "first.last mention", - input: `

Hey @Igor.Logachev, check this

`, - expected: `

Hey ` + MentionToHTML("sgid-igor", "Igor Logachev") + `, check this

`, + input: `
Hey @Igor.Logachev, check this
`, + expected: `
Hey ` + MentionToHTML("sgid-igor", "Igor Logachev") + `, check this
`, }, { name: "multiple mentions", - input: `

@John and @Igor please review

`, - expected: `

` + MentionToHTML("sgid-john", "John Doe") + ` and ` + MentionToHTML("sgid-igor", "Igor Logachev") + ` please review

`, + input: `
@John and @Igor please review
`, + expected: `
` + MentionToHTML("sgid-john", "John Doe") + ` and ` + MentionToHTML("sgid-igor", "Igor Logachev") + ` please review
`, }, { name: "no mentions", - input: `

Hello world

`, - expected: `

Hello world

`, + input: `
Hello world
`, + expected: `
Hello world
`, }, { name: "mention at start of line", @@ -1560,12 +1567,12 @@ func TestResolveMentions(t *testing.T) { }, { name: "email not treated as mention", - input: `

Send to user@John.com

`, - expected: `

Send to user@John.com

`, + input: `
Send to user@John.com
`, + expected: `
Send to user@John.com
`, }, { name: "unresolved mention is error", - input: `

Hey @Unknown

`, + input: `
Hey @Unknown
`, wantErr: true, }, { @@ -1580,13 +1587,13 @@ func TestResolveMentions(t *testing.T) { }, { name: "unicode name mention", - input: `

Hey @José, check this

`, - expected: `

Hey ` + MentionToHTML("sgid-jose", "José García") + `, check this

`, + input: `
Hey @José, check this
`, + expected: `
Hey ` + MentionToHTML("sgid-jose", "José García") + `, check this
`, }, { name: "mention inside code block is skipped", - input: `

Use @John syntax

`, - expected: `

Use @John syntax

`, + input: `
Use @John syntax
`, + expected: `
Use @John syntax
`, }, { name: "mention inside pre block is skipped", @@ -1606,41 +1613,41 @@ func TestResolveMentions(t *testing.T) { // Expanded prefix tests { name: "mention after open paren", - input: `

(@John) check this

`, - expected: `

(` + MentionToHTML("sgid-john", "John Doe") + `) check this

`, + input: `
(@John) check this
`, + expected: `
(` + MentionToHTML("sgid-john", "John Doe") + `) check this
`, }, { name: "mention after open bracket", - input: `

[@John] check this

`, - expected: `

[` + MentionToHTML("sgid-john", "John Doe") + `] check this

`, + input: `
[@John] check this
`, + expected: `
[` + MentionToHTML("sgid-john", "John Doe") + `] check this
`, }, { name: "mention after double quote", - input: `

"@John" check this

`, - expected: `

"` + MentionToHTML("sgid-john", "John Doe") + `" check this

`, + input: `
"@John" check this
`, + expected: `
"` + MentionToHTML("sgid-john", "John Doe") + `" check this
`, }, { name: "mention after single quote", - input: `

'@John' check this

`, - expected: `

'` + MentionToHTML("sgid-john", "John Doe") + `' check this

`, + input: `
'@John' check this
`, + expected: `
'` + MentionToHTML("sgid-john", "John Doe") + `' check this
`, }, // Trailing-character bailout tests { name: "hyphen bailout", - input: `

Hey @John-Doe

`, - expected: `

Hey @John-Doe

`, + input: `
Hey @John-Doe
`, + expected: `
Hey @John-Doe
`, wantErr: false, }, { name: "apostrophe letter bailout", - input: `

Hey @John's stuff

`, - expected: `

Hey @John's stuff

`, + input: `
Hey @John's stuff
`, + expected: `
Hey @John's stuff
`, wantErr: false, }, { name: "apostrophe then non-letter is not bailout", - input: `

'@John' said hi

`, - expected: `

'` + MentionToHTML("sgid-john", "John Doe") + `' said hi

`, + input: `
'@John' said hi
`, + expected: `
'` + MentionToHTML("sgid-john", "John Doe") + `' said hi
`, }, // Case-insensitive bc-attachment guard { @@ -1687,8 +1694,8 @@ func TestResolveMentions_MentionSGID(t *testing.T) { }, { name: "mention in paragraph", - input: `

Hey @Jane Smith, check this

`, - expected: `

Hey ` + MentionToHTML("BAh7CEkiCG", "Jane Smith") + `, check this

`, + input: `
Hey @Jane Smith, check this
`, + expected: `
Hey ` + MentionToHTML("BAh7CEkiCG", "Jane Smith") + `, check this
`, }, { name: "mention inside code block is skipped", @@ -1760,8 +1767,8 @@ func TestResolveMentions_PersonID(t *testing.T) { }, { name: "person scheme in paragraph", - input: `

Hey @Jane, check this

`, - expected: `

Hey ` + MentionToHTML("sgid-jane", "Jane Smith") + `, check this

`, + input: `
Hey @Jane, check this
`, + expected: `
Hey ` + MentionToHTML("sgid-jane", "Jane Smith") + `, check this
`, }, { name: "person scheme — not pingable", @@ -1802,8 +1809,8 @@ func TestResolveMentions_SGIDInline(t *testing.T) { }{ { name: "sgid inline — direct embed", - input: `

Hey @sgid:BAh7CEkiCG, check this

`, - expected: `

Hey ` + MentionToHTML("BAh7CEkiCG", "BAh7CEkiCG") + `, check this

`, + input: `
Hey @sgid:BAh7CEkiCG, check this
`, + expected: `
Hey ` + MentionToHTML("BAh7CEkiCG", "BAh7CEkiCG") + `, check this
`, }, { name: "sgid at start of line", @@ -1812,8 +1819,8 @@ func TestResolveMentions_SGIDInline(t *testing.T) { }, { name: "sgid with base64 chars", - input: `

Hey @sgid:BAh7+CG/k=, check

`, - expected: `

Hey ` + MentionToHTML("BAh7+CG/k=", "BAh7+CG/k=") + `, check

`, + input: `
Hey @sgid:BAh7+CG/k=, check
`, + expected: `
Hey ` + MentionToHTML("BAh7+CG/k=", "BAh7+CG/k=") + `, check
`, }, { name: "sgid inside code is skipped",