diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 374650bf9..703ca7519 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 (``, ``) to `Literate.ParseAndCheckScriptFile` and `Literate.ParseScriptString` to match the documentation style of the other `Literate.Parse*` methods. ### Fixed diff --git a/src/fsdocs-tool/BuildCommand.fs b/src/fsdocs-tool/BuildCommand.fs index 8df394de5..04e6fbc87 100644 --- a/src/fsdocs-tool/BuildCommand.fs +++ b/src/fsdocs-tool/BuildCommand.fs @@ -2325,6 +2325,141 @@ type CoreBuildOptions(watch) = abstract port_option: int default x.port_option = 0 +/// Helpers for the fsdocs convert command. +module private ConvertHelpers = + + open System.Text.RegularExpressions + + // Compiled at module load; shared across all calls to embedResourcesInHtml. + let private cssPattern = + Regex( + """]*\brel=["']stylesheet["'])[^>]*\bhref=["']([^"']+)["'][^>]*/?>""", + RegexOptions.IgnoreCase ||| RegexOptions.Compiled + ) + + let private jsPattern = + Regex( + """]*\bsrc=["']([^"']+)["'][^>]*>\s*""", + RegexOptions.IgnoreCase ||| RegexOptions.Compiled + ) + + let private imgPattern = + Regex("""(]*\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: /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 and + let html = + cssPattern.Replace( + html, + fun m -> + let href = m.Groups[1].Value + + match tryFindFile href with + | Some fullPath -> sprintf "" (File.ReadAllText(fullPath)) + | None -> m.Value + ) + + // Inline JS: (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 "" (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) + [] @@ -2342,7 +2477,8 @@ type ConvertCommand() = [] + 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 [] member val parameters = Seq.empty with get, set + [] + member val noEmbedResources = false with get, set + member this.Execute() = let inputFile = Path.GetFullPath(this.input) @@ -2402,11 +2545,28 @@ type ConvertCommand() = else this.output - let templateOpt = + // Handle --template fsdocs: extract the embedded default template to a temp file. + // Handle --template : 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 @@ -2418,6 +2578,64 @@ type ConvertCommand() = 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) @@ -2432,7 +2650,7 @@ type ConvertCommand() = output = outputFile, outputKind = outputKind, lineNumbers = this.linenumbers, - substitutions = userSubstitutions + substitutions = substitutions ) 0 @@ -2452,7 +2670,7 @@ type ConvertCommand() = outputKind = outputKind, lineNumbers = this.linenumbers, ?fsiEvaluator = fsiEvaluator, - substitutions = userSubstitutions + substitutions = substitutions ) 0 @@ -2465,7 +2683,7 @@ type ConvertCommand() = output = outputFile, outputKind = outputKind, lineNumbers = this.linenumbers, - substitutions = userSubstitutions + substitutions = substitutions ) 0 @@ -2476,6 +2694,25 @@ type ConvertCommand() = 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 [] type BuildCommand() = diff --git a/tests/fsdocs-tool.Tests/ConvertCommandTests.fs b/tests/fsdocs-tool.Tests/ConvertCommandTests.fs index 19123926c..ca3c6e1d3 100644 --- a/tests/fsdocs-tool.Tests/ConvertCommandTests.fs +++ b/tests/fsdocs-tool.Tests/ConvertCommandTests.fs @@ -137,3 +137,124 @@ let ``ConvertCommand returns error code for unsupported file extension`` () = cmd.output <- outputFile let result = cmd.Execute() result |> shouldEqual 1 + +// -------------------------------------------------------------------------------------- +// Unit tests for resource embedding (ConvertHelpers.embedResourcesInHtml) +// -------------------------------------------------------------------------------------- + +/// Helper: create a unique temp directory for each test. +let private mkTempDir (name: string) = + let dir = Path.Combine(Path.GetTempPath(), "fsdocs-embed-tests", name) + Directory.CreateDirectory(dir) |> ignore + dir + +[] +let ``embedResourcesInHtml inlines local CSS stylesheet`` () = + let dir = mkTempDir "css" + let cssFile = dir "style.css" + File.WriteAllText(cssFile, "body { color: red; }") + + let template = dir "_template.html" + + File.WriteAllText( + template, + """{{fsdocs-content}}""" + ) + + let input = dir "test.md" + File.WriteAllText(input, "# Hello") + let output = dir "out.html" + + let cmd = ConvertCommand() + cmd.input <- input + cmd.output <- output + cmd.template <- template + let result = cmd.Execute() + + result |> shouldEqual 0 + let html = File.ReadAllText(output) + html |> shouldContainText "" + html |> shouldNotContainText "href=\"style.css\"" + +[] +let ``embedResourcesInHtml inlines local JavaScript`` () = + let dir = mkTempDir "js" + let jsFile = dir "app.js" + File.WriteAllText(jsFile, "console.log('hi');") + + let template = dir "_template.html" + + File.WriteAllText( + template, + """{{fsdocs-content}}""" + ) + + let input = dir "test.md" + File.WriteAllText(input, "# Hello") + let output = dir "out.html" + + let cmd = ConvertCommand() + cmd.input <- input + cmd.output <- output + cmd.template <- template + let result = cmd.Execute() + + result |> shouldEqual 0 + let html = File.ReadAllText(output) + html |> shouldContainText "" + html |> shouldNotContainText "src=\"app.js\"" + +[] +let ``embedResourcesInHtml leaves remote URLs unchanged`` () = + let dir = mkTempDir "remote" + + let template = dir "_template.html" + + File.WriteAllText( + template, + """{{fsdocs-content}}""" + ) + + let input = dir "test.md" + File.WriteAllText(input, "# Hello") + let output = dir "out.html" + + let cmd = ConvertCommand() + cmd.input <- input + cmd.output <- output + cmd.template <- template + let result = cmd.Execute() + + result |> shouldEqual 0 + let html = File.ReadAllText(output) + html |> shouldContainText "https://cdn.example.com/styles.css" + html |> shouldContainText "https://cdn.example.com/app.js" + +[] +let ``embedResourcesInHtml skips embedding when --no-embed-resources is set`` () = + let dir = mkTempDir "noembed" + let cssFile = dir "style.css" + File.WriteAllText(cssFile, "body { color: blue; }") + + let template = dir "_template.html" + + File.WriteAllText( + template, + """{{fsdocs-content}}""" + ) + + let input = dir "test.md" + File.WriteAllText(input, "# Hello") + let output = dir "out.html" + + let cmd = ConvertCommand() + cmd.input <- input + cmd.output <- output + cmd.template <- template + cmd.noEmbedResources <- true + let result = cmd.Execute() + + result |> shouldEqual 0 + let html = File.ReadAllText(output) + html |> shouldContainText "href=\"style.css\"" + html |> shouldNotContainText "" diff --git a/tests/manual/convert/README.md b/tests/manual/convert/README.md new file mode 100644 index 000000000..3ac2aeac4 --- /dev/null +++ b/tests/manual/convert/README.md @@ -0,0 +1,254 @@ +# Manual Validation: `fsdocs convert` + +This directory contains example files for manually validating the `fsdocs convert` command, +including the `--template fsdocs` and `--no-embed-resources` options added in PR #1072. + +## Prerequisites + +Build the tool from the repo root: + +```bash +dotnet build FSharp.Formatting.sln +``` + +Set a variable to avoid repetition (Linux/macOS): + +```bash +FSDOCS=/path/to/FSharp.Formatting/src/fsdocs-tool/bin/Debug/net10.0/fsdocs.dll +# e.g.: +FSDOCS=$(pwd)/src/fsdocs-tool/bin/Debug/net10.0/fsdocs.dll +``` + +On Windows (PowerShell): + +```powershell +$FSDOCS = ".\src\fsdocs-tool\bin\Debug\net10.0\fsdocs.dll" +``` + +All commands below use `dotnet $FSDOCS` (Linux/macOS). +On Windows substitute `dotnet $FSDOCS` → `dotnet $FSDOCS`. +Run commands from the **repo root**, or replace `tests/manual/convert/` with the full path. + +--- + +## Scenario 1 — Raw HTML (no template) + +```bash +dotnet $FSDOCS convert tests/manual/convert/example.md +``` + +**Expected output file:** `example.html` (in the current directory) + +**What to check:** +- The file contains rendered HTML for the headings, code block, table. +- There is **no** `` wrapper / `` section — just the body fragment. +- No `{{...}}` placeholder text appears anywhere. + +--- + +## Scenario 2 — Minimal custom template (recommended starting point) + +```bash +dotnet $FSDOCS convert tests/manual/convert/example.md \ + --template tests/manual/convert/_template-minimal.html \ + -o /tmp/example-minimal.html +``` + +**What to check:** +- The file is a **complete, self-contained HTML document** (has ``, ``, ``). +- CSS from `content/fsdocs-default.css` and `content/fsdocs-theme.css` is **inlined** as `