diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 33214cbc..a5592ddf 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 diff --git a/src/FSharp.Formatting.Markdown/MarkdownUtils.fs b/src/FSharp.Formatting.Markdown/MarkdownUtils.fs index 59bf09a3..4b6705a0 100644 --- a/src/FSharp.Formatting.Markdown/MarkdownUtils.fs +++ b/src/FSharp.Formatting.Markdown/MarkdownUtils.fs @@ -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 diff --git a/tests/FSharp.Markdown.Tests/Markdown.fs b/tests/FSharp.Markdown.Tests/Markdown.fs index 984047f7..bad34a01 100644 --- a/tests/FSharp.Markdown.Tests/Markdown.fs +++ b/tests/FSharp.Markdown.Tests/Markdown.fs @@ -1239,6 +1239,30 @@ let ``ToMd preserves strong (bold) text`` () = let ``ToMd preserves inline code`` () = "Use `printf` here." |> toMd |> should contain "`printf`" +[] +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) + +[] +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) + [] let ``ToMd preserves a direct link`` () = "[FSharp](https://fsharp.org)"