From c4c7ff6126020321cc684d530f7539b6ec6abd8e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 28 Feb 2026 12:19:07 +0000 Subject: [PATCH 1/4] Add (*** include-toc ***) directive for inline table of contents in literate F# scripts Implements issue #163. Adds a new LiterateParagraph case TableOfContents and a new transformation generateTableOfContents that replaces it with a ListBlock of DirectLink elements pointing to all heading anchors in the document. Usage in .fsx literate scripts: (*** include-toc ***) - list all headings up to depth 3 (*** include-toc:2 ***) - list headings up to depth 2 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- RELEASE_NOTES.md | 3 + src/FSharp.Formatting.Literate/Document.fs | 9 +- src/FSharp.Formatting.Literate/Formatting.fs | 5 +- src/FSharp.Formatting.Literate/ParseScript.fs | 13 +++ .../Transformations.fs | 88 +++++++++++++++++++ 5 files changed, 116 insertions(+), 2 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 8c782f07..b18eef1c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Added +* Add `(*** include-toc ***)` and `(*** include-toc:N ***)` directives for literate F# scripts (`.fsx`) to generate an inline table of contents listing all document headings (up to depth N, default 3). [#163](https://github.com/fsprojects/FSharp.Formatting/issues/163) + ### Refactored * Split `MarkdownParser.fs` (1500 lines) into `MarkdownInlineParser.fs` (inline formatting) and `MarkdownParser.fs` (block-level parsing) for better maintainability. [#1022](https://github.com/fsprojects/FSharp.Formatting/issues/1022) diff --git a/src/FSharp.Formatting.Literate/Document.fs b/src/FSharp.Formatting.Literate/Document.fs index 78a1bd15..f1a0312b 100644 --- a/src/FSharp.Formatting.Literate/Document.fs +++ b/src/FSharp.Formatting.Literate/Document.fs @@ -91,6 +91,12 @@ type LiterateParagraph = /// Block simply emitted without any formatting equivalent to
 tag in html
     | RawBlock of lines: Line list * paragraphOptions: LiterateParagraphOptions
 
