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
2 changes: 2 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* `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)
* `fsdocs convert` now embeds CSS, JS, and local images directly into the HTML output by default, producing a single self-contained file. Use `--no-embed-resources` to disable. Pass `--template fsdocs` to use the built-in default template without needing a local `_template.html`. [#1068](https://github.com/fsprojects/FSharp.Formatting/issues/1068)
* `fsdocs convert` now supplies sensible defaults for all standard `{{fsdocs-*}}` template substitution parameters (e.g. `{{fsdocs-page-title}}` defaults to the input filename) so templates work cleanly without requiring `--parameters`. [#1072](https://github.com/fsprojects/FSharp.Formatting/pull/1072)
* Added full XML doc comments (`<summary>`, `<param>`) to `Literate.ParseAndCheckScriptFile` and `Literate.ParseScriptString` to match the documentation style of the other `Literate.Parse*` methods.

### Fixed
Expand Down
251 changes: 244 additions & 7 deletions src/fsdocs-tool/BuildCommand.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2325,6 +2325,141 @@
abstract port_option: int
default x.port_option = 0

/// Helpers for the <c>fsdocs convert</c> command.
module private ConvertHelpers =

open System.Text.RegularExpressions

// Compiled at module load; shared across all calls to embedResourcesInHtml.
let private cssPattern =
Regex(
"""<link\b(?=[^>]*\brel=["']stylesheet["'])[^>]*\bhref=["']([^"']+)["'][^>]*/?>""",
RegexOptions.IgnoreCase ||| RegexOptions.Compiled
)

let private jsPattern =
Regex(
"""<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*>\s*</script>""",
RegexOptions.IgnoreCase ||| RegexOptions.Compiled
)

let private imgPattern =
Regex("""(<img\b[^>]*\bsrc=["'])([^"']+)(["'][^>]*>)""", RegexOptions.IgnoreCase ||| RegexOptions.Compiled)

/// Return candidate directories in which to search for locally-referenced assets (CSS, JS, images).
/// The search order is: output directory β†’ template directory β†’ default content directories.
let findContentSearchDirs (outputFile: string) (templateFile: string option) =
let dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)

[ yield Path.GetDirectoryName(Path.GetFullPath(outputFile))

match templateFile with
| Some t when not (String.IsNullOrWhiteSpace t) -> yield Path.GetDirectoryName(Path.GetFullPath(t))
| _ -> ()

// NuGet package layout: <package-root>/extras contains a "content" sub-directory.
let nugetExtras = Path.GetFullPath(Path.Combine(dir, "..", "..", "..", "extras"))

if
(try
Directory.Exists(nugetExtras)
with _ ->
false)
then
yield nugetExtras

// In-repo development layout: src/fsdocs-tool/bin/…/fsdocs.exe β†’ docs/
let repoDocs = Path.GetFullPath(Path.Combine(dir, "..", "..", "..", "..", "..", "docs"))

if
(try
Directory.Exists(repoDocs)
with _ ->
false)
then
yield repoDocs ]

/// Inline local CSS, JS, and image resources that are referenced in the generated HTML file.
/// Remote URLs (http/https) and data-URIs are left untouched.
let embedResourcesInHtml (htmlPath: string) (searchDirs: string list) =
let isRemote (href: string) =
href.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| href.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|| href.StartsWith("data:", StringComparison.OrdinalIgnoreCase)
|| href.StartsWith("//", StringComparison.OrdinalIgnoreCase)

let tryFindFile (href: string) =
if isRemote href then
None
else
let normalized =
if href.StartsWith("./", StringComparison.Ordinal) then
href[2..]
else
href

searchDirs
|> List.tryPick (fun dir ->
let fullPath = Path.GetFullPath(Path.Combine(dir, normalized))
if File.Exists(fullPath) then Some fullPath else None)

let html = File.ReadAllText(htmlPath)

// Inline CSS: handles both <link rel="stylesheet" href="..."> and <link href="..." rel="stylesheet">
let html =
cssPattern.Replace(
html,
fun m ->
let href = m.Groups[1].Value

match tryFindFile href with
| Some fullPath -> sprintf "<style>%s</style>" (File.ReadAllText(fullPath))
| None -> m.Value
)

// Inline JS: <script src="..."></script> (self-closing or with optional whitespace body)
let html =
jsPattern.Replace(
html,
fun m ->
let src = m.Groups[1].Value

match tryFindFile src with
| Some fullPath -> sprintf "<script>%s</script>" (File.ReadAllText(fullPath))
| None -> m.Value
)

// Inline local images as base64 data-URIs.
// Capture groups: 1 = everything up to and including src=", 2 = path, 3 = " and rest of tag.
let html =
imgPattern.Replace(
html,
fun m ->
let src = m.Groups[2].Value

match tryFindFile src with
| Some fullPath ->
let bytes = File.ReadAllBytes(fullPath)
let ext = Path.GetExtension(fullPath).TrimStart('.').ToLowerInvariant()

let mimeType =
match ext with
| "png" -> "image/png"
| "jpg"
| "jpeg" -> "image/jpeg"
| "gif" -> "image/gif"
| "svg" -> "image/svg+xml"
| "ico" -> "image/x-icon"
| "webp" -> "image/webp"
| _ -> "image/png"

let b64 = Convert.ToBase64String(bytes)
sprintf "%sdata:%s;base64,%s%s" m.Groups[1].Value mimeType b64 m.Groups[3].Value
| None -> m.Value
)

File.WriteAllText(htmlPath, html)

[<Verb("convert",
HelpText =
"convert a single document (.md, .fsx, .ipynb) to HTML or another output format without building a full documentation site")>]
Expand All @@ -2342,7 +2477,8 @@

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

[<Option("outputformat",
Expand All @@ -2363,6 +2499,13 @@
HelpText = "Additional substitution parameters, e.g. --parameters key1 value1 key2 value2")>]
member val parameters = Seq.empty<string> with get, set

[<Option("no-embed-resources",
Default = false,
Required = false,
HelpText =
"Disable automatic inlining of local CSS, JS, and images into the output HTML. By default, when a template is used for HTML output, all locally-referenced assets are embedded so the output is a self-contained single file.")>]
member val noEmbedResources = false with get, set

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

Expand Down Expand Up @@ -2402,11 +2545,28 @@
else
this.output

let templateOpt =
// Handle --template fsdocs: extract the embedded default template to a temp file.
// Handle --template <path>: use as-is.
// Handle no template: raw content only (no resource embedding needed).
let templateOpt, tempFileToCleanUp =
if String.IsNullOrWhiteSpace this.template then
None
None, None
elif this.template.Equals("fsdocs", StringComparison.OrdinalIgnoreCase) then
let asm = Assembly.GetExecutingAssembly()
use stream = asm.GetManifestResourceStream("fsdocs._template.html")
use reader = new StreamReader(stream)
let content = reader.ReadToEnd()

let tmp =
Path.Combine(
Path.GetTempPath(),
sprintf "fsdocs-template-%s.html" (Guid.NewGuid().ToString("N"))
)

File.WriteAllText(tmp, content)
Some tmp, Some tmp
else
Some this.template
Some this.template, None

let userSubstitutions =
let parameters = Array.ofSeq this.parameters
Expand All @@ -2418,6 +2578,64 @@
evalPairwiseStringsNoOption parameters
|> List.map (fun (a, b) -> (ParamKey a, b))

// When embedding resources we need {{root}} to resolve to "" so that paths like
// "{{root}}content/fsdocs-default.css" become "content/fsdocs-default.css".
// Only add this default if the user has not already supplied a root substitution.
let embedResources = not this.noEmbedResources && outputKind = OutputKind.Html && templateOpt.IsSome

// When a template is used, supply sensible defaults for every standard fsdocs template
// parameter so that {{fsdocs-*}} placeholders in the template are replaced with empty
// strings (or a meaningful value) rather than being left as raw text in the output.
// User-supplied --parameters values always take priority.
let substitutions =
match templateOpt with
| None -> userSubstitutions
| Some _ ->
let pageTitle = Path.GetFileNameWithoutExtension(inputFile)

let defaults =
[ ParamKeys.root, (if embedResources then "" else "")
ParamKeys.``fsdocs-page-title``, pageTitle
ParamKeys.``fsdocs-source-basename``, pageTitle
ParamKeys.``fsdocs-source-filename``, Path.GetFileName(inputFile)
ParamKeys.``fsdocs-collection-name``, pageTitle
ParamKeys.``fsdocs-authors``, ""
ParamKeys.``fsdocs-body-class``, "content"
ParamKeys.``fsdocs-body-extra``, ""
ParamKeys.``fsdocs-copyright``, ""
ParamKeys.``fsdocs-favicon-src``, ""
ParamKeys.``fsdocs-head-extra``, ""
ParamKeys.``fsdocs-license-link``, "#"
ParamKeys.``fsdocs-list-of-documents``, ""
ParamKeys.``fsdocs-list-of-namespaces``, ""
ParamKeys.``fsdocs-logo-alt``, pageTitle
ParamKeys.``fsdocs-logo-link``, "#"
ParamKeys.``fsdocs-logo-src``, ""
ParamKeys.``fsdocs-meta-tags``, ""
ParamKeys.``fsdocs-page-content-list``, ""
ParamKeys.``fsdocs-package-license-expression``, ""
ParamKeys.``fsdocs-package-project-url``, ""
ParamKeys.``fsdocs-package-tags``, ""
ParamKeys.``fsdocs-package-version``, ""
ParamKeys.``fsdocs-package-icon-url``, ""
ParamKeys.``fsdocs-release-notes-link``, "#"
ParamKeys.``fsdocs-repository-link``, "#"
ParamKeys.``fsdocs-repository-branch``, ""
ParamKeys.``fsdocs-repository-commit``, ""
ParamKeys.``fsdocs-source``, ""
ParamKeys.``fsdocs-theme``, ""
ParamKeys.``fsdocs-tooltips``, ""
ParamKeys.``fsdocs-watch-script``, ""
ParamKeys.``fsdocs-collection-name-link``, "#"
ParamKeys.``fsdocs-page-source``, "" ]

// User-supplied values override defaults.
let userKeys = userSubstitutions |> List.map fst |> set

let filteredDefaults = defaults |> List.filter (fun (k, _) -> not (userKeys.Contains k))

userSubstitutions @ filteredDefaults

let isFsx = inputFile.EndsWith(".fsx", StringComparison.OrdinalIgnoreCase)
let isMd = inputFile.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
let isPynb = inputFile.EndsWith(".ipynb", StringComparison.OrdinalIgnoreCase)
Expand All @@ -2432,7 +2650,7 @@
output = outputFile,
outputKind = outputKind,
lineNumbers = this.linenumbers,
substitutions = userSubstitutions
substitutions = substitutions
)

0
Expand All @@ -2441,7 +2659,7 @@

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

Check warning on line 2662 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 2662 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 2662 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 2662 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
else
None

Expand All @@ -2452,7 +2670,7 @@
outputKind = outputKind,
lineNumbers = this.linenumbers,
?fsiEvaluator = fsiEvaluator,
substitutions = userSubstitutions
substitutions = substitutions
)

0
Expand All @@ -2465,7 +2683,7 @@
output = outputFile,
outputKind = outputKind,
lineNumbers = this.linenumbers,
substitutions = userSubstitutions
substitutions = substitutions
)

0
Expand All @@ -2476,6 +2694,25 @@
with ex ->
printfn "Error during conversion: %O" ex
1
|> fun exitCode ->
// Clean up any temporary template file we created.
match tempFileToCleanUp with
| Some tmp ->
try
File.Delete(tmp)
with _ ->
()
| None -> ()

// Post-process the HTML to inline all local asset references.
if exitCode = 0 && embedResources then
let searchDirs =
ConvertHelpers.findContentSearchDirs outputFile (Option.map Path.GetFullPath templateOpt)

printfn "embedding resources into %s (search dirs: %s)" outputFile (String.concat ", " searchDirs)
ConvertHelpers.embedResourcesInHtml outputFile searchDirs

exitCode

[<Verb("build", HelpText = "build the documentation for a solution based on content and defaults")>]
type BuildCommand() =
Expand Down
Loading