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

", "

", "