From cb57e6dca2f867670d347aab8ba20065bbad6ce6 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 17 Aug 2026 09:15:09 +0200 Subject: [PATCH 1/2] Blank lines between blocks vanish when CLI content is edited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Markdown pipeline separated top-level blocks with a bare
, and the raw-HTML passthrough inserted one between adjacent paragraphs. A bare top-level
is not a block in Basecamp's editor document model, so the editor discards it on import: the content displays correctly until someone opens it, makes any edit and saves, at which point every blank line is gone for good. Emit the editor's own separator instead — an empty paragraph — which survives the round trip untouched. Inside a blockquote the break stays a
: there it is inline content, which the editor keeps. Bare
separators arriving through the HTML passthrough are rewritten to the durable form for the same reason. Verified against lexxy v0.9.29 (the editor bc3 ships): a document with four separators comes back with zero after an edit before this change, and with all four after it. --- internal/richtext/richtext.go | 62 +++++++++++++++++++---------- internal/richtext/richtext_test.go | 64 +++++++++++++++++++----------- 2 files changed, 83 insertions(+), 43 deletions(-) diff --git a/internal/richtext/richtext.go b/internal/richtext/richtext.go index b8a9054ba..aea01fedb 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 = "


" + +// TrixBreak is a custom block node that renders the blank line between blocks: +// an empty paragraph at the top level, a
inside a block (see +// renderTrixBreak). type TrixBreak struct{ ast.BaseBlock } // KindTrixBreak is the node kind for TrixBreak. @@ -329,11 +337,18 @@ func (r *trixRenderer) renderFencedCodeBlock(w util.BufWriter, source []byte, no return ast.WalkContinue, nil } -func (r *trixRenderer) renderTrixBreak(w util.BufWriter, _ []byte, _ ast.Node, entering bool) (ast.WalkStatus, error) { +// renderTrixBreak emits an empty paragraph for a top-level break and a
for +// one inside a block. Only the top level needs a block-level separator: +// a
nested in a blockquote is inline content, which survives editing. +func (r *trixRenderer) renderTrixBreak(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } - _, _ = w.WriteString("
\n") + if parent := node.Parent(); parent != nil && parent.Kind() == ast.KindDocument { + _, _ = w.WriteString(paragraphSeparator + "\n") + } else { + _, _ = w.WriteString("
\n") + } return ast.WalkContinue, nil } @@ -348,8 +363,8 @@ 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 passed through with existing -// formatting preserved, except that a
separator is inserted between -// directly adjacent paragraph blocks (see insertParagraphSeparators). +// formatting preserved, except that a separator is inserted between directly +// adjacent paragraph blocks (see insertParagraphSeparators). func MarkdownToHTML(md string) string { if md == "" { return "" @@ -370,9 +385,9 @@ 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. +// insertParagraphSeparators puts an empty separator paragraph 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 @@ -383,14 +398,14 @@ func MarkdownToHTML(md string) string { // 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. +// A boundary already carrying an empty separator paragraph (


or +//

) on either side is left untouched, so running this on Basecamp editor +// output — or on its own output — is a no-op. A boundary separated only by bare +//
tags is rewritten to the separator paragraph: the editor drops those on +// the first edit, so leaving them would keep the spacing they express fragile. +// 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 { @@ -416,9 +431,9 @@ func insertParagraphSeparators(s string) string { nextStart := locs[i+1][0] gap := s[end:nextStart] - if !empty[i] && !empty[i+1] && strings.TrimSpace(gap) == "" { - b.WriteString(gap) - b.WriteString("
") + if !empty[i] && !empty[i+1] && isSeparatorGap(gap) { + b.WriteString(reBR.ReplaceAllString(gap, "")) + b.WriteString(paragraphSeparator) cursor = nextStart } } @@ -426,11 +441,18 @@ func insertParagraphSeparators(s string) string { return b.String() } +// isSeparatorGap reports whether the markup between two paragraph blocks holds +// nothing but whitespace and bare
tags — i.e. whatever spacing it expresses +// can be replaced by a separator paragraph. +func isSeparatorGap(gap string) bool { + return strings.TrimSpace(reBR.ReplaceAllString(gap, "")) == "" +} + // isEmptyParagraph reports whether a

...

block has no visible content — // 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..15d1cfcb8 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: "\n
\n

Following paragraph.

", + expected: "\n


\n

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: "

Title

\n
\n

This is bold and italic and code.

", + expected: "

Title

\n


\n

This is bold and italic and code.

", }, { name: "escapes HTML", @@ -143,12 +143,12 @@ func TestMarkdownToHTML(t *testing.T) { { name: "paragraph spacing with blank line", input: "First paragraph\n\nSecond paragraph", - expected: "

First paragraph

\n
\n

Second paragraph

", + expected: "

First paragraph

\n


\n

Second paragraph

", }, { name: "multiple blank lines collapse to one break", input: "First\n\n\n\nSecond", - expected: "

First

\n
\n

Second

", + expected: "

First

\n


\n

Second

", }, { 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

\n
\n", + expected: "

Intro

\n


\n", }, { name: "blank line before code block", input: "Intro\n\n```\ncode\n```", - expected: "

Intro

\n
\n
code\n
", + expected: "

Intro

\n


\n
code\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

\n
\n
A quote
", + expected: "

Intro

\n


\n
A quote
", }, { name: "blank line before horizontal rule", input: "Intro\n\n---", - expected: "

Intro

\n
\n
", + expected: "

Intro

\n


\n
", }, { name: "heading flushes accumulated paragraph", @@ -214,7 +214,7 @@ func TestMarkdownToHTML(t *testing.T) { { name: "fenced code block containing HTML tags is converted", input: "intro\n\n```\n
hello
\n```", - expected: "

intro

\n
\n
<div>hello</div>\n
", + expected: "

intro

\n


\n
<div>hello</div>\n
", }, } @@ -2223,30 +2223,30 @@ 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

\n

B

", - expected: "

A

\n

B

", + expected: "

A

\n


B

", }, { - name: "existing bare br separator is left untouched (idempotent)", + name: "bare br separator becomes an editor-durable separator paragraph", input: "

A


B

", - expected: "

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

", }, @@ -2293,7 +2293,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", @@ -2301,9 +2301,9 @@ func TestMarkdownToHTMLInsertsParagraphSeparators(t *testing.T) { expected: "

A

", }, { - name: "mix of separated and contiguous only fills the gap that lacks a separator", + name: "mix of contiguous and bare-br boundaries both get separator paragraphs", input: "

A

B


C

", - expected: "

A


B


C

", + expected: "

A


B


C

", }, } @@ -2325,6 +2325,7 @@ func TestMarkdownToHTMLParagraphSeparatorsIdempotent(t *testing.T) { `

A

B

`, "

A

\n

B

", "

A


B

", + "

A


B

", "

A

H

B

", } @@ -2345,10 +2346,27 @@ 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 fromMarkdown != "

Line 1

\n


\n

Line 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
the first time the content is +// edited, collapsing the spacing. No blank line between blocks may rely on one. +func TestMarkdownToHTMLEmitsNoBareTopLevelBreaks(t *testing.T) { + markdown := "Para one.\n\nPara two.\n\n## Heading\n\n- a\n- b\n\n> A quote\n\n```\ncode\n```\n\n---\n\nClosing." + + html := MarkdownToHTML(markdown) + + for _, block := range []string{"

", "

", "