diff --git a/.gitignore b/.gitignore index 4626b20db..4a9f6aef6 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ version.props src/Common/AssemblyInfo.?s tests/FSharp.Literate.Tests/output1/ .vscode/ +.DS_Store diff --git a/docs/styling.md b/docs/styling.md index 880393f06..b85e3a6d5 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -118,6 +118,27 @@ with the existing default template. > NOTE: There is no guarantee that your template will continue to work with future versions of F# Formatting. > If you do develop a good template please consider contributing it back to F# Formatting. +## Customizing menu items by template + +You can add advanced styling to the sidebar generated menu items by creating a new template for it. +`fsdoc` will look for menu templates in the `--input` folder which defaults to the docs folder. + +To customize the generated menu-item headers, use file `_menu_template.html` with starting template: + +```html + +{{fsdocs-menu-items}} +``` + +Similarly, to customize the individual menu item list, use file `_menu-item_template.html` with starting template: + +```html + +``` +Do note that files need to be added prior running or won't be generated. +In case you want to get a unique identifier for a header or menu item, you can use `{{fsdocs-menu-header-id}}` and `{{fsdocs-menu-item-id}}`, respectively. ## Customizing by generating your own site using your own code diff --git a/src/FSharp.Formatting.ApiDocs/ApiDocs.fs b/src/FSharp.Formatting.ApiDocs/ApiDocs.fs index b8c637eee..f2d4e2a8a 100644 --- a/src/FSharp.Formatting.ApiDocs/ApiDocs.fs +++ b/src/FSharp.Formatting.ApiDocs/ApiDocs.fs @@ -85,7 +85,8 @@ type ApiDocs = ?libDirs, ?otherFlags, ?urlRangeHighlight, - ?onError + ?onError, + ?menuTemplateFolder ) = let root = defaultArg root "/" let qualify = defaultArg qualify false @@ -106,7 +107,7 @@ type ApiDocs = extensions = extensions ) - let renderer = GenerateHtml.HtmlRender(model) + let renderer = GenerateHtml.HtmlRender(model, ?menuTemplateFolder = menuTemplateFolder) let index = GenerateSearchIndex.searchIndexEntriesForModel model @@ -184,7 +185,8 @@ type ApiDocs = ?libDirs, ?otherFlags, ?urlRangeHighlight, - ?onError + ?onError, + ?menuTemplateFolder ) = let root = defaultArg root "/" let qualify = defaultArg qualify false @@ -205,7 +207,7 @@ type ApiDocs = extensions = extensions ) - let renderer = GenerateMarkdown.MarkdownRender(model) + let renderer = GenerateMarkdown.MarkdownRender(model, ?menuTemplateFolder = menuTemplateFolder) let index = GenerateSearchIndex.searchIndexEntriesForModel model diff --git a/src/FSharp.Formatting.ApiDocs/FSharp.Formatting.ApiDocs.fsproj b/src/FSharp.Formatting.ApiDocs/FSharp.Formatting.ApiDocs.fsproj index 1a3fc323a..3b4fc20b2 100644 --- a/src/FSharp.Formatting.ApiDocs/FSharp.Formatting.ApiDocs.fsproj +++ b/src/FSharp.Formatting.ApiDocs/FSharp.Formatting.ApiDocs.fsproj @@ -20,6 +20,7 @@ + diff --git a/src/FSharp.Formatting.ApiDocs/GenerateHtml.fs b/src/FSharp.Formatting.ApiDocs/GenerateHtml.fs index 7d08e93ce..a175c1346 100644 --- a/src/FSharp.Formatting.ApiDocs/GenerateHtml.fs +++ b/src/FSharp.Formatting.ApiDocs/GenerateHtml.fs @@ -13,7 +13,7 @@ open FSharp.Formatting.HtmlModel.Html /// Embed some HTML generateed in GenerateModel let embed (x: ApiDocHtml) = !!x.HtmlText -type HtmlRender(model: ApiDocModel) = +type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) = let root = model.Root let collectionName = model.Collection.CollectionName let qualify = model.Qualify @@ -615,9 +615,42 @@ type HtmlRender(model: ApiDocModel) = | _ -> () ] let listOfNamespacesNav otherDocs (nsOpt: ApiDocNamespace option) = - listOfNamespacesNavAux otherDocs nsOpt - |> List.map (fun html -> html.ToString()) - |> String.concat " \n" + let isTemplatingAvailable = + match menuTemplateFolder with + | None -> false + | Some input -> Menu.isTemplatingAvailable input + + if isTemplatingAvailable then + if otherDocs && model.Collection.CollectionName <> "FSharp.Core" then + let menuItems = + let title = "All Namespaces" + let link = model.IndexFileUrl(root, collectionName, qualify, model.FileExtensions.InUrl) + + [ { Menu.MenuItem.Link = link + Menu.MenuItem.Content = title } ] + + Menu.createMenu menuTemplateFolder.Value "API Reference" menuItems + + else + let categorise = Categorise.model model + + if categorise.Length = 0 then + "" + else + let menuItems = + categorise + |> List.map (fun (_, ns) -> + let link = ns.Url(root, collectionName, qualify, model.FileExtensions.InUrl) + let name = ns.Name + + { Menu.MenuItem.Link = link + Menu.MenuItem.Content = name }) + + Menu.createMenu menuTemplateFolder.Value "Namespaces" menuItems + else + listOfNamespacesNavAux otherDocs nsOpt + |> List.map (fun html -> html.ToString()) + |> String.concat " \n" /// Get the substitutions relevant to all member _.GlobalSubstitutions: Substitutions = diff --git a/src/FSharp.Formatting.ApiDocs/GenerateMarkdown.fs b/src/FSharp.Formatting.ApiDocs/GenerateMarkdown.fs index deaa718ff..be6040501 100644 --- a/src/FSharp.Formatting.ApiDocs/GenerateMarkdown.fs +++ b/src/FSharp.Formatting.ApiDocs/GenerateMarkdown.fs @@ -3,6 +3,7 @@ module internal FSharp.Formatting.ApiDocs.GenerateMarkdown open System open System.IO open System.Web +open FSharp.Formatting.Common open FSharp.Formatting.Markdown open FSharp.Formatting.Markdown.Dsl open FSharp.Formatting.Templating @@ -22,7 +23,7 @@ let embed (x: ApiDocHtml) = !!(htmlString x) let embedSafe (x: ApiDocHtml) = !!(htmlStringSafe x) let br = !! "
" -type MarkdownRender(model: ApiDocModel) = +type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = let root = model.Root let collectionName = model.Collection.CollectionName let qualify = model.Qualify @@ -362,9 +363,42 @@ type MarkdownRender(model: ApiDocModel) = | _ -> () ] let listOfNamespaces otherDocs nav (nsOpt: ApiDocNamespace option) = - listOfNamespacesAux otherDocs nav nsOpt - |> List.map (fun html -> html.ToString()) - |> String.concat " \n" + let isTemplatingAvailable = + match menuTemplateFolder with + | None -> false + | Some input -> Menu.isTemplatingAvailable input + + if isTemplatingAvailable then + if otherDocs && nav && model.Collection.CollectionName <> "FSharp.Core" then + let menuItems = + let title = "All Namespaces" + let link = model.IndexFileUrl(root, collectionName, qualify, model.FileExtensions.InUrl) + + [ { Menu.MenuItem.Link = link + Menu.MenuItem.Content = title } ] + + Menu.createMenu menuTemplateFolder.Value "API Reference" menuItems + + else + let categorise = Categorise.model model + + if categorise.Length = 0 then + "" + else + let menuItems = + categorise + |> List.map (fun (_, ns) -> + let link = ns.Url(root, collectionName, qualify, model.FileExtensions.InUrl) + let name = ns.Name + + { Menu.MenuItem.Link = link + Menu.MenuItem.Content = name }) + + Menu.createMenu menuTemplateFolder.Value "Namespaces" menuItems + else + listOfNamespacesAux otherDocs nav nsOpt + |> List.map (fun html -> html.ToString()) + |> String.concat " \n" /// Get the substitutions relevant to all member _.GlobalSubstitutions: Substitutions = diff --git a/src/FSharp.Formatting.Common/FSharp.Formatting.Common.fsproj b/src/FSharp.Formatting.Common/FSharp.Formatting.Common.fsproj index 91efff1d6..9eccb0aec 100644 --- a/src/FSharp.Formatting.Common/FSharp.Formatting.Common.fsproj +++ b/src/FSharp.Formatting.Common/FSharp.Formatting.Common.fsproj @@ -14,6 +14,7 @@ +
diff --git a/src/FSharp.Formatting.Common/Menu.fs b/src/FSharp.Formatting.Common/Menu.fs new file mode 100644 index 000000000..a33a1758a --- /dev/null +++ b/src/FSharp.Formatting.Common/Menu.fs @@ -0,0 +1,55 @@ +module FSharp.Formatting.Common.Menu + +open System +open System.IO +open FSharp.Formatting.Templating + +type MenuItem = { Link: string; Content: string } + +let private snakeCase (v: string) = + System + .Text + .RegularExpressions + .Regex + .Replace(v, "[A-Z]", "$0") + .Replace(" ", "_") + .ToLower() + +let createMenu (input: string) (header: string) (items: MenuItem list) : string = + let pwd = Directory.GetCurrentDirectory() + let menuTemplate = File.ReadAllText(Path.Combine(pwd, input, "_menu_template.html")) + let menuItemTemplate = File.ReadAllText(Path.Combine(pwd, input, "_menu-item_template.html")) + + let menuItems = + items + |> List.map (fun (model: MenuItem) -> + let link = model.Link + let title = System.Web.HttpUtility.HtmlEncode model.Content + let id = snakeCase title + + SimpleTemplating.ApplySubstitutionsInText + [| ParamKeys.``fsdocs-menu-item-link``, link + ParamKeys.``fsdocs-menu-item-content``, title + ParamKeys.``fsdocs-menu-item-id``, id |] + menuItemTemplate) + |> String.concat "\n" + + SimpleTemplating.ApplySubstitutionsInText + [| ParamKeys.``fsdocs-menu-header-content``, header + ParamKeys.``fsdocs-menu-header-id``, snakeCase header + ParamKeys.``fsdocs-menu-items``, menuItems |] + menuTemplate + +let isTemplatingAvailable (input: string) : bool = + let pwd = Directory.GetCurrentDirectory() + let menuTemplate = Path.Combine(pwd, input, "_menu_template.html") + let menuItemTemplate = Path.Combine(pwd, input, "_menu-item_template.html") + File.Exists(menuTemplate) && File.Exists(menuItemTemplate) + +let getLastWriteTimes (input: string) : DateTime list = + let pwd = Directory.GetCurrentDirectory() + + let getLastWriteTime f = + Path.Combine(pwd, input, f) |> File.GetLastWriteTime + + [ getLastWriteTime "_menu_template.html"; getLastWriteTime "_menu-item_template.html" ] diff --git a/src/FSharp.Formatting.Common/Templating.fs b/src/FSharp.Formatting.Common/Templating.fs index cdd280ff1..c9e929083 100644 --- a/src/FSharp.Formatting.Common/Templating.fs +++ b/src/FSharp.Formatting.Common/Templating.fs @@ -115,6 +115,24 @@ module ParamKeys = /// A parameter key known to FSharp.Formatting let ``fsdocs-watch-script`` = ParamKey "fsdocs-watch-script" + /// A parameter key known to FSharp.Formatting, available in _menu_template.html + let ``fsdocs-menu-header-content`` = ParamKey "fsdocs-menu-header-content" + + /// A parameter key known to FSharp.Formatting, available in _menu_template.html + let ``fsdocs-menu-header-id`` = ParamKey "fsdocs-menu-header-id" + + /// A parameter key known to FSharp.Formatting, available in _menu_template.html + let ``fsdocs-menu-items`` = ParamKey "fsdocs-menu-items" + + /// A parameter key known to FSharp.Formatting, available in _menu-item_template.html + let ``fsdocs-menu-item-link`` = ParamKey "fsdocs-menu-item-link" + + /// A parameter key known to FSharp.Formatting, available in _menu-item_template.html + let ``fsdocs-menu-item-content`` = ParamKey "fsdocs-menu-item-content" + + /// A parameter key known to FSharp.Formatting, available in _menu-item_template.html + let ``fsdocs-menu-item-id`` = ParamKey "fsdocs-menu-item-id" + module internal SimpleTemplating = #if NETSTANDARD2_0 diff --git a/src/fsdocs-tool/BuildCommand.fs b/src/fsdocs-tool/BuildCommand.fs index 33f580d2a..1290a0477 100644 --- a/src/fsdocs-tool/BuildCommand.fs +++ b/src/fsdocs-tool/BuildCommand.fs @@ -216,7 +216,13 @@ type internal DocContent match template with | Some t when isFsx || isMd -> try - File.GetLastWriteTime(t) + let fi = FileInfo(t) + let input = fi.Directory.Name + + [ yield File.GetLastWriteTime(t) + if Menu.isTemplatingAvailable input then + yield! Menu.getLastWriteTimes input ] + |> List.max with _ -> DateTime.MaxValue | _ -> DateTime.MinValue @@ -526,7 +532,7 @@ type internal DocContent uri = model.Uri(root) } | _ -> () |] - member _.GetNavigationEntries(docModels: (string * bool * LiterateDocModel) list) = + member _.GetNavigationEntries(input, docModels: (string * bool * LiterateDocModel) list) = let modelsForList = [ for thing in docModels do match thing with @@ -550,40 +556,66 @@ type internal DocContent Int32.MaxValue) | None -> Int32.MaxValue) - [ - // 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) - - li [ Class "nav-item" ] [ 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 = - modelsInCategory - |> List.sortBy (fun model -> - match model.Index with - | Some s -> - (try - int32 s - with _ -> - Int32.MaxValue) - | None -> Int32.MaxValue) - - match cat with - | Some c -> li [ Class "nav-header" ] [ !!c ] - | None -> li [ Class "nav-header" ] [ !! "Other" ] - - for model in modelsInCategory do + let orderList list = + list + |> List.sortBy (fun model -> + match model.Index with + | Some s -> + (try + int32 s + with _ -> + Int32.MaxValue) + | None -> Int32.MaxValue) + + if Menu.isTemplatingAvailable input then + let createGroup (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.createMenu input header menuItems + // No categories specified + if modelsByCategory.Length = 1 && (fst modelsByCategory.[0]) = None then + let _, items = modelsByCategory.[0] + createGroup "Documentation" items + else + modelsByCategory + |> List.map (fun (header, items) -> + let header = Option.defaultValue "Other" header + createGroup 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) - li [ Class "nav-item" ] [ a [ Class "nav-link"; (Href link) ] [ encode model.Title ] ] ] - |> List.map (fun html -> html.ToString()) - |> String.concat " \n" + li [ Class "nav-item" ] [ 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 + + match cat with + | Some c -> li [ Class "nav-header" ] [ !!c ] + | None -> li [ Class "nav-header" ] [ !! "Other" ] + + for model in modelsInCategory do + let link = model.Uri(root) + + li [ Class "nav-item" ] [ 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 = @@ -1479,7 +1511,8 @@ type CoreBuildOptions(watch) = otherFlags = apiDocOtherFlags @ Seq.toList this.fscoptions, root = root, libDirs = paths, - onError = onError + onError = onError, + menuTemplateFolder = this.input ) | OutputKind.Markdown -> ApiDocs.GenerateMarkdownPhased( @@ -1564,7 +1597,7 @@ type CoreBuildOptions(watch) = let extrasForSearchIndex = docContent.GetSearchIndexEntries(actualDocModels) - let navEntries = docContent.GetNavigationEntries(actualDocModels) + let navEntries = docContent.GetNavigationEntries(this.input, actualDocModels) let results = Map.ofList @@ -1750,7 +1783,8 @@ type CoreBuildOptions(watch) = // When _template.* change rebuild everything for templateWatcher in templateWatchers do templateWatcher.IncludeSubdirectories <- true - templateWatcher.Filter <- "_template.html" + // _menu_template.html or _menu-item_template.html could be changed as well. + templateWatcher.Filter <- "*template.html" templateWatcher.NotifyFilter <- NotifyFilters.LastWrite templateWatcher.Changed.Add(fun _ -> diff --git a/tests/FSharp.ApiDocs.Tests/ApiDocsTests.fs b/tests/FSharp.ApiDocs.Tests/ApiDocsTests.fs index dbc7a2815..474b53d02 100644 --- a/tests/FSharp.ApiDocs.Tests/ApiDocsTests.fs +++ b/tests/FSharp.ApiDocs.Tests/ApiDocsTests.fs @@ -1,6 +1,7 @@ [] module ApiDocs.Tests +open System open FsUnit open System.IO open NUnit.Framework @@ -1165,6 +1166,66 @@ let ``Metadata generates cross-type links for Inline Code`` (format: OutputForma format.ExtensionInUrl ) +[] +[] +let ``Phased generation allows a custom menu template folder`` (format: OutputFormat) = + let library = testBin "FsLib1.dll" |> fullpath + let inputs = ApiDocInput.FromFile(library) + let output = getOutputDir format "Phased" + let templateFolder = DirectoryInfo(Directory.GetCurrentDirectory() "menu") + templateFolder.Create() + + File.WriteAllText( + templateFolder.FullName "_menu_template.html", + """ +HEADER: {{fsdocs-menu-header-content}} +HEADER ID: {{fsdocs-menu-header-id}} +ITEMS: {{fsdocs-menu-items}} +""" + ) + + File.WriteAllText( + templateFolder.FullName "_menu-item_template.html", + """ +LINK: {{fsdocs-menu-item-link}} +LINK ID: {{fsdocs-menu-item-id}} +CONTENT: {{fsdocs-menu-item-content}} +""" + ) + + let _, substitutions, _, _ = + match format with + | OutputFormat.Html -> + ApiDocs.GenerateHtmlPhased([ inputs ], output, "Collection", [], menuTemplateFolder = "menu") + | OutputFormat.Markdown -> + ApiDocs.GenerateMarkdownPhased([ inputs ], output, "Collection", [], menuTemplateFolder = "menu") + + let listOfNamespaces = + substitutions + |> Seq.choose (function + | key, content -> + if key = ParamKeys.``fsdocs-list-of-namespaces`` then + Some content + else + None) + |> Seq.head + |> fun s -> + s + .Replace("\r", "") + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + |> Array.map (fun s -> s.Trim()) + |> String.concat "\n" + + Assert.AreEqual( + $"""HEADER: API Reference +HEADER ID: api_reference +ITEMS: +LINK: /reference/index{format.ExtensionInUrl} +LINK ID: all_namespaces +CONTENT: All Namespaces""" + .Replace("\r", ""), + listOfNamespaces + ) let runtest testfn = try