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
+{{fsdocs-menu-item-content}}
+```
+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