Skip to content
Merged
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
1 change: 1 addition & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## [Unreleased]

### Fixed
* Fix `Markdown.ToMd` dropping link titles in `DirectLink` and `DirectImage` spans. Links with a title attribute (e.g. `[text](url "title")`) now round-trip correctly; without this fix the title was silently discarded on serialisation.
* 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
Expand Down
18 changes: 16 additions & 2 deletions src/FSharp.Formatting.Markdown/MarkdownUtils.fs
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,27 @@ module internal MarkdownUtils =
| HardLineBreak(_) -> "\n"

| AnchorLink _ -> ""
| DirectLink(body, link, title, _) ->
let t =
title
|> Option.map (fun t -> sprintf " \"%s\"" (t.Replace("\"", "\\\"")))
|> Option.defaultValue ""

"[" + formatSpans ctx body + "](" + link + t + ")"

| IndirectLink(body, _, LookupKey ctx.Links (link, _), _)
| DirectLink(body, link, _, _)
| IndirectLink(body, link, _, _) -> "[" + formatSpans ctx body + "](" + link + ")"

| IndirectImage(body, _, LookupKey ctx.Links (link, _), _) -> sprintf "![%s](%s)" body link
| IndirectImage(body, _, key, _) -> sprintf "![%s][%s]" body key
| DirectImage(body, link, _, _) -> sprintf "![%s](%s)" body link

| DirectImage(body, link, title, _) ->
let t =
title
|> Option.map (fun t -> sprintf " \"%s\"" (t.Replace("\"", "\\\"")))
|> Option.defaultValue ""

sprintf "![%s](%s)" body (link + t)
| Strong(body, _) -> "**" + formatSpans ctx body + "**"
| InlineCode(body, _) ->
// Pick the shortest backtick fence that does not appear in the body.
Expand Down
22 changes: 22 additions & 0 deletions tests/FSharp.Markdown.Tests/Markdown.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1269,10 +1269,32 @@ let ``ToMd preserves a direct link`` () =
|> toMd
|> should contain "[FSharp](https://fsharp.org)"

[<Test>]
let ``ToMd preserves a direct link with title`` () =
let md = "[FSharp](https://fsharp.org \"F# language\")"
let result = toMd md
result |> should contain "[FSharp]("
result |> should contain "https://fsharp.org"
result |> should contain "\"F# language\""

[<Test>]
let ``ToMd preserves a direct link without title unchanged`` () =
let result = "[link](http://example.com)" |> toMd
result |> should contain "[link](http://example.com)"
result |> should not' (contain "\"")

[<Test>]
let ``ToMd preserves a direct image`` () =
"![alt text](image.png)" |> toMd |> should contain "![alt text](image.png)"

[<Test>]
let ``ToMd preserves a direct image with title`` () =
let md = "![photo](image.png \"My Photo\")"
let result = toMd md
result |> should contain "![photo]("
result |> should contain "image.png"
result |> should contain "\"My Photo\""

[<Test>]
let ``ToMd preserves an unordered list`` () =
let md = "* apple\n* banana\n* cherry"
Expand Down