From eb3f15cc78dd8974c52e99c4de3b80e398a73fa5 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 7 Jul 2026 09:11:31 -0400 Subject: [PATCH 1/2] Separate contiguous paragraphs in HTML passed to the CLI Basecamp rich text relies on explicit separator nodes for paragraph spacing, not CSS margins, so contiguous

A

B

renders squished. The Markdown pipeline already inserts a separator between blank-line-separated paragraphs via TrixBreak; this brings the raw-HTML passthrough in MarkdownToHTML into line with that behavior. insertParagraphSeparators inserts a bare
between directly adjacent, non-empty

blocks. It is byte-preserving apart from the inserted separators and idempotent: boundaries that already carry a separator (a bare
, or an empty


/

on either side) are left untouched, so already-separated content (including Basecamp editor output) is a no-op. Only adjacent

pairs are separated; a heading, list, or attachment between them already provides its own break. --- internal/richtext/richtext.go | 70 +++++++++++++++- internal/richtext/richtext_test.go | 124 +++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 1 deletion(-) diff --git a/internal/richtext/richtext.go b/internal/richtext/richtext.go index 65557443..95c0260d 100644 --- a/internal/richtext/richtext.go +++ b/internal/richtext/richtext.go @@ -351,7 +351,7 @@ func MarkdownToHTML(md string) string { } if IsHTML(md) { - return md + return insertParagraphSeparators(md) } md = strings.ReplaceAll(md, "\r\n", "\n") @@ -365,6 +365,74 @@ func MarkdownToHTML(md string) string { return strings.TrimSpace(buf.String()) } +// insertParagraphSeparators inserts a
between directly adjacent, non-empty +// paragraph blocks so that HTML supplied to the CLI renders with visible +// paragraph spacing. +// +// Basecamp's rich text relies on explicit separator nodes for paragraph +// spacing, not CSS margins: contiguous

A

B

renders squished. The +// Markdown pipeline already inserts a separator between blank-line-separated +// paragraphs (via TrixBreak), so this brings the raw-HTML passthrough into line +// with that behavior. Unlike Basecamp's editor, the CLI has no concept of an +// intentionally-tight single-line-break paragraph (its edit loop collapses that +// distinction), so contiguous paragraphs from HTML input are treated as +// separate paragraphs. +// +// The transform is byte-preserving apart from the inserted separators and is +// idempotent: a boundary that already carries a separator — a bare
between +// the paragraphs, or an empty separator paragraph (


or

) on +// either side — is left untouched, so running it on already-separated content +// (including Basecamp editor output) is a no-op. Only directly adjacent

+// blocks are separated; anything between them (whitespace excepted), 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 { + return s + } + + empty := make([]bool, len(locs)) + for i, loc := range locs { + empty[i] = isEmptyParagraph(s[loc[0]:loc[1]]) + } + + var b strings.Builder + b.Grow(len(s) + len(locs)*4) + cursor := 0 + for i := 0; i < len(locs); i++ { + end := locs[i][1] + b.WriteString(s[cursor:end]) + cursor = end + + if i+1 == len(locs) { + break + } + + nextStart := locs[i+1][0] + gap := s[end:nextStart] + if !empty[i] && !empty[i+1] && strings.TrimSpace(gap) == "" { + b.WriteString(gap) + b.WriteString("
") + cursor = nextStart + } + } + b.WriteString(s[cursor:]) + return b.String() +} + +// isEmptyParagraph reports whether a

...

block has no visible content — +// i.e. it is empty or contains only
tags and whitespace. Such paragraphs +// act as separators, so no additional
is inserted adjacent to them. +func isEmptyParagraph(block string) bool { + m := reP.FindStringSubmatch(block) + if m == nil { + return false + } + inner := reBR.ReplaceAllString(m[1], "") + return strings.TrimSpace(inner) == "" +} + // escapeHTML escapes special HTML characters. func escapeHTML(s string) string { s = strings.ReplaceAll(s, "&", "&") diff --git a/internal/richtext/richtext_test.go b/internal/richtext/richtext_test.go index 1e468fd8..d3329d6e 100644 --- a/internal/richtext/richtext_test.go +++ b/internal/richtext/richtext_test.go @@ -2213,3 +2213,127 @@ func TestParsedAttachmentDisplayURL(t *testing.T) { }) } } + +func TestMarkdownToHTMLInsertsParagraphSeparators(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "two contiguous paragraphs get a separator", + input: "

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

", + }, + { + name: "paragraphs with attributes are separated", + input: `

A

B

`, + expected: `

A


B

