Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6aeb6ef
Add dotnet fsdocs convert command for single-file conversion (#811)
Copilot Feb 25, 2026
52c2910
ci: trigger CI checks
github-actions[bot] Feb 25, 2026
b5e1d1a
Add unit and integration tests for ConvertPynbFile and ConvertCommand
Feb 25, 2026
91b3dbf
ci: trigger CI checks
github-actions[bot] Feb 25, 2026
be17647
Improve fsdocs convert CLI UX and add fsdocs-tool.Tests project
github-actions[bot] Feb 25, 2026
0231407
ci: trigger CI checks
github-actions[bot] Feb 25, 2026
8da4915
Merge branch 'main' into repo-assist/feature-811-fsdocs-convert-2cf2c…
dsyme Feb 26, 2026
bdfa156
Merge branch 'main' into repo-assist/feature-811-fsdocs-convert-2cf2c…
dsyme Feb 26, 2026
fb56855
Merge branch 'main' into repo-assist/feature-811-fsdocs-convert-2cf2c…
dsyme Feb 26, 2026
6cc9166
Merge branch 'main' into repo-assist/feature-811-fsdocs-convert-2cf2c…
dsyme Feb 26, 2026
0420bd4
Update RELEASE_NOTES.md
dsyme Feb 26, 2026
623f2fb
Improve build.fsx error message when RELEASE_NOTES.md has no versione…
github-actions[bot] Feb 26, 2026
98ad3d9
ci: trigger CI checks
github-actions[bot] Feb 26, 2026
53d9b19
Merge branch 'main' into repo-assist/feature-811-fsdocs-convert-2cf2c…
dsyme Feb 28, 2026
6c29b58
Merge branch 'main' into repo-assist/feature-811-fsdocs-convert-2cf2c…
dsyme Mar 1, 2026
991095f
Remove fsdocs-tip divs from no-template output
Mar 3, 2026
c797e22
ci: trigger CI checks
github-actions[bot] Mar 3, 2026
4468bd4
Merge branch 'main' into repo-assist/feature-811-fsdocs-convert-2cf2c…
dsyme Mar 5, 2026
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
7 changes: 7 additions & 0 deletions FSharp.Formatting.sln
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "content", "content", "{FAD5
docs\content\fsdocs-theme-set-dark.js = docs\content\fsdocs-theme-set-dark.js
EndProjectSection
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "fsdocs-tool.Tests", "tests\fsdocs-tool.Tests\fsdocs-tool.Tests.fsproj", "{F748A965-C949-4FE7-BFE9-40449F3C58B8}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -230,6 +232,10 @@ Global
{CB78F0EA-8005-4735-A02C-B86CEDC29D85}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CB78F0EA-8005-4735-A02C-B86CEDC29D85}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CB78F0EA-8005-4735-A02C-B86CEDC29D85}.Release|Any CPU.Build.0 = Release|Any CPU
{F748A965-C949-4FE7-BFE9-40449F3C58B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F748A965-C949-4FE7-BFE9-40449F3C58B8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F748A965-C949-4FE7-BFE9-40449F3C58B8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F748A965-C949-4FE7-BFE9-40449F3C58B8}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -265,6 +271,7 @@ Global
{188DC91F-2202-4495-ACD2-542D7C30364E} = {C7804F57-7FC6-4CF6-BDF6-127D6F9EBEA6}
{FAD5C374-4748-4A3D-A435-FFA425916F3A} = {312E452A-1068-4804-89E7-0AFBAD5F885F}
{52B949AA-A3F7-4894-B713-804BAEB71118} = {4AE0198D-EDE5-40B0-A5CD-FC7B6F891D94}
{F748A965-C949-4FE7-BFE9-40449F3C58B8} = {8D44B659-E9F7-4CE4-B5DA-D37CDDCD2525}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {76F121F8-70E0-49FB-9ADF-C7B660C0EB67}
Expand Down
8 changes: 8 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## [Unreleased]

