Skip to content

Commit 399c19c

Browse files
[Repo Assist] perf: pre-compute navigation menu structure once per build (O(n²) → O(n)) (#1129)
* perf: pre-compute navigation structure once per build (O(n²) → O(n)) Replace GetNavigationEntries (called once per page) with GetNavigationEntriesFactory, which pre-computes the expensive filter/group/sort structure once and returns a cheap closure that only applies IsActive flags and generates HTML. For a site with n pages, this reduces: - sorting/grouping: from O(n × n log n) to O(n log n) - File.Exists calls for template detection: from 2n to 2 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: trigger checks * fix: use valid Keep a Changelog subsection type 'Changed' instead of 'Performance' '### Performance' is not a recognised Keep a Changelog subsection. Ionide.KeepAChangelog.Tasks 0.3.3 enforces the standard categories (Added, Changed, Deprecated, Fixed, Removed, Security), causing the build to fail with IKC0002. Rename to '### Changed'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: trigger checks * tests: add unit tests for GetNavigationEntriesFactory (PR #1129) 15 tests covering: - Empty input → empty output - Single uncategorized model → Documentation header - None currentPagePath → no active item - Matching/non-matching page path → correct active state - Exactly one active item among multiple pages - Exclusion of isOtherLang, non-HTML, and index file models - ignoreUncategorized flag (true and false) - Category ordering by CategoryIndex - Item ordering within a category by Index - Factory idempotency (multiple calls, same result) - Per-page active state correctness across successive calls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: trigger checks --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 7027b34 commit 399c19c

3 files changed

Lines changed: 348 additions & 102 deletions

File tree

RELEASE_NOTES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
* Fix `Markdown.ToMd` serialising italic spans with asterisks incorrectly as bold spans. [#1102](https://github.com/fsprojects/FSharp.Formatting/pull/1102)
1414
* Fix `Markdown.ToMd` serialising ordered list items with incorrect numbering and formatting. [#1102](https://github.com/fsprojects/FSharp.Formatting/pull/1102)
1515

16+
### Changed
17+
* `fsdocs build` now pre-computes the navigation menu structure (filter/group/sort) once per build rather than once per output page, reducing work from O(n²) to O(n) for sites with n pages. The filesystem check for custom menu templates is also cached per build. [#1129](https://github.com/fsprojects/FSharp.Formatting/pull/1129)
18+
1619
## [22.0.0-alpha.2] - 2026-03-13
1720

1821
### Added

src/fsdocs-tool/BuildCommand.fs

Lines changed: 107 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -705,114 +705,122 @@ type internal DocContent
705705
``type`` = "content" }
706706
| _ -> () |]
707707

708-
member _.GetNavigationEntries
709-
(
710-
input,
711-
docModels: (string * bool * LiterateDocModel) list,
712-
currentPagePath: string option,
713-
ignoreUncategorized: bool
714-
) =
715-
let modelsForList =
716-
[ for thing in docModels do
717-
match thing with
718-
| (inputFileFullPath, isOtherLang, model) when
708+
/// Pre-computes the expensive navigation structure (filter/group/sort) once, returning a
709+
/// cheap render function that generates nav HTML for any given current page path.
710+
/// This avoids O(n²) work when building a site with n pages, since the structure
711+
/// (grouping, sorting, templating check) is the same for every page.
712+
member _.GetNavigationEntriesFactory
713+
(input, docModels: (string * bool * LiterateDocModel) list, ignoreUncategorized: bool)
714+
: string option -> string =
715+
716+
// Pre-compute: filter eligible models, keeping paths for active-page detection
717+
let baseModels =
718+
[ for (inputFileFullPath, isOtherLang, model) in docModels do
719+
if
719720
not isOtherLang
720721
&& model.OutputKind = OutputKind.Html
721-
&& (Path.GetFileNameWithoutExtension(inputFileFullPath) <> "index")
722-
->
723-
{ model with
724-
IsActive =
725-
match currentPagePath with
726-
| None -> false
727-
| Some currentPagePath -> currentPagePath = inputFileFullPath }
728-
| _ -> () ]
729-
730-
let excludeUncategorized =
722+
&& Path.GetFileNameWithoutExtension(inputFileFullPath) <> "index"
723+
then
724+
yield (inputFileFullPath, model) ]
725+
726+
let filteredBase =
731727
if ignoreUncategorized then
732-
List.filter (fun (model: LiterateDocModel) -> model.Category.IsSome)
728+
baseModels |> List.filter (fun (_, model) -> model.Category.IsSome)
733729
else
734-
id
735-
736-
let modelsByCategory =
737-
modelsForList
738-
|> excludeUncategorized
739-
|> List.groupBy (fun (model) -> model.Category)
740-
|> List.sortBy (fun (_, ms) ->
741-
match ms.[0].CategoryIndex with
730+
baseModels
731+
732+
// Pre-sort items within each category (independent of active page)
733+
let orderGroup items =
734+
items
735+
|> List.sortBy (fun (_, model: LiterateDocModel) -> Option.defaultValue Int32.MaxValue model.Index)
736+
737+
// Pre-compute: group by category, sort categories, sort items within each group
738+
let sortedGroups =
739+
filteredBase
740+
|> List.groupBy (fun (_, model) -> model.Category)
741+
|> List.sortBy (fun (_, items) ->
742+
match (snd items.[0]).CategoryIndex with
742743
| Some s ->
743744
(try
744745
int32 s
745746
with _ ->
746747
Int32.MaxValue)
747748
| None -> Int32.MaxValue)
748-
749-
let orderList (list: (LiterateDocModel) list) =
750-
list
751-
|> List.sortBy (fun model -> Option.defaultValue Int32.MaxValue model.Index)
752-
753-
if Menu.isTemplatingAvailable input then
754-
let createGroup (isCategoryActive: bool) (header: string) (items: LiterateDocModel list) : string =
755-
//convert items into menuitem list
756-
let menuItems =
757-
orderList items
758-
|> List.map (fun (model: LiterateDocModel) ->
759-
let link = model.Uri(root)
760-
let title = System.Web.HttpUtility.HtmlEncode model.Title
761-
762-
{ Menu.MenuItem.Link = link
763-
Menu.MenuItem.Content = title
764-
Menu.MenuItem.IsActive = model.IsActive })
765-
766-
Menu.createMenu input isCategoryActive header menuItems
767-
// No categories specified
768-
if modelsByCategory.Length = 1 && (fst modelsByCategory.[0]) = None then
769-
let _, items = modelsByCategory.[0]
770-
createGroup false "Documentation" items
749+
|> List.map (fun (cat, items) -> cat, orderGroup items)
750+
751+
// Cache filesystem check — same result for all pages in a build
752+
let useTemplating = Menu.isTemplatingAvailable input
753+
754+
// Cheap render function: only sets IsActive and generates HTML (no sorting/grouping)
755+
fun (currentPagePath: string option) ->
756+
let modelsByCategory =
757+
sortedGroups
758+
|> List.map (fun (cat, items) ->
759+
cat,
760+
items
761+
|> List.map (fun (path, model) ->
762+
{ model with
763+
IsActive =
764+
match currentPagePath with
765+
| None -> false
766+
| Some cp -> cp = path }))
767+
768+
if useTemplating then
769+
let createGroup (isCategoryActive: bool) (header: string) (items: LiterateDocModel list) : string =
770+
let menuItems =
771+
items
772+
|> List.map (fun (model: LiterateDocModel) ->
773+
let link = model.Uri(root)
774+
let title = System.Web.HttpUtility.HtmlEncode model.Title
775+
776+
{ Menu.MenuItem.Link = link
777+
Menu.MenuItem.Content = title
778+
Menu.MenuItem.IsActive = model.IsActive })
779+
780+
Menu.createMenu input isCategoryActive header menuItems
781+
782+
if modelsByCategory.Length = 1 && (fst modelsByCategory.[0]) = None then
783+
let _, items = modelsByCategory.[0]
784+
createGroup false "Documentation" items
785+
else
786+
modelsByCategory
787+
|> List.map (fun (header, items) ->
788+
let header = Option.defaultValue "Other" header
789+
let isActive = items |> List.exists (fun m -> m.IsActive)
790+
createGroup isActive header items)
791+
|> String.concat "\n"
771792
else
772-
modelsByCategory
773-
|> List.map (fun (header, items) ->
774-
let header = Option.defaultValue "Other" header
775-
let isActive = items |> List.exists (fun m -> m.IsActive)
776-
createGroup isActive header items)
777-
|> String.concat "\n"
778-
else
779-
[
780-
// No categories specified
781-
if modelsByCategory.Length = 1 && (fst modelsByCategory.[0]) = None then
782-
li [ Class "nav-header" ] [ !!"Documentation" ]
783-
784-
for model in snd modelsByCategory.[0] do
785-
let link = model.Uri(root)
786-
let activeClass = if model.IsActive then "active" else ""
787-
788-
li
789-
[ Class $"nav-item %s{activeClass}" ]
790-
[ a [ Class "nav-link"; (Href link) ] [ encode model.Title ] ]
791-
else
792-
// At least one category has been specified. Sort each category by index and emit
793-
// Use 'Other' as a header for uncategorised things
794-
for (cat, modelsInCategory) in modelsByCategory do
795-
let modelsInCategory = orderList modelsInCategory
796-
797-
let categoryActiveClass =
798-
if modelsInCategory |> List.exists (fun m -> m.IsActive) then
799-
"active"
800-
else
801-
""
802-
803-
match cat with
804-
| Some c -> li [ Class $"nav-header %s{categoryActiveClass}" ] [ !!c ]
805-
| None -> li [ Class $"nav-header %s{categoryActiveClass}" ] [ !!"Other" ]
793+
[ if modelsByCategory.Length = 1 && (fst modelsByCategory.[0]) = None then
794+
li [ Class "nav-header" ] [ !!"Documentation" ]
806795

807-
for model in modelsInCategory do
796+
for model in snd modelsByCategory.[0] do
808797
let link = model.Uri(root)
809798
let activeClass = if model.IsActive then "active" else ""
810799

811800
li
812801
[ Class $"nav-item %s{activeClass}" ]
813-
[ a [ Class "nav-link"; (Href link) ] [ encode model.Title ] ] ]
814-
|> List.map (fun html -> html.ToString())
815-
|> String.concat " \n"
802+
[ a [ Class "nav-link"; (Href link) ] [ encode model.Title ] ]
803+
else
804+
for (cat, modelsInCategory) in modelsByCategory do
805+
let categoryActiveClass =
806+
if modelsInCategory |> List.exists (fun m -> m.IsActive) then
807+
"active"
808+
else
809+
""
810+
811+
match cat with
812+
| Some c -> li [ Class $"nav-header %s{categoryActiveClass}" ] [ !!c ]
813+
| None -> li [ Class $"nav-header %s{categoryActiveClass}" ] [ !!"Other" ]
814+
815+
for model in modelsInCategory do
816+
let link = model.Uri(root)
817+
let activeClass = if model.IsActive then "active" else ""
818+
819+
li
820+
[ Class $"nav-item %s{activeClass}" ]
821+
[ a [ Class "nav-link"; (Href link) ] [ encode model.Title ] ] ]
822+
|> List.map (fun html -> html.ToString())
823+
|> String.concat " \n"
816824

817825
/// Processes and runs Suave server to host them on localhost
818826
module Serve =
@@ -2027,14 +2035,17 @@ type CoreBuildOptions(watch) =
20272035
let actualDocModels = docModels |> List.map fst |> List.choose id
20282036
let extrasForSearchIndex = docContent.GetSearchIndexEntries(actualDocModels)
20292037

2030-
let navEntriesWithoutActivePage =
2031-
docContent.GetNavigationEntries(
2038+
// Pre-compute the navigation structure once; returned closure cheaply
2039+
// generates per-page nav HTML by only re-applying active-page flags.
2040+
let getNavEntries =
2041+
docContent.GetNavigationEntriesFactory(
20322042
this.input,
20332043
actualDocModels,
2034-
None,
20352044
ignoreUncategorized = this.ignoreuncategorized
20362045
)
20372046

2047+
let navEntriesWithoutActivePage = getNavEntries None
2048+
20382049
let headTemplateContent =
20392050
let headTemplatePath = Path.Combine(this.input, "_head.html")
20402051

@@ -2078,14 +2089,8 @@ type CoreBuildOptions(watch) =
20782089
match optDocModel with
20792090
| None -> globals
20802091
| Some(currentPagePath, _, _) ->
2081-
// Update the nav entries with the current page doc model
2082-
let navEntries =
2083-
docContent.GetNavigationEntries(
2084-
this.input,
2085-
actualDocModels,
2086-
Some currentPagePath,
2087-
ignoreUncategorized = this.ignoreuncategorized
2088-
)
2092+
// Use the pre-computed factory closure (only sets IsActive, no re-sorting)
2093+
let navEntries = getNavEntries (Some currentPagePath)
20892094

20902095
globals
20912096
|> List.map (fun (pk, v) ->

0 commit comments

Comments
 (0)