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

### 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)

Expand Down
36 changes: 36 additions & 0 deletions docs/literate.fsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion src/FSharp.Formatting.Literate/Document.fs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ type LiterateParagraph =
/// Block simply emitted without any formatting equivalent to <pre> tag in html
| RawBlock of lines: Line list * paragraphOptions: LiterateParagraphOptions

/// <summary>
/// <c>(*** include-toc ***)</c> — Insert a table of contents listing all headings in the document.<br/>
/// <c>(*** include-toc:N ***)</c> — Limit to headings up to depth N (default 3).
/// </summary>
| TableOfContents of maxDepth: int * paragraphOptions: LiterateParagraphOptions

member x.ParagraphOptions =
match x with
| CodeReference(paragraphOptions = popts)
Expand All @@ -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() =
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 @@ -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 =
Expand Down
34 changes: 28 additions & 6 deletions src/FSharp.Formatting.Literate/Literate.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/FSharp.Formatting.Literate/ParseScript.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions src/FSharp.Formatting.Literate/Transformations.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
/// <c>HtmlFormatting.formatAnchor</c> (words joined with "-", falling back to "header").
let private computeHeadingAnchor (spans: MarkdownSpans) =
let extractWords (text: string) =
Regex.Matches(text, @"\w+")
|> Seq.cast<System.Text.RegularExpressions.Match>
|> 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 <c>TableOfContents</c> paragraph with a <c>ListBlock</c> of links to the
/// headings found in the document. Uses the same anchor-name generation as
/// <c>HtmlFormatting.formatAnchor</c> so that links resolve correctly when
/// <c>generateAnchors = true</c> (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<string, int>()

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)
105 changes: 105 additions & 0 deletions tests/FSharp.Literate.Tests/LiterateTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2045,3 +2045,108 @@ let ``Emoji in ConvertScriptFile Markdown output file are preserved`` () =
md |> shouldContainText emojiStar

// End emoji tests

// --------------------------------------------------------------------------------------
// Tests for (*** include-toc ***) directive
// --------------------------------------------------------------------------------------

[<Test>]
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)

[<Test>]
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)

[<Test>]
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\""

[<Test>]
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\""

[<Test>]
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\""