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"