diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 72bd4ec10..58517069a 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 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) 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 b16f57306..e9d7f2f99 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 diff --git a/tests/FSharp.Literate.Tests/DocContentTests.fs b/tests/FSharp.Literate.Tests/DocContentTests.fs index 5a4ef7e9d..7f8f2e74f 100644 --- a/tests/FSharp.Literate.Tests/DocContentTests.fs +++ b/tests/FSharp.Literate.Tests/DocContentTests.fs @@ -6,6 +6,7 @@ open FSharp.Formatting.Templating open fsdocs open NUnit.Framework open FsUnitTyped +open FSharp.Formatting.Literate do FSharp.Formatting.TestHelpers.enableLogging () @@ -507,3 +508,292 @@ let ``LlmsTxt collapses excessive blank lines in content`` () = llmsFullTxt.Contains("\n\n\n") |> shouldEqual false llmsFullTxt |> shouldContainText "First paragraph" llmsFullTxt |> shouldContainText "Second paragraph" + +// -------------------------------------------------------------------------------------- +// Tests for FrontMatterFile.ParseFromLines +// -------------------------------------------------------------------------------------- + +[] +let ``FrontMatterFile.ParseFromLines parses standard YAML front-matter`` () = + let lines = + seq { + "---" + "category: Basics" + "categoryindex: 1" + "index: 2" + "---" + "# Title" + } + + let result = FrontMatterFile.ParseFromLines "test.md" lines + result |> shouldNotEqual None + let fm = result.Value + fm.FileName |> shouldEqual "test.md" + fm.Category |> shouldEqual "Basics" + fm.CategoryIndex |> shouldEqual 1 + fm.Index |> shouldEqual 2 + +[] +let ``FrontMatterFile.ParseFromLines preserves colons in category values`` () = + // Regression test for the fix in PR #1105 — previously only the part before the + // second colon was kept, so "F#: Intro" would be captured as "F#". + let lines = + seq { + "---" + "category: F#: An Introduction" + "categoryindex: 1" + "index: 1" + "---" + } + + let result = FrontMatterFile.ParseFromLines "test.md" lines + result |> shouldNotEqual None + result.Value.Category |> shouldEqual "F#: An Introduction" + +[] +let ``FrontMatterFile.ParseFromLines returns None when category is missing`` () = + let lines = + seq { + "---" + "categoryindex: 1" + "index: 2" + "---" + } + + FrontMatterFile.ParseFromLines "test.md" lines |> shouldEqual None + +[] +let ``FrontMatterFile.ParseFromLines returns None when categoryindex is missing`` () = + let lines = + seq { + "---" + "category: Basics" + "index: 1" + "---" + } + + FrontMatterFile.ParseFromLines "test.md" lines |> shouldEqual None + +[] +let ``FrontMatterFile.ParseFromLines returns None when index is missing`` () = + let lines = + seq { + "---" + "category: Basics" + "categoryindex: 1" + "---" + } + + FrontMatterFile.ParseFromLines "test.md" lines |> shouldEqual None + +[] +let ``FrontMatterFile.ParseFromLines returns None when categoryindex is non-numeric`` () = + let lines = + seq { + "---" + "category: Basics" + "categoryindex: abc" + "index: 1" + "---" + } + + FrontMatterFile.ParseFromLines "test.md" lines |> shouldEqual None + +[] +let ``FrontMatterFile.ParseFromLines parses fsx-style front-matter`` () = + let lines = + seq { + "(**" + "category: Scripting" + "categoryindex: 2" + "index: 3" + "*)" + } + + let result = FrontMatterFile.ParseFromLines "test.fsx" lines + result |> shouldNotEqual None + let fm = result.Value + fm.FileName |> shouldEqual "test.fsx" + fm.Category |> shouldEqual "Scripting" + fm.CategoryIndex |> shouldEqual 2 + fm.Index |> shouldEqual 3 + +[] +let ``FrontMatterFile.ParseFromLines trims whitespace from category`` () = + let lines = + seq { + "---" + "category: Basics with spaces " + "categoryindex: 1" + "index: 1" + "---" + } + + let result = FrontMatterFile.ParseFromLines "test.md" lines + result |> shouldNotEqual None + result.Value.Category |> shouldEqual "Basics with spaces" + +[] +let ``FrontMatterFile.ParseFromLines returns None for empty input`` () = + FrontMatterFile.ParseFromLines "test.md" Seq.empty |> shouldEqual None + +// -------------------------------------------------------------------------------------- +// Tests for GetNavigationEntries — flat and nested categories +// -------------------------------------------------------------------------------------- + +/// Builds a minimal LiterateDocModel for use in navigation tests. +let private makeNavModel path (category: string option) (categoryIndex: int option) (index: int option) title = + { Title = title + Substitutions = [] + IndexText = None + Category = category + CategoryIndex = categoryIndex + Index = index + OutputPath = path + OutputKind = OutputKind.Html + IsActive = false } + +/// Constructs a DocContent instance suitable for navigation-only tests +/// (no actual I/O or evaluation required). +let private makeDocContentForNav () = + DocContent( + Path.GetTempPath(), + Map.empty, + lineNumbers = None, + evaluate = false, + substitutions = [], + saveImages = None, + watch = false, + root = "/", + crefResolver = (fun _ -> None), + onError = failwith + ) + +/// Returns a temp directory path that contains no template files. +let private noTemplateDir () = Path.GetTempPath() + +[] +let ``GetNavigationEntries with no category emits Documentation header`` () = + let content = makeDocContentForNav () + + let models = + [ "/input/doc1.md", false, makeNavModel "doc1.html" None None (Some 1) "Doc One" + "/input/doc2.md", false, makeNavModel "doc2.html" None None (Some 2) "Doc Two" ] + + let result = content.GetNavigationEntries(noTemplateDir (), models, None, false) + result |> shouldContainText "Documentation" + result |> shouldContainText "doc1.html" + result |> shouldContainText "doc2.html" + +[] +let ``GetNavigationEntries with flat categories emits nav-header for each category`` () = + let content = makeDocContentForNav () + + let models = + [ "/input/doc1.md", false, makeNavModel "doc1.html" (Some "API") (Some 1) (Some 1) "Doc One" + "/input/doc2.md", false, makeNavModel "doc2.html" (Some "Guides") (Some 2) (Some 1) "Doc Two" ] + + let result = content.GetNavigationEntries(noTemplateDir (), models, None, false) + result |> shouldContainText "nav-header" + result |> shouldContainText "API" + result |> shouldContainText "Guides" + result |> shouldNotContainText "nav-sub-header" + +[] +let ``GetNavigationEntries with nested categories emits nav-sub-header`` () = + let content = makeDocContentForNav () + + let models = + [ "/input/arrays.md", false, makeNavModel "arrays.html" (Some "Collections/Arrays") (Some 1) (Some 1) "Arrays" + "/input/lists.md", false, makeNavModel "lists.html" (Some "Collections/Lists") (Some 2) (Some 1) "Lists" ] + + let result = content.GetNavigationEntries(noTemplateDir (), models, None, false) + result |> shouldContainText "nav-header" + result |> shouldContainText "Collections" + result |> shouldContainText "nav-sub-header" + result |> shouldContainText "Arrays" + result |> shouldContainText "Lists" + +[] +let ``GetNavigationEntries with nested categories does not emit parent name as nav-sub-header`` () = + let content = makeDocContentForNav () + + let models = + [ "/input/arrays.md", false, makeNavModel "arrays.html" (Some "Collections/Arrays") (Some 1) (Some 1) "Arrays" ] + + let result = content.GetNavigationEntries(noTemplateDir (), models, None, false) + // "Collections" is the parent → nav-header, not nav-sub-header + result |> shouldContainText """class="nav-header""" + // Verify that Collections appears somewhere in the output + result |> shouldContainText "Collections" + +[] +let ``GetNavigationEntries with nested categories orders parents by minimum CategoryIndex`` () = + let content = makeDocContentForNav () + + // Reference group has categoryindex: 1; Collections has categoryindex: 2 + // So Reference should appear first in the navigation + let models = + [ "/input/arrays.md", false, makeNavModel "arrays.html" (Some "Collections/Arrays") (Some 2) (Some 1) "Arrays" + "/input/intro.md", false, makeNavModel "intro.html" (Some "Reference/Intro") (Some 1) (Some 1) "Intro" ] + + let result = content.GetNavigationEntries(noTemplateDir (), models, None, false) + let refPos = result.IndexOf("Reference") + let colPos = result.IndexOf("Collections") + refPos |> shouldBeGreaterThan -1 + colPos |> shouldBeGreaterThan -1 + // Reference (min catIdx 1) should come before Collections (min catIdx 2) + refPos |> shouldBeSmallerThan colPos + +[] +let ``GetNavigationEntries with mixed nested and flat categories renders both`` () = + let content = makeDocContentForNav () + + let models = + [ "/input/arrays.md", false, makeNavModel "arrays.html" (Some "Collections/Arrays") (Some 1) (Some 1) "Arrays" + "/input/getting-started.md", + false, + makeNavModel "getting-started.html" (Some "Collections") (Some 1) (Some 2) "Getting Started" ] + + let result = content.GetNavigationEntries(noTemplateDir (), models, None, false) + result |> shouldContainText "nav-header" + result |> shouldContainText "Collections" + result |> shouldContainText "Arrays" + result |> shouldContainText "Getting Started" + +[] +let ``GetNavigationEntries marks current page as active`` () = + let content = makeDocContentForNav () + + let models = + [ "/input/doc1.md", false, makeNavModel "doc1.html" (Some "API") (Some 1) (Some 1) "Doc One" + "/input/doc2.md", false, makeNavModel "doc2.html" (Some "API") (Some 1) (Some 2) "Doc Two" ] + + let result = content.GetNavigationEntries(noTemplateDir (), models, Some "/input/doc1.md", false) + // doc1's nav-item should have the "active" class + result |> shouldContainText "active" + +[] +let ``GetNavigationEntries with ignoreUncategorized excludes uncategorized docs`` () = + let content = makeDocContentForNav () + + let models = + [ "/input/doc1.md", false, makeNavModel "doc1.html" (Some "API") (Some 1) (Some 1) "Doc One" + "/input/doc2.md", false, makeNavModel "doc2.html" None None None "Uncategorized Doc" ] + + let result = content.GetNavigationEntries(noTemplateDir (), models, None, true) + result |> shouldContainText "Doc One" + result |> shouldNotContainText "Uncategorized Doc" + +[] +let ``GetNavigationEntries index files are excluded from navigation`` () = + let content = makeDocContentForNav () + + let models = + [ "/input/index.md", false, makeNavModel "index.html" (Some "API") (Some 1) (Some 1) "Index Page" + "/input/doc1.md", false, makeNavModel "doc1.html" (Some "API") (Some 1) (Some 2) "Doc One" ] + + let result = content.GetNavigationEntries(noTemplateDir (), models, None, false) + // index.md should be excluded from navigation + result |> shouldNotContainText "Index Page" + result |> shouldContainText "Doc One"