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


" + +// 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 @@ -387,10 +402,12 @@ func MarkdownToHTML(md string) string { // 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. +// (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: "

\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,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

\n

B

", - expected: "

A

\n

B

", + expected: "

A

\n


B

", }, { - 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
between paragraphs nested in a blockquote is inline content, + // which the editor keeps โ€” matching is not nesting-aware, so leaving + // caller-supplied separators alone is what protects it. + name: "bare br inside a blockquote is left untouched", + input: "

A


B

", + expected: "

A


B

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

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

\n

B

", "

A


B

", + "

A


B

", "

A

H

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

", "

", "