Skip to content
Open
8 changes: 6 additions & 2 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
### Fixed
* Add regression test confirming that types whose name matches their enclosing namespace are correctly included in generated API docs. [#944](https://github.com/fsprojects/FSharp.Formatting/issues/944)
* Fix crash (`failwith "tbd - IndirectImage"`) when `Markdown.ToMd` is called on a document containing reference-style images with bracket syntax. The indirect image is now serialised as `![alt](url)` when the reference is resolved, or in bracket notation when it is not. [#1094](https://github.com/fsprojects/FSharp.Formatting/pull/1094)
* Fix `Markdown.ToMd` serialising italic spans with asterisks incorrectly as bold spans. [#1102](https://github.com/fsprojects/FSharp.Formatting/pull/1102)
* Fix `Markdown.ToMd` serialising ordered list items with incorrect numbering and formatting. [#1102](https://github.com/fsprojects/FSharp.Formatting/pull/1102)
* Fix `Markdown.ToMd` serialising `*emphasis*` (italic) spans as `**...**` (bold) instead of `*...*`. [#1102](https://github.com/fsprojects/FSharp.Formatting/pull/1102)
* Fix `Markdown.ToMd` serialising ordered list items with 0-based numbering and no period (e.g. `0 first`) instead of 1-based with a period (e.g. `1. first`). [#1102](https://github.com/fsprojects/FSharp.Formatting/pull/1102)
* Fix `Markdown.ToMd` serialising a multi-paragraph blockquote as multiple separate blockquotes. The blank separator between paragraphs inside a `QuotedBlock` is now emitted as `>` (an empty blockquote line) instead of a plain blank line, so re-parsing the output yields a single `QuotedBlock` with all paragraphs intact. Also eliminates `> ` lines with trailing whitespace that the previous code produced.

### Changed
* Tooltips in generated documentation are now interactive: moving the mouse from a code token into the tooltip keeps it visible, so users can hover over, select, and copy text from the tooltip. The tooltip is dismissed when the mouse leaves it without returning to the originating token. A short hide-delay ensures that moving the mouse from the symbol to a tooltip that is not immediately adjacent (e.g. repositioned to stay inside the viewport) does not dismiss it prematurely. [#949](https://github.com/fsprojects/FSharp.Formatting/issues/949) [#1106](https://github.com/fsprojects/FSharp.Formatting/pull/1106)

## [22.0.0-alpha.2] - 2026-03-13

Expand Down
40 changes: 38 additions & 2 deletions docs/content/fsdocs-tips.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
let currentTip = null;
let currentTipElement = null;
let hideTimer = null;

function cancelHide() {
if (hideTimer !== null) {
clearTimeout(hideTimer);
hideTimer = null;
}
}

function hideTip(name) {
cancelHide();
const el = document.getElementById(name);
if (el) {
try { el.hidePopover(); } catch (_) { }
Expand All @@ -10,7 +19,19 @@ function hideTip(name) {
currentTipElement = null;
}

// Schedule a hide after a short delay so the mouse can travel from the trigger
// to the tooltip (which may have a positional gap) without the tooltip disappearing.
function scheduleHide(name) {
cancelHide();
hideTimer = setTimeout(function () {
hideTimer = null;
hideTip(name);
}, 300);
}

function showTip(evt, name, unique) {
// Cancel any pending hide so hovering back over the trigger keeps the tooltip open.
cancelHide();
if (currentTip === unique) return;

// Hide the previously shown tooltip before showing the new one
Expand Down Expand Up @@ -51,6 +72,11 @@ function showTip(evt, name, unique) {

// Event delegation: trigger tooltips from data-fsdocs-tip attributes
document.addEventListener('mouseover', function (evt) {
// Cancel any pending hide when the mouse enters the tooltip itself.
if (evt.target.closest('div.fsdocs-tip')) {
cancelHide();
return;
}
const target = evt.target.closest('[data-fsdocs-tip]');
if (!target) return;
const name = target.dataset.fsdocsTip;
Expand All @@ -64,8 +90,18 @@ document.addEventListener('mouseout', function (evt) {
// Only hide when the mouse has left the trigger element entirely
if (target.contains(evt.relatedTarget)) return;
const name = target.dataset.fsdocsTip;
const unique = parseInt(target.dataset.fsdocsTipUnique, 10);
hideTip(name);
// Use a short delay so the mouse can travel across the gap between the trigger
// and the tooltip without the tooltip disappearing.
scheduleHide(name);
});

// Hide the tooltip when the mouse leaves it, unless it returns to the trigger element.
document.addEventListener('mouseout', function (evt) {
const tip = evt.target.closest('div.fsdocs-tip');
if (!tip) return;
// Still moving within the tooltip (between child elements) β€” keep it open.
if (tip.contains(evt.relatedTarget)) return;
scheduleHide(tip.id);
});

function Clipboard_CopyTo(value) {
Expand Down
13 changes: 11 additions & 2 deletions src/FSharp.Formatting.Markdown/MarkdownUtils.fs
Original file line number Diff line number Diff line change
Expand Up @@ -238,13 +238,22 @@ module internal MarkdownUtils =
| YamlFrontmatter _ -> ()
| Span(body = body) -> yield formatSpans ctx body
| QuotedBlock(paragraphs = paragraphs) ->
for paragraph in paragraphs do
for (i, paragraph) in List.indexed paragraphs do
// Separate paragraphs within the same blockquote using an empty blockquote line.
// A plain blank line would close the blockquote, causing a round-trip failure.
if i > 0 then
yield ">"

let lines = formatParagraph ctx paragraph

// Drop the trailing blank line that formatParagraph normally appends;
// prefixing it with "> " would produce lines with trailing whitespace.
let lines = lines |> List.rev |> List.skipWhile System.String.IsNullOrEmpty |> List.rev

for line in lines do
yield "> " + line

yield ""
yield ""
| _ ->
printfn "// can't yet format %0A to markdown" paragraph
yield "" ]
Expand Down
26 changes: 26 additions & 0 deletions tests/FSharp.Markdown.Tests/Markdown.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1298,6 +1298,32 @@ let ``ToMd preserves a blockquote`` () =
result |> should contain "> "
result |> should contain "This is a quote."

[<Test>]
let ``ToMd preserves a multi-paragraph blockquote as a single blockquote`` () =
// A blockquote with two paragraphs must round-trip as one blockquote, not two.
// The separator between the two paragraphs must keep the ">" prefix so that
// re-parsing does not split it into separate QuotedBlock nodes.
let md = "> First paragraph.\n>\n> Second paragraph."
let result = toMd md
result |> should contain "> First paragraph."
result |> should contain "> Second paragraph."
// Re-parse and confirm we get exactly one QuotedBlock
let reparsed = Markdown.Parse(result)

match reparsed.Paragraphs with
| [ QuotedBlock _ ] -> () // single blockquote β€” correct
| other -> failwith $"Expected a single QuotedBlock but got: %A{other}"

[<Test>]
let ``ToMd blockquote does not produce trailing-whitespace lines`` () =
// "> " (greater-than + space) on its own is an empty blockquote line and has trailing whitespace.
// The serialiser should not emit such lines.
let md = "> A short quote."
let result = toMd md
let lines = result.Split('\n')

lines |> Array.filter (fun l -> l = "> ") |> Array.length |> should equal 0

[<Test>]
let ``ToMd preserves a horizontal rule`` () =
let md = "Before\n\n---\n\nAfter"
Expand Down