diff --git a/internal/commands/chat_test.go b/internal/commands/chat_test.go
index 8d126f7d5..8631cada2 100644
--- a/internal/commands/chat_test.go
+++ b/internal/commands/chat_test.go
@@ -1238,7 +1238,8 @@ func TestChatUpdatePlainTextOptOut(t *testing.T) {
// TestChatUpdatePlainTextSerializesLiterally is the F2 literal case: text/plain
// serializes via richtext.PlainToHTML, so HTML-special characters are escaped
-// (rendered as typed, not interpreted) and line breaks are preserved as
.
+// (rendered as typed, not interpreted) and line breaks are preserved as
+// inside the paragraph, where Basecamp's editor keeps them.
func TestChatUpdatePlainTextSerializesLiterally(t *testing.T) {
t.Setenv("BASECAMP_NO_KEYRING", "1")
@@ -1255,10 +1256,8 @@ func TestChatUpdatePlainTextSerializesLiterally(t *testing.T) {
content, ok := requestBody["content"].(string)
require.True(t, ok)
- assert.Contains(t, content, "<strong>x</strong>",
- "HTML-special characters should be escaped, not interpreted as markup")
- assert.Contains(t, content, "
",
- "line breaks should be preserved as
")
+ assert.Contains(t, content, "<strong>x</strong>
line2
A
B
renders squished. The @@ -408,10 +423,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 {
@@ -439,7 +456,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
}
}
@@ -451,7 +468,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 {
@@ -462,20 +479,60 @@ func isEmptyParagraph(block string) bool {
return strings.TrimSpace(inner) == ""
}
-// PlainToHTML serializes literal plain text as Basecamp rich text: HTML-special
+// PlainToHTML serializes literal plain text as Basecamp rich text. HTML-special
// characters are escaped so they render as typed (not interpreted as markup),
-// and line breaks are preserved as
so multi-line input keeps its shape.
-// Windows CRLF and bare CR are normalized to LF first so a single
is
-// emitted per line break. Use this when the caller wants the text delivered
-// verbatim to an endpoint that always stores rich text.
+// and the line structure is kept in the one shape Basecamp's editor preserves
+// across an edit: each run of non-blank lines becomes a
with single line
+// breaks as
between its lines, and each blank line between runs becomes an
+// empty paragraph (paragraphSeparator).
+//
+// The editor drops a root-level
on import and keeps a
only when it
+// sits between two text runs inside a block, so neither bare
between
+// lines nor
for a blank line survives the first edit in Basecamp.
+// Leading and trailing blank lines are dropped — they have no paragraphs to
+// separate — matching the Markdown path; a whitespace-only line counts as
+// blank. Windows CRLF and bare CR are normalized to LF first. Use this when the
+// caller wants the text delivered verbatim to an endpoint that always stores
+// rich text.
func PlainToHTML(s string) string {
- if s == "" {
- return ""
- }
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
- s = escapeHTML(s)
- return strings.ReplaceAll(s, "\n", "
")
+ lines := trimBlankLines(strings.Split(escapeHTML(s), "\n"))
+
+ var b strings.Builder
+ var run []string
+ flush := func() {
+ if len(run) > 0 {
+ b.WriteString("
" + strings.Join(run, "
") + "
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",
},
{
// Issue #405: a GFM table renders as a bare