diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 02b87c379..004f69436 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,7 +2,11 @@ ## [Unreleased] +### Added +* Support nested navigation categories using `/` as a separator in the `category` front-matter field (e.g. `category: Reference/API`). Parent categories are rendered as top-level nav headers; sub-categories appear as indented sub-headers beneath them. Documents without a sub-category are listed directly under their parent header. Existing flat categories continue to work unchanged. [#927](https://github.com/fsprojects/FSharp.Formatting/issues/927) + ### Fixed +* Fix front-matter parsing to correctly handle values that contain `:` (e.g. `title: F#: An Introduction`). Previously, only the text before the second `:` was captured; now the full value is preserved. * Add regression test confirming that types whose name matches their enclosing namespace are correctly included in generated API docs. [#944](https://github.com/fsprojects/FSharp.Formatting/issues/944) * Fix crash (`failwith "tbd - IndirectImage"`) when `Markdown.ToMd` is called on a document containing reference-style images (`![alt][ref]`). The indirect image is now serialised as `![alt](url)` when the reference is resolved, or `![alt][ref]` when it is not. [#1094](https://github.com/fsprojects/FSharp.Formatting/pull/1094) * Fix `Markdown.ToMd` serialising `*emphasis*` (italic) spans as `**...**` (bold) instead of `*...*`. [#1102](https://github.com/fsprojects/FSharp.Formatting/pull/1102) diff --git a/docs/content/fsdocs-default.css b/docs/content/fsdocs-default.css index 5e2c1261e..b4fbc59bf 100644 --- a/docs/content/fsdocs-default.css +++ b/docs/content/fsdocs-default.css @@ -390,6 +390,15 @@ main { font-weight: 500; color: var(--menu-color); } + + .nav-sub-header { + margin-top: var(--spacing-200); + padding-left: var(--spacing-200); + font-size: var(--font-200); + font-weight: 500; + color: var(--menu-color); + opacity: 0.8; + } } .nav-header:first-child { diff --git a/src/FSharp.Formatting.Common/Templating.fs b/src/FSharp.Formatting.Common/Templating.fs index 0b83593ad..9d29292b4 100644 --- a/src/FSharp.Formatting.Common/Templating.fs +++ b/src/FSharp.Formatting.Common/Templating.fs @@ -53,7 +53,7 @@ type FrontMatterFile = let parts = line.Split(":") |> Array.toList match parts with - | first :: second :: _ -> Some(first.ToLowerInvariant(), second) + | first :: rest when rest <> [] -> Some(first.ToLowerInvariant(), String.concat ":" rest) | _ -> None else None) diff --git a/src/fsdocs-tool/BuildCommand.fs b/src/fsdocs-tool/BuildCommand.fs index 8df394de5..d1992b04a 100644 --- a/src/fsdocs-tool/BuildCommand.fs +++ b/src/fsdocs-tool/BuildCommand.fs @@ -733,6 +733,21 @@ type internal DocContent else id + /// Parses a category string into (parentCategory, subCategory option). + /// "Collections/Lists" → (Some "Collections", Some "Lists") + /// "Collections" → (Some "Collections", None) + /// None → (None, None) + 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 modelsByCategory = modelsForList |> excludeUncategorized @@ -750,6 +765,13 @@ type internal DocContent list |> List.sortBy (fun model -> Option.defaultValue Int32.MaxValue model.Index) + let hasNestedCategories = + modelsForList + |> List.exists (fun m -> + match m.Category with + | Some s -> s.Contains('/') + | None -> false) + if Menu.isTemplatingAvailable input then let createGroup (isCategoryActive: bool) (header: string) (items: LiterateDocModel list) : string = //convert items into menuitem list @@ -764,12 +786,21 @@ type internal DocContent 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 + // For templating path, group by parent category (flatten nested structure) + let groupsByParent = + modelsByCategory + |> List.groupBy (fun (cat, _) -> fst (parseNestedCategory cat)) + |> List.map (fun (parentCat, groups) -> + let allItems = groups |> List.collect snd + (parentCat, allItems)) + + groupsByParent |> List.map (fun (header, items) -> let header = Option.defaultValue "Other" header let isActive = items |> List.exists (fun m -> m.IsActive) @@ -788,6 +819,59 @@ type internal DocContent li [ Class $"nav-item %s{activeClass}" ] [ a [ Class "nav-link"; (Href link) ] [ encode model.Title ] ] + elif hasNestedCategories then + // Nested category rendering: group by parent, then sub-category + // Parent ordering uses the minimum CategoryIndex of any child + let modelsByParent = + modelsForList + |> excludeUncategorized + |> List.groupBy (fun model -> fst (parseNestedCategory model.Category)) + |> List.sortBy (fun (parentCat, ms) -> + match parentCat with + | None -> Int32.MaxValue + | Some _ -> + ms + |> List.choose (fun m -> m.CategoryIndex) + |> function + | [] -> Int32.MaxValue + | idxs -> List.min idxs) + + for (parentCat, parentItems) in modelsByParent do + let parentActive = parentItems |> List.exists (fun m -> m.IsActive) + let parentActiveClass = if parentActive then "active" else "" + let parentHeader = Option.defaultValue "Other" parentCat + + li [ Class $"nav-header %s{parentActiveClass}" ] [ !!parentHeader ] + + // Group by sub-category; items with no sub-cat come first (before sub-headers) + let subGroups = + parentItems + |> List.groupBy (fun model -> snd (parseNestedCategory model.Category)) + |> List.sortBy (fun (subCat, ms) -> + match subCat with + | None -> Int32.MinValue + | Some _ -> + ms + |> List.choose (fun m -> m.CategoryIndex) + |> function + | [] -> Int32.MaxValue + | idxs -> List.min idxs) + + for (subCat, subItems) in subGroups do + match subCat with + | Some sub -> + let subActive = subItems |> List.exists (fun m -> m.IsActive) + let subActiveClass = if subActive then "active" else "" + li [ Class $"nav-sub-header %s{subActiveClass}" ] [ !!sub ] + | None -> () + + for model in orderList subItems 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