diff --git a/internal/richtext/richtext.go b/internal/richtext/richtext.go index 65557443..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)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
+// 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 onlyLine 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
\nB
", + expected: "A
\nB
", + }, + { + 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: "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
", + expected: "Only one
", + }, + { + name: "heading between paragraphs is left alone", + input: "A
B
", + expected: "A
B
", + }, + { + name: "list between paragraphs is left alone", + input: "A
B
", + expected: "A
B
", + }, + { + name: "non-paragraph HTML is unchanged", + 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
\nB
", + "A
B
", + "A
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, "Line 1
Line 2
" { + t.Errorf("HTML path = %q, want %q", fromHTML, "Line 1
Line 2
") + } +}