Skip to content
Open
4 changes: 4 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 with bracket syntax. The indirect image is now serialised as `![alt](url)` when the reference is resolved, or in bracket notation when it is not. [#1094](https://github.com/fsprojects/FSharp.Formatting/pull/1094)
* Fix `Markdown.ToMd` serialising italic spans with asterisks incorrectly as bold spans. [#1102](https://github.com/fsprojects/FSharp.Formatting/pull/1102)
Expand Down
9 changes: 9 additions & 0 deletions docs/content/fsdocs-default.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/FSharp.Formatting.Common/Templating.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
86 changes: 85 additions & 1 deletion src/fsdocs-tool/BuildCommand.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down
Loading