Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [Unreleased]

### Fixed
* Fix `Markdown.ToMd` serialising inline code spans that contain backtick characters. Previously, `InlineCode` was always wrapped in single backticks, producing syntactically incorrect Markdown when the code body contained backticks. Now the serialiser selects the shortest backtick fence that does not collide with the body content (e.g. a double-backtick fence for bodies containing single backticks, triple for double, etc.), matching the CommonMark spec.

## [22.0.0] - 2026-04-03

### Fixed
Expand Down
23 changes: 22 additions & 1 deletion src/FSharp.Formatting.Markdown/MarkdownUtils.fs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,28 @@ module internal MarkdownUtils =
| IndirectImage(body, _, key, _) -> sprintf "![%s][%s]" body key
| DirectImage(body, link, _, _) -> sprintf "![%s](%s)" body link
| Strong(body, _) -> "**" + formatSpans ctx body + "**"
| InlineCode(body, _) -> "`" + body + "`"
| InlineCode(body, _) ->
// Pick the shortest backtick fence that does not appear in the body.
// E.g. body "``h``" needs a triple-backtick fence; body "a`b" needs double.
let maxConsecutiveBackticks =
body
|> Seq.fold
(fun (maxR, run) c ->
if c = '`' then
let run' = run + 1
(max maxR run'), run'
else
maxR, 0)
(0, 0)
|> fst

let fence = String.replicate (maxConsecutiveBackticks + 1) "`"
// Surround with spaces when the body starts or ends with a backtick so the
// fence and content do not merge (e.g. `` ``h`` `` would look like 4-backtick).
if body.Length > 0 && (body.[0] = '`' || body.[body.Length - 1] = '`') then
fence + " " + body + " " + fence
else
fence + body + fence
| Emphasis(body, _) -> "*" + formatSpans ctx body + "*"

/// Format a list of MarkdownSpan
Expand Down
24 changes: 24 additions & 0 deletions tests/FSharp.Markdown.Tests/Markdown.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1239,6 +1239,30 @@ let ``ToMd preserves strong (bold) text`` () =
let ``ToMd preserves inline code`` () =
"Use `printf` here." |> toMd |> should contain "`printf`"

[<Test>]
let ``ToMd round-trips inline code containing a single backtick`` () =
// "a`b" must be serialised with a double-backtick fence so it re-parses correctly.
let original = "`` a`b ``"
let md = Markdown.Parse original
let result = Markdown.ToMd md
// The serialised form must round-trip: re-parsing must yield the same InlineCode body.
let reparsed = Markdown.Parse result

match reparsed.Paragraphs with
| [ Paragraph([ InlineCode("a`b", _) ], _) ] -> ()
| _ -> Assert.Fail(sprintf "Expected InlineCode(\"a`b\") after round-trip, got: %A" reparsed.Paragraphs)

[<Test>]
let ``ToMd round-trips inline code containing multiple backticks`` () =
// Body "``h``" contains double backticks β€” needs a triple-backtick fence.
let original = "` ``h`` `"
let md = Markdown.Parse original
let result = Markdown.ToMd md

match (Markdown.Parse result).Paragraphs with
| [ Paragraph([ InlineCode("``h``", _) ], _) ] -> ()
| _ -> Assert.Fail(sprintf "Expected InlineCode(\"``h``\") after round-trip, got: %A" result)

[<Test>]
let ``ToMd preserves a direct link`` () =
"[FSharp](https://fsharp.org)"
Expand Down