diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 18d66ea0..8abd5abc 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,6 +3,10 @@ ## [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) * Add `dotnet fsdocs convert` command to convert a single `.md`, `.fsx`, or `.ipynb` file to HTML (or another output format) without building a full documentation site. [#811](https://github.com/fsprojects/FSharp.Formatting/issues/811) * `fsdocs convert` now accepts the input file as a positional argument (e.g. `fsdocs convert notebook.ipynb -o notebook.html`). [#1019](https://github.com/fsprojects/FSharp.Formatting/pull/1019) * `fsdocs convert` infers the output format from the output file extension when `--outputformat` is not specified (e.g. `-o out.md` implies `--outputformat markdown`). [#1019](https://github.com/fsprojects/FSharp.Formatting/pull/1019) 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/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/Literate.fs b/src/FSharp.Formatting.Literate/Literate.fs
index 0386dcb6..9ec36ed4 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/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)
diff --git a/tests/FSharp.Literate.Tests/LiterateTests.fs b/tests/FSharp.Literate.Tests/LiterateTests.fs
index 9025c115..e14c3f00 100644
--- a/tests/FSharp.Literate.Tests/LiterateTests.fs
+++ b/tests/FSharp.Literate.Tests/LiterateTests.fs
@@ -2047,6 +2047,110 @@ let ``Emoji in ConvertScriptFile Markdown output file are preserved`` () =
// 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\""
+
// Tests for Literate.ConvertPynbFile
// --------------------------------------------------------------------------------------