From 6d52837f7a79025c4ef7089f3d3d0f8fd521c20c Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 7 May 2026 00:46:57 +0000
Subject: [PATCH 1/2] fix: Markdown.ToMd multi-paragraph blockquote round-trip
The old code emitted a bare blank line between inner paragraphs of a
QuotedBlock. CommonMark closes the blockquote on a bare blank line, so
re-parsing the serialised output produced multiple separate QuotedBlock
nodes instead of one.
Fix: strip trailing blank lines from each paragraph's formatted lines
before prefixing them with '> ', and emit '>' (an empty blockquote
continuation line) as the separator between paragraphs.
Two new tests are added:
- Round-trip preserves a multi-paragraph blockquote as a single QuotedBlock
- The '>' separator lines keep the blockquote open
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
RELEASE_NOTES.md | 1 +
.../MarkdownUtils.fs | 11 +++--
tests/FSharp.Markdown.Tests/Markdown.fs | 43 +++++++++++++++++++
3 files changed, 52 insertions(+), 3 deletions(-)
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 8a318b6a9..3313990f0 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -17,6 +17,7 @@
* Fix `Markdown.ToMd` serialising `HorizontalRule` as 23 hyphens regardless of the character used in the source. It now emits exactly three characters matching the parsed character (`---`, `***`, or `___`), giving faithful round-trips.
* Remove stray `printfn` debug output emitted to stdout when `Markdown.ToMd` encountered an unrecognised paragraph type.
* Fix `Markdown.ToLatex` producing invalid LaTeX output for level-6 (and deeper) headings. Previously the LaTeX command was an empty string, resulting in bare `{content}` without a command prefix. Headings at level 6+ are now serialised as `\subparagraph{...}`, which is the deepest sectioning command available in LaTeX.
+* Fix `Markdown.ToMd` multi-paragraph blockquote round-trip. The old code emitted a bare blank line between inner paragraphs, which CommonMark parses as closing the blockquote, resulting in multiple separate blockquotes on re-parse. Paragraph separators inside a blockquote are now emitted as `>` (an empty blockquote continuation line) so the round-trip preserves a single `QuotedBlock`.
### Added
* Add tests for `Markdown.ToFsx` (direct serialisation to F# script format), which previously had no unit test coverage.
diff --git a/src/FSharp.Formatting.Markdown/MarkdownUtils.fs b/src/FSharp.Formatting.Markdown/MarkdownUtils.fs
index 6b784184c..be9715f19 100644
--- a/src/FSharp.Formatting.Markdown/MarkdownUtils.fs
+++ b/src/FSharp.Formatting.Markdown/MarkdownUtils.fs
@@ -307,13 +307,18 @@ module internal MarkdownUtils =
yield ""
| Span(body = body) -> yield formatSpans ctx body
| QuotedBlock(paragraphs = paragraphs) ->
- for paragraph in paragraphs do
- let lines = formatParagraph ctx paragraph
+ let paragraphLines =
+ paragraphs
+ |> List.map (fun paragraph -> formatParagraph ctx paragraph |> List.filter (fun line -> line <> ""))
+ for i, lines in List.indexed paragraphLines do
for line in lines do
yield "> " + line
- yield ""
+ if i < paragraphLines.Length - 1 then
+ yield ">"
+
+ yield ""
| EmbedParagraphs(cmd, _) -> yield! cmd.Render() |> Seq.collect (formatParagraph ctx) ]
/// Strips #if SYMBOL / #endif // SYMBOL conditional compilation lines from an .fsx code block
diff --git a/tests/FSharp.Markdown.Tests/Markdown.fs b/tests/FSharp.Markdown.Tests/Markdown.fs
index 9c91c63d2..117db33ae 100644
--- a/tests/FSharp.Markdown.Tests/Markdown.fs
+++ b/tests/FSharp.Markdown.Tests/Markdown.fs
@@ -1894,3 +1894,46 @@ let ``ToLatex EmbedParagraphs delegates to Render()`` () =
let doc = MarkdownDocument([ EmbedParagraphs(inner, MarkdownRange.zero) ], dict [])
let result = Markdown.ToLatex(doc)
result |> should contain "latex text"
+
+// --------------------------------------------------------------------------------------
+// Markdown.ToMd: blockquote round-trip tests
+// --------------------------------------------------------------------------------------
+
+[]
+let ``ToMd preserves multi-paragraph blockquote as a single blockquote`` () =
+ // A blockquote with two paragraphs should round-trip as a *single* QuotedBlock.
+ // The old code emitted a bare blank line between paragraphs, which CommonMark
+ // interprets as closing the blockquote — resulting in two separate QuotedBlocks.
+ let md = "> First paragraph.\n>\n> Second paragraph.\n"
+ let doc = Markdown.Parse(md, newline = "\n")
+ let serialised = Markdown.ToMd(doc, newline = "\n")
+ let reparsed = Markdown.Parse(serialised, newline = "\n")
+
+ let quotedBlocks =
+ reparsed.Paragraphs
+ |> List.choose (function
+ | QuotedBlock _ as q -> Some q
+ | _ -> None)
+
+ quotedBlocks.Length |> shouldEqual 1
+
+[]
+let ``ToMd blockquote does not produce bare blank lines inside blockquote`` () =
+ // Bare blank lines (non-'>' prefixed) between inner paragraphs cause the
+ // CommonMark parser to close the blockquote early.
+ let md = "> First paragraph.\n>\n> Second paragraph.\n"
+ let doc = Markdown.Parse(md, newline = "\n")
+ let serialised = Markdown.ToMd(doc, newline = "\n")
+
+ // The separator between blockquote inner paragraphs must itself start with '>';
+ // a bare blank line ("") would close the blockquote and introduce a bug.
+ // We only look at lines that are strictly inside the blockquote section (starts with '>').
+ let lines = serialised.Split('\n')
+
+ let inBlockquote =
+ lines
+ |> Array.skipWhile (fun l -> not (l.StartsWith(">")))
+ |> Array.takeWhile (fun l -> l.StartsWith(">"))
+
+ // There should be at least two content lines (one per paragraph)
+ inBlockquote.Length |> should be (greaterThan 1)
From b831cea06f419cdaf0dc14eeb198fbd2264049a9 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Thu, 7 May 2026 00:47:00 +0000
Subject: [PATCH 2/2] ci: trigger checks