From 99b60166c46e63e3b96bdb5580a46dc9882b94d7 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 22 Aug 2026 00:55:47 -0700 Subject: [PATCH] Restore the lost blank line before Markdown tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every top-level block gets a


separator when the Markdown put a blank line before it — except tables. Goldmark's table extension builds the Table node inside a paragraph transformer that replaces the source paragraph without carrying its blank-previous-lines flag over, so the flag is always false and "Intro.\n\n| a | b |..." rendered the table jammed against the paragraph while every other block kind kept its spacing (the separator after the table was unaffected: the next block keeps its own flag). Recover the answer from the source. The table's Pos is the start of the replaced paragraph's first line, so the table was blank-line-separated exactly when the line above that position is blank. The one case the false flag is right about — a table interrupting a paragraph mid-flight, where the transformer leaves the leading lines behind as a paragraph — is detected by that leftover paragraph sharing the table's Pos, and stays attached. The issue-#405 fixture encoded the old flag-loss behavior (heading, blank line, table, no separator); it now expects the same separator every other block gets. --- internal/richtext/richtext.go | 46 ++++++++++++++++++++- internal/richtext/richtext_test.go | 64 ++++++++++++++++++++++++++++-- 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/internal/richtext/richtext.go b/internal/richtext/richtext.go index 675a6743..1c649c44 100644 --- a/internal/richtext/richtext.go +++ b/internal/richtext/richtext.go @@ -222,13 +222,57 @@ func (t *trixTransformer) Transform(node *ast.Document, reader text.Reader, pc p // Phase 2: Insert TrixBreak nodes before blank-line-separated top-level blocks for child := node.FirstChild(); child != nil; child = child.NextSibling() { - if child.HasBlankPreviousLines() && child.PreviousSibling() != nil { + if child.PreviousSibling() != nil && hasBlankPreviousLines(child, reader.Source()) { br := &TrixBreak{} node.InsertBefore(node, child, br) } } } +// hasBlankPreviousLines reports whether a top-level block was separated from +// the previous block by a blank line. For most blocks this is the parser's own +// flag. Tables are the exception: goldmark's table extension builds the Table +// node inside a paragraph transformer that replaces the source paragraph +// without carrying its blank-previous-lines flag over, so the flag is always +// false and a blank-line-separated table would lose its separator. Recover the +// answer from the source instead: the table's Pos is the start of the replaced +// paragraph's first line, so the table was blank-line-separated exactly when +// the line above that position is blank. +// +// A table that interrupted a paragraph ("Intro.\n| a | b |\n|---|---|") is the +// case the flag being false is right about: the transformer leaves the leading +// lines behind as a paragraph sharing the table's Pos, so the line above Pos +// belongs to whatever preceded that paragraph, not to the table. Detect it by +// that shared Pos and keep the table attached. +func hasBlankPreviousLines(child ast.Node, source []byte) bool { + table, ok := child.(*east.Table) + if !ok { + return child.HasBlankPreviousLines() + } + if p, isPara := child.PreviousSibling().(*ast.Paragraph); isPara && p.Pos() == table.Pos() { + return false + } + return precedingLineIsBlank(source, table.Pos()) +} + +// precedingLineIsBlank reports whether the line immediately above pos in +// source is blank — empty or whitespace-only. +func precedingLineIsBlank(source []byte, pos int) bool { + i := pos - 1 + if i >= 0 && source[i] == '\n' { + i-- + } + if i >= 0 && source[i] == '\r' { + i-- + } + for ; i >= 0 && source[i] != '\n'; i-- { + if c := source[i]; c != ' ' && c != '\t' && c != '\r' { + return false + } + } + return true +} + func replaceParagraphsWithTextBlocks(parent ast.Node) { for child := parent.FirstChild(); child != nil; { next := child.NextSibling() diff --git a/internal/richtext/richtext_test.go b/internal/richtext/richtext_test.go index 007f453b..e1d8aa00 100644 --- a/internal/richtext/richtext_test.go +++ b/internal/richtext/richtext_test.go @@ -222,7 +222,7 @@ func TestMarkdownToHTML(t *testing.T) { // heading+table regression. name: "gfm table with heading (issue #405)", input: "# Report\n\n| Foo | Bar |\n| --- | --- |\n| Baz | Qux |", - expected: "

Report

\n\n\n\n\n\n\n\n\n\n\n\n\n\n
FooBar
BazQux
", + expected: "

Report

\n


\n\n\n\n\n\n\n\n\n\n\n\n\n\n
FooBar
BazQux
", }, } @@ -2467,11 +2467,11 @@ func TestMarkdownToHTMLParagraphSeparatorsMatchMarkdownPath(t *testing.T) { // 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." + 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\n| a | b |\n|---|---|\n\nClosing." html := MarkdownToHTML(markdown) - for _, block := range []string{"

", "

", "