Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 (`![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)
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 @@
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 @@
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 @@
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 @@
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 Expand Up @@ -2441,7 +2525,7 @@

let fsiEvaluator =
if this.eval then
Some(FsiEvaluator(options = [| "--multiemit-" |]) :> IFsiEvaluator)

Check warning on line 2528 in src/fsdocs-tool/BuildCommand.fs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

It is recommended that objects supporting the IDisposable interface are created using the syntax 'new Type(args)', rather than 'Type(args)' or 'Type' as a function value representing the constructor, to indicate that resources may be owned by the generated value

Check warning on line 2528 in src/fsdocs-tool/BuildCommand.fs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

It is recommended that objects supporting the IDisposable interface are created using the syntax 'new Type(args)', rather than 'Type(args)' or 'Type' as a function value representing the constructor, to indicate that resources may be owned by the generated value

Check warning on line 2528 in src/fsdocs-tool/BuildCommand.fs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

It is recommended that objects supporting the IDisposable interface are created using the syntax 'new Type(args)', rather than 'Type(args)' or 'Type' as a function value representing the constructor, to indicate that resources may be owned by the generated value

Check warning on line 2528 in src/fsdocs-tool/BuildCommand.fs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

It is recommended that objects supporting the IDisposable interface are created using the syntax 'new Type(args)', rather than 'Type(args)' or 'Type' as a function value representing the constructor, to indicate that resources may be owned by the generated value
else
None

Expand Down