diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 33214cbc..3b4c0b50 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -2,6 +2,9 @@
## [Unreleased]
+### Changed
+* Compile `Regex` instances to module-level singletons (with `RegexOptions.Compiled`) in `PageContentList`, `HtmlFormatting`, `Formatting`, `Menu`, and `LlmsTxt`. Previously a new, uncompiled `Regex` was constructed on every call (once per page heading, once per HTML page, once per menu item, once per llms.txt entry), incurring repeated JIT overhead. The patterns are now compiled once at module load and reused across all calls.
+
## [22.0.0] - 2026-04-03
### Fixed
diff --git a/src/FSharp.Formatting.Common/Menu.fs b/src/FSharp.Formatting.Common/Menu.fs
index b788de4a..508b0453 100644
--- a/src/FSharp.Formatting.Common/Menu.fs
+++ b/src/FSharp.Formatting.Common/Menu.fs
@@ -12,8 +12,11 @@ type MenuItem =
IsActive: bool }
/// Converts a display string to a snake_case HTML id attribute value
+let private snakeCaseRegex =
+ System.Text.RegularExpressions.Regex("[A-Z]", System.Text.RegularExpressions.RegexOptions.Compiled)
+
let private snakeCase (v: string) =
- System.Text.RegularExpressions.Regex.Replace(v, "[A-Z]", "$0").Replace(" ", "_").ToLower()
+ snakeCaseRegex.Replace(v, "$0").Replace(" ", "_").ToLower()
/// Renders an HTML navigation menu for the given header and items using template files in `input`
let createMenu (input: string) (isCategoryActive: bool) (header: string) (items: MenuItem list) : string =
diff --git a/src/FSharp.Formatting.Common/PageContentList.fs b/src/FSharp.Formatting.Common/PageContentList.fs
index 5300132b..fb92d31e 100644
--- a/src/FSharp.Formatting.Common/PageContentList.fs
+++ b/src/FSharp.Formatting.Common/PageContentList.fs
@@ -13,10 +13,11 @@ let EmptyContent = "
"
/// We process the html to collect the table of content.
/// We can't use the doc.MarkdownDocument because we cannot easily get the generated id values.
/// It is safer to parse the html.
-let mkPageContentMenu (html: string) =
- let headingLinkPattern = "]*href=\"([^\"]+)\">([^<]+)"
- let regex = Regex(headingLinkPattern)
+// Compiled once at module load; reused across all pages.
+let private headingLinkRegex = Regex("]*href=\"([^\"]+)\">([^<]+)", RegexOptions.Compiled)
+
+let mkPageContentMenu (html: string) =
let extractHeadingLinks (matchItem: Match) =
let level = int matchItem.Groups.[1].Value
@@ -26,7 +27,7 @@ let mkPageContentMenu (html: string) =
linkText, li [ Class $"level-%i{level}" ] [ a [ Href href ] [ !!linkText ] ]
let headingTexts, listItems =
- regex.Matches(html)
+ headingLinkRegex.Matches(html)
|> Seq.cast
|> Seq.map extractHeadingLinks
|> Seq.toList
diff --git a/src/FSharp.Formatting.Literate/Formatting.fs b/src/FSharp.Formatting.Literate/Formatting.fs
index 3bc583e8..65355c6b 100644
--- a/src/FSharp.Formatting.Literate/Formatting.fs
+++ b/src/FSharp.Formatting.Literate/Formatting.fs
@@ -13,6 +13,9 @@ open FSharp.Formatting.Templating
/// substitution key–value pairs used by the templating engine to populate page templates.
module internal Formatting =
+ // Compiled once at module load; reused for every HTML page's search-index text extraction.
+ let private htmlTagRegex = Regex("<.*?>", RegexOptions.Compiled ||| RegexOptions.Singleline)
+
/// Format document with the specified output kind
let format (doc: MarkdownDocument) generateAnchors outputKind substitutions crefResolver mdlinkResolver =
match outputKind with
@@ -290,7 +293,7 @@ module internal Formatting =
(match ctx.OutputKind with
| OutputKind.Html ->
// Strip the html tags
- let fullText = Regex.Replace(formattedDocument, "<.*?>", "")
+ let fullText = htmlTagRegex.Replace(formattedDocument, "")
Some(IndexText(fullText, headingTexts))
| _ -> None)
diff --git a/src/FSharp.Formatting.Markdown/HtmlFormatting.fs b/src/FSharp.Formatting.Markdown/HtmlFormatting.fs
index 11cf1c58..0a2f56d2 100644
--- a/src/FSharp.Formatting.Markdown/HtmlFormatting.fs
+++ b/src/FSharp.Formatting.Markdown/HtmlFormatting.fs
@@ -129,9 +129,12 @@ let rec internal formatSpan (ctx: FormattingContext) span =
and internal formatSpans ctx = List.iter (formatSpan ctx)
/// generate anchor name from Markdown text
+// Compiled once at module load; reused for every heading anchor generated.
+let private wordRegex = Regex(@"\w+", RegexOptions.Compiled)
+
let internal formatAnchor (ctx: FormattingContext) (spans: MarkdownSpans) =
let extractWords (text: string) =
- Regex.Matches(text, @"\w+") |> Seq.cast |> Seq.map (fun m -> m.Value)
+ wordRegex.Matches(text) |> Seq.cast |> Seq.map (fun m -> m.Value)
let rec gather (span: MarkdownSpan) : string seq =
seq {
diff --git a/src/fsdocs-tool/BuildCommand.fs b/src/fsdocs-tool/BuildCommand.fs
index b5abd1db..7732e025 100644
--- a/src/fsdocs-tool/BuildCommand.fs
+++ b/src/fsdocs-tool/BuildCommand.fs
@@ -1319,6 +1319,13 @@ module Serve =
/// Helpers for generating llms.txt and llms-full.txt content.
module internal LlmsTxt =
+ // Compiled once at module load; reused across all llms.txt page entries.
+ let private multipleNewlinesRegex =
+ System.Text.RegularExpressions.Regex(@"\n{3,}", System.Text.RegularExpressions.RegexOptions.Compiled)
+
+ let private whitespaceRunRegex =
+ System.Text.RegularExpressions.Regex(@"\s+", System.Text.RegularExpressions.RegexOptions.Compiled)
+
/// Decode HTML entities (e.g. " → ", > → >) in a string.
let private decodeHtml (s: string) = System.Net.WebUtility.HtmlDecode(s)
@@ -1338,11 +1345,11 @@ module internal LlmsTxt =
/// Collapse three or more consecutive newlines into at most two.
let private collapseBlankLines (s: string) =
- System.Text.RegularExpressions.Regex.Replace(s, @"\n{3,}", "\n\n")
+ multipleNewlinesRegex.Replace(s, "\n\n")
/// Normalise a title: trim and collapse internal whitespace/newlines to a single space.
let private normaliseTitle (s: string) =
- System.Text.RegularExpressions.Regex.Replace(s.Trim(), @"\s+", " ")
+ whitespaceRunRegex.Replace(s.Trim(), " ")
/// Decode HTML entities and remove --eval noise from content.
let private cleanContent (s: string) =