### Added
* 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)
* `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)
* `fsdocs convert` infers the output format from the output file extension when `--outputformat` is not specified (e.g. `-o out.md` implies `--outputformat markdown`). [#1019](https://github.com/fsprojects/FSharp.Formatting/pull/1019)
* `fsdocs convert` now accepts `-o` as a shorthand for `--output`. [#1019](https://github.com/fsprojects/FSharp.Formatting/pull/1019)

### Changed
* When no template is provided (e.g. `fsdocs convert` without `--template`), `fsdocs-tip` tooltip divs are no longer included in the output. Tooltips require JavaScript/CSS from a template to function, so omitting them produces cleaner raw output. [#1019](https://github.com/fsprojects/FSharp.Formatting/pull/1019)
## 22.0.0-alpha.1 - 2026-03-03

### Added
Expand Down
9 changes: 8 additions & 1 deletion build.fsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ let releaseNugetVersion, _, _ =

match Parser.parseChangeLog changeLog with
| Error(msg, error) -> failwithf "%s msg\n%A" msg error
| Ok result -> result.Releases |> List.head
| Ok result ->
match result.Releases with
| [] ->
failwith
"RELEASE_NOTES.md has no versioned releases. \
Note: blank lines between items inside a section (e.g. '### Added') \
cause Ionide.KeepAChangelog 0.1.8 to stop parsing — remove them."
| h :: _ -> h

let solutionFile = "FSharp.Formatting.sln"

Expand Down
6 changes: 2 additions & 4 deletions src/FSharp.Formatting.Common/Templating.fs
Original file line number Diff line number Diff line change
Expand Up @@ -285,15 +285,13 @@ module internal SimpleTemplating =

match opt with
| None ->
// If there is no template or the template is an empty file, return just document + tooltips (tooltips empty if not HTML)
// If there is no template or the template is an empty file, return just the document content.
// Tooltips are omitted: they rely on JavaScript/CSS provided by a template to function.
let lookup = readOnlyDict substitutions

(match lookup.TryGetValue ParamKeys.``fsdocs-content`` with
| true, lookupContent -> lookupContent
| false, _ -> "")
+ (match lookup.TryGetValue ParamKeys.``fsdocs-tooltips`` with
| true, lookupTips -> "\n\n" + lookupTips
| false, _ -> "")
| Some templateText -> ApplySubstitutionsInText substitutions templateText

let UseFileAsSimpleTemplate (substitutions, templateOpt, outputFile) =
Expand Down
49 changes: 49 additions & 0 deletions src/FSharp.Formatting.Literate/Literate.fs
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,55 @@ type Literate private () =

SimpleTemplating.UseFileAsSimpleTemplate(res.Substitutions, template, output)

/// Convert a pynb notebook file into HTML or another output kind
static member ConvertPynbFile
(
input,
?template,
?output,
?outputKind,
?prefix,
?fscOptions,
?lineNumbers,
?references,
?substitutions,
?generateAnchors,
?imageSaver,
?rootInputFolder,
?crefResolver,
?mdlinkResolver,
?onError,
?filesWithFrontMatter
) =

let outputKind = defaultArg outputKind OutputKind.Html
let output = defaultOutput output input outputKind
let crefResolver = defaultArg crefResolver (fun _ -> None)
let mdlinkResolver = defaultArg mdlinkResolver (fun _ -> None)
let substitutions = defaultArg substitutions []
let filesWithFrontMatter = defaultArg filesWithFrontMatter Array.empty

let res =
Literate.ParseAndTransformPynbFile(
input,
output = output,
outputKind = outputKind,
prefix = prefix,
fscOptions = fscOptions,
lineNumbers = lineNumbers,
references = references,
substitutions = substitutions,
generateAnchors = generateAnchors,
imageSaver = imageSaver,
rootInputFolder = rootInputFolder,
crefResolver = crefResolver,
mdlinkResolver = mdlinkResolver,
onError = onError,
filesWithFrontMatter = filesWithFrontMatter
)

SimpleTemplating.UseFileAsSimpleTemplate(res.Substitutions, template, output)


[<assembly: InternalsVisibleTo("fsdocs")>]
[<assembly: InternalsVisibleTo("FSharp.Formatting.TestHelpers")>]
Expand Down
152 changes: 152 additions & 0 deletions src/fsdocs-tool/BuildCommand.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2323,6 +2323,158 @@
abstract port_option: int
default x.port_option = 0

[<Verb("convert",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/repo-assist it might be time to split these commands up to multiple files.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup these "/repo-assist" in PR comments still not working

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handling this in s a separate issue #1022

HelpText =
"convert a single document (.md, .fsx, .ipynb) to HTML or another output format without building a full documentation site")>]
type ConvertCommand() =

[<Value(0, MetaName = "input", Required = true, HelpText = "Input file to convert (.md, .fsx or .ipynb).")>]
member val input = "" with get, set

[<Option('o',
"output",
Required = false,
HelpText =
"Output file path. Defaults to the input filename with the output format extension in the current directory.")>]
member val output = "" with get, set

[<Option("template",
Required = false,
HelpText = "Path to an HTML (or other format) template file. When omitted, raw content is written.")>]
member val template = "" with get, set

[<Option("outputformat",
Required = false,
Default = "",
HelpText =
"Output format: html (default), ipynb, latex, fsx, markdown. When not specified, inferred from the output file extension.")>]
member val outputFormat = "" with get, set

[<Option("eval", Default = false, Required = false, HelpText = "Evaluate F# fragments in scripts.")>]
member val eval = false with get, set

[<Option("linenumbers", Default = false, Required = false, HelpText = "Add line numbers.")>]
member val linenumbers = false with get, set

[<Option("parameters",
Required = false,
HelpText = "Additional substitution parameters, e.g. --parameters key1 value1 key2 value2")>]
member val parameters = Seq.empty<string> with get, set

member this.Execute() =
let inputFile = Path.GetFullPath(this.input)

if not (File.Exists inputFile) then
printfn "error: input file '%s' does not exist" inputFile
1
else

// Infer output format: explicit flag > extension of -o > default html
let resolvedFormat =
if not (String.IsNullOrWhiteSpace this.outputFormat) then
this.outputFormat.ToLowerInvariant()
elif not (String.IsNullOrWhiteSpace this.output) then
let ext = Path.GetExtension(this.output).TrimStart('.').ToLowerInvariant()

match ext with
| "md" -> "markdown"
| "ipynb" -> "ipynb"
| "tex" -> "latex"
| "fsx" -> "fsx"
| _ -> "html"
else
"html"

let outputKind =
match resolvedFormat with
| "ipynb" -> OutputKind.Pynb
| "latex" -> OutputKind.Latex
| "fsx" -> OutputKind.Fsx
| "markdown" -> OutputKind.Markdown
| _ -> OutputKind.Html

let outputFile =
if String.IsNullOrWhiteSpace this.output then
let basename = Path.GetFileNameWithoutExtension(inputFile)
sprintf "%s.%s" basename outputKind.Extension
else
this.output

let templateOpt =
if String.IsNullOrWhiteSpace this.template then
None
else
Some this.template

let userSubstitutions =
let parameters = Array.ofSeq this.parameters

if parameters.Length % 2 = 1 then
printfn "The --parameters option's argument count must be even"
exit 1

evalPairwiseStringsNoOption parameters
|> List.map (fun (a, b) -> (ParamKey a, b))

let isFsx = inputFile.EndsWith(".fsx", StringComparison.OrdinalIgnoreCase)
let isMd = inputFile.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
let isPynb = inputFile.EndsWith(".ipynb", StringComparison.OrdinalIgnoreCase)

try
if isMd then
printfn "converting %s --> %s" inputFile outputFile

Literate.ConvertMarkdownFile(
inputFile,
?template = templateOpt,
output = outputFile,
outputKind = outputKind,
lineNumbers = this.linenumbers,
substitutions = userSubstitutions
)

0
elif isFsx then
printfn "converting %s --> %s" inputFile outputFile

let fsiEvaluator =
if this.eval then
Some(FsiEvaluator(options = [| "--multiemit-" |]) :> IFsiEvaluator)

Check warning on line 2442 in src/fsdocs-tool/BuildCommand.fs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

It is recommended that objects supporting the IDisposable interface are created using the syntax 'new Type(args)', rather than 'Type(args)' or 'Type' as a function value representing the constructor, to indicate that resources may be owned by the generated value

Check warning on line 2442 in src/fsdocs-tool/BuildCommand.fs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

It is recommended that objects supporting the IDisposable interface are created using the syntax 'new Type(args)', rather than 'Type(args)' or 'Type' as a function value representing the constructor, to indicate that resources may be owned by the generated value

Check warning on line 2442 in src/fsdocs-tool/BuildCommand.fs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

It is recommended that objects supporting the IDisposable interface are created using the syntax 'new Type(args)', rather than 'Type(args)' or 'Type' as a function value representing the constructor, to indicate that resources may be owned by the generated value

Check warning on line 2442 in src/fsdocs-tool/BuildCommand.fs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

It is recommended that objects supporting the IDisposable interface are created using the syntax 'new Type(args)', rather than 'Type(args)' or 'Type' as a function value representing the constructor, to indicate that resources may be owned by the generated value
else
None

Literate.ConvertScriptFile(
inputFile,
?template = templateOpt,
output = outputFile,
outputKind = outputKind,
lineNumbers = this.linenumbers,
?fsiEvaluator = fsiEvaluator,
substitutions = userSubstitutions
)

0
elif isPynb then
printfn "converting %s --> %s" inputFile outputFile

Literate.ConvertPynbFile(
inputFile,
?template = templateOpt,
output = outputFile,
outputKind = outputKind,
lineNumbers = this.linenumbers,
substitutions = userSubstitutions
)

0
else
printfn "error: unsupported input file type '%s'" (Path.GetExtension inputFile)
printfn "supported types: .md, .fsx, .ipynb"
1
with ex ->
printfn "Error during conversion: %O" ex
1

[<Verb("build", HelpText = "build the documentation for a solution based on content and defaults")>]
type BuildCommand() =
inherit CoreBuildOptions(false)
Expand Down
3 changes: 2 additions & 1 deletion src/fsdocs-tool/Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ do ()
[<EntryPoint>]
let main argv =
CommandLine.Parser.Default
.ParseArguments<BuildCommand, WatchCommand, InitCommand>(argv)
.ParseArguments<ConvertCommand, BuildCommand, WatchCommand, InitCommand>(argv)
.MapResult(
(fun (opts: ConvertCommand) -> opts.Execute()),
(fun (opts: BuildCommand) -> opts.Execute()),
(fun (opts: WatchCommand) -> opts.Execute()),
(fun (opts: InitCommand) -> opts.Execute()),
Expand Down
1 change: 1 addition & 0 deletions tests/FSharp.Literate.Tests/DocContentTests.fs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
module FSharp.Literate.Tests.DocContent

open System.IO
open FSharp.Formatting.Literate
open FSharp.Formatting.Templating
open fsdocs
open NUnit.Framework
Expand Down
56 changes: 56 additions & 0 deletions tests/FSharp.Literate.Tests/LiterateTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2045,3 +2045,59 @@ let ``Emoji in ConvertScriptFile Markdown output file are preserved`` () =
md |> shouldContainText emojiStar

// End emoji tests

// --------------------------------------------------------------------------------------
// Tests for Literate.ConvertPynbFile
// --------------------------------------------------------------------------------------

[<Test>]
let ``ConvertPynbFile converts notebook to HTML without template`` () =
use temp = new TempFile()

Literate.ConvertPynbFile(__SOURCE_DIRECTORY__ </> "files" </> "simple3.ipynb", output = temp.File)

temp.Content |> shouldContainText "Heading"
temp.Content |> shouldContainText "Code sample"

[<Test>]
let ``ConvertPynbFile converts notebook to HTML with template`` () =
let templateHtml = __SOURCE_DIRECTORY__ </> "files/template.html"

use temp = new TempFile()

Literate.ConvertPynbFile(
__SOURCE_DIRECTORY__ </> "files" </> "simple3.ipynb",
template = templateHtml,
output = temp.File
)

temp.Content |> shouldContainText "Heading"
temp.Content |> shouldContainText "Code sample"
temp.Content |> shouldContainText "<title>"

[<Test>]
let ``ConvertPynbFile converts notebook to Markdown output kind`` () =
let outputFile = __SOURCE_DIRECTORY__ </> "output" </> "simple3-from-pynb.md"

Literate.ConvertPynbFile(
__SOURCE_DIRECTORY__ </> "files" </> "simple3.ipynb",
outputKind = OutputKind.Markdown,
output = outputFile
)

let md = File.ReadAllText outputFile
md |> shouldContainText "Heading"
md |> shouldContainText "Code sample"

[<Test>]
let ``ConvertPynbFile converts notebook to FSX output kind`` () =
let outputFile = __SOURCE_DIRECTORY__ </> "output" </> "simple3-from-pynb.fsx"

Literate.ConvertPynbFile(
__SOURCE_DIRECTORY__ </> "files" </> "simple3.ipynb",
outputKind = OutputKind.Fsx,
output = outputFile
)

let fsx = File.ReadAllText outputFile
fsx |> shouldContainText "Code sample"
Loading