Skip to content

Commit 7b504b1

Browse files
authored
Merge branch 'main' into repo-assist/fix-tomd-yaml-frontmatter-2026-04-13-bca272bf909c4735
2 parents b2a8e4b + 06d7467 commit 7b504b1

4 files changed

Lines changed: 113 additions & 13 deletions

File tree

RELEASE_NOTES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### Fixed
66
* Fix `Markdown.ToMd` silently dropping YAML frontmatter when serialising a parsed `MarkdownDocument` back to Markdown text. Frontmatter is now preserved with its `---` delimiters.
7+
* 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.
8+
* 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.
79

810
## [22.0.0] - 2026-04-03
911

@@ -22,6 +24,8 @@
2224
## [22.0.0-alpha.2] - 2026-03-13
2325

2426
### Added
27+
* Add `--root` option to `fsdocs watch` to override the root URL for generated pages. Useful for serving docs via GitHub Codespaces, reverse proxies, or other remote hosting where `localhost` URLs are inaccessible. E.g. `fsdocs watch --root /` or `fsdocs watch --root https://example.com/docs/`. When not set, defaults to `http://localhost:<port>/` as before. [#924](https://github.com/fsprojects/FSharp.Formatting/issues/924)
28+
* Fix `fsdocs watch` hot-reload WebSocket to connect using the page's actual host (`window.location.host`) instead of a hardcoded `localhost:<port>`, so hot-reload works correctly in GitHub Codespaces, behind reverse proxies, and over HTTPS. [#924](https://github.com/fsprojects/FSharp.Formatting/issues/924)
2529
* Search dialog now auto-focuses the search input when opened, clears on close, and can be triggered with `Ctrl+K` / `Cmd+K` in addition to `/`.
2630
* Add `dotnet fsdocs convert` command to convert a single `.md`, `.fsx`, or `.ipynb` file to HTML (or another output format) without building a full documentation site. [#811](https://github.com/fsprojects/FSharp.Formatting/issues/811)
2731
* `fsdocs convert` now accepts the input file as a positional argument (e.g. `fsdocs convert notebook.ipynb -o notebook.html`). [#1019](https://github.com/fsprojects/FSharp.Formatting/pull/1019)

src/FSharp.Formatting.Markdown/MarkdownUtils.fs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,50 @@ module internal MarkdownUtils =
106106
| HardLineBreak(_) -> "\n"
107107

108108
| AnchorLink _ -> ""
109+
| DirectLink(body, link, title, _) ->
110+
let t =
111+
title
112+
|> Option.map (fun t -> sprintf " \"%s\"" (t.Replace("\"", "\\\"")))
113+
|> Option.defaultValue ""
114+
115+
"[" + formatSpans ctx body + "](" + link + t + ")"
116+
109117
| IndirectLink(body, _, LookupKey ctx.Links (link, _), _)
110-
| DirectLink(body, link, _, _)
111118
| IndirectLink(body, link, _, _) -> "[" + formatSpans ctx body + "](" + link + ")"
112119

113120
| IndirectImage(body, _, LookupKey ctx.Links (link, _), _) -> sprintf "![%s](%s)" body link
114121
| IndirectImage(body, _, key, _) -> sprintf "![%s][%s]" body key
115-
| DirectImage(body, link, _, _) -> sprintf "![%s](%s)" body link
122+
123+
| DirectImage(body, link, title, _) ->
124+
let t =
125+
title
126+
|> Option.map (fun t -> sprintf " \"%s\"" (t.Replace("\"", "\\\"")))
127+
|> Option.defaultValue ""
128+
129+
sprintf "![%s](%s)" body (link + t)
116130
| Strong(body, _) -> "**" + formatSpans ctx body + "**"
117-
| InlineCode(body, _) -> "`" + body + "`"
131+
| InlineCode(body, _) ->
132+
// Pick the shortest backtick fence that does not appear in the body.
133+
// E.g. body "``h``" needs a triple-backtick fence; body "a`b" needs double.
134+
let maxConsecutiveBackticks =
135+
body
136+
|> Seq.fold
137+
(fun (maxR, run) c ->
138+
if c = '`' then
139+
let run' = run + 1
140+
(max maxR run'), run'
141+
else
142+
maxR, 0)
143+
(0, 0)
144+
|> fst
145+
146+
let fence = String.replicate (maxConsecutiveBackticks + 1) "`"
147+
// Surround with spaces when the body starts or ends with a backtick so the
148+
// fence and content do not merge (e.g. `` ``h`` `` would look like 4-backtick).
149+
if body.Length > 0 && (body.[0] = '`' || body.[body.Length - 1] = '`') then
150+
fence + " " + body + " " + fence
151+
else
152+
fence + body + fence
118153
| Emphasis(body, _) -> "*" + formatSpans ctx body + "*"
119154

120155
/// Format a list of MarkdownSpan

src/fsdocs-tool/BuildCommand.fs

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -827,11 +827,10 @@ module Serve =
827827
let refreshEvent = FSharp.Control.Event<string>()
828828

829829
/// generate the script to inject into html to enable hot reload during development
830-
let generateWatchScript (port: int) =
831-
let tag =
832-
"""
830+
let generateWatchScript () =
831+
"""
833832
<script type="text/javascript">
834-
var wsUri = "ws://localhost:{{PORT}}/websocket";
833+
var wsUri = "ws://" + window.location.host + "/websocket";
835834
function init()
836835
{
837836
websocket = new WebSocket(wsUri);
@@ -858,8 +857,6 @@ module Serve =
858857
</script>
859858
"""
860859

861-
tag.Replace("{{PORT}}", string<int> port)
862-
863860
let connectedClients = ConcurrentDictionary<WebSocket, unit>()
864861

865862
let socketHandler (webSocket: WebSocket) (context: HttpContext) =
@@ -1568,9 +1565,15 @@ type CoreBuildOptions(watch) =
15681565
// Adjust the user substitutions for 'watch' mode root
15691566
let userRoot, userParameters =
15701567
if watch then
1571-
let userRoot = sprintf "http://localhost:%d/" this.port_option
1572-
1573-
if userParametersDict.ContainsKey(ParamKeys.root) then
1568+
let userRoot =
1569+
match this.root_override_option with
1570+
| Some r -> r
1571+
| None -> sprintf "http://localhost:%d/" this.port_option
1572+
1573+
if
1574+
userParametersDict.ContainsKey(ParamKeys.root)
1575+
&& this.root_override_option.IsNone
1576+
then
15741577
printfn "ignoring user-specified root since in watch mode, root = %s" userRoot
15751578

15761579
let userParameters =
@@ -1881,7 +1884,7 @@ type CoreBuildOptions(watch) =
18811884
let getLatestWatchScript () =
18821885
if watch then
18831886
// if running in watch mode, inject hot reload script
1884-
[ ParamKeys.``fsdocs-watch-script``, Serve.generateWatchScript this.port_option ]
1887+
[ ParamKeys.``fsdocs-watch-script``, Serve.generateWatchScript () ]
18851888
else
18861889
// otherwise, inject empty replacement string
18871890
[ ParamKeys.``fsdocs-watch-script``, "" ]
@@ -2330,6 +2333,9 @@ type CoreBuildOptions(watch) =
23302333
abstract port_option: int
23312334
default x.port_option = 0
23322335

2336+
abstract root_override_option: string option
2337+
default x.root_override_option = None
2338+
23332339
/// Helpers for the <c>fsdocs convert</c> command.
23342340
module private ConvertHelpers =
23352341

@@ -2746,3 +2752,12 @@ type WatchCommand() =
27462752

27472753
[<Option("port", Required = false, Default = 8901, HelpText = "Port to serve content for http://localhost serving.")>]
27482754
member val port = 8901 with get, set
2755+
2756+
override x.root_override_option = if x.root = "" then None else Some x.root
2757+
2758+
[<Option("root",
2759+
Required = false,
2760+
Default = "",
2761+
HelpText =
2762+
"Override the root URL for generated pages. Useful for reverse proxies or GitHub Codespaces. E.g. --root / or --root https://example.com/docs/. When not set, defaults to http://localhost:<port>/.")>]
2763+
member val root = "" with get, set

tests/FSharp.Markdown.Tests/Markdown.fs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1239,16 +1239,62 @@ let ``ToMd preserves strong (bold) text`` () =
12391239
let ``ToMd preserves inline code`` () =
12401240
"Use `printf` here." |> toMd |> should contain "`printf`"
12411241

1242+
[<Test>]
1243+
let ``ToMd round-trips inline code containing a single backtick`` () =
1244+
// "a`b" must be serialised with a double-backtick fence so it re-parses correctly.
1245+
let original = "`` a`b ``"
1246+
let md = Markdown.Parse original
1247+
let result = Markdown.ToMd md
1248+
// The serialised form must round-trip: re-parsing must yield the same InlineCode body.
1249+
let reparsed = Markdown.Parse result
1250+
1251+
match reparsed.Paragraphs with
1252+
| [ Paragraph([ InlineCode("a`b", _) ], _) ] -> ()
1253+
| _ -> Assert.Fail(sprintf "Expected InlineCode(\"a`b\") after round-trip, got: %A" reparsed.Paragraphs)
1254+
1255+
[<Test>]
1256+
let ``ToMd round-trips inline code containing multiple backticks`` () =
1257+
// Body "``h``" contains double backticks — needs a triple-backtick fence.
1258+
let original = "` ``h`` `"
1259+
let md = Markdown.Parse original
1260+
let result = Markdown.ToMd md
1261+
1262+
match (Markdown.Parse result).Paragraphs with
1263+
| [ Paragraph([ InlineCode("``h``", _) ], _) ] -> ()
1264+
| _ -> Assert.Fail(sprintf "Expected InlineCode(\"``h``\") after round-trip, got: %A" result)
1265+
12421266
[<Test>]
12431267
let ``ToMd preserves a direct link`` () =
12441268
"[FSharp](https://fsharp.org)"
12451269
|> toMd
12461270
|> should contain "[FSharp](https://fsharp.org)"
12471271

1272+
[<Test>]
1273+
let ``ToMd preserves a direct link with title`` () =
1274+
let md = "[FSharp](https://fsharp.org \"F# language\")"
1275+
let result = toMd md
1276+
result |> should contain "[FSharp]("
1277+
result |> should contain "https://fsharp.org"
1278+
result |> should contain "\"F# language\""
1279+
1280+
[<Test>]
1281+
let ``ToMd preserves a direct link without title unchanged`` () =
1282+
let result = "[link](http://example.com)" |> toMd
1283+
result |> should contain "[link](http://example.com)"
1284+
result |> should not' (contain "\"")
1285+
12481286
[<Test>]
12491287
let ``ToMd preserves a direct image`` () =
12501288
"![alt text](image.png)" |> toMd |> should contain "![alt text](image.png)"
12511289

1290+
[<Test>]
1291+
let ``ToMd preserves a direct image with title`` () =
1292+
let md = "![photo](image.png \"My Photo\")"
1293+
let result = toMd md
1294+
result |> should contain "![photo]("
1295+
result |> should contain "image.png"
1296+
result |> should contain "\"My Photo\""
1297+
12521298
[<Test>]
12531299
let ``ToMd preserves an unordered list`` () =
12541300
let md = "* apple\n* banana\n* cherry"

0 commit comments

Comments
 (0)