+    /// 
+    /// (*** include-toc ***) — Insert a table of contents listing all headings in the document.
+ /// (*** include-toc:N ***) — Limit to headings up to depth N (default 3). + ///
+ | TableOfContents of maxDepth: int * paragraphOptions: LiterateParagraphOptions + member x.ParagraphOptions = match x with | CodeReference(paragraphOptions = popts) @@ -102,7 +108,8 @@ type LiterateParagraph = | ValueReference(paragraphOptions = popts) | LiterateCode(paragraphOptions = popts) | LanguageTaggedCode(paragraphOptions = popts) - | RawBlock(paragraphOptions = popts) -> popts + | RawBlock(paragraphOptions = popts) + | TableOfContents(paragraphOptions = popts) -> popts interface MarkdownEmbedParagraphs with member x.Render() = diff --git a/src/FSharp.Formatting.Literate/Formatting.fs b/src/FSharp.Formatting.Literate/Formatting.fs index 48c300a4..f2f89778 100644 --- a/src/FSharp.Formatting.Literate/Formatting.fs +++ b/src/FSharp.Formatting.Literate/Formatting.fs @@ -187,7 +187,10 @@ module internal Formatting = defaultArg (findHeadings doc.Paragraphs ctx.GenerateHeaderAnchors ctx.OutputKind) name // Replace all special elements with ordinary Html/Latex Markdown - let doc = Transformations.replaceLiterateParagraphs ctx doc + let doc = + doc + |> Transformations.generateTableOfContents + |> Transformations.replaceLiterateParagraphs ctx // construct previous and next urls let nextPreviousPageSubstitutions = diff --git a/src/FSharp.Formatting.Literate/ParseScript.fs b/src/FSharp.Formatting.Literate/ParseScript.fs index d5987ca1..e98677f6 100644 --- a/src/FSharp.Formatting.Literate/ParseScript.fs +++ b/src/FSharp.Formatting.Literate/ParseScript.fs @@ -275,6 +275,19 @@ type internal ParseScript(parseOptions, ctx: CompilerContext) = transformBlocks false None count noEval (p :: acc) defs blocks + // Include table of contents (optionally limited to headings up to depth N) + | BlockCommand(Command "include-toc" depthStr as cmds) :: blocks, _ -> + let popts = getParaOptions cmds + + let depth = + match System.Int32.TryParse(depthStr) with + | true, n when n > 0 -> n + | _ -> 3 + + let p = EmbedParagraphs(TableOfContents(depth, popts), None) + + transformBlocks false None count noEval (p :: acc) defs blocks + // Include code without evaluation | BlockCommand(Command "raw" _ as cmds) :: BlockSnippet(snip) :: blocks, _ -> let popts = getParaOptions cmds diff --git a/src/FSharp.Formatting.Literate/Transformations.fs b/src/FSharp.Formatting.Literate/Transformations.fs index 39034d89..4343c160 100644 --- a/src/FSharp.Formatting.Literate/Transformations.fs +++ b/src/FSharp.Formatting.Literate/Transformations.fs @@ -3,6 +3,7 @@ namespace FSharp.Formatting.Literate open System open System.IO open System.Collections.Generic +open System.Text.RegularExpressions open FSharp.Formatting.CSharpFormat open FSharp.Patterns @@ -547,6 +548,9 @@ module internal Transformations = | OutputKind.Markdown -> code Some(InlineHtmlBlock(inlined, None, None)) + | TableOfContents _ -> + // Should have been replaced by generateTableOfContents before this stage + None // Traverse all other structures recursively | MarkdownPatterns.ParagraphNested(pn, nested) -> let nested = List.map (List.choose (replaceLiterateParagraph ctx formatted)) nested @@ -623,3 +627,87 @@ module internal Transformations = let newParagraphs = doc.Paragraphs |> List.choose (replaceLiterateParagraph ctx lookup) doc.With(paragraphs = newParagraphs, formattedTips = formatted.ToolTip) + + // ---------------------------------------------------------------------------------------------- + // Replace (*** include-toc ***) directives with generated table-of-contents list blocks + // ---------------------------------------------------------------------------------------------- + + /// Compute a heading anchor name from its spans, using the same algorithm as + /// HtmlFormatting.formatAnchor (words joined with "-", falling back to "header"). + let private computeHeadingAnchor (spans: MarkdownSpans) = + let extractWords (text: string) = + Regex.Matches(text, @"\w+") + |> Seq.cast + |> Seq.map (fun m -> m.Value) + + let rec gather span = + seq { + match span with + | Literal(str, _) -> yield! extractWords str + | Strong(body, _) -> yield! gathers body + | Emphasis(body, _) -> yield! gathers body + | DirectLink(body, _, _, _) -> yield! gathers body + | _ -> () + } + + and gathers spans = Seq.collect gather spans + + spans + |> gathers + |> String.concat "-" + |> fun name -> if String.IsNullOrWhiteSpace(name) then "header" else name + + /// Replace every TableOfContents paragraph with a ListBlock of links to the + /// headings found in the document. Uses the same anchor-name generation as + /// HtmlFormatting.formatAnchor so that links resolve correctly when + /// generateAnchors = true (the fsdocs default). + let generateTableOfContents (doc: LiterateDocument) = + // Collect all headings in document order (top-level only; we do not descend into + // list items or quotes because headings cannot appear there in Markdown anyway). + let allHeadings = + doc.Paragraphs + |> List.choose (function + | Heading(level, spans, _) -> Some(level, spans) + | _ -> None) + + if allHeadings.IsEmpty then + doc + else + // Simulate UniqueNameGenerator from HtmlFormatting to produce identical anchor names. + let counts = Dictionary() + + let getUniqueName (name: string) = + let ok, i = counts.TryGetValue name + + if ok then + counts.[name] <- i + 1 + sprintf "%s-%d" name i + else + counts.[name] <- 1 + name + + let headingsWithAnchors = + allHeadings + |> List.map (fun (level, spans) -> + let anchor = computeHeadingAnchor spans |> getUniqueName + level, spans, anchor) + + let rec replaceInParagraph para = + match para with + | MarkdownPatterns.LiterateParagraph(TableOfContents(maxDepth, _)) -> + let items = + headingsWithAnchors + |> List.choose (fun (level, spans, anchor) -> + if level <= maxDepth then + Some [ Span([ DirectLink(spans, "#" + anchor, None, None) ], None) ] + else + None) + + Some(ListBlock(MarkdownListKind.Unordered, items, None)) + | MarkdownPatterns.ParagraphNested(pn, nested) -> + let nested = List.map (List.choose replaceInParagraph) nested + Some(MarkdownPatterns.ParagraphNested(pn, nested)) + | par -> Some par + + let newParagraphs = doc.Paragraphs |> List.choose replaceInParagraph + doc.With(paragraphs = newParagraphs) From a1ffaafdeed8af4d3fd8c5a415c6b063b3906ca9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 28 Feb 2026 12:21:46 +0000 Subject: [PATCH 2/4] ci: trigger CI checks From 98c54e97f959eb9c1c4ab4b440ecd9541531bcef Mon Sep 17 00:00:00 2001 From: Repo Assist Date: Tue, 3 Mar 2026 01:03:29 +0000 Subject: [PATCH 3/4] Add tests and docs for include-toc directive; fix ToHtml/ToLatex/ToPynb/ToFsx paths - Add 5 NUnit tests covering parsing, HTML output, depth filtering, and duplicate anchor handling for (*** include-toc ***) and (*** include-toc:N ***) - Fix bug: generateTableOfContents was only called in the transformDocument path (Formatting.fs) but not in the direct Literate.ToHtml, WriteHtml, ToLatex, WriteLatex, ToPynb, ToFsx methods in Literate.fs - Add documentation in docs/literate.fsx explaining the directive, usage syntax, and correct placement as a standalone command Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/literate.fsx | 36 +++++++ src/FSharp.Formatting.Literate/Literate.fs | 34 ++++-- tests/FSharp.Literate.Tests/LiterateTests.fs | 105 +++++++++++++++++++ 3 files changed, 169 insertions(+), 6 deletions(-) diff --git a/docs/literate.fsx b/docs/literate.fsx index f456d0d0..ee5b9f6f 100644 --- a/docs/literate.fsx +++ b/docs/literate.fsx @@ -101,6 +101,42 @@ F# language. | `(*** include-it-raw: output-name ***)` | The unformatted result of the snippet (named with define-output) | | `(*** include-value: value-name ***)` | The formatted value, an F# identifier name | +### Table of Contents + +The `include-toc` command inserts an inline table of contents at the point where the command appears. +It generates an unordered list of hyperlinks to every heading in the document. + +| Literate Command | Description | +|:-----------------------|:----------------------------| +| `(*** include-toc ***)` | Insert a TOC listing all headings up to depth 3 (default) | +| `(*** include-toc:N ***)` | Insert a TOC listing headings up to depth N (e.g. `include-toc:2` for H1 and H2 only) | + +The links point to the anchor names generated for each heading (e.g. `#Section-One`). +This requires `generateAnchors = true`, which is the default when using `fsdocs build`. + +Example: + +```text +(** +# My Document +*) +(*** include-toc:2 ***) +(** +## Section One + +Some content here. + +## Section Two + +More content here. +*) +``` + +This renders the TOC as: +- [My Document](#My-Document) +- [Section One](#Section-One) +- [Section Two](#Section-Two) + #### Hiding code snippets The command `hide` specifies that the following F# code block (until the next comment or command) should be diff --git a/src/FSharp.Formatting.Literate/Literate.fs b/src/FSharp.Formatting.Literate/Literate.fs index 77c24586..1e37a0bd 100644 --- a/src/FSharp.Formatting.Literate/Literate.fs +++ b/src/FSharp.Formatting.Literate/Literate.fs @@ -283,7 +283,10 @@ type Literate private () = mdlinkResolver tokenKindToCss - let doc = Transformations.replaceLiterateParagraphs ctx doc + let doc = + doc + |> Transformations.generateTableOfContents + |> Transformations.replaceLiterateParagraphs ctx let doc = MarkdownDocument(doc.Paragraphs @ [ InlineHtmlBlock(doc.FormattedTips, None, None) ], doc.DefinedLinks) @@ -332,7 +335,10 @@ type Literate private () = mdlinkResolver tokenKindToCss - let doc = Transformations.replaceLiterateParagraphs ctx doc + let doc = + doc + |> Transformations.generateTableOfContents + |> Transformations.replaceLiterateParagraphs ctx let paragraphs = doc.Paragraphs @ [ InlineHtmlBlock(doc.FormattedTips, None, None) ], doc.DefinedLinks @@ -367,7 +373,10 @@ type Literate private () = mdlinkResolver None - let doc = Transformations.replaceLiterateParagraphs ctx doc + let doc = + doc + |> Transformations.generateTableOfContents + |> Transformations.replaceLiterateParagraphs ctx Markdown.ToLatex( MarkdownDocument(doc.Paragraphs, doc.DefinedLinks), @@ -401,7 +410,10 @@ type Literate private () = mdlinkResolver None - let doc = Transformations.replaceLiterateParagraphs ctx doc + let doc = + doc + |> Transformations.generateTableOfContents + |> Transformations.replaceLiterateParagraphs ctx Markdown.WriteLatex( MarkdownDocument(doc.Paragraphs, doc.DefinedLinks), @@ -416,7 +428,12 @@ type Literate private () = let mdlinkResolver = defaultArg mdlinkResolver (fun _ -> None) let substitutions = defaultArg substitutions [] let ctx = makeFormattingContext OutputKind.Pynb None None None substitutions crefResolver mdlinkResolver None - let doc = Transformations.replaceLiterateParagraphs ctx doc + + let doc = + doc + |> Transformations.generateTableOfContents + |> Transformations.replaceLiterateParagraphs ctx + Markdown.ToPynb(MarkdownDocument(doc.Paragraphs, doc.DefinedLinks), substitutions = substitutions) /// Formate the literate document as an .fsx script @@ -425,7 +442,12 @@ type Literate private () = let mdlinkResolver = defaultArg mdlinkResolver (fun _ -> None) let substitutions = defaultArg substitutions [] let ctx = makeFormattingContext OutputKind.Fsx None None None substitutions crefResolver mdlinkResolver None - let doc = Transformations.replaceLiterateParagraphs ctx doc + + let doc = + doc + |> Transformations.generateTableOfContents + |> Transformations.replaceLiterateParagraphs ctx + Markdown.ToFsx(MarkdownDocument(doc.Paragraphs, doc.DefinedLinks), substitutions = substitutions) /// Parse and transform a markdown document diff --git a/tests/FSharp.Literate.Tests/LiterateTests.fs b/tests/FSharp.Literate.Tests/LiterateTests.fs index 242e7302..68361093 100644 --- a/tests/FSharp.Literate.Tests/LiterateTests.fs +++ b/tests/FSharp.Literate.Tests/LiterateTests.fs @@ -2045,3 +2045,108 @@ let ``Emoji in ConvertScriptFile Markdown output file are preserved`` () = md |> shouldContainText emojiStar // End emoji tests + +// -------------------------------------------------------------------------------------- +// Tests for (*** include-toc ***) directive +// -------------------------------------------------------------------------------------- + +[] +let ``include-toc parses as TableOfContents with default depth 3`` () = + let content = + """ +(** # Heading One *) +(*** include-toc ***) +let x = 1 +""" + + let doc = Literate.ParseScriptString(content, "C" "A.fsx") + + doc.Paragraphs + |> shouldMatchPar (function + | EmbedParagraphs(:? LiterateParagraph as lp, _) -> + match lp with + | TableOfContents(3, _) -> true + | _ -> false + | _ -> false) + +[] +let ``include-toc:2 parses as TableOfContents with depth 2`` () = + let content = + """ +(** # Heading One *) +(*** include-toc:2 ***) +let x = 1 +""" + + let doc = Literate.ParseScriptString(content, "C" "A.fsx") + + doc.Paragraphs + |> shouldMatchPar (function + | EmbedParagraphs(:? LiterateParagraph as lp, _) -> + match lp with + | TableOfContents(2, _) -> true + | _ -> false + | _ -> false) + +[] +let ``include-toc generates anchor links for all headings in HTML output`` () = + let content = + """ +(** +# My Document +*) +(*** include-toc ***) +(** +## Section One + +Some content. + +## Section Two + +More content. +*) +""" + + let doc = Literate.ParseScriptString(content, "C" "A.fsx") + let html = Literate.ToHtml(doc) + html |> shouldContainText "href=\"#My-Document\"" + html |> shouldContainText "href=\"#Section-One\"" + html |> shouldContainText "href=\"#Section-Two\"" + +[] +let ``include-toc:N filters out headings deeper than N`` () = + let content = + """ +(** +# Top Level +*) +(*** include-toc:1 ***) +(** +## Sub Section +*) +""" + + let doc = Literate.ParseScriptString(content, "C" "A.fsx") + let html = Literate.ToHtml(doc) + html |> shouldContainText "href=\"#Top-Level\"" + html |> shouldNotContainText "href=\"#Sub-Section\"" + +[] +let ``include-toc handles duplicate heading names with unique anchors`` () = + let content = + """ +(** +# Introduction +*) +(*** include-toc ***) +(** +## Notes + +## Notes +*) +""" + + let doc = Literate.ParseScriptString(content, "C" "A.fsx") + let html = Literate.ToHtml(doc) + html |> shouldContainText "href=\"#Notes\"" + html |> shouldContainText "href=\"#Notes-1\"" From ef12d83e260b1f77399beb2e2699d45c9fbe52ff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 3 Mar 2026 01:05:05 +0000 Subject: [PATCH 4/4] ci: trigger CI checks