@@ -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
818826module 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