Skip to content
Closed
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
3 changes: 3 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [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)

### Changed
* Avoided a per-character `Seq.windowed`/`String()` allocation in `StartsWithNTimesTrimIgnoreStartWhitespace`, the active pattern used to count repeated fence characters (`` ` `` / `~`) when parsing code fences and headers. The count is now computed with `String.CompareOrdinal` over string offsets, with no intermediate substring allocations. Behavior is unchanged.

Expand Down
7 changes: 7 additions & 0 deletions docs/content.fsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ The `title` is used in the navigation bar instead of any title inferred from the
The `description` is used in `<meta name="description"` as part of the `{{fsdocs-meta-tags}}` substitution.
The `keywords` are also used in a meta tag as part of `{{fsdocs-meta-tags}}`. Separate them using a `,`.

A `category` can be nested one level by using `/` as a separator, e.g. `category: Collections/Lists`.
The part before the `/` is rendered as a top-level navigation header, and the part after it as an
indented sub-header beneath it. Documents that share the same parent but have no sub-category (or a
flat `category` with no `/`) are listed directly under the parent header. The parent's position among
the other top-level headers is determined by the lowest `categoryindex` of any document under it,
including those in its sub-categories.

## Link Translation for Inputs

If an input is used in markdown as a target of a markdown direct link, then that is replaced by the output file. For example:
Expand Down
14 changes: 14 additions & 0 deletions docs/content/fsdocs-default.css
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,20 @@ main {
font-weight: 700;
}
}

.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;

&.active {
font-weight: 700;
opacity: 1;
}
}
}

.nav-header:first-child {
Expand Down
134 changes: 94 additions & 40 deletions src/fsdocs-tool/DocContent.fs
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,21 @@ module internal Content =
rootKeys: ParamKey list
) : string -> 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
Expand All @@ -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
Expand All @@ -635,43 +670,62 @@ 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 ""

li
[ 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"
Expand Down
64 changes: 64 additions & 0 deletions tests/fsdocs-tool.Tests/DocContentTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -331,3 +331,67 @@ let ``navigation factory excludes index pages and marks the active page`` () =
html |> shouldContainText "B &amp; co"
html |> shouldContainText "nav-item active"
html.IndexOf("A") < html.IndexOf("B &amp; co") |> shouldEqual true

[<Test>]
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"

[<Test>]
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"
Loading