From 652e0ce493cff58f03bdcac2b17e96f4e39adcab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 23:03:36 +0000 Subject: [PATCH 1/4] Split BuildCommand.fs into DocContent.fs, WatchServer.fs, and BuildCommand.fs BuildCommand.fs was 2289 lines long - too large to be a cohesive whole. Split into three focused files: - DocContent.fs (795 lines): DocContent type - converts markdown/scripts into static site output files - WatchServer.fs (~500 lines): Serve module - Suave WebSocket dev server for hot-reload watch mode - BuildCommand.fs (~1000 lines): CoreBuildOptions, ConvertCommand, BuildCommand, WatchCommand - CLI verb types and entry points Addresses #1022. All 7 fsdocs-tool tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/fsdocs-tool/BuildCommand.fs | 1256 ---------------------------- src/fsdocs-tool/DocContent.fs | 794 ++++++++++++++++++ src/fsdocs-tool/WatchServer.fs | 507 +++++++++++ src/fsdocs-tool/fsdocs-tool.fsproj | 2 + 4 files changed, 1303 insertions(+), 1256 deletions(-) create mode 100644 src/fsdocs-tool/DocContent.fs create mode 100644 src/fsdocs-tool/WatchServer.fs diff --git a/src/fsdocs-tool/BuildCommand.fs b/src/fsdocs-tool/BuildCommand.fs index 796c5dad0..2a6df981f 100644 --- a/src/fsdocs-tool/BuildCommand.fs +++ b/src/fsdocs-tool/BuildCommand.fs @@ -31,1262 +31,6 @@ open FSharp.Formatting.Markdown #nowarn "44" // Obsolete WebClient -/// Convert markdown, script and other content into a static site -type internal DocContent - ( - rootOutputFolderAsGiven, - previous: Map<_, _>, - lineNumbers, - evaluate, - substitutions, - saveImages, - watch, - root, - crefResolver, - onError - ) = - - let createImageSaver (rootOutputFolderAsGiven) = - // Download images so that they can be embedded - let wc = new WebClient() - let mutable counter = 0 - - fun (url: string) -> - if - url.StartsWith("http", StringComparison.Ordinal) - || url.StartsWith("https", StringComparison.Ordinal) - then - counter <- counter + 1 - let ext = Path.GetExtension(url) - - let url2 = sprintf "savedimages/saved%d%s" counter ext - - let fn = sprintf "%s/%s" rootOutputFolderAsGiven url2 - - ensureDirectory (sprintf "%s/savedimages" rootOutputFolderAsGiven) - printfn "downloading %s --> %s" url fn - wc.DownloadFile(url, fn) - url2 - else - url - - let getOutputFileNames (inputFileFullPath: string) (outputKind: OutputKind) outputFolderRelativeToRoot = - let inputFileName = Path.GetFileName(inputFileFullPath) - let isFsx = inputFileFullPath.EndsWith(".fsx", true, CultureInfo.InvariantCulture) - let isMd = inputFileFullPath.EndsWith(".md", true, CultureInfo.InvariantCulture) - let isPynb = inputFileFullPath.EndsWith(".ipynb", true, CultureInfo.InvariantCulture) - let ext = outputKind.Extension - - let outputFileRelativeToRoot = - if isFsx || isMd || isPynb then - let basename = Path.GetFileNameWithoutExtension(inputFileFullPath) - - Path.Combine(outputFolderRelativeToRoot, sprintf "%s.%s" basename ext) - else - Path.Combine(outputFolderRelativeToRoot, inputFileName) - - let outputFileFullPath = Path.GetFullPath(Path.Combine(rootOutputFolderAsGiven, outputFileRelativeToRoot)) - outputFileRelativeToRoot, outputFileFullPath - - // Check if a sub-folder is actually the output directory - let subFolderIsOutput subInputFolderFullPath = - let subFolderFullPath = Path.GetFullPath(subInputFolderFullPath) - let rootOutputFolderFullPath = Path.GetFullPath(rootOutputFolderAsGiven) - (subFolderFullPath = rootOutputFolderFullPath) - - let allCultures = - CultureInfo.GetCultures(CultureTypes.AllCultures) - |> Array.choose (fun x -> - if x.TwoLetterISOLanguageName.Length <> 2 then - None - else - Some x.TwoLetterISOLanguageName) - |> Array.distinct - - let makeMarkdownLinkResolver - (inputFolderAsGiven, outputFolderRelativeToRoot, fullPathFileMap: Map<(string * OutputKind), string>, outputKind) - (markdownReference: string) - = - let markdownReferenceAsFullInputPathOpt = - try - Path.GetFullPath(Path.Combine(inputFolderAsGiven, markdownReference)) |> Some - with _ -> - None - - match markdownReferenceAsFullInputPathOpt with - | None -> None - | Some markdownReferenceFullInputPath -> - match fullPathFileMap.TryFind(markdownReferenceFullInputPath, outputKind) with - | None -> None - | Some markdownReferenceFullOutputPath -> - try - let outputFolderFullPath = - Path.GetFullPath(Path.Combine(rootOutputFolderAsGiven, outputFolderRelativeToRoot)) - - let uri = - Uri(outputFolderFullPath + "/").MakeRelativeUri(Uri(markdownReferenceFullOutputPath)).ToString() - - Some uri - with _ -> - printfn - $"Couldn't map markdown reference %s{markdownReference} that seemed to correspond to an input file" - - None - - /// Prepare the map of input file to output file. This map is used to make substitutions through markdown - /// source such A.md --> A.html or A.fsx --> A.html. The substitutions depend on the output kind. - let prepFile (inputFileFullPath: string) (outputKind: OutputKind) outputFolderRelativeToRoot = - [ let inputFileName = Path.GetFileName(inputFileFullPath) - - if - not (inputFileName.StartsWith('.')) - && not (inputFileName.StartsWith("_template", StringComparison.Ordinal)) - then - let inputFileFullPath = Path.GetFullPath(inputFileFullPath) - - let _relativeOutputFile, outputFileFullPath = - getOutputFileNames inputFileFullPath outputKind outputFolderRelativeToRoot - - yield ((inputFileFullPath, outputKind), outputFileFullPath) ] - - /// Likewise prepare the map of input files to output files - let rec prepFolder (inputFolderAsGiven: string) outputFolderRelativeToRoot = - [ let inputs = Directory.GetFiles(inputFolderAsGiven, "*") - - for input in inputs do - yield! prepFile input OutputKind.Html outputFolderRelativeToRoot - yield! prepFile input OutputKind.Latex outputFolderRelativeToRoot - yield! prepFile input OutputKind.Pynb outputFolderRelativeToRoot - yield! prepFile input OutputKind.Fsx outputFolderRelativeToRoot - yield! prepFile input OutputKind.Markdown outputFolderRelativeToRoot - - for subInputFolderFullPath in Directory.EnumerateDirectories(inputFolderAsGiven) do - let subInputFolderName = Path.GetFileName(subInputFolderFullPath) - let subFolderIsSkipped = subInputFolderName.StartsWith '.' - let subFolderIsOutput = subFolderIsOutput subInputFolderFullPath - - if not subFolderIsOutput && not subFolderIsSkipped then - yield! - prepFolder - (Path.Combine(inputFolderAsGiven, subInputFolderName)) - (Path.Combine(outputFolderRelativeToRoot, subInputFolderName)) ] - - let processFile - rootInputFolder - (isOtherLang: bool) - (inputFileFullPath: string) - outputKind - template - outputFolderRelativeToRoot - imageSaver - mdlinkResolver - (filesWithFrontMatter: FrontMatterFile array) - = - [ let name = Path.GetFileName(inputFileFullPath) - - if name.StartsWith('.') then - printfn "skipping file %s" inputFileFullPath - elif not (name.StartsWith("_template", StringComparison.Ordinal)) then - let isFsx = inputFileFullPath.EndsWith(".fsx", StringComparison.OrdinalIgnoreCase) - - let isMd = inputFileFullPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase) - - let isPynb = inputFileFullPath.EndsWith(".ipynb", StringComparison.OrdinalIgnoreCase) - - // A _template.tex or _template.pynb is needed to generate those files - match outputKind, template with - | OutputKind.Pynb, None -> () - | OutputKind.Latex, None -> () - | OutputKind.Fsx, None -> () - | OutputKind.Markdown, None -> () - | _ -> - - let imageSaverOpt = - match outputKind with - | OutputKind.Pynb when saveImages <> Some false -> Some imageSaver - | OutputKind.Latex when saveImages <> Some false -> Some imageSaver - | OutputKind.Fsx when saveImages = Some true -> Some imageSaver - | OutputKind.Html when saveImages = Some true -> Some imageSaver - | OutputKind.Markdown when saveImages = Some true -> Some imageSaver - | _ -> None - - let outputFileRelativeToRoot, outputFileFullPath = - getOutputFileNames inputFileFullPath outputKind outputFolderRelativeToRoot - - // Update only when needed - template or file or tool has changed - - let changed = - let fileChangeTime = - try - File.GetLastWriteTime(inputFileFullPath) - with _ -> - DateTime.MaxValue - - let templateChangeTime = - match template with - | Some t when isFsx || isMd || isPynb -> - try - let fi = FileInfo(t) - let input = fi.Directory.Name - let headPath = Path.Combine(input, "_head.html") - let bodyPath = Path.Combine(input, "_body.html") - - [ yield File.GetLastWriteTime(t) - if Menu.isTemplatingAvailable input then - yield! Menu.getLastWriteTimes input - if File.Exists headPath then - yield File.GetLastWriteTime headPath - if File.Exists bodyPath then - yield File.GetLastWriteTime bodyPath ] - |> List.max - with _ -> - DateTime.MaxValue - | _ -> DateTime.MinValue - - let toolChangeTime = - try - File.GetLastWriteTime(Assembly.GetExecutingAssembly().Location) - with _ -> - DateTime.MaxValue - - let changeTime = fileChangeTime |> max templateChangeTime |> max toolChangeTime - - let generateTime = - try - File.GetLastWriteTime(outputFileFullPath) - with _ -> - System.DateTime.MinValue - - changeTime > generateTime - - // If it's changed or we don't know anything about it - // we have to compute the model to get the global substitutions right - let mainRun = (outputKind = OutputKind.Html) - let haveModel = previous.TryFind inputFileFullPath - - if changed || (watch && mainRun && haveModel.IsNone) then - if isFsx then - printfn " generating model for %s --> %s" inputFileFullPath outputFileRelativeToRoot - - let fsiEvaluator = - (if evaluate then - Some( - FsiEvaluator(onError = onError, options = [| "--multiemit-" |]) :> IFsiEvaluator - ) - else - None) - - let model = - Literate.ParseAndTransformScriptFile( - inputFileFullPath, - output = outputFileRelativeToRoot, - outputKind = outputKind, - prefix = None, - fscOptions = None, - lineNumbers = lineNumbers, - references = Some false, - fsiEvaluator = fsiEvaluator, - substitutions = substitutions, - generateAnchors = Some true, - imageSaver = imageSaverOpt, - rootInputFolder = rootInputFolder, - crefResolver = crefResolver, - mdlinkResolver = mdlinkResolver, - onError = Some onError, - filesWithFrontMatter = filesWithFrontMatter - ) - - yield - ((if mainRun then - Some(inputFileFullPath, isOtherLang, model) - else - None), - (fun p -> - printfn " writing %s --> %s" inputFileFullPath outputFileRelativeToRoot - ensureDirectory (Path.GetDirectoryName(outputFileFullPath)) - - SimpleTemplating.UseFileAsSimpleTemplate( - p @ model.Substitutions, - template, - outputFileFullPath - ))) - - elif isMd then - printfn " preparing %s --> %s" inputFileFullPath outputFileRelativeToRoot - - let model = - Literate.ParseAndTransformMarkdownFile( - inputFileFullPath, - output = outputFileRelativeToRoot, - outputKind = outputKind, - prefix = None, - fscOptions = None, - lineNumbers = lineNumbers, - references = Some false, - substitutions = substitutions, - generateAnchors = Some true, - imageSaver = imageSaverOpt, - rootInputFolder = rootInputFolder, - crefResolver = crefResolver, - mdlinkResolver = mdlinkResolver, - parseOptions = MarkdownParseOptions.AllowYamlFrontMatter, - onError = Some onError, - filesWithFrontMatter = filesWithFrontMatter - ) - - yield - ((if mainRun then - Some(inputFileFullPath, isOtherLang, model) - else - None), - (fun p -> - printfn " writing %s --> %s" inputFileFullPath outputFileRelativeToRoot - ensureDirectory (Path.GetDirectoryName(outputFileFullPath)) - - SimpleTemplating.UseFileAsSimpleTemplate( - p @ model.Substitutions, - template, - outputFileFullPath - ))) - elif isPynb then - printfn " preparing %s --> %s" inputFileFullPath outputFileRelativeToRoot - - let evaluateNotebook ipynbFile = - let args = - $"repl --run %s{ipynbFile} --default-kernel fsharp --exit-after-run --output-path %s{ipynbFile}" - - let psi = - ProcessStartInfo( - fileName = "dotnet", - arguments = args, - UseShellExecute = false, - CreateNoWindow = true - ) - - try - let p = Process.Start(psi) - p.WaitForExit() - with _ -> - let msg = - $"Failed to evaluate notebook %s{ipynbFile} using dotnet-repl\n" - + $"""try running "%s{args}" at the command line and inspect the error""" - - failwith msg - - let checkDotnetReplInstall () = - let failmsg = - "'dotnet-repl' is not installed. Please install it using 'dotnet tool install dotnet-repl'" - - try - let psi = - ProcessStartInfo( - fileName = "dotnet", - arguments = "tool list --local", - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true - ) - - let p = Process.Start(psi) - let ol = p.StandardOutput.ReadToEnd() - p.WaitForExit() - psi.Arguments <- "tool list --global" - p.Start() |> ignore - let og = p.StandardOutput.ReadToEnd() - let output = $"%s{ol}\n%s{og}" - - if not (output.Contains("dotnet-repl")) then - failwith failmsg - - p.WaitForExit() - with _ -> - failwith failmsg - - if evaluate then - checkDotnetReplInstall () - printfn $" evaluating %s{inputFileFullPath} with dotnet-repl" - evaluateNotebook inputFileFullPath - - - let model = - Literate.ParseAndTransformPynbFile( - inputFileFullPath, - output = outputFileRelativeToRoot, - outputKind = outputKind, - prefix = None, - fscOptions = None, - lineNumbers = lineNumbers, - references = Some false, - substitutions = substitutions, - generateAnchors = Some true, - imageSaver = imageSaverOpt, - rootInputFolder = rootInputFolder, - crefResolver = crefResolver, - mdlinkResolver = mdlinkResolver, - onError = Some onError, - filesWithFrontMatter = filesWithFrontMatter - ) - - yield - ((if mainRun then - Some(inputFileFullPath, isOtherLang, model) - else - None), - (fun p -> - printfn " writing %s --> %s" inputFileFullPath outputFileRelativeToRoot - ensureDirectory (Path.GetDirectoryName(outputFileFullPath)) - - SimpleTemplating.UseFileAsSimpleTemplate( - p @ model.Substitutions, - template, - outputFileFullPath - ))) - - else if mainRun then - yield - (None, - (fun _p -> - printfn " copying %s --> %s" inputFileFullPath outputFileRelativeToRoot - ensureDirectory (Path.GetDirectoryName(outputFileFullPath)) - // check the file still exists for the incremental case - if (File.Exists inputFileFullPath) then - // ignore errors in watch mode - try - File.Copy(inputFileFullPath, outputFileFullPath, true) - File.SetLastWriteTime(outputFileFullPath, DateTime.Now) - with _ when watch -> - ())) - //printfn "skipping unchanged file %s" inputFileFullPath - else if mainRun && watch then - match haveModel with - | None -> () - | Some haveModel -> yield (Some(inputFileFullPath, isOtherLang, haveModel), (fun _ -> ())) ] - - let rec processFolder - (htmlTemplate, texTemplate, pynbTemplate, fsxTemplate, mdTemplate, isOtherLang, rootInputFolder, fullPathFileMap) - (inputFolderAsGiven: string) - outputFolderRelativeToRoot - (filesWithFrontMatter: FrontMatterFile array) - = - [ - // Look for the presence of the _template.* files to activate the - // generation of the content. - let indirName = Path.GetFileName(inputFolderAsGiven).ToLower() - - // Two-letter directory names (e.g. 'ja') with 'docs' count as multi-language and are suppressed from table-of-content - // generation and site search index - let isOtherLang = isOtherLang || (indirName.Length = 2 && allCultures |> Array.contains indirName) - - let possibleNewHtmlTemplate = Path.Combine(inputFolderAsGiven, "_template.html") - - let htmlTemplate = - if File.Exists(possibleNewHtmlTemplate) then - Some possibleNewHtmlTemplate - else - htmlTemplate - - let possibleNewPynbTemplate = Path.Combine(inputFolderAsGiven, "_template.ipynb") - - let pynbTemplate = - if File.Exists(possibleNewPynbTemplate) then - Some possibleNewPynbTemplate - else - pynbTemplate - - let possibleNewFsxTemplate = Path.Combine(inputFolderAsGiven, "_template.fsx") - - let fsxTemplate = - if File.Exists(possibleNewFsxTemplate) then - Some possibleNewFsxTemplate - else - fsxTemplate - - let possibleNewMdTemplate = Path.Combine(inputFolderAsGiven, "_template.md") - - let mdTemplate = - if File.Exists(possibleNewMdTemplate) then - Some possibleNewMdTemplate - else - mdTemplate - - let possibleNewLatexTemplate = Path.Combine(inputFolderAsGiven, "_template.tex") - - let texTemplate = - if File.Exists(possibleNewLatexTemplate) then - Some possibleNewLatexTemplate - else - texTemplate - - ensureDirectory (Path.Combine(rootOutputFolderAsGiven, outputFolderRelativeToRoot)) - - let inputs = Directory.GetFiles(inputFolderAsGiven, "*") - - let imageSaver = createImageSaver (Path.Combine(rootOutputFolderAsGiven, outputFolderRelativeToRoot)) - - // Look for the four different kinds of content - for input in inputs do - yield! - processFile - rootInputFolder - isOtherLang - input - OutputKind.Html - htmlTemplate - outputFolderRelativeToRoot - imageSaver - (makeMarkdownLinkResolver ( - inputFolderAsGiven, - outputFolderRelativeToRoot, - fullPathFileMap, - OutputKind.Html - )) - filesWithFrontMatter - - yield! - processFile - rootInputFolder - isOtherLang - input - OutputKind.Latex - texTemplate - outputFolderRelativeToRoot - imageSaver - (makeMarkdownLinkResolver ( - inputFolderAsGiven, - outputFolderRelativeToRoot, - fullPathFileMap, - OutputKind.Latex - )) - filesWithFrontMatter - - yield! - processFile - rootInputFolder - isOtherLang - input - OutputKind.Pynb - pynbTemplate - outputFolderRelativeToRoot - imageSaver - (makeMarkdownLinkResolver ( - inputFolderAsGiven, - outputFolderRelativeToRoot, - fullPathFileMap, - OutputKind.Pynb - )) - filesWithFrontMatter - - yield! - processFile - rootInputFolder - isOtherLang - input - OutputKind.Fsx - fsxTemplate - outputFolderRelativeToRoot - imageSaver - (makeMarkdownLinkResolver ( - inputFolderAsGiven, - outputFolderRelativeToRoot, - fullPathFileMap, - OutputKind.Fsx - )) - filesWithFrontMatter - - yield! - processFile - rootInputFolder - isOtherLang - input - OutputKind.Markdown - mdTemplate - outputFolderRelativeToRoot - imageSaver - (makeMarkdownLinkResolver ( - inputFolderAsGiven, - outputFolderRelativeToRoot, - fullPathFileMap, - OutputKind.Markdown - )) - filesWithFrontMatter - - for subInputFolderFullPath in Directory.EnumerateDirectories(inputFolderAsGiven) do - let subInputFolderName = Path.GetFileName(subInputFolderFullPath) - let subFolderIsSkipped = subInputFolderName.StartsWith '.' - let subFolderIsOutput = subFolderIsOutput subInputFolderFullPath - - if subFolderIsOutput || subFolderIsSkipped then - - printfn " skipping directory %s" subInputFolderFullPath - else - yield! - processFolder - (htmlTemplate, - texTemplate, - pynbTemplate, - fsxTemplate, - mdTemplate, - isOtherLang, - rootInputFolder, - fullPathFileMap) - (Path.Combine(inputFolderAsGiven, subInputFolderName)) - (Path.Combine(outputFolderRelativeToRoot, subInputFolderName)) - filesWithFrontMatter ] - - member _.Convert(rootInputFolderAsGiven, htmlTemplate, extraInputs) = - - let inputDirectories = extraInputs @ [ (rootInputFolderAsGiven, ".") ] - - // Maps full input paths to full output paths - let fullPathFileMap = - [ for (rootInputFolderAsGiven, outputFolderRelativeToRoot) in inputDirectories do - yield! prepFolder rootInputFolderAsGiven outputFolderRelativeToRoot ] - |> Map.ofList - - // In order to create {{next-page-url}} and {{previous-page-url}} - // We need to scan all *.fsx and *.md files for their frontmatter. - let filesWithFrontMatter = - fullPathFileMap - |> Map.keys - |> Seq.map fst - |> Seq.distinct - |> Seq.choose (fun fileName -> - let ext = Path.GetExtension fileName - - if ext = ".fsx" then - ParseScript.ParseFrontMatter(fileName) - elif ext = ".md" then - File.ReadLines fileName |> FrontMatterFile.ParseFromLines fileName - elif ext = ".ipynb" then - ParsePynb.parseFrontMatter fileName - else - None) - |> Seq.sortBy (fun { Index = idx; CategoryIndex = cIdx } -> cIdx, idx) - |> Seq.toArray - - [ for (rootInputFolderAsGiven, outputFolderRelativeToRoot) in inputDirectories do - yield! - processFolder - (htmlTemplate, None, None, None, None, false, Some rootInputFolderAsGiven, fullPathFileMap) - rootInputFolderAsGiven - outputFolderRelativeToRoot - filesWithFrontMatter ] - - member _.GetSearchIndexEntries(docModels: (string * bool * LiterateDocModel) list) = - [| for (_inputFile, isOtherLang, model) in docModels do - if not isOtherLang then - match model.IndexText with - | Some(IndexText(fullContent, headings)) -> - { title = model.Title - content = fullContent - headings = headings - uri = model.Uri(root) - ``type`` = "content" } - | _ -> () |] - - member _.GetNavigationEntries - ( - input, - docModels: (string * bool * LiterateDocModel) list, - currentPagePath: string option, - ignoreUncategorized: bool - ) = - let modelsForList = - [ for thing in docModels do - match thing with - | (inputFileFullPath, isOtherLang, model) when - not isOtherLang - && model.OutputKind = OutputKind.Html - && (Path.GetFileNameWithoutExtension(inputFileFullPath) <> "index") - -> - { model with - IsActive = - match currentPagePath with - | None -> false - | Some currentPagePath -> currentPagePath = inputFileFullPath } - | _ -> () ] - - let excludeUncategorized = - if ignoreUncategorized then - List.filter (fun (model: LiterateDocModel) -> model.Category.IsSome) - else - id - - let modelsByCategory = - modelsForList - |> excludeUncategorized - |> List.groupBy (fun (model) -> model.Category) - |> List.sortBy (fun (_, ms) -> - match ms.[0].CategoryIndex with - | Some s -> - (try - int32 s - with _ -> - Int32.MaxValue) - | None -> Int32.MaxValue) - - let orderList (list: (LiterateDocModel) list) = - list - |> List.sortBy (fun model -> Option.defaultValue Int32.MaxValue model.Index) - - if Menu.isTemplatingAvailable input then - let createGroup (isCategoryActive: bool) (header: string) (items: LiterateDocModel list) : string = - //convert items into menuitem list - let menuItems = - orderList items - |> List.map (fun (model: LiterateDocModel) -> - let link = model.Uri(root) - let title = System.Web.HttpUtility.HtmlEncode model.Title - - { Menu.MenuItem.Link = link - Menu.MenuItem.Content = title - Menu.MenuItem.IsActive = model.IsActive }) - - Menu.createMenu input isCategoryActive header menuItems - // No categories specified - if modelsByCategory.Length = 1 && (fst modelsByCategory.[0]) = None then - let _, items = modelsByCategory.[0] - createGroup false "Documentation" items - else - modelsByCategory - |> List.map (fun (header, items) -> - let header = Option.defaultValue "Other" header - let isActive = items |> List.exists (fun m -> m.IsActive) - createGroup isActive header items) - |> String.concat "\n" - else - [ - // No categories specified - if modelsByCategory.Length = 1 && (fst modelsByCategory.[0]) = None then - li [ Class "nav-header" ] [ !!"Documentation" ] - - for model in snd modelsByCategory.[0] do - let link = model.Uri(root) - let activeClass = if model.IsActive then "active" else "" - - li - [ Class $"nav-item %s{activeClass}" ] - [ a [ Class "nav-link"; (Href link) ] [ encode model.Title ] ] - else - // At least one category has been specified. Sort each category by index and emit - // Use 'Other' as a header for uncategorised things - for (cat, modelsInCategory) in modelsByCategory do - let modelsInCategory = orderList modelsInCategory - - let categoryActiveClass = - if modelsInCategory |> List.exists (fun m -> m.IsActive) then - "active" - else - "" - - match cat with - | Some c -> li [ Class $"nav-header %s{categoryActiveClass}" ] [ !!c ] - | None -> li [ Class $"nav-header %s{categoryActiveClass}" ] [ !!"Other" ] - - for model in modelsInCategory do - let link = model.Uri(root) - let activeClass = if model.IsActive then "active" else "" - - li - [ Class $"nav-item %s{activeClass}" ] - [ a [ Class "nav-link"; (Href link) ] [ encode model.Title ] ] ] - |> List.map (fun html -> html.ToString()) - |> String.concat " \n" - -/// Processes and runs Suave server to host them on localhost -module Serve = - let refreshEvent = FSharp.Control.Event() - - /// generate the script to inject into html to enable hot reload during development - let generateWatchScript (port: int) = - let tag = - """ - -""" - - tag.Replace("{{PORT}}", string port) - - let connectedClients = ConcurrentDictionary() - - let socketHandler (webSocket: WebSocket) (context: HttpContext) = - context.runtime.logger.info (Message.eventX "New websocket connection") - connectedClients.TryAdd(webSocket, ()) |> ignore - - socket { - let! msg = webSocket.read () - - match msg with - | Close, _, _ -> - context.runtime.logger.info (Message.eventX "Closing connection") - connectedClients.TryRemove webSocket |> ignore - let emptyResponse = [||] |> ByteSegment - do! webSocket.send Close emptyResponse true - | _ -> () - } - - let broadCastReload (msg: string) = - let msg = msg |> Encoding.UTF8.GetBytes |> ByteSegment - - connectedClients.Keys - |> Seq.map (fun client -> - async { - let! _ = client.send Text msg true - () - }) - |> Async.Parallel - |> Async.Ignore - |> Async.RunSynchronously - - refreshEvent.Publish - |> Event.add (fun fileName -> - if Path.HasExtension fileName then - let fileName = fileName.Replace("\\", "/").TrimEnd('~') - broadCastReload fileName) - - let startWebServer rootOutputFolderAsGiven localPort = - let mimeTypesMap ext = - match ext with - | ".323" -> Writers.createMimeType "text/h323" false - | ".3g2" -> Writers.createMimeType "video/3gpp2" false - | ".3gp2" -> Writers.createMimeType "video/3gpp2" false - | ".3gp" -> Writers.createMimeType "video/3gpp" false - | ".3gpp" -> Writers.createMimeType "video/3gpp" false - | ".aac" -> Writers.createMimeType "audio/aac" false - | ".aaf" -> Writers.createMimeType "application/octet-stream" false - | ".aca" -> Writers.createMimeType "application/octet-stream" false - | ".accdb" -> Writers.createMimeType "application/msaccess" false - | ".accde" -> Writers.createMimeType "application/msaccess" false - | ".accdt" -> Writers.createMimeType "application/msaccess" false - | ".acx" -> Writers.createMimeType "application/internet-property-stream" false - | ".adt" -> Writers.createMimeType "audio/vnd.dlna.adts" false - | ".adts" -> Writers.createMimeType "audio/vnd.dlna.adts" false - | ".afm" -> Writers.createMimeType "application/octet-stream" false - | ".ai" -> Writers.createMimeType "application/postscript" false - | ".aif" -> Writers.createMimeType "audio/x-aiff" false - | ".aifc" -> Writers.createMimeType "audio/aiff" false - | ".aiff" -> Writers.createMimeType "audio/aiff" false - | ".appcache" -> Writers.createMimeType "text/cache-manifest" false - | ".application" -> Writers.createMimeType "application/x-ms-application" false - | ".art" -> Writers.createMimeType "image/x-jg" false - | ".asd" -> Writers.createMimeType "application/octet-stream" false - | ".asf" -> Writers.createMimeType "video/x-ms-asf" false - | ".asi" -> Writers.createMimeType "application/octet-stream" false - | ".asm" -> Writers.createMimeType "text/plain" false - | ".asr" -> Writers.createMimeType "video/x-ms-asf" false - | ".asx" -> Writers.createMimeType "video/x-ms-asf" false - | ".atom" -> Writers.createMimeType "application/atom+xml" false - | ".au" -> Writers.createMimeType "audio/basic" false - | ".avi" -> Writers.createMimeType "video/x-msvideo" false - | ".axs" -> Writers.createMimeType "application/olescript" false - | ".bas" -> Writers.createMimeType "text/plain" false - | ".bcpio" -> Writers.createMimeType "application/x-bcpio" false - | ".bin" -> Writers.createMimeType "application/octet-stream" false - | ".bmp" -> Writers.createMimeType "image/bmp" false - | ".c" -> Writers.createMimeType "text/plain" false - | ".cab" -> Writers.createMimeType "application/vnd.ms-cab-compressed" false - | ".calx" -> Writers.createMimeType "application/vnd.ms-office.calx" false - | ".cat" -> Writers.createMimeType "application/vnd.ms-pki.seccat" false - | ".cdf" -> Writers.createMimeType "application/x-cdf" false - | ".chm" -> Writers.createMimeType "application/octet-stream" false - | ".class" -> Writers.createMimeType "application/x-java-applet" false - | ".clp" -> Writers.createMimeType "application/x-msclip" false - | ".cmx" -> Writers.createMimeType "image/x-cmx" false - | ".cnf" -> Writers.createMimeType "text/plain" false - | ".cod" -> Writers.createMimeType "image/cis-cod" false - | ".cpio" -> Writers.createMimeType "application/x-cpio" false - | ".cpp" -> Writers.createMimeType "text/plain" false - | ".crd" -> Writers.createMimeType "application/x-mscardfile" false - | ".crl" -> Writers.createMimeType "application/pkix-crl" false - | ".crt" -> Writers.createMimeType "application/x-x509-ca-cert" false - | ".csh" -> Writers.createMimeType "application/x-csh" false - | ".css" -> Writers.createMimeType "text/css" false - | ".csv" -> Writers.createMimeType "text/csv" false - | ".cur" -> Writers.createMimeType "application/octet-stream" false - | ".dcr" -> Writers.createMimeType "application/x-director" false - | ".deploy" -> Writers.createMimeType "application/octet-stream" false - | ".der" -> Writers.createMimeType "application/x-x509-ca-cert" false - | ".dib" -> Writers.createMimeType "image/bmp" false - | ".dir" -> Writers.createMimeType "application/x-director" false - | ".disco" -> Writers.createMimeType "text/xml" false - | ".dlm" -> Writers.createMimeType "text/dlm" false - | ".doc" -> Writers.createMimeType "application/msword" false - | ".docm" -> Writers.createMimeType "application/vnd.ms-word.document.macroEnabled.12" false - | ".docx" -> - Writers.createMimeType "application/vnd.openxmlformats-officedocument.wordprocessingml.document" false - | ".dot" -> Writers.createMimeType "application/msword" false - | ".dotm" -> Writers.createMimeType "application/vnd.ms-word.template.macroEnabled.12" false - | ".dotx" -> - Writers.createMimeType "application/vnd.openxmlformats-officedocument.wordprocessingml.template" false - | ".dsp" -> Writers.createMimeType "application/octet-stream" false - | ".dtd" -> Writers.createMimeType "text/xml" false - | ".dvi" -> Writers.createMimeType "application/x-dvi" false - | ".dvr-ms" -> Writers.createMimeType "video/x-ms-dvr" false - | ".dwf" -> Writers.createMimeType "drawing/x-dwf" false - | ".dwp" -> Writers.createMimeType "application/octet-stream" false - | ".dxr" -> Writers.createMimeType "application/x-director" false - | ".eml" -> Writers.createMimeType "message/rfc822" false - | ".emz" -> Writers.createMimeType "application/octet-stream" false - | ".eot" -> Writers.createMimeType "application/vnd.ms-fontobject" false - | ".eps" -> Writers.createMimeType "application/postscript" false - | ".etx" -> Writers.createMimeType "text/x-setext" false - | ".evy" -> Writers.createMimeType "application/envoy" false - | ".exe" -> Writers.createMimeType "application/vnd.microsoft.portable-executable" false - | ".fdf" -> Writers.createMimeType "application/vnd.fdf" false - | ".fif" -> Writers.createMimeType "application/fractals" false - | ".fla" -> Writers.createMimeType "application/octet-stream" false - | ".flr" -> Writers.createMimeType "x-world/x-vrml" false - | ".flv" -> Writers.createMimeType "video/x-flv" false - | ".gif" -> Writers.createMimeType "image/gif" false - | ".gtar" -> Writers.createMimeType "application/x-gtar" false - | ".gz" -> Writers.createMimeType "application/x-gzip" false - | ".h" -> Writers.createMimeType "text/plain" false - | ".hdf" -> Writers.createMimeType "application/x-hdf" false - | ".hdml" -> Writers.createMimeType "text/x-hdml" false - | ".hhc" -> Writers.createMimeType "application/x-oleobject" false - | ".hhk" -> Writers.createMimeType "application/octet-stream" false - | ".hhp" -> Writers.createMimeType "application/octet-stream" false - | ".hlp" -> Writers.createMimeType "application/winhlp" false - | ".hqx" -> Writers.createMimeType "application/mac-binhex40" false - | ".hta" -> Writers.createMimeType "application/hta" false - | ".htc" -> Writers.createMimeType "text/x-component" false - | ".htm" -> Writers.createMimeType "text/html" false - | ".html" -> Writers.createMimeType "text/html" false - | ".htt" -> Writers.createMimeType "text/webviewhtml" false - | ".hxt" -> Writers.createMimeType "text/html" false - | ".ical" -> Writers.createMimeType "text/calendar" false - | ".icalendar" -> Writers.createMimeType "text/calendar" false - | ".ico" -> Writers.createMimeType "image/x-icon" false - | ".ics" -> Writers.createMimeType "text/calendar" false - | ".ief" -> Writers.createMimeType "image/ief" false - | ".ifb" -> Writers.createMimeType "text/calendar" false - | ".iii" -> Writers.createMimeType "application/x-iphone" false - | ".inf" -> Writers.createMimeType "application/octet-stream" false - | ".ins" -> Writers.createMimeType "application/x-internet-signup" false - | ".isp" -> Writers.createMimeType "application/x-internet-signup" false - | ".IVF" -> Writers.createMimeType "video/x-ivf" false - | ".jar" -> Writers.createMimeType "application/java-archive" false - | ".java" -> Writers.createMimeType "application/octet-stream" false - | ".jck" -> Writers.createMimeType "application/liquidmotion" false - | ".jcz" -> Writers.createMimeType "application/liquidmotion" false - | ".jfif" -> Writers.createMimeType "image/pjpeg" false - | ".jpb" -> Writers.createMimeType "application/octet-stream" false - | ".jpe" -> Writers.createMimeType "image/jpeg" false - | ".jpeg" -> Writers.createMimeType "image/jpeg" false - | ".jpg" -> Writers.createMimeType "image/jpeg" false - | ".js" -> Writers.createMimeType "text/javascript" false - | ".json" -> Writers.createMimeType "application/json" false - | ".jsx" -> Writers.createMimeType "text/jscript" false - | ".latex" -> Writers.createMimeType "application/x-latex" false - | ".lit" -> Writers.createMimeType "application/x-ms-reader" false - | ".lpk" -> Writers.createMimeType "application/octet-stream" false - | ".lsf" -> Writers.createMimeType "video/x-la-asf" false - | ".lsx" -> Writers.createMimeType "video/x-la-asf" false - | ".lzh" -> Writers.createMimeType "application/octet-stream" false - | ".m13" -> Writers.createMimeType "application/x-msmediaview" false - | ".m14" -> Writers.createMimeType "application/x-msmediaview" false - | ".m1v" -> Writers.createMimeType "video/mpeg" false - | ".m2ts" -> Writers.createMimeType "video/vnd.dlna.mpeg-tts" false - | ".m3u" -> Writers.createMimeType "audio/x-mpegurl" false - | ".m4a" -> Writers.createMimeType "audio/mp4" false - | ".m4v" -> Writers.createMimeType "video/mp4" false - | ".man" -> Writers.createMimeType "application/x-troff-man" false - | ".manifest" -> Writers.createMimeType "application/x-ms-manifest" false - | ".map" -> Writers.createMimeType "text/plain" false - | ".markdown" -> Writers.createMimeType "text/markdown" false - | ".md" -> Writers.createMimeType "text/markdown" false - | ".mdb" -> Writers.createMimeType "application/x-msaccess" false - | ".mdp" -> Writers.createMimeType "application/octet-stream" false - | ".me" -> Writers.createMimeType "application/x-troff-me" false - | ".mht" -> Writers.createMimeType "message/rfc822" false - | ".mhtml" -> Writers.createMimeType "message/rfc822" false - | ".mid" -> Writers.createMimeType "audio/mid" false - | ".midi" -> Writers.createMimeType "audio/mid" false - | ".mix" -> Writers.createMimeType "application/octet-stream" false - | ".mjs" -> Writers.createMimeType "text/javascript" false - | ".mmf" -> Writers.createMimeType "application/x-smaf" false - | ".mno" -> Writers.createMimeType "text/xml" false - | ".mny" -> Writers.createMimeType "application/x-msmoney" false - | ".mov" -> Writers.createMimeType "video/quicktime" false - | ".movie" -> Writers.createMimeType "video/x-sgi-movie" false - | ".mp2" -> Writers.createMimeType "video/mpeg" false - | ".mp3" -> Writers.createMimeType "audio/mpeg" false - | ".mp4" -> Writers.createMimeType "video/mp4" false - | ".mp4v" -> Writers.createMimeType "video/mp4" false - | ".mpa" -> Writers.createMimeType "video/mpeg" false - | ".mpe" -> Writers.createMimeType "video/mpeg" false - | ".mpeg" -> Writers.createMimeType "video/mpeg" false - | ".mpg" -> Writers.createMimeType "video/mpeg" false - | ".mpp" -> Writers.createMimeType "application/vnd.ms-project" false - | ".mpv2" -> Writers.createMimeType "video/mpeg" false - | ".ms" -> Writers.createMimeType "application/x-troff-ms" false - | ".msi" -> Writers.createMimeType "application/octet-stream" false - | ".mso" -> Writers.createMimeType "application/octet-stream" false - | ".mvb" -> Writers.createMimeType "application/x-msmediaview" false - | ".mvc" -> Writers.createMimeType "application/x-miva-compiled" false - | ".nc" -> Writers.createMimeType "application/x-netcdf" false - | ".nsc" -> Writers.createMimeType "video/x-ms-asf" false - | ".nws" -> Writers.createMimeType "message/rfc822" false - | ".ocx" -> Writers.createMimeType "application/octet-stream" false - | ".oda" -> Writers.createMimeType "application/oda" false - | ".odc" -> Writers.createMimeType "text/x-ms-odc" false - | ".ods" -> Writers.createMimeType "application/oleobject" false - | ".oga" -> Writers.createMimeType "audio/ogg" false - | ".ogg" -> Writers.createMimeType "video/ogg" false - | ".ogv" -> Writers.createMimeType "video/ogg" false - | ".ogx" -> Writers.createMimeType "application/ogg" false - | ".one" -> Writers.createMimeType "application/onenote" false - | ".onea" -> Writers.createMimeType "application/onenote" false - | ".onetoc" -> Writers.createMimeType "application/onenote" false - | ".onetoc2" -> Writers.createMimeType "application/onenote" false - | ".onetmp" -> Writers.createMimeType "application/onenote" false - | ".onepkg" -> Writers.createMimeType "application/onenote" false - | ".osdx" -> Writers.createMimeType "application/opensearchdescription+xml" false - | ".otf" -> Writers.createMimeType "font/otf" false - | ".p10" -> Writers.createMimeType "application/pkcs10" false - | ".p12" -> Writers.createMimeType "application/x-pkcs12" false - | ".p7b" -> Writers.createMimeType "application/x-pkcs7-certificates" false - | ".p7c" -> Writers.createMimeType "application/pkcs7-mime" false - | ".p7m" -> Writers.createMimeType "application/pkcs7-mime" false - | ".p7r" -> Writers.createMimeType "application/x-pkcs7-certreqresp" false - | ".p7s" -> Writers.createMimeType "application/pkcs7-signature" false - | ".pbm" -> Writers.createMimeType "image/x-portable-bitmap" false - | ".pcx" -> Writers.createMimeType "application/octet-stream" false - | ".pcz" -> Writers.createMimeType "application/octet-stream" false - | ".pdf" -> Writers.createMimeType "application/pdf" false - | ".pfb" -> Writers.createMimeType "application/octet-stream" false - | ".pfm" -> Writers.createMimeType "application/octet-stream" false - | ".pfx" -> Writers.createMimeType "application/x-pkcs12" false - | ".pgm" -> Writers.createMimeType "image/x-portable-graymap" false - | ".pko" -> Writers.createMimeType "application/vnd.ms-pki.pko" false - | ".pma" -> Writers.createMimeType "application/x-perfmon" false - | ".pmc" -> Writers.createMimeType "application/x-perfmon" false - | ".pml" -> Writers.createMimeType "application/x-perfmon" false - | ".pmr" -> Writers.createMimeType "application/x-perfmon" false - | ".pmw" -> Writers.createMimeType "application/x-perfmon" false - | ".png" -> Writers.createMimeType "image/png" false - | ".pnm" -> Writers.createMimeType "image/x-portable-anymap" false - | ".pnz" -> Writers.createMimeType "image/png" false - | ".pot" -> Writers.createMimeType "application/vnd.ms-powerpoint" false - | ".potm" -> Writers.createMimeType "application/vnd.ms-powerpoint.template.macroEnabled.12" false - | ".potx" -> - Writers.createMimeType "application/vnd.openxmlformats-officedocument.presentationml.template" false - | ".ppam" -> Writers.createMimeType "application/vnd.ms-powerpoint.addin.macroEnabled.12" false - | ".ppm" -> Writers.createMimeType "image/x-portable-pixmap" false - | ".pps" -> Writers.createMimeType "application/vnd.ms-powerpoint" false - | ".ppsm" -> Writers.createMimeType "application/vnd.ms-powerpoint.slideshow.macroEnabled.12" false - | ".ppsx" -> - Writers.createMimeType "application/vnd.openxmlformats-officedocument.presentationml.slideshow" false - | ".ppt" -> Writers.createMimeType "application/vnd.ms-powerpoint" false - | ".pptm" -> Writers.createMimeType "application/vnd.ms-powerpoint.presentation.macroEnabled.12" false - | ".pptx" -> - Writers.createMimeType "application/vnd.openxmlformats-officedocument.presentationml.presentation" false - | ".prf" -> Writers.createMimeType "application/pics-rules" false - | ".prm" -> Writers.createMimeType "application/octet-stream" false - | ".prx" -> Writers.createMimeType "application/octet-stream" false - | ".ps" -> Writers.createMimeType "application/postscript" false - | ".psd" -> Writers.createMimeType "application/octet-stream" false - | ".psm" -> Writers.createMimeType "application/octet-stream" false - | ".psp" -> Writers.createMimeType "application/octet-stream" false - | ".pub" -> Writers.createMimeType "application/x-mspublisher" false - | ".qt" -> Writers.createMimeType "video/quicktime" false - | ".qtl" -> Writers.createMimeType "application/x-quicktimeplayer" false - | ".qxd" -> Writers.createMimeType "application/octet-stream" false - | ".ra" -> Writers.createMimeType "audio/x-pn-realaudio" false - | ".ram" -> Writers.createMimeType "audio/x-pn-realaudio" false - | ".rar" -> Writers.createMimeType "application/octet-stream" false - | ".ras" -> Writers.createMimeType "image/x-cmu-raster" false - | ".rf" -> Writers.createMimeType "image/vnd.rn-realflash" false - | ".rgb" -> Writers.createMimeType "image/x-rgb" false - | ".rm" -> Writers.createMimeType "application/vnd.rn-realmedia" false - | ".rmi" -> Writers.createMimeType "audio/mid" false - | ".roff" -> Writers.createMimeType "application/x-troff" false - | ".rpm" -> Writers.createMimeType "audio/x-pn-realaudio-plugin" false - | ".rtf" -> Writers.createMimeType "application/rtf" false - | ".rtx" -> Writers.createMimeType "text/richtext" false - | ".scd" -> Writers.createMimeType "application/x-msschedule" false - | ".sct" -> Writers.createMimeType "text/scriptlet" false - | ".sea" -> Writers.createMimeType "application/octet-stream" false - | ".setpay" -> Writers.createMimeType "application/set-payment-initiation" false - | ".setreg" -> Writers.createMimeType "application/set-registration-initiation" false - | ".sgml" -> Writers.createMimeType "text/sgml" false - | ".sh" -> Writers.createMimeType "application/x-sh" false - | ".shar" -> Writers.createMimeType "application/x-shar" false - | ".sit" -> Writers.createMimeType "application/x-stuffit" false - | ".sldm" -> Writers.createMimeType "application/vnd.ms-powerpoint.slide.macroEnabled.12" false - | ".sldx" -> - Writers.createMimeType "application/vnd.openxmlformats-officedocument.presentationml.slide" false - | ".smd" -> Writers.createMimeType "audio/x-smd" false - | ".smi" -> Writers.createMimeType "application/octet-stream" false - | ".smx" -> Writers.createMimeType "audio/x-smd" false - | ".smz" -> Writers.createMimeType "audio/x-smd" false - | ".snd" -> Writers.createMimeType "audio/basic" false - | ".snp" -> Writers.createMimeType "application/octet-stream" false - | ".spc" -> Writers.createMimeType "application/x-pkcs7-certificates" false - | ".spl" -> Writers.createMimeType "application/futuresplash" false - | ".spx" -> Writers.createMimeType "audio/ogg" false - | ".src" -> Writers.createMimeType "application/x-wais-source" false - | ".ssm" -> Writers.createMimeType "application/streamingmedia" false - | ".sst" -> Writers.createMimeType "application/vnd.ms-pki.certstore" false - | ".stl" -> Writers.createMimeType "application/vnd.ms-pki.stl" false - | ".sv4cpio" -> Writers.createMimeType "application/x-sv4cpio" false - | ".sv4crc" -> Writers.createMimeType "application/x-sv4crc" false - | ".svg" -> Writers.createMimeType "image/svg+xml" false - | ".svgz" -> Writers.createMimeType "image/svg+xml" false - | ".swf" -> Writers.createMimeType "application/x-shockwave-flash" false - | ".t" -> Writers.createMimeType "application/x-troff" false - | ".tar" -> Writers.createMimeType "application/x-tar" false - | ".tcl" -> Writers.createMimeType "application/x-tcl" false - | ".tex" -> Writers.createMimeType "application/x-tex" false - | ".texi" -> Writers.createMimeType "application/x-texinfo" false - | ".texinfo" -> Writers.createMimeType "application/x-texinfo" false - | ".tgz" -> Writers.createMimeType "application/x-compressed" false - | ".thmx" -> Writers.createMimeType "application/vnd.ms-officetheme" false - | ".thn" -> Writers.createMimeType "application/octet-stream" false - | ".tif" -> Writers.createMimeType "image/tiff" false - | ".tiff" -> Writers.createMimeType "image/tiff" false - | ".toc" -> Writers.createMimeType "application/octet-stream" false - | ".tr" -> Writers.createMimeType "application/x-troff" false - | ".trm" -> Writers.createMimeType "application/x-msterminal" false - | ".ts" -> Writers.createMimeType "video/vnd.dlna.mpeg-tts" false - | ".tsv" -> Writers.createMimeType "text/tab-separated-values" false - | ".ttc" -> Writers.createMimeType "application/x-font-ttf" false - | ".ttf" -> Writers.createMimeType "application/x-font-ttf" false - | ".tts" -> Writers.createMimeType "video/vnd.dlna.mpeg-tts" false - | ".txt" -> Writers.createMimeType "text/plain" false - | ".u32" -> Writers.createMimeType "application/octet-stream" false - | ".uls" -> Writers.createMimeType "text/iuls" false - | ".ustar" -> Writers.createMimeType "application/x-ustar" false - | ".vbs" -> Writers.createMimeType "text/vbscript" false - | ".vcf" -> Writers.createMimeType "text/x-vcard" false - | ".vcs" -> Writers.createMimeType "text/plain" false - | ".vdx" -> Writers.createMimeType "application/vnd.ms-visio.viewer" false - | ".vml" -> Writers.createMimeType "text/xml" false - | ".vsd" -> Writers.createMimeType "application/vnd.visio" false - | ".vss" -> Writers.createMimeType "application/vnd.visio" false - | ".vst" -> Writers.createMimeType "application/vnd.visio" false - | ".vsto" -> Writers.createMimeType "application/x-ms-vsto" false - | ".vsw" -> Writers.createMimeType "application/vnd.visio" false - | ".vsx" -> Writers.createMimeType "application/vnd.visio" false - | ".vtx" -> Writers.createMimeType "application/vnd.visio" false - | ".wasm" -> Writers.createMimeType "application/wasm" false - | ".wav" -> Writers.createMimeType "audio/wav" false - | ".wax" -> Writers.createMimeType "audio/x-ms-wax" false - | ".wbmp" -> Writers.createMimeType "image/vnd.wap.wbmp" false - | ".wcm" -> Writers.createMimeType "application/vnd.ms-works" false - | ".wdb" -> Writers.createMimeType "application/vnd.ms-works" false - | ".webm" -> Writers.createMimeType "video/webm" false - | ".webmanifest" -> Writers.createMimeType "application/manifest+json" false - | ".webp" -> Writers.createMimeType "image/webp" false - | ".wks" -> Writers.createMimeType "application/vnd.ms-works" false - | ".wm" -> Writers.createMimeType "video/x-ms-wm" false - | ".wma" -> Writers.createMimeType "audio/x-ms-wma" false - | ".wmd" -> Writers.createMimeType "application/x-ms-wmd" false - | ".wmf" -> Writers.createMimeType "application/x-msmetafile" false - | ".wml" -> Writers.createMimeType "text/vnd.wap.wml" false - | ".wmlc" -> Writers.createMimeType "application/vnd.wap.wmlc" false - | ".wmls" -> Writers.createMimeType "text/vnd.wap.wmlscript" false - | ".wmlsc" -> Writers.createMimeType "application/vnd.wap.wmlscriptc" false - | ".wmp" -> Writers.createMimeType "video/x-ms-wmp" false - | ".wmv" -> Writers.createMimeType "video/x-ms-wmv" false - | ".wmx" -> Writers.createMimeType "video/x-ms-wmx" false - | ".wmz" -> Writers.createMimeType "application/x-ms-wmz" false - | ".woff" -> Writers.createMimeType "application/font-woff" false - | ".woff2" -> Writers.createMimeType "font/woff2" false - | ".wps" -> Writers.createMimeType "application/vnd.ms-works" false - | ".wri" -> Writers.createMimeType "application/x-mswrite" false - | ".wrl" -> Writers.createMimeType "x-world/x-vrml" false - | ".wrz" -> Writers.createMimeType "x-world/x-vrml" false - | ".wsdl" -> Writers.createMimeType "text/xml" false - | ".wtv" -> Writers.createMimeType "video/x-ms-wtv" false - | ".wvx" -> Writers.createMimeType "video/x-ms-wvx" false - | ".x" -> Writers.createMimeType "application/directx" false - | ".xaf" -> Writers.createMimeType "x-world/x-vrml" false - | ".xaml" -> Writers.createMimeType "application/xaml+xml" false - | ".xap" -> Writers.createMimeType "application/x-silverlight-app" false - | ".xbap" -> Writers.createMimeType "application/x-ms-xbap" false - | ".xbm" -> Writers.createMimeType "image/x-xbitmap" false - | ".xdr" -> Writers.createMimeType "text/plain" false - | ".xht" -> Writers.createMimeType "application/xhtml+xml" false - | ".xhtml" -> Writers.createMimeType "application/xhtml+xml" false - | ".xla" -> Writers.createMimeType "application/vnd.ms-excel" false - | ".xlam" -> Writers.createMimeType "application/vnd.ms-excel.addin.macroEnabled.12" false - | ".xlc" -> Writers.createMimeType "application/vnd.ms-excel" false - | ".xlm" -> Writers.createMimeType "application/vnd.ms-excel" false - | ".xls" -> Writers.createMimeType "application/vnd.ms-excel" false - | ".xlsb" -> Writers.createMimeType "application/vnd.ms-excel.sheet.binary.macroEnabled.12" false - | ".xlsm" -> Writers.createMimeType "application/vnd.ms-excel.sheet.macroEnabled.12" false - | ".xlsx" -> - Writers.createMimeType "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" false - | ".xlt" -> Writers.createMimeType "application/vnd.ms-excel" false - | ".xltm" -> Writers.createMimeType "application/vnd.ms-excel.template.macroEnabled.12" false - | ".xltx" -> - Writers.createMimeType "application/vnd.openxmlformats-officedocument.spreadsheetml.template" false - | ".xlw" -> Writers.createMimeType "application/vnd.ms-excel" false - | ".xml" -> Writers.createMimeType "text/xml" false - | ".xof" -> Writers.createMimeType "x-world/x-vrml" false - | ".xpm" -> Writers.createMimeType "image/x-xpixmap" false - | ".xps" -> Writers.createMimeType "application/vnd.ms-xpsdocument" false - | ".xsd" -> Writers.createMimeType "text/xml" false - | ".xsf" -> Writers.createMimeType "text/xml" false - | ".xsl" -> Writers.createMimeType "text/xml" false - | ".xslt" -> Writers.createMimeType "text/xml" false - | ".xsn" -> Writers.createMimeType "application/octet-stream" false - | ".xtp" -> Writers.createMimeType "application/octet-stream" false - | ".xwd" -> Writers.createMimeType "image/x-xwindowdump" false - | ".z" -> Writers.createMimeType "application/x-compress" false - | ".zip" -> Writers.createMimeType "application/x-zip-compressed" false - | _ -> None - - let defaultBinding = defaultConfig.bindings.[0] - - let withPort = - { defaultBinding.socketBinding with - port = uint16 localPort } - - let serverConfig = - { defaultConfig with - bindings = - [ { defaultBinding with - socketBinding = withPort } ] - homeFolder = Some rootOutputFolderAsGiven - mimeTypesMap = mimeTypesMap } - - let app = - choose - [ path "/" >=> Redirection.redirect "/index.html" - path "/websocket" >=> handShake socketHandler - Writers.setHeader "Cache-Control" "no-cache, no-store, must-revalidate" - >=> Writers.setHeader "Pragma" "no-cache" - >=> Writers.setHeader "Expires" "0" - >=> Files.browseHome ] - - startWebServerAsync serverConfig app |> snd |> Async.Start - type CoreBuildOptions(watch) = [] diff --git a/src/fsdocs-tool/DocContent.fs b/src/fsdocs-tool/DocContent.fs new file mode 100644 index 000000000..101b17d4d --- /dev/null +++ b/src/fsdocs-tool/DocContent.fs @@ -0,0 +1,794 @@ +namespace fsdocs + +open System.Collections.Concurrent +open CommandLine + +open System +open System.Diagnostics +open System.IO +open System.Globalization +open System.Net +open System.Reflection +open System.Text + +open FSharp.Formatting.Common +open FSharp.Formatting.HtmlModel +open FSharp.Formatting.HtmlModel.Html +open FSharp.Formatting.Literate +open FSharp.Formatting.ApiDocs +open FSharp.Formatting.Literate.Evaluation +open fsdocs.Common +open FSharp.Formatting.Templating + +open Suave +open Suave.Sockets +open Suave.Sockets.Control +open Suave.WebSocket +open Suave.Operators +open Suave.Filters +open Suave.Logging +open FSharp.Formatting.Markdown + +#nowarn "44" // Obsolete WebClient + +/// Convert markdown, script and other content into a static site +type internal DocContent + ( + rootOutputFolderAsGiven, + previous: Map<_, _>, + lineNumbers, + evaluate, + substitutions, + saveImages, + watch, + root, + crefResolver, + onError + ) = + + let createImageSaver (rootOutputFolderAsGiven) = + // Download images so that they can be embedded + let wc = new WebClient() + let mutable counter = 0 + + fun (url: string) -> + if + url.StartsWith("http", StringComparison.Ordinal) + || url.StartsWith("https", StringComparison.Ordinal) + then + counter <- counter + 1 + let ext = Path.GetExtension(url) + + let url2 = sprintf "savedimages/saved%d%s" counter ext + + let fn = sprintf "%s/%s" rootOutputFolderAsGiven url2 + + ensureDirectory (sprintf "%s/savedimages" rootOutputFolderAsGiven) + printfn "downloading %s --> %s" url fn + wc.DownloadFile(url, fn) + url2 + else + url + + let getOutputFileNames (inputFileFullPath: string) (outputKind: OutputKind) outputFolderRelativeToRoot = + let inputFileName = Path.GetFileName(inputFileFullPath) + let isFsx = inputFileFullPath.EndsWith(".fsx", true, CultureInfo.InvariantCulture) + let isMd = inputFileFullPath.EndsWith(".md", true, CultureInfo.InvariantCulture) + let isPynb = inputFileFullPath.EndsWith(".ipynb", true, CultureInfo.InvariantCulture) + let ext = outputKind.Extension + + let outputFileRelativeToRoot = + if isFsx || isMd || isPynb then + let basename = Path.GetFileNameWithoutExtension(inputFileFullPath) + + Path.Combine(outputFolderRelativeToRoot, sprintf "%s.%s" basename ext) + else + Path.Combine(outputFolderRelativeToRoot, inputFileName) + + let outputFileFullPath = Path.GetFullPath(Path.Combine(rootOutputFolderAsGiven, outputFileRelativeToRoot)) + outputFileRelativeToRoot, outputFileFullPath + + // Check if a sub-folder is actually the output directory + let subFolderIsOutput subInputFolderFullPath = + let subFolderFullPath = Path.GetFullPath(subInputFolderFullPath) + let rootOutputFolderFullPath = Path.GetFullPath(rootOutputFolderAsGiven) + (subFolderFullPath = rootOutputFolderFullPath) + + let allCultures = + CultureInfo.GetCultures(CultureTypes.AllCultures) + |> Array.choose (fun x -> + if x.TwoLetterISOLanguageName.Length <> 2 then + None + else + Some x.TwoLetterISOLanguageName) + |> Array.distinct + + let makeMarkdownLinkResolver + (inputFolderAsGiven, outputFolderRelativeToRoot, fullPathFileMap: Map<(string * OutputKind), string>, outputKind) + (markdownReference: string) + = + let markdownReferenceAsFullInputPathOpt = + try + Path.GetFullPath(Path.Combine(inputFolderAsGiven, markdownReference)) |> Some + with _ -> + None + + match markdownReferenceAsFullInputPathOpt with + | None -> None + | Some markdownReferenceFullInputPath -> + match fullPathFileMap.TryFind(markdownReferenceFullInputPath, outputKind) with + | None -> None + | Some markdownReferenceFullOutputPath -> + try + let outputFolderFullPath = + Path.GetFullPath(Path.Combine(rootOutputFolderAsGiven, outputFolderRelativeToRoot)) + + let uri = + Uri(outputFolderFullPath + "/").MakeRelativeUri(Uri(markdownReferenceFullOutputPath)).ToString() + + Some uri + with _ -> + printfn + $"Couldn't map markdown reference %s{markdownReference} that seemed to correspond to an input file" + + None + + /// Prepare the map of input file to output file. This map is used to make substitutions through markdown + /// source such A.md --> A.html or A.fsx --> A.html. The substitutions depend on the output kind. + let prepFile (inputFileFullPath: string) (outputKind: OutputKind) outputFolderRelativeToRoot = + [ let inputFileName = Path.GetFileName(inputFileFullPath) + + if + not (inputFileName.StartsWith('.')) + && not (inputFileName.StartsWith("_template", StringComparison.Ordinal)) + then + let inputFileFullPath = Path.GetFullPath(inputFileFullPath) + + let _relativeOutputFile, outputFileFullPath = + getOutputFileNames inputFileFullPath outputKind outputFolderRelativeToRoot + + yield ((inputFileFullPath, outputKind), outputFileFullPath) ] + + /// Likewise prepare the map of input files to output files + let rec prepFolder (inputFolderAsGiven: string) outputFolderRelativeToRoot = + [ let inputs = Directory.GetFiles(inputFolderAsGiven, "*") + + for input in inputs do + yield! prepFile input OutputKind.Html outputFolderRelativeToRoot + yield! prepFile input OutputKind.Latex outputFolderRelativeToRoot + yield! prepFile input OutputKind.Pynb outputFolderRelativeToRoot + yield! prepFile input OutputKind.Fsx outputFolderRelativeToRoot + yield! prepFile input OutputKind.Markdown outputFolderRelativeToRoot + + for subInputFolderFullPath in Directory.EnumerateDirectories(inputFolderAsGiven) do + let subInputFolderName = Path.GetFileName(subInputFolderFullPath) + let subFolderIsSkipped = subInputFolderName.StartsWith '.' + let subFolderIsOutput = subFolderIsOutput subInputFolderFullPath + + if not subFolderIsOutput && not subFolderIsSkipped then + yield! + prepFolder + (Path.Combine(inputFolderAsGiven, subInputFolderName)) + (Path.Combine(outputFolderRelativeToRoot, subInputFolderName)) ] + + let processFile + rootInputFolder + (isOtherLang: bool) + (inputFileFullPath: string) + outputKind + template + outputFolderRelativeToRoot + imageSaver + mdlinkResolver + (filesWithFrontMatter: FrontMatterFile array) + = + [ let name = Path.GetFileName(inputFileFullPath) + + if name.StartsWith('.') then + printfn "skipping file %s" inputFileFullPath + elif not (name.StartsWith("_template", StringComparison.Ordinal)) then + let isFsx = inputFileFullPath.EndsWith(".fsx", StringComparison.OrdinalIgnoreCase) + + let isMd = inputFileFullPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + + let isPynb = inputFileFullPath.EndsWith(".ipynb", StringComparison.OrdinalIgnoreCase) + + // A _template.tex or _template.pynb is needed to generate those files + match outputKind, template with + | OutputKind.Pynb, None -> () + | OutputKind.Latex, None -> () + | OutputKind.Fsx, None -> () + | OutputKind.Markdown, None -> () + | _ -> + + let imageSaverOpt = + match outputKind with + | OutputKind.Pynb when saveImages <> Some false -> Some imageSaver + | OutputKind.Latex when saveImages <> Some false -> Some imageSaver + | OutputKind.Fsx when saveImages = Some true -> Some imageSaver + | OutputKind.Html when saveImages = Some true -> Some imageSaver + | OutputKind.Markdown when saveImages = Some true -> Some imageSaver + | _ -> None + + let outputFileRelativeToRoot, outputFileFullPath = + getOutputFileNames inputFileFullPath outputKind outputFolderRelativeToRoot + + // Update only when needed - template or file or tool has changed + + let changed = + let fileChangeTime = + try + File.GetLastWriteTime(inputFileFullPath) + with _ -> + DateTime.MaxValue + + let templateChangeTime = + match template with + | Some t when isFsx || isMd || isPynb -> + try + let fi = FileInfo(t) + let input = fi.Directory.Name + let headPath = Path.Combine(input, "_head.html") + let bodyPath = Path.Combine(input, "_body.html") + + [ yield File.GetLastWriteTime(t) + if Menu.isTemplatingAvailable input then + yield! Menu.getLastWriteTimes input + if File.Exists headPath then + yield File.GetLastWriteTime headPath + if File.Exists bodyPath then + yield File.GetLastWriteTime bodyPath ] + |> List.max + with _ -> + DateTime.MaxValue + | _ -> DateTime.MinValue + + let toolChangeTime = + try + File.GetLastWriteTime(Assembly.GetExecutingAssembly().Location) + with _ -> + DateTime.MaxValue + + let changeTime = fileChangeTime |> max templateChangeTime |> max toolChangeTime + + let generateTime = + try + File.GetLastWriteTime(outputFileFullPath) + with _ -> + System.DateTime.MinValue + + changeTime > generateTime + + // If it's changed or we don't know anything about it + // we have to compute the model to get the global substitutions right + let mainRun = (outputKind = OutputKind.Html) + let haveModel = previous.TryFind inputFileFullPath + + if changed || (watch && mainRun && haveModel.IsNone) then + if isFsx then + printfn " generating model for %s --> %s" inputFileFullPath outputFileRelativeToRoot + + let fsiEvaluator = + (if evaluate then + Some( + FsiEvaluator(onError = onError, options = [| "--multiemit-" |]) :> IFsiEvaluator + ) + else + None) + + let model = + Literate.ParseAndTransformScriptFile( + inputFileFullPath, + output = outputFileRelativeToRoot, + outputKind = outputKind, + prefix = None, + fscOptions = None, + lineNumbers = lineNumbers, + references = Some false, + fsiEvaluator = fsiEvaluator, + substitutions = substitutions, + generateAnchors = Some true, + imageSaver = imageSaverOpt, + rootInputFolder = rootInputFolder, + crefResolver = crefResolver, + mdlinkResolver = mdlinkResolver, + onError = Some onError, + filesWithFrontMatter = filesWithFrontMatter + ) + + yield + ((if mainRun then + Some(inputFileFullPath, isOtherLang, model) + else + None), + (fun p -> + printfn " writing %s --> %s" inputFileFullPath outputFileRelativeToRoot + ensureDirectory (Path.GetDirectoryName(outputFileFullPath)) + + SimpleTemplating.UseFileAsSimpleTemplate( + p @ model.Substitutions, + template, + outputFileFullPath + ))) + + elif isMd then + printfn " preparing %s --> %s" inputFileFullPath outputFileRelativeToRoot + + let model = + Literate.ParseAndTransformMarkdownFile( + inputFileFullPath, + output = outputFileRelativeToRoot, + outputKind = outputKind, + prefix = None, + fscOptions = None, + lineNumbers = lineNumbers, + references = Some false, + substitutions = substitutions, + generateAnchors = Some true, + imageSaver = imageSaverOpt, + rootInputFolder = rootInputFolder, + crefResolver = crefResolver, + mdlinkResolver = mdlinkResolver, + parseOptions = MarkdownParseOptions.AllowYamlFrontMatter, + onError = Some onError, + filesWithFrontMatter = filesWithFrontMatter + ) + + yield + ((if mainRun then + Some(inputFileFullPath, isOtherLang, model) + else + None), + (fun p -> + printfn " writing %s --> %s" inputFileFullPath outputFileRelativeToRoot + ensureDirectory (Path.GetDirectoryName(outputFileFullPath)) + + SimpleTemplating.UseFileAsSimpleTemplate( + p @ model.Substitutions, + template, + outputFileFullPath + ))) + elif isPynb then + printfn " preparing %s --> %s" inputFileFullPath outputFileRelativeToRoot + + let evaluateNotebook ipynbFile = + let args = + $"repl --run %s{ipynbFile} --default-kernel fsharp --exit-after-run --output-path %s{ipynbFile}" + + let psi = + ProcessStartInfo( + fileName = "dotnet", + arguments = args, + UseShellExecute = false, + CreateNoWindow = true + ) + + try + let p = Process.Start(psi) + p.WaitForExit() + with _ -> + let msg = + $"Failed to evaluate notebook %s{ipynbFile} using dotnet-repl\n" + + $"""try running "%s{args}" at the command line and inspect the error""" + + failwith msg + + let checkDotnetReplInstall () = + let failmsg = + "'dotnet-repl' is not installed. Please install it using 'dotnet tool install dotnet-repl'" + + try + let psi = + ProcessStartInfo( + fileName = "dotnet", + arguments = "tool list --local", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true + ) + + let p = Process.Start(psi) + let ol = p.StandardOutput.ReadToEnd() + p.WaitForExit() + psi.Arguments <- "tool list --global" + p.Start() |> ignore + let og = p.StandardOutput.ReadToEnd() + let output = $"%s{ol}\n%s{og}" + + if not (output.Contains("dotnet-repl")) then + failwith failmsg + + p.WaitForExit() + with _ -> + failwith failmsg + + if evaluate then + checkDotnetReplInstall () + printfn $" evaluating %s{inputFileFullPath} with dotnet-repl" + evaluateNotebook inputFileFullPath + + + let model = + Literate.ParseAndTransformPynbFile( + inputFileFullPath, + output = outputFileRelativeToRoot, + outputKind = outputKind, + prefix = None, + fscOptions = None, + lineNumbers = lineNumbers, + references = Some false, + substitutions = substitutions, + generateAnchors = Some true, + imageSaver = imageSaverOpt, + rootInputFolder = rootInputFolder, + crefResolver = crefResolver, + mdlinkResolver = mdlinkResolver, + onError = Some onError, + filesWithFrontMatter = filesWithFrontMatter + ) + + yield + ((if mainRun then + Some(inputFileFullPath, isOtherLang, model) + else + None), + (fun p -> + printfn " writing %s --> %s" inputFileFullPath outputFileRelativeToRoot + ensureDirectory (Path.GetDirectoryName(outputFileFullPath)) + + SimpleTemplating.UseFileAsSimpleTemplate( + p @ model.Substitutions, + template, + outputFileFullPath + ))) + + else if mainRun then + yield + (None, + (fun _p -> + printfn " copying %s --> %s" inputFileFullPath outputFileRelativeToRoot + ensureDirectory (Path.GetDirectoryName(outputFileFullPath)) + // check the file still exists for the incremental case + if (File.Exists inputFileFullPath) then + // ignore errors in watch mode + try + File.Copy(inputFileFullPath, outputFileFullPath, true) + File.SetLastWriteTime(outputFileFullPath, DateTime.Now) + with _ when watch -> + ())) + //printfn "skipping unchanged file %s" inputFileFullPath + else if mainRun && watch then + match haveModel with + | None -> () + | Some haveModel -> yield (Some(inputFileFullPath, isOtherLang, haveModel), (fun _ -> ())) ] + + let rec processFolder + (htmlTemplate, texTemplate, pynbTemplate, fsxTemplate, mdTemplate, isOtherLang, rootInputFolder, fullPathFileMap) + (inputFolderAsGiven: string) + outputFolderRelativeToRoot + (filesWithFrontMatter: FrontMatterFile array) + = + [ + // Look for the presence of the _template.* files to activate the + // generation of the content. + let indirName = Path.GetFileName(inputFolderAsGiven).ToLower() + + // Two-letter directory names (e.g. 'ja') with 'docs' count as multi-language and are suppressed from table-of-content + // generation and site search index + let isOtherLang = isOtherLang || (indirName.Length = 2 && allCultures |> Array.contains indirName) + + let possibleNewHtmlTemplate = Path.Combine(inputFolderAsGiven, "_template.html") + + let htmlTemplate = + if File.Exists(possibleNewHtmlTemplate) then + Some possibleNewHtmlTemplate + else + htmlTemplate + + let possibleNewPynbTemplate = Path.Combine(inputFolderAsGiven, "_template.ipynb") + + let pynbTemplate = + if File.Exists(possibleNewPynbTemplate) then + Some possibleNewPynbTemplate + else + pynbTemplate + + let possibleNewFsxTemplate = Path.Combine(inputFolderAsGiven, "_template.fsx") + + let fsxTemplate = + if File.Exists(possibleNewFsxTemplate) then + Some possibleNewFsxTemplate + else + fsxTemplate + + let possibleNewMdTemplate = Path.Combine(inputFolderAsGiven, "_template.md") + + let mdTemplate = + if File.Exists(possibleNewMdTemplate) then + Some possibleNewMdTemplate + else + mdTemplate + + let possibleNewLatexTemplate = Path.Combine(inputFolderAsGiven, "_template.tex") + + let texTemplate = + if File.Exists(possibleNewLatexTemplate) then + Some possibleNewLatexTemplate + else + texTemplate + + ensureDirectory (Path.Combine(rootOutputFolderAsGiven, outputFolderRelativeToRoot)) + + let inputs = Directory.GetFiles(inputFolderAsGiven, "*") + + let imageSaver = createImageSaver (Path.Combine(rootOutputFolderAsGiven, outputFolderRelativeToRoot)) + + // Look for the four different kinds of content + for input in inputs do + yield! + processFile + rootInputFolder + isOtherLang + input + OutputKind.Html + htmlTemplate + outputFolderRelativeToRoot + imageSaver + (makeMarkdownLinkResolver ( + inputFolderAsGiven, + outputFolderRelativeToRoot, + fullPathFileMap, + OutputKind.Html + )) + filesWithFrontMatter + + yield! + processFile + rootInputFolder + isOtherLang + input + OutputKind.Latex + texTemplate + outputFolderRelativeToRoot + imageSaver + (makeMarkdownLinkResolver ( + inputFolderAsGiven, + outputFolderRelativeToRoot, + fullPathFileMap, + OutputKind.Latex + )) + filesWithFrontMatter + + yield! + processFile + rootInputFolder + isOtherLang + input + OutputKind.Pynb + pynbTemplate + outputFolderRelativeToRoot + imageSaver + (makeMarkdownLinkResolver ( + inputFolderAsGiven, + outputFolderRelativeToRoot, + fullPathFileMap, + OutputKind.Pynb + )) + filesWithFrontMatter + + yield! + processFile + rootInputFolder + isOtherLang + input + OutputKind.Fsx + fsxTemplate + outputFolderRelativeToRoot + imageSaver + (makeMarkdownLinkResolver ( + inputFolderAsGiven, + outputFolderRelativeToRoot, + fullPathFileMap, + OutputKind.Fsx + )) + filesWithFrontMatter + + yield! + processFile + rootInputFolder + isOtherLang + input + OutputKind.Markdown + mdTemplate + outputFolderRelativeToRoot + imageSaver + (makeMarkdownLinkResolver ( + inputFolderAsGiven, + outputFolderRelativeToRoot, + fullPathFileMap, + OutputKind.Markdown + )) + filesWithFrontMatter + + for subInputFolderFullPath in Directory.EnumerateDirectories(inputFolderAsGiven) do + let subInputFolderName = Path.GetFileName(subInputFolderFullPath) + let subFolderIsSkipped = subInputFolderName.StartsWith '.' + let subFolderIsOutput = subFolderIsOutput subInputFolderFullPath + + if subFolderIsOutput || subFolderIsSkipped then + + printfn " skipping directory %s" subInputFolderFullPath + else + yield! + processFolder + (htmlTemplate, + texTemplate, + pynbTemplate, + fsxTemplate, + mdTemplate, + isOtherLang, + rootInputFolder, + fullPathFileMap) + (Path.Combine(inputFolderAsGiven, subInputFolderName)) + (Path.Combine(outputFolderRelativeToRoot, subInputFolderName)) + filesWithFrontMatter ] + + member _.Convert(rootInputFolderAsGiven, htmlTemplate, extraInputs) = + + let inputDirectories = extraInputs @ [ (rootInputFolderAsGiven, ".") ] + + // Maps full input paths to full output paths + let fullPathFileMap = + [ for (rootInputFolderAsGiven, outputFolderRelativeToRoot) in inputDirectories do + yield! prepFolder rootInputFolderAsGiven outputFolderRelativeToRoot ] + |> Map.ofList + + // In order to create {{next-page-url}} and {{previous-page-url}} + // We need to scan all *.fsx and *.md files for their frontmatter. + let filesWithFrontMatter = + fullPathFileMap + |> Map.keys + |> Seq.map fst + |> Seq.distinct + |> Seq.choose (fun fileName -> + let ext = Path.GetExtension fileName + + if ext = ".fsx" then + ParseScript.ParseFrontMatter(fileName) + elif ext = ".md" then + File.ReadLines fileName |> FrontMatterFile.ParseFromLines fileName + elif ext = ".ipynb" then + ParsePynb.parseFrontMatter fileName + else + None) + |> Seq.sortBy (fun { Index = idx; CategoryIndex = cIdx } -> cIdx, idx) + |> Seq.toArray + + [ for (rootInputFolderAsGiven, outputFolderRelativeToRoot) in inputDirectories do + yield! + processFolder + (htmlTemplate, None, None, None, None, false, Some rootInputFolderAsGiven, fullPathFileMap) + rootInputFolderAsGiven + outputFolderRelativeToRoot + filesWithFrontMatter ] + + member _.GetSearchIndexEntries(docModels: (string * bool * LiterateDocModel) list) = + [| for (_inputFile, isOtherLang, model) in docModels do + if not isOtherLang then + match model.IndexText with + | Some(IndexText(fullContent, headings)) -> + { title = model.Title + content = fullContent + headings = headings + uri = model.Uri(root) + ``type`` = "content" } + | _ -> () |] + + member _.GetNavigationEntries + ( + input, + docModels: (string * bool * LiterateDocModel) list, + currentPagePath: string option, + ignoreUncategorized: bool + ) = + let modelsForList = + [ for thing in docModels do + match thing with + | (inputFileFullPath, isOtherLang, model) when + not isOtherLang + && model.OutputKind = OutputKind.Html + && (Path.GetFileNameWithoutExtension(inputFileFullPath) <> "index") + -> + { model with + IsActive = + match currentPagePath with + | None -> false + | Some currentPagePath -> currentPagePath = inputFileFullPath } + | _ -> () ] + + let excludeUncategorized = + if ignoreUncategorized then + List.filter (fun (model: LiterateDocModel) -> model.Category.IsSome) + else + id + + let modelsByCategory = + modelsForList + |> excludeUncategorized + |> List.groupBy (fun (model) -> model.Category) + |> List.sortBy (fun (_, ms) -> + match ms.[0].CategoryIndex with + | Some s -> + (try + int32 s + with _ -> + Int32.MaxValue) + | None -> Int32.MaxValue) + + let orderList (list: (LiterateDocModel) list) = + list + |> List.sortBy (fun model -> Option.defaultValue Int32.MaxValue model.Index) + + if Menu.isTemplatingAvailable input then + let createGroup (isCategoryActive: bool) (header: string) (items: LiterateDocModel list) : string = + //convert items into menuitem list + let menuItems = + orderList items + |> List.map (fun (model: LiterateDocModel) -> + let link = model.Uri(root) + let title = System.Web.HttpUtility.HtmlEncode model.Title + + { Menu.MenuItem.Link = link + Menu.MenuItem.Content = title + Menu.MenuItem.IsActive = model.IsActive }) + + Menu.createMenu input isCategoryActive header menuItems + // No categories specified + if modelsByCategory.Length = 1 && (fst modelsByCategory.[0]) = None then + let _, items = modelsByCategory.[0] + createGroup false "Documentation" items + else + modelsByCategory + |> List.map (fun (header, items) -> + let header = Option.defaultValue "Other" header + let isActive = items |> List.exists (fun m -> m.IsActive) + createGroup isActive header items) + |> String.concat "\n" + else + [ + // No categories specified + if modelsByCategory.Length = 1 && (fst modelsByCategory.[0]) = None then + li [ Class "nav-header" ] [ !!"Documentation" ] + + for model in snd modelsByCategory.[0] do + let link = model.Uri(root) + let activeClass = if model.IsActive then "active" else "" + + li + [ Class $"nav-item %s{activeClass}" ] + [ a [ Class "nav-link"; (Href link) ] [ encode model.Title ] ] + else + // At least one category has been specified. Sort each category by index and emit + // Use 'Other' as a header for uncategorised things + for (cat, modelsInCategory) in modelsByCategory do + let modelsInCategory = orderList modelsInCategory + + let categoryActiveClass = + if modelsInCategory |> List.exists (fun m -> m.IsActive) then + "active" + else + "" + + match cat with + | Some c -> li [ Class $"nav-header %s{categoryActiveClass}" ] [ !!c ] + | None -> li [ Class $"nav-header %s{categoryActiveClass}" ] [ !!"Other" ] + + for model in modelsInCategory do + let link = model.Uri(root) + let activeClass = if model.IsActive then "active" else "" + + li + [ Class $"nav-item %s{activeClass}" ] + [ a [ Class "nav-link"; (Href link) ] [ encode model.Title ] ] ] + |> List.map (fun html -> html.ToString()) + |> String.concat " \n" diff --git a/src/fsdocs-tool/WatchServer.fs b/src/fsdocs-tool/WatchServer.fs new file mode 100644 index 000000000..6576c3e21 --- /dev/null +++ b/src/fsdocs-tool/WatchServer.fs @@ -0,0 +1,507 @@ +namespace fsdocs + +open System.Collections.Concurrent +open System.IO +open System.Text + +open Suave +open Suave.Filters +open Suave.Logging +open Suave.Operators +open Suave.Sockets +open Suave.Sockets.Control +open Suave.WebSocket + +/// Processes and runs Suave server to host them on localhost +module Serve = + let refreshEvent = FSharp.Control.Event() + + /// generate the script to inject into html to enable hot reload during development + let generateWatchScript (port: int) = + let tag = + """ + +""" + + tag.Replace("{{PORT}}", string port) + + let connectedClients = ConcurrentDictionary() + + let socketHandler (webSocket: WebSocket) (context: HttpContext) = + context.runtime.logger.info (Message.eventX "New websocket connection") + connectedClients.TryAdd(webSocket, ()) |> ignore + + socket { + let! msg = webSocket.read () + + match msg with + | Close, _, _ -> + context.runtime.logger.info (Message.eventX "Closing connection") + connectedClients.TryRemove webSocket |> ignore + let emptyResponse = [||] |> ByteSegment + do! webSocket.send Close emptyResponse true + | _ -> () + } + + let broadCastReload (msg: string) = + let msg = msg |> Encoding.UTF8.GetBytes |> ByteSegment + + connectedClients.Keys + |> Seq.map (fun client -> + async { + let! _ = client.send Text msg true + () + }) + |> Async.Parallel + |> Async.Ignore + |> Async.RunSynchronously + + refreshEvent.Publish + |> Event.add (fun fileName -> + if Path.HasExtension fileName then + let fileName = fileName.Replace("\\", "/").TrimEnd('~') + broadCastReload fileName) + + let startWebServer rootOutputFolderAsGiven localPort = + let mimeTypesMap ext = + match ext with + | ".323" -> Writers.createMimeType "text/h323" false + | ".3g2" -> Writers.createMimeType "video/3gpp2" false + | ".3gp2" -> Writers.createMimeType "video/3gpp2" false + | ".3gp" -> Writers.createMimeType "video/3gpp" false + | ".3gpp" -> Writers.createMimeType "video/3gpp" false + | ".aac" -> Writers.createMimeType "audio/aac" false + | ".aaf" -> Writers.createMimeType "application/octet-stream" false + | ".aca" -> Writers.createMimeType "application/octet-stream" false + | ".accdb" -> Writers.createMimeType "application/msaccess" false + | ".accde" -> Writers.createMimeType "application/msaccess" false + | ".accdt" -> Writers.createMimeType "application/msaccess" false + | ".acx" -> Writers.createMimeType "application/internet-property-stream" false + | ".adt" -> Writers.createMimeType "audio/vnd.dlna.adts" false + | ".adts" -> Writers.createMimeType "audio/vnd.dlna.adts" false + | ".afm" -> Writers.createMimeType "application/octet-stream" false + | ".ai" -> Writers.createMimeType "application/postscript" false + | ".aif" -> Writers.createMimeType "audio/x-aiff" false + | ".aifc" -> Writers.createMimeType "audio/aiff" false + | ".aiff" -> Writers.createMimeType "audio/aiff" false + | ".appcache" -> Writers.createMimeType "text/cache-manifest" false + | ".application" -> Writers.createMimeType "application/x-ms-application" false + | ".art" -> Writers.createMimeType "image/x-jg" false + | ".asd" -> Writers.createMimeType "application/octet-stream" false + | ".asf" -> Writers.createMimeType "video/x-ms-asf" false + | ".asi" -> Writers.createMimeType "application/octet-stream" false + | ".asm" -> Writers.createMimeType "text/plain" false + | ".asr" -> Writers.createMimeType "video/x-ms-asf" false + | ".asx" -> Writers.createMimeType "video/x-ms-asf" false + | ".atom" -> Writers.createMimeType "application/atom+xml" false + | ".au" -> Writers.createMimeType "audio/basic" false + | ".avi" -> Writers.createMimeType "video/x-msvideo" false + | ".axs" -> Writers.createMimeType "application/olescript" false + | ".bas" -> Writers.createMimeType "text/plain" false + | ".bcpio" -> Writers.createMimeType "application/x-bcpio" false + | ".bin" -> Writers.createMimeType "application/octet-stream" false + | ".bmp" -> Writers.createMimeType "image/bmp" false + | ".c" -> Writers.createMimeType "text/plain" false + | ".cab" -> Writers.createMimeType "application/vnd.ms-cab-compressed" false + | ".calx" -> Writers.createMimeType "application/vnd.ms-office.calx" false + | ".cat" -> Writers.createMimeType "application/vnd.ms-pki.seccat" false + | ".cdf" -> Writers.createMimeType "application/x-cdf" false + | ".chm" -> Writers.createMimeType "application/octet-stream" false + | ".class" -> Writers.createMimeType "application/x-java-applet" false + | ".clp" -> Writers.createMimeType "application/x-msclip" false + | ".cmx" -> Writers.createMimeType "image/x-cmx" false + | ".cnf" -> Writers.createMimeType "text/plain" false + | ".cod" -> Writers.createMimeType "image/cis-cod" false + | ".cpio" -> Writers.createMimeType "application/x-cpio" false + | ".cpp" -> Writers.createMimeType "text/plain" false + | ".crd" -> Writers.createMimeType "application/x-mscardfile" false + | ".crl" -> Writers.createMimeType "application/pkix-crl" false + | ".crt" -> Writers.createMimeType "application/x-x509-ca-cert" false + | ".csh" -> Writers.createMimeType "application/x-csh" false + | ".css" -> Writers.createMimeType "text/css" false + | ".csv" -> Writers.createMimeType "text/csv" false + | ".cur" -> Writers.createMimeType "application/octet-stream" false + | ".dcr" -> Writers.createMimeType "application/x-director" false + | ".deploy" -> Writers.createMimeType "application/octet-stream" false + | ".der" -> Writers.createMimeType "application/x-x509-ca-cert" false + | ".dib" -> Writers.createMimeType "image/bmp" false + | ".dir" -> Writers.createMimeType "application/x-director" false + | ".disco" -> Writers.createMimeType "text/xml" false + | ".dlm" -> Writers.createMimeType "text/dlm" false + | ".doc" -> Writers.createMimeType "application/msword" false + | ".docm" -> Writers.createMimeType "application/vnd.ms-word.document.macroEnabled.12" false + | ".docx" -> + Writers.createMimeType "application/vnd.openxmlformats-officedocument.wordprocessingml.document" false + | ".dot" -> Writers.createMimeType "application/msword" false + | ".dotm" -> Writers.createMimeType "application/vnd.ms-word.template.macroEnabled.12" false + | ".dotx" -> + Writers.createMimeType "application/vnd.openxmlformats-officedocument.wordprocessingml.template" false + | ".dsp" -> Writers.createMimeType "application/octet-stream" false + | ".dtd" -> Writers.createMimeType "text/xml" false + | ".dvi" -> Writers.createMimeType "application/x-dvi" false + | ".dvr-ms" -> Writers.createMimeType "video/x-ms-dvr" false + | ".dwf" -> Writers.createMimeType "drawing/x-dwf" false + | ".dwp" -> Writers.createMimeType "application/octet-stream" false + | ".dxr" -> Writers.createMimeType "application/x-director" false + | ".eml" -> Writers.createMimeType "message/rfc822" false + | ".emz" -> Writers.createMimeType "application/octet-stream" false + | ".eot" -> Writers.createMimeType "application/vnd.ms-fontobject" false + | ".eps" -> Writers.createMimeType "application/postscript" false + | ".etx" -> Writers.createMimeType "text/x-setext" false + | ".evy" -> Writers.createMimeType "application/envoy" false + | ".exe" -> Writers.createMimeType "application/vnd.microsoft.portable-executable" false + | ".fdf" -> Writers.createMimeType "application/vnd.fdf" false + | ".fif" -> Writers.createMimeType "application/fractals" false + | ".fla" -> Writers.createMimeType "application/octet-stream" false + | ".flr" -> Writers.createMimeType "x-world/x-vrml" false + | ".flv" -> Writers.createMimeType "video/x-flv" false + | ".gif" -> Writers.createMimeType "image/gif" false + | ".gtar" -> Writers.createMimeType "application/x-gtar" false + | ".gz" -> Writers.createMimeType "application/x-gzip" false + | ".h" -> Writers.createMimeType "text/plain" false + | ".hdf" -> Writers.createMimeType "application/x-hdf" false + | ".hdml" -> Writers.createMimeType "text/x-hdml" false + | ".hhc" -> Writers.createMimeType "application/x-oleobject" false + | ".hhk" -> Writers.createMimeType "application/octet-stream" false + | ".hhp" -> Writers.createMimeType "application/octet-stream" false + | ".hlp" -> Writers.createMimeType "application/winhlp" false + | ".hqx" -> Writers.createMimeType "application/mac-binhex40" false + | ".hta" -> Writers.createMimeType "application/hta" false + | ".htc" -> Writers.createMimeType "text/x-component" false + | ".htm" -> Writers.createMimeType "text/html" false + | ".html" -> Writers.createMimeType "text/html" false + | ".htt" -> Writers.createMimeType "text/webviewhtml" false + | ".hxt" -> Writers.createMimeType "text/html" false + | ".ical" -> Writers.createMimeType "text/calendar" false + | ".icalendar" -> Writers.createMimeType "text/calendar" false + | ".ico" -> Writers.createMimeType "image/x-icon" false + | ".ics" -> Writers.createMimeType "text/calendar" false + | ".ief" -> Writers.createMimeType "image/ief" false + | ".ifb" -> Writers.createMimeType "text/calendar" false + | ".iii" -> Writers.createMimeType "application/x-iphone" false + | ".inf" -> Writers.createMimeType "application/octet-stream" false + | ".ins" -> Writers.createMimeType "application/x-internet-signup" false + | ".isp" -> Writers.createMimeType "application/x-internet-signup" false + | ".IVF" -> Writers.createMimeType "video/x-ivf" false + | ".jar" -> Writers.createMimeType "application/java-archive" false + | ".java" -> Writers.createMimeType "application/octet-stream" false + | ".jck" -> Writers.createMimeType "application/liquidmotion" false + | ".jcz" -> Writers.createMimeType "application/liquidmotion" false + | ".jfif" -> Writers.createMimeType "image/pjpeg" false + | ".jpb" -> Writers.createMimeType "application/octet-stream" false + | ".jpe" -> Writers.createMimeType "image/jpeg" false + | ".jpeg" -> Writers.createMimeType "image/jpeg" false + | ".jpg" -> Writers.createMimeType "image/jpeg" false + | ".js" -> Writers.createMimeType "text/javascript" false + | ".json" -> Writers.createMimeType "application/json" false + | ".jsx" -> Writers.createMimeType "text/jscript" false + | ".latex" -> Writers.createMimeType "application/x-latex" false + | ".lit" -> Writers.createMimeType "application/x-ms-reader" false + | ".lpk" -> Writers.createMimeType "application/octet-stream" false + | ".lsf" -> Writers.createMimeType "video/x-la-asf" false + | ".lsx" -> Writers.createMimeType "video/x-la-asf" false + | ".lzh" -> Writers.createMimeType "application/octet-stream" false + | ".m13" -> Writers.createMimeType "application/x-msmediaview" false + | ".m14" -> Writers.createMimeType "application/x-msmediaview" false + | ".m1v" -> Writers.createMimeType "video/mpeg" false + | ".m2ts" -> Writers.createMimeType "video/vnd.dlna.mpeg-tts" false + | ".m3u" -> Writers.createMimeType "audio/x-mpegurl" false + | ".m4a" -> Writers.createMimeType "audio/mp4" false + | ".m4v" -> Writers.createMimeType "video/mp4" false + | ".man" -> Writers.createMimeType "application/x-troff-man" false + | ".manifest" -> Writers.createMimeType "application/x-ms-manifest" false + | ".map" -> Writers.createMimeType "text/plain" false + | ".markdown" -> Writers.createMimeType "text/markdown" false + | ".md" -> Writers.createMimeType "text/markdown" false + | ".mdb" -> Writers.createMimeType "application/x-msaccess" false + | ".mdp" -> Writers.createMimeType "application/octet-stream" false + | ".me" -> Writers.createMimeType "application/x-troff-me" false + | ".mht" -> Writers.createMimeType "message/rfc822" false + | ".mhtml" -> Writers.createMimeType "message/rfc822" false + | ".mid" -> Writers.createMimeType "audio/mid" false + | ".midi" -> Writers.createMimeType "audio/mid" false + | ".mix" -> Writers.createMimeType "application/octet-stream" false + | ".mjs" -> Writers.createMimeType "text/javascript" false + | ".mmf" -> Writers.createMimeType "application/x-smaf" false + | ".mno" -> Writers.createMimeType "text/xml" false + | ".mny" -> Writers.createMimeType "application/x-msmoney" false + | ".mov" -> Writers.createMimeType "video/quicktime" false + | ".movie" -> Writers.createMimeType "video/x-sgi-movie" false + | ".mp2" -> Writers.createMimeType "video/mpeg" false + | ".mp3" -> Writers.createMimeType "audio/mpeg" false + | ".mp4" -> Writers.createMimeType "video/mp4" false + | ".mp4v" -> Writers.createMimeType "video/mp4" false + | ".mpa" -> Writers.createMimeType "video/mpeg" false + | ".mpe" -> Writers.createMimeType "video/mpeg" false + | ".mpeg" -> Writers.createMimeType "video/mpeg" false + | ".mpg" -> Writers.createMimeType "video/mpeg" false + | ".mpp" -> Writers.createMimeType "application/vnd.ms-project" false + | ".mpv2" -> Writers.createMimeType "video/mpeg" false + | ".ms" -> Writers.createMimeType "application/x-troff-ms" false + | ".msi" -> Writers.createMimeType "application/octet-stream" false + | ".mso" -> Writers.createMimeType "application/octet-stream" false + | ".mvb" -> Writers.createMimeType "application/x-msmediaview" false + | ".mvc" -> Writers.createMimeType "application/x-miva-compiled" false + | ".nc" -> Writers.createMimeType "application/x-netcdf" false + | ".nsc" -> Writers.createMimeType "video/x-ms-asf" false + | ".nws" -> Writers.createMimeType "message/rfc822" false + | ".ocx" -> Writers.createMimeType "application/octet-stream" false + | ".oda" -> Writers.createMimeType "application/oda" false + | ".odc" -> Writers.createMimeType "text/x-ms-odc" false + | ".ods" -> Writers.createMimeType "application/oleobject" false + | ".oga" -> Writers.createMimeType "audio/ogg" false + | ".ogg" -> Writers.createMimeType "video/ogg" false + | ".ogv" -> Writers.createMimeType "video/ogg" false + | ".ogx" -> Writers.createMimeType "application/ogg" false + | ".one" -> Writers.createMimeType "application/onenote" false + | ".onea" -> Writers.createMimeType "application/onenote" false + | ".onetoc" -> Writers.createMimeType "application/onenote" false + | ".onetoc2" -> Writers.createMimeType "application/onenote" false + | ".onetmp" -> Writers.createMimeType "application/onenote" false + | ".onepkg" -> Writers.createMimeType "application/onenote" false + | ".osdx" -> Writers.createMimeType "application/opensearchdescription+xml" false + | ".otf" -> Writers.createMimeType "font/otf" false + | ".p10" -> Writers.createMimeType "application/pkcs10" false + | ".p12" -> Writers.createMimeType "application/x-pkcs12" false + | ".p7b" -> Writers.createMimeType "application/x-pkcs7-certificates" false + | ".p7c" -> Writers.createMimeType "application/pkcs7-mime" false + | ".p7m" -> Writers.createMimeType "application/pkcs7-mime" false + | ".p7r" -> Writers.createMimeType "application/x-pkcs7-certreqresp" false + | ".p7s" -> Writers.createMimeType "application/pkcs7-signature" false + | ".pbm" -> Writers.createMimeType "image/x-portable-bitmap" false + | ".pcx" -> Writers.createMimeType "application/octet-stream" false + | ".pcz" -> Writers.createMimeType "application/octet-stream" false + | ".pdf" -> Writers.createMimeType "application/pdf" false + | ".pfb" -> Writers.createMimeType "application/octet-stream" false + | ".pfm" -> Writers.createMimeType "application/octet-stream" false + | ".pfx" -> Writers.createMimeType "application/x-pkcs12" false + | ".pgm" -> Writers.createMimeType "image/x-portable-graymap" false + | ".pko" -> Writers.createMimeType "application/vnd.ms-pki.pko" false + | ".pma" -> Writers.createMimeType "application/x-perfmon" false + | ".pmc" -> Writers.createMimeType "application/x-perfmon" false + | ".pml" -> Writers.createMimeType "application/x-perfmon" false + | ".pmr" -> Writers.createMimeType "application/x-perfmon" false + | ".pmw" -> Writers.createMimeType "application/x-perfmon" false + | ".png" -> Writers.createMimeType "image/png" false + | ".pnm" -> Writers.createMimeType "image/x-portable-anymap" false + | ".pnz" -> Writers.createMimeType "image/png" false + | ".pot" -> Writers.createMimeType "application/vnd.ms-powerpoint" false + | ".potm" -> Writers.createMimeType "application/vnd.ms-powerpoint.template.macroEnabled.12" false + | ".potx" -> + Writers.createMimeType "application/vnd.openxmlformats-officedocument.presentationml.template" false + | ".ppam" -> Writers.createMimeType "application/vnd.ms-powerpoint.addin.macroEnabled.12" false + | ".ppm" -> Writers.createMimeType "image/x-portable-pixmap" false + | ".pps" -> Writers.createMimeType "application/vnd.ms-powerpoint" false + | ".ppsm" -> Writers.createMimeType "application/vnd.ms-powerpoint.slideshow.macroEnabled.12" false + | ".ppsx" -> + Writers.createMimeType "application/vnd.openxmlformats-officedocument.presentationml.slideshow" false + | ".ppt" -> Writers.createMimeType "application/vnd.ms-powerpoint" false + | ".pptm" -> Writers.createMimeType "application/vnd.ms-powerpoint.presentation.macroEnabled.12" false + | ".pptx" -> + Writers.createMimeType "application/vnd.openxmlformats-officedocument.presentationml.presentation" false + | ".prf" -> Writers.createMimeType "application/pics-rules" false + | ".prm" -> Writers.createMimeType "application/octet-stream" false + | ".prx" -> Writers.createMimeType "application/octet-stream" false + | ".ps" -> Writers.createMimeType "application/postscript" false + | ".psd" -> Writers.createMimeType "application/octet-stream" false + | ".psm" -> Writers.createMimeType "application/octet-stream" false + | ".psp" -> Writers.createMimeType "application/octet-stream" false + | ".pub" -> Writers.createMimeType "application/x-mspublisher" false + | ".qt" -> Writers.createMimeType "video/quicktime" false + | ".qtl" -> Writers.createMimeType "application/x-quicktimeplayer" false + | ".qxd" -> Writers.createMimeType "application/octet-stream" false + | ".ra" -> Writers.createMimeType "audio/x-pn-realaudio" false + | ".ram" -> Writers.createMimeType "audio/x-pn-realaudio" false + | ".rar" -> Writers.createMimeType "application/octet-stream" false + | ".ras" -> Writers.createMimeType "image/x-cmu-raster" false + | ".rf" -> Writers.createMimeType "image/vnd.rn-realflash" false + | ".rgb" -> Writers.createMimeType "image/x-rgb" false + | ".rm" -> Writers.createMimeType "application/vnd.rn-realmedia" false + | ".rmi" -> Writers.createMimeType "audio/mid" false + | ".roff" -> Writers.createMimeType "application/x-troff" false + | ".rpm" -> Writers.createMimeType "audio/x-pn-realaudio-plugin" false + | ".rtf" -> Writers.createMimeType "application/rtf" false + | ".rtx" -> Writers.createMimeType "text/richtext" false + | ".scd" -> Writers.createMimeType "application/x-msschedule" false + | ".sct" -> Writers.createMimeType "text/scriptlet" false + | ".sea" -> Writers.createMimeType "application/octet-stream" false + | ".setpay" -> Writers.createMimeType "application/set-payment-initiation" false + | ".setreg" -> Writers.createMimeType "application/set-registration-initiation" false + | ".sgml" -> Writers.createMimeType "text/sgml" false + | ".sh" -> Writers.createMimeType "application/x-sh" false + | ".shar" -> Writers.createMimeType "application/x-shar" false + | ".sit" -> Writers.createMimeType "application/x-stuffit" false + | ".sldm" -> Writers.createMimeType "application/vnd.ms-powerpoint.slide.macroEnabled.12" false + | ".sldx" -> + Writers.createMimeType "application/vnd.openxmlformats-officedocument.presentationml.slide" false + | ".smd" -> Writers.createMimeType "audio/x-smd" false + | ".smi" -> Writers.createMimeType "application/octet-stream" false + | ".smx" -> Writers.createMimeType "audio/x-smd" false + | ".smz" -> Writers.createMimeType "audio/x-smd" false + | ".snd" -> Writers.createMimeType "audio/basic" false + | ".snp" -> Writers.createMimeType "application/octet-stream" false + | ".spc" -> Writers.createMimeType "application/x-pkcs7-certificates" false + | ".spl" -> Writers.createMimeType "application/futuresplash" false + | ".spx" -> Writers.createMimeType "audio/ogg" false + | ".src" -> Writers.createMimeType "application/x-wais-source" false + | ".ssm" -> Writers.createMimeType "application/streamingmedia" false + | ".sst" -> Writers.createMimeType "application/vnd.ms-pki.certstore" false + | ".stl" -> Writers.createMimeType "application/vnd.ms-pki.stl" false + | ".sv4cpio" -> Writers.createMimeType "application/x-sv4cpio" false + | ".sv4crc" -> Writers.createMimeType "application/x-sv4crc" false + | ".svg" -> Writers.createMimeType "image/svg+xml" false + | ".svgz" -> Writers.createMimeType "image/svg+xml" false + | ".swf" -> Writers.createMimeType "application/x-shockwave-flash" false + | ".t" -> Writers.createMimeType "application/x-troff" false + | ".tar" -> Writers.createMimeType "application/x-tar" false + | ".tcl" -> Writers.createMimeType "application/x-tcl" false + | ".tex" -> Writers.createMimeType "application/x-tex" false + | ".texi" -> Writers.createMimeType "application/x-texinfo" false + | ".texinfo" -> Writers.createMimeType "application/x-texinfo" false + | ".tgz" -> Writers.createMimeType "application/x-compressed" false + | ".thmx" -> Writers.createMimeType "application/vnd.ms-officetheme" false + | ".thn" -> Writers.createMimeType "application/octet-stream" false + | ".tif" -> Writers.createMimeType "image/tiff" false + | ".tiff" -> Writers.createMimeType "image/tiff" false + | ".toc" -> Writers.createMimeType "application/octet-stream" false + | ".tr" -> Writers.createMimeType "application/x-troff" false + | ".trm" -> Writers.createMimeType "application/x-msterminal" false + | ".ts" -> Writers.createMimeType "video/vnd.dlna.mpeg-tts" false + | ".tsv" -> Writers.createMimeType "text/tab-separated-values" false + | ".ttc" -> Writers.createMimeType "application/x-font-ttf" false + | ".ttf" -> Writers.createMimeType "application/x-font-ttf" false + | ".tts" -> Writers.createMimeType "video/vnd.dlna.mpeg-tts" false + | ".txt" -> Writers.createMimeType "text/plain" false + | ".u32" -> Writers.createMimeType "application/octet-stream" false + | ".uls" -> Writers.createMimeType "text/iuls" false + | ".ustar" -> Writers.createMimeType "application/x-ustar" false + | ".vbs" -> Writers.createMimeType "text/vbscript" false + | ".vcf" -> Writers.createMimeType "text/x-vcard" false + | ".vcs" -> Writers.createMimeType "text/plain" false + | ".vdx" -> Writers.createMimeType "application/vnd.ms-visio.viewer" false + | ".vml" -> Writers.createMimeType "text/xml" false + | ".vsd" -> Writers.createMimeType "application/vnd.visio" false + | ".vss" -> Writers.createMimeType "application/vnd.visio" false + | ".vst" -> Writers.createMimeType "application/vnd.visio" false + | ".vsto" -> Writers.createMimeType "application/x-ms-vsto" false + | ".vsw" -> Writers.createMimeType "application/vnd.visio" false + | ".vsx" -> Writers.createMimeType "application/vnd.visio" false + | ".vtx" -> Writers.createMimeType "application/vnd.visio" false + | ".wasm" -> Writers.createMimeType "application/wasm" false + | ".wav" -> Writers.createMimeType "audio/wav" false + | ".wax" -> Writers.createMimeType "audio/x-ms-wax" false + | ".wbmp" -> Writers.createMimeType "image/vnd.wap.wbmp" false + | ".wcm" -> Writers.createMimeType "application/vnd.ms-works" false + | ".wdb" -> Writers.createMimeType "application/vnd.ms-works" false + | ".webm" -> Writers.createMimeType "video/webm" false + | ".webmanifest" -> Writers.createMimeType "application/manifest+json" false + | ".webp" -> Writers.createMimeType "image/webp" false + | ".wks" -> Writers.createMimeType "application/vnd.ms-works" false + | ".wm" -> Writers.createMimeType "video/x-ms-wm" false + | ".wma" -> Writers.createMimeType "audio/x-ms-wma" false + | ".wmd" -> Writers.createMimeType "application/x-ms-wmd" false + | ".wmf" -> Writers.createMimeType "application/x-msmetafile" false + | ".wml" -> Writers.createMimeType "text/vnd.wap.wml" false + | ".wmlc" -> Writers.createMimeType "application/vnd.wap.wmlc" false + | ".wmls" -> Writers.createMimeType "text/vnd.wap.wmlscript" false + | ".wmlsc" -> Writers.createMimeType "application/vnd.wap.wmlscriptc" false + | ".wmp" -> Writers.createMimeType "video/x-ms-wmp" false + | ".wmv" -> Writers.createMimeType "video/x-ms-wmv" false + | ".wmx" -> Writers.createMimeType "video/x-ms-wmx" false + | ".wmz" -> Writers.createMimeType "application/x-ms-wmz" false + | ".woff" -> Writers.createMimeType "application/font-woff" false + | ".woff2" -> Writers.createMimeType "font/woff2" false + | ".wps" -> Writers.createMimeType "application/vnd.ms-works" false + | ".wri" -> Writers.createMimeType "application/x-mswrite" false + | ".wrl" -> Writers.createMimeType "x-world/x-vrml" false + | ".wrz" -> Writers.createMimeType "x-world/x-vrml" false + | ".wsdl" -> Writers.createMimeType "text/xml" false + | ".wtv" -> Writers.createMimeType "video/x-ms-wtv" false + | ".wvx" -> Writers.createMimeType "video/x-ms-wvx" false + | ".x" -> Writers.createMimeType "application/directx" false + | ".xaf" -> Writers.createMimeType "x-world/x-vrml" false + | ".xaml" -> Writers.createMimeType "application/xaml+xml" false + | ".xap" -> Writers.createMimeType "application/x-silverlight-app" false + | ".xbap" -> Writers.createMimeType "application/x-ms-xbap" false + | ".xbm" -> Writers.createMimeType "image/x-xbitmap" false + | ".xdr" -> Writers.createMimeType "text/plain" false + | ".xht" -> Writers.createMimeType "application/xhtml+xml" false + | ".xhtml" -> Writers.createMimeType "application/xhtml+xml" false + | ".xla" -> Writers.createMimeType "application/vnd.ms-excel" false + | ".xlam" -> Writers.createMimeType "application/vnd.ms-excel.addin.macroEnabled.12" false + | ".xlc" -> Writers.createMimeType "application/vnd.ms-excel" false + | ".xlm" -> Writers.createMimeType "application/vnd.ms-excel" false + | ".xls" -> Writers.createMimeType "application/vnd.ms-excel" false + | ".xlsb" -> Writers.createMimeType "application/vnd.ms-excel.sheet.binary.macroEnabled.12" false + | ".xlsm" -> Writers.createMimeType "application/vnd.ms-excel.sheet.macroEnabled.12" false + | ".xlsx" -> + Writers.createMimeType "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" false + | ".xlt" -> Writers.createMimeType "application/vnd.ms-excel" false + | ".xltm" -> Writers.createMimeType "application/vnd.ms-excel.template.macroEnabled.12" false + | ".xltx" -> + Writers.createMimeType "application/vnd.openxmlformats-officedocument.spreadsheetml.template" false + | ".xlw" -> Writers.createMimeType "application/vnd.ms-excel" false + | ".xml" -> Writers.createMimeType "text/xml" false + | ".xof" -> Writers.createMimeType "x-world/x-vrml" false + | ".xpm" -> Writers.createMimeType "image/x-xpixmap" false + | ".xps" -> Writers.createMimeType "application/vnd.ms-xpsdocument" false + | ".xsd" -> Writers.createMimeType "text/xml" false + | ".xsf" -> Writers.createMimeType "text/xml" false + | ".xsl" -> Writers.createMimeType "text/xml" false + | ".xslt" -> Writers.createMimeType "text/xml" false + | ".xsn" -> Writers.createMimeType "application/octet-stream" false + | ".xtp" -> Writers.createMimeType "application/octet-stream" false + | ".xwd" -> Writers.createMimeType "image/x-xwindowdump" false + | ".z" -> Writers.createMimeType "application/x-compress" false + | ".zip" -> Writers.createMimeType "application/x-zip-compressed" false + | _ -> None + + let defaultBinding = defaultConfig.bindings.[0] + + let withPort = + { defaultBinding.socketBinding with + port = uint16 localPort } + + let serverConfig = + { defaultConfig with + bindings = + [ { defaultBinding with + socketBinding = withPort } ] + homeFolder = Some rootOutputFolderAsGiven + mimeTypesMap = mimeTypesMap } + + let app = + choose + [ path "/" >=> Redirection.redirect "/index.html" + path "/websocket" >=> handShake socketHandler + Writers.setHeader "Cache-Control" "no-cache, no-store, must-revalidate" + >=> Writers.setHeader "Pragma" "no-cache" + >=> Writers.setHeader "Expires" "0" + >=> Files.browseHome ] + + startWebServerAsync serverConfig app |> snd |> Async.Start diff --git a/src/fsdocs-tool/fsdocs-tool.fsproj b/src/fsdocs-tool/fsdocs-tool.fsproj index 73d5540cd..0ef3eaf7e 100644 --- a/src/fsdocs-tool/fsdocs-tool.fsproj +++ b/src/fsdocs-tool/fsdocs-tool.fsproj @@ -18,6 +18,8 @@ + + From 7e3985804f62c251504d7adde469f017b7ac893c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 23:08:26 +0000 Subject: [PATCH 2/4] ci: trigger CI checks From 9aad6d6027f012b4ffde7b5f5a096adcd91fb934 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 01:02:56 +0000 Subject: [PATCH 3/4] Resolve merge conflicts with base branch - Keep the DocContent/WatchServer split structure from this PR - Apply _menu template fix from merged main commit to DocContent.fs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/fsdocs-tool/BuildCommand.fs | 1 + src/fsdocs-tool/DocContent.fs | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/fsdocs-tool/BuildCommand.fs b/src/fsdocs-tool/BuildCommand.fs index 2a6df981f..1dc3d682b 100644 --- a/src/fsdocs-tool/BuildCommand.fs +++ b/src/fsdocs-tool/BuildCommand.fs @@ -31,6 +31,7 @@ open FSharp.Formatting.Markdown #nowarn "44" // Obsolete WebClient + type CoreBuildOptions(watch) = [] diff --git a/src/fsdocs-tool/DocContent.fs b/src/fsdocs-tool/DocContent.fs index 101b17d4d..aef1ee92c 100644 --- a/src/fsdocs-tool/DocContent.fs +++ b/src/fsdocs-tool/DocContent.fs @@ -141,6 +141,10 @@ type internal DocContent if not (inputFileName.StartsWith('.')) && not (inputFileName.StartsWith("_template", StringComparison.Ordinal)) + && not ( + inputFileName.StartsWith("_menu", StringComparison.Ordinal) + && inputFileName.EndsWith("_template.html", StringComparison.Ordinal) + ) then let inputFileFullPath = Path.GetFullPath(inputFileFullPath) @@ -186,7 +190,13 @@ type internal DocContent if name.StartsWith('.') then printfn "skipping file %s" inputFileFullPath - elif not (name.StartsWith("_template", StringComparison.Ordinal)) then + elif + not (name.StartsWith("_template", StringComparison.Ordinal)) + && not ( + name.StartsWith("_menu", StringComparison.Ordinal) + && name.EndsWith("_template.html", StringComparison.Ordinal) + ) + then let isFsx = inputFileFullPath.EndsWith(".fsx", StringComparison.OrdinalIgnoreCase) let isMd = inputFileFullPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase) From bf78c2c916657422b7035a0172d4add02af6095a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 01:04:27 +0000 Subject: [PATCH 4/4] ci: trigger CI checks