diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index c328fb393..ac13a6611 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -19,6 +19,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`.
* Fix `Markdown.ToMd` serialising unresolved indirect links as `[body](key)` (treating the reference key as a URL) instead of the correct `[body][key]` form. Unresolved indirect links are now preserved in their original indirect-reference form, consistent with how unresolved indirect images are handled.
### Added
diff --git a/src/FSharp.Formatting.Markdown/MarkdownUtils.fs b/src/FSharp.Formatting.Markdown/MarkdownUtils.fs
index 06b8d6584..faf1627cf 100644
--- a/src/FSharp.Formatting.Markdown/MarkdownUtils.fs
+++ b/src/FSharp.Formatting.Markdown/MarkdownUtils.fs
@@ -306,13 +306,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 e1d3edf84..373865de7 100644
--- a/tests/FSharp.Markdown.Tests/Markdown.fs
+++ b/tests/FSharp.Markdown.Tests/Markdown.fs
@@ -1977,6 +1977,49 @@ let ``ToLatex EmbedParagraphs delegates to Render()`` () =
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)
+
// --------------------------------------------------------------------------------------
// ToMd: untested paragraph types — OutputBlock, code-block language specifier
// --------------------------------------------------------------------------------------