`, + }, + { + name: "whitespace-only gap is preserved and separator added", + input: "

A

\n

B

", + expected: "

A

\n

B

", + }, + { + name: "existing bare br separator is left untouched (idempotent)", + input: "

A


B

", + expected: "

A


B

", + }, + { + name: "empty separator paragraph is left untouched (Lexxy canonical)", + input: "

A


B

", + expected: "

A


B

", + }, + { + name: "empty paragraph separator is left untouched", + input: "

A

B

", + expected: "

A

B

", + }, + { + name: "single paragraph is unchanged", + input: "

Only one

", + expected: "

Only one

", + }, + { + name: "heading between paragraphs is left alone", + input: "

A

H

B

", + expected: "

A

H

B

", + }, + { + name: "list between paragraphs is left alone", + input: "

A

B

", + expected: "

A

B

", + }, + { + name: "non-paragraph HTML is unchanged", + input: "
Hi
", + expected: "
Hi
", + }, + { + name: "paragraph with inline br is still non-empty and separated", + input: "

A
C

B

", + expected: "

A
C


B

", + }, + { + name: "leading empty paragraph is left alone", + input: "

A

", + expected: "

A

", + }, + { + name: "mix of separated and contiguous only fills the gap that lacks a separator", + input: "

A

B


C

", + expected: "

A


B


C

", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MarkdownToHTML(tt.input) + if result != tt.expected { + t.Errorf("MarkdownToHTML(%q)\ngot: %q\nwant: %q", tt.input, result, tt.expected) + } + }) + } +} + +// Running the separator insertion on its own output must be a no-op. +func TestMarkdownToHTMLParagraphSeparatorsIdempotent(t *testing.T) { + inputs := []string{ + "

Line 1

Line 2

", + "

A

B

C

", + `

A

B

`, + "

A

\n

B

", + "

A


B

", + "

A

H

B

", + } + + for _, in := range inputs { + t.Run(in, func(t *testing.T) { + once := MarkdownToHTML(in) + twice := MarkdownToHTML(once) + if once != twice { + t.Errorf("not idempotent for %q\nonce: %q\ntwice: %q", in, once, twice) + } + }) + } +} + +// A blank-line Markdown paragraph break and the equivalent contiguous-

HTML +// must converge on the same separated shape. +func TestMarkdownToHTMLParagraphSeparatorsMatchMarkdownPath(t *testing.T) { + fromMarkdown := MarkdownToHTML("Line 1\n\nLine 2") + fromHTML := MarkdownToHTML("

Line 1

Line 2

") + + if !strings.Contains(fromMarkdown, "
") { + t.Fatalf("markdown path unexpectedly produced no
: %q", fromMarkdown) + } + if fromHTML != "

Line 1


Line 2

" { + t.Errorf("HTML path = %q, want %q", fromHTML, "

Line 1


Line 2

") + } +} From 8539d0d6421e0080cec5a769abc2ff0c6ab655af Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 7 Jul 2026 09:35:00 -0400 Subject: [PATCH 2/2] Address PR review: nbsp separator paragraphs and doc comment - isEmptyParagraph now treats paragraphs containing only non-breaking space entities ( ,  ,  ) as empty, so editor-authored

 

separator lines are recognized and not wrapped in extra
separators. Adds tests for the three entity forms. - Update MarkdownToHTML doc comment: HTML input is no longer returned unchanged; a
is inserted between directly adjacent paragraphs. --- internal/richtext/richtext.go | 14 +++++++++++--- internal/richtext/richtext_test.go | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/internal/richtext/richtext.go b/internal/richtext/richtext.go index 95c0260d..b8a9054b 100644 --- a/internal/richtext/richtext.go +++ b/internal/richtext/richtext.go @@ -49,6 +49,9 @@ var ( reP = regexp.MustCompile(`(?is)]*)?>(.*?)

`) reBR = regexp.MustCompile(`(?i)`) reHR = regexp.MustCompile(`(?i)`) + // reNbsp matches non-breaking-space entities used as visual filler in + // otherwise-empty paragraphs ( ,  ,   /  ). + reNbsp = regexp.MustCompile(`(?i) | | `) ) // Pre-compiled regexes for HTMLToMarkdown inline elements @@ -344,7 +347,9 @@ func (r *trixRenderer) renderEscapedAt(w util.BufWriter, _ []byte, _ ast.Node, e // MarkdownToHTML converts Markdown text to HTML suitable for Basecamp's rich text fields. // It uses goldmark with custom AST transformations for Trix editor compatibility. -// If the input already appears to be HTML, it is returned unchanged to preserve existing formatting. +// If the input already appears to be HTML, it is passed through with existing +// formatting preserved, except that a
separator is inserted between +// directly adjacent paragraph blocks (see insertParagraphSeparators). func MarkdownToHTML(md string) string { if md == "" { return "" @@ -422,14 +427,17 @@ func insertParagraphSeparators(s string) string { } // isEmptyParagraph reports whether a

...

block has no visible content — -// i.e. it is empty or contains only
tags and whitespace. Such paragraphs -// act as separators, so no additional
is inserted adjacent to them. +// 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. func isEmptyParagraph(block string) bool { m := reP.FindStringSubmatch(block) if m == nil { return false } inner := reBR.ReplaceAllString(m[1], "") + inner = reNbsp.ReplaceAllString(inner, "") return strings.TrimSpace(inner) == "" } diff --git a/internal/richtext/richtext_test.go b/internal/richtext/richtext_test.go index d3329d6e..8fb3246e 100644 --- a/internal/richtext/richtext_test.go +++ b/internal/richtext/richtext_test.go @@ -2255,6 +2255,21 @@ func TestMarkdownToHTMLInsertsParagraphSeparators(t *testing.T) { input: "

A

B

", expected: "

A

B

", }, + { + name: "nbsp entity separator paragraph is left untouched", + input: "

A

 

B

", + expected: "

A

 

B

", + }, + { + name: "numeric nbsp separator paragraph is left untouched", + input: "

A

 

B

", + expected: "

A

 

B

", + }, + { + name: "hex nbsp separator paragraph is left untouched", + input: "

A

 

B

", + expected: "

A

 

B

", + }, { name: "single paragraph is unchanged", input: "

Only one

",