Skip to content
Draft
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]

### 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
Expand Down
5 changes: 4 additions & 1 deletion src/FSharp.Formatting.Common/Menu.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
9 changes: 5 additions & 4 deletions src/FSharp.Formatting.Common/PageContentList.fs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ let EmptyContent = "<div class=\"empty\"></div>"
/// 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 = "<h(\\d)><a [^>]*href=\"([^\"]+)\">([^<]+)</a></h\\d>"

let regex = Regex(headingLinkPattern)
// Compiled once at module load; reused across all pages.
let private headingLinkRegex = Regex("<h(\\d)><a [^>]*href=\"([^\"]+)\">([^<]+)</a></h\\d>", RegexOptions.Compiled)

let mkPageContentMenu (html: string) =

let extractHeadingLinks (matchItem: Match) =
let level = int matchItem.Groups.[1].Value
Expand All @@ -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<Match>
|> Seq.map extractHeadingLinks
|> Seq.toList
Expand Down
5 changes: 4 additions & 1 deletion src/FSharp.Formatting.Literate/Formatting.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
5 changes: 4 additions & 1 deletion src/FSharp.Formatting.Markdown/HtmlFormatting.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Match> |> Seq.map (fun m -> m.Value)
wordRegex.Matches(text) |> Seq.cast<Match> |> Seq.map (fun m -> m.Value)

let rec gather (span: MarkdownSpan) : string seq =
seq {
Expand Down
11 changes: 9 additions & 2 deletions src/fsdocs-tool/BuildCommand.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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. &quot; → ", &gt; → >) in a string.
let private decodeHtml (s: string) = System.Net.WebUtility.HtmlDecode(s)

Expand All @@ -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) =
Expand Down