From 1094399636f12cb1fad49f6289e1e642a56245f0 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 19 Sep 2026 00:48:19 +0000
Subject: [PATCH] Add nested navigation categories via '/' separator in
category front matter
Support 'category: Parent/Sub' syntax in document front matter. Documents
using a slash separator are grouped under a parent nav header, with a
sub-header rendered for each distinct sub-category beneath it. Documents
without a sub-category remain listed directly under the parent. Existing
flat categories are unchanged (no '/' -> identical output). Parent ordering
uses the minimum categoryindex of any document under it, including those
in its sub-categories.
Closes #927.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
RELEASE_NOTES.md | 5 +
docs/content.fsx | 7 ++
docs/content/fsdocs-default.css | 14 +++
src/fsdocs-tool/DocContent.fs | 134 +++++++++++++++------
tests/fsdocs-tool.Tests/DocContentTests.fs | 64 ++++++++++
5 files changed, 184 insertions(+), 40 deletions(-)
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 9e0162bfb..f18d8c97f 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,5 +1,10 @@
# Changelog
+## [Unreleased]
+
+### Added
+* Support nested navigation categories using `/` as a separator in the `category` front-matter field (e.g. `category: Collections/Lists`). The part before the `/` renders as a top-level nav header; the part after it renders as an indented sub-header beneath it. Documents without a sub-category, or using a flat `category` with no `/`, continue to render exactly as before. [#927](https://github.com/fsprojects/FSharp.Formatting/issues/927)
+
## [23.0.0-alpha.8] - 2026-09-18
### Added
diff --git a/docs/content.fsx b/docs/content.fsx
index 707401ab4..4048f9e39 100644
--- a/docs/content.fsx
+++ b/docs/content.fsx
@@ -100,6 +100,13 @@ The `title` is used in the navigation bar instead of any title inferred from the
The `description` is used in ` string option -> string =
+ // Splits a `category` front-matter value on the first `/` into a parent category and an
+ // optional sub-category, e.g. "Collections/Lists" -> (Some "Collections", Some "Lists").
+ // A category without a `/` is treated as a parent with no sub-category, so flat categories
+ // render exactly as before.
+ let parseNestedCategory (cat: string option) =
+ match cat with
+ | None -> (None, None)
+ | Some s ->
+ let idx = s.IndexOf('/')
+
+ if idx > 0 && idx < s.Length - 1 then
+ (Some(s.[.. idx - 1].Trim()), Some(s.[idx + 1 ..].Trim()))
+ else
+ (Some s, None)
+
let baseModels =
[
for page in pages do
@@ -582,37 +597,57 @@ module internal Content =
items
|> List.sortBy (fun (_, model: NavPage) -> Option.defaultValue Int32.MaxValue model.Index)
- // Pre-compute: group by category, sort categories, sort items within each group
+ let minCategoryIndex items =
+ items
+ |> List.choose (fun (_, model: NavPage) -> model.CategoryIndex)
+ |> function
+ | [] -> Int32.MaxValue
+ | idxs -> List.min idxs
+
+ // Pre-compute: group by (parent, sub) category, sort parents, sort sub-groups within each
+ // parent, sort items within each sub-group. Parent ordering uses the minimum CategoryIndex
+ // of any item under it (including items in sub-categories); items with no sub-category are
+ // listed before any sub-headers within their parent.
let sortedGroups =
filteredBase
- |> List.groupBy (fun (_, model) -> model.Category)
- |> List.sortBy (fun (_, items) ->
- match (snd items.[0]).CategoryIndex with
- | Some s ->
- (try
- int32 s
- with _ ->
- Int32.MaxValue)
- | None -> Int32.MaxValue)
- |> List.map (fun (cat, items) -> cat, orderGroup items)
+ |> List.groupBy (fun (_, model) -> parseNestedCategory model.Category)
+ |> List.groupBy (fun ((parentCat, _), _) -> parentCat)
+ |> List.sortBy (fun (_, subGroups) -> subGroups |> List.collect snd |> minCategoryIndex)
+ |> List.map (fun (parentCat, subGroups) ->
+ let orderedSubGroups =
+ subGroups
+ |> List.sortBy (fun ((_, subCat), items) ->
+ match subCat with
+ | None -> Int32.MinValue
+ | Some _ -> minCategoryIndex items)
+ |> List.map (fun ((_, subCat), items) -> subCat, orderGroup items)
+
+ parentCat, orderedSubGroups)
// Cache filesystem check — same result for all pages in a build
let useTemplating = Menu.isTemplatingAvailable input
// Cheap render function: only sets IsActive and generates HTML (no sorting/grouping)
fun (root: string) (currentPagePath: string option) ->
+ let setActive items =
+ items
+ |> List.map (fun (path, model) ->
+ let isActive =
+ match currentPagePath with
+ | None -> false
+ | Some cp -> cp = path
+
+ model, isActive)
+
let modelsByCategory =
sortedGroups
- |> List.map (fun (cat, items) ->
- cat,
- items
- |> List.map (fun (path, model) ->
- let isActive =
- match currentPagePath with
- | None -> false
- | Some cp -> cp = path
+ |> List.map (fun (parentCat, subGroups) ->
+ parentCat, subGroups |> List.map (fun (subCat, items) -> subCat, setActive items))
- model, isActive))
+ let isSingleFlatCategory =
+ match modelsByCategory with
+ | [ (None, [ (None, _) ]) ] -> true
+ | _ -> false
if useTemplating then
// The menu templates get what the page gets, root included, so they can link
@@ -635,22 +670,29 @@ module internal Content =
Menu.createMenu input pageSubstitutions isCategoryActive header menuItems
- if modelsByCategory.Length = 1 && (fst modelsByCategory.[0]) = None then
- let _, items = modelsByCategory.[0]
+ if isSingleFlatCategory then
+ let _, subGroups = modelsByCategory.[0]
+ let _, items = subGroups.[0]
createGroup false "Documentation" items
else
+ // The menu template system has no notion of a sub-header, so a nested category
+ // is flattened here: every item under a parent, sub-categorised or not, is
+ // rendered as one flat group under the parent's own header.
modelsByCategory
- |> List.map (fun (header, items) ->
- let header = Option.defaultValue "Other" header
+ |> List.map (fun (parentCat, subGroups) ->
+ let header = Option.defaultValue "Other" parentCat
+ let items = subGroups |> List.collect snd
let isActive = items |> List.exists snd
createGroup isActive header items)
|> String.concat "\n"
else
[
- if modelsByCategory.Length = 1 && (fst modelsByCategory.[0]) = None then
+ if isSingleFlatCategory then
+ let _, subGroups = modelsByCategory.[0]
+ let _, items = subGroups.[0]
li [ Class "nav-header" ] [ !!"Documentation" ]
- for (model, isActive) in snd modelsByCategory.[0] do
+ for (model, isActive) in items do
let link = model.Uri(root)
let activeClass = if isActive then "active" else ""
@@ -658,20 +700,32 @@ module internal Content =
[ Class $"nav-item %s{activeClass}" ]
[ a [ Class "nav-link"; (Href link) ] [ encode model.Title ] ]
else
- for (cat, modelsInCategory) in modelsByCategory do
- let categoryActiveClass = if modelsInCategory |> List.exists snd 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, isActive) in modelsInCategory do
- let link = model.Uri(root)
- let activeClass = if isActive then "active" else ""
-
- li
- [ Class $"nav-item %s{activeClass}" ]
- [ a [ Class "nav-link"; (Href link) ] [ encode model.Title ] ]
+ for (parentCat, subGroups) in modelsByCategory do
+ let parentActiveClass =
+ if subGroups |> List.exists (fun (_, items) -> items |> List.exists snd) then
+ "active"
+ else
+ ""
+
+ match parentCat with
+ | Some c -> li [ Class $"nav-header %s{parentActiveClass}" ] [ !!c ]
+ | None -> li [ Class $"nav-header %s{parentActiveClass}" ] [ !!"Other" ]
+
+ for (subCat, itemsInGroup) in subGroups do
+ match subCat with
+ | Some sub ->
+ let subActiveClass = if itemsInGroup |> List.exists snd then "active" else ""
+
+ li [ Class $"nav-sub-header %s{subActiveClass}" ] [ !!sub ]
+ | None -> ()
+
+ for (model, isActive) in itemsInGroup do
+ let link = model.Uri(root)
+ let activeClass = if 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/tests/fsdocs-tool.Tests/DocContentTests.fs b/tests/fsdocs-tool.Tests/DocContentTests.fs
index c87223474..6e2bf48a7 100644
--- a/tests/fsdocs-tool.Tests/DocContentTests.fs
+++ b/tests/fsdocs-tool.Tests/DocContentTests.fs
@@ -331,3 +331,67 @@ let ``navigation factory excludes index pages and marks the active page`` () =
html |> shouldContainText "B & co"
html |> shouldContainText "nav-item active"
html.IndexOf("A") < html.IndexOf("B & co") |> shouldEqual true
+
+[]
+let ``navigation factory renders a Parent/Child category as a nested header, closes #927`` () =
+ let page path title category index =
+ {
+ NavPage.InputPath = path
+ OutputPath = Path.GetFileNameWithoutExtension path + ".html"
+ Title = title
+ Category = Some category
+ CategoryIndex = Some 1
+ Index = Some index
+ }
+
+ let pages =
+ [
+ page "/docs/lists.md" "Lists" "Collections/Lists" 1
+ page "/docs/arrays.md" "Arrays" "Collections/Arrays" 1
+ page "/docs/misc.md" "Misc" "Collections" 1
+ ]
+
+ let render =
+ Content.getNavigationEntriesFactory (tempDir, pages, false, [], [ FSharp.Formatting.Templating.ParamKeys.root ])
+
+ let html = render "../" (Some "/docs/lists.md")
+
+ // One parent header, not one per document.
+ html |> shouldContainText "nav-header"
+ html |> shouldContainText "Collections"
+
+ // Each sub-category gets its own sub-header.
+ html |> shouldContainText "nav-sub-header"
+ html |> shouldContainText "Arrays"
+ html |> shouldContainText "Lists"
+
+ // A document without a sub-category is listed directly under the parent, before any sub-header.
+ let miscIdx = html.IndexOf("Misc")
+ let firstSubHeaderIdx = html.IndexOf("nav-sub-header")
+ miscIdx < firstSubHeaderIdx |> shouldEqual true
+
+ // The active document's page marks its own sub-header active, not the whole parent alone.
+ html |> shouldContainText "nav-sub-header active"
+
+[]
+let ``navigation factory renders a flat category unchanged when no category uses a slash`` () =
+ let page path title category index =
+ {
+ NavPage.InputPath = path
+ OutputPath = Path.GetFileNameWithoutExtension path + ".html"
+ Title = title
+ Category = Some category
+ CategoryIndex = Some 1
+ Index = Some index
+ }
+
+ let pages = [ page "/docs/a.md" "A" "Docs" 1; page "/docs/b.md" "B" "Docs" 2 ]
+
+ let render =
+ Content.getNavigationEntriesFactory (tempDir, pages, false, [], [ FSharp.Formatting.Templating.ParamKeys.root ])
+
+ let html = render "../" None
+
+ html |> shouldNotContainText "nav-sub-header"
+ html |> shouldContainText "nav-header"
+ html |> shouldContainText "Docs"