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 // --------------------------------------------------------------------------------------