From fe463b825d7b21c9751ee28193396acbbf18b190 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:39:25 +0000 Subject: [PATCH 1/2] perf: avoid O(n^2) re-scan when splitting Markdown pipe-table rows pipeTableFindSplits recomputed List.length over the full remaining line and the post-delimiter remainder on every recursive call, to compute the chunk size for the current cell. For a row with many delimiters (cells), this makes parsing quadratic in the row length. Track the number of consumed characters incrementally while scanning instead, so each character is counted exactly once. No behavior change: same delimiter/escape/code/math handling, same output for all existing table tests. Added a new test exercising an escaped pipe inside a cell to cover the escape-handling branch of the scan. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- RELEASE_NOTES.md | 5 +++ .../MarkdownTableParser.fs | 34 ++++++++++++------- tests/FSharp.Markdown.Tests/Markdown.fs | 29 ++++++++++++++++ 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index abac577c5..e6a2f3980 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,10 @@ # Changelog +## [Unreleased] + +### Changed +* Avoid recomputing `List.length` on the shrinking remainder for every scanned character when splitting a Markdown pipe-table row into cells (`pipeTableFindSplits` in `MarkdownTableParser.fs`). The scan now tracks the number of consumed characters incrementally, turning an O(n²) split into an O(n) one for rows with many delimiters, with no behavior change. + ## [23.0.0-alpha.2] - 2026-09-10 ### Fixed diff --git a/src/FSharp.Formatting.Markdown/MarkdownTableParser.fs b/src/FSharp.Formatting.Markdown/MarkdownTableParser.fs index 0ea689154..c0a9c5d50 100644 --- a/src/FSharp.Formatting.Markdown/MarkdownTableParser.fs +++ b/src/FSharp.Formatting.Markdown/MarkdownTableParser.fs @@ -20,26 +20,34 @@ let rec pipeTableFindSplits (delim: char array) (line: char list) = let cLstToStr (x: char list) = x |> Array.ofList |> System.String.Concat + // Scans the line and, on success, returns the number of characters consumed up to and + // including the found delimiter, together with the remaining list. Tracking the consumed + // count incrementally (rather than recomputing `List.length` over the shrinking remainder + // on every character) keeps this scan linear instead of quadratic in the row length. let rec ptfs delim line = match line with - | DelimitedLatexDisplayMath [ '$'; '$' ] (_body, rest) -> ptfs delim rest - | DelimitedLatexInlineMath [ '$' ] (_body, rest) -> ptfs delim rest - | List.DelimitedWith [ '`'; ' ' ] [ ' '; '`' ] (_body, rest, _s, _e) -> ptfs delim rest - | List.DelimitedNTimes '`' (_body, rest, _s, _e) -> ptfs delim rest - | x :: rest when Array.exists ((=) x) delim -> Some rest - | '\\' :: _ :: rest - | _ :: rest -> ptfs delim rest + | DelimitedLatexDisplayMath [ '$'; '$' ] (_body, rest) + | DelimitedLatexInlineMath [ '$' ] (_body, rest) + | List.DelimitedWith [ '`'; ' ' ] [ ' '; '`' ] (_body, rest, _, _) + | List.DelimitedNTimes '`' (_body, rest, _, _) -> + let consumedHere = List.length line - List.length rest + + ptfs delim rest + |> Option.map (fun (count, remainder) -> (count + consumedHere, remainder)) + | x :: rest when Array.exists ((=) x) delim -> Some(1, rest) + | '\\' :: _ :: rest -> ptfs delim rest |> Option.map (fun (count, remainder) -> (count + 2, remainder)) + | _ :: rest -> ptfs delim rest |> Option.map (fun (count, remainder) -> (count + 1, remainder)) | [] -> None - let rest = ptfs delim line + let result = ptfs delim line - match rest with + match result with | None -> [ cLstToStr line ] - | Some _x when List.isEmpty line -> [ "" ] - | Some x -> - let chunkSize = List.length line - List.length x - 1 + | Some _ when List.isEmpty line -> [ "" ] + | Some(count, x) -> + let chunkSize = count - 1 - cLstToStr (Seq.take chunkSize line |> Seq.toList) :: pipeTableFindSplits delim x + cLstToStr (List.truncate chunkSize line) :: pipeTableFindSplits delim x diff --git a/tests/FSharp.Markdown.Tests/Markdown.fs b/tests/FSharp.Markdown.Tests/Markdown.fs index bc25ea0ef..2d7ead07f 100644 --- a/tests/FSharp.Markdown.Tests/Markdown.fs +++ b/tests/FSharp.Markdown.Tests/Markdown.fs @@ -818,6 +818,35 @@ let ``Transform tables with delimiters in code or math correctly`` () = Markdown.ToHtml doc |> shouldEqualNoWhiteSpace expected +[] +let ``Transform tables with escaped pipe characters correctly`` () = + let doc = + """| a | b | +|---|---| +| 1\|2 | 3 | +""" + + let expected = + """ + + + + + + + + + + + + +

a

b

1\|2

3

+ +""" + |> properNewLines + + Markdown.ToHtml doc |> shouldEqualNoWhiteSpace expected + [] let ``Parse empty blockquote followed by content`` () = let doc = From dc8971c8967458694263bdc54f277e54bd6b3fd7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:55:26 +0000 Subject: [PATCH 2/2] [repo-assist] refactor: remove long-dead commented-out code (#1347) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .config/dotnet-tools.json | 2 +- .editorconfig | 2 +- AGENTS.md | 10 +- Directory.Packages.props | 6 +- RELEASE_NOTES.md | 69 +++ build.fsx | 252 +++++++- docs/_body.html | 6 +- docs/codeformat.fsx | 28 +- docs/commandline.md | 8 + docs/content/fsdocs-copy-button.js | 21 +- docs/content/fsdocs-default.css | 297 +++++---- docs/content/fsdocs-hotkeys.js | 55 +- docs/evaluation.fsx | 13 +- docs/mermaid.md | 6 +- docs/styling.md | 51 ++ .../FSharp.Formatting.ApiDocs.fsproj | 4 +- src/FSharp.Formatting.ApiDocs/GenerateHtml.fs | 579 +++++++++++------- .../GenerateHtml.fsi | 71 +++ .../GenerateMarkdown.fs | 401 ++++++------ .../GenerateMarkdown.fsi | 48 ++ src/FSharp.Formatting.ApiDocs/SymbolReader.fs | 14 - .../TypeFormatter.fs | 6 +- .../CodeFormat.fs | 6 +- .../CodeFormatAgent.fs | 10 - .../FSharp.Formatting.CodeFormat.fsproj | 4 +- .../HtmlFormatting.fs | 72 ++- .../NiceSignaturePrint.fs | 232 +++++++ .../NiceSignaturePrint.fsi | 19 + .../SourceCode.fs | 6 +- .../ToolTipReader.fs | 279 ++++++++- .../files/style.css | 88 --- .../files/tilde.png | Bin 2835 -> 0 bytes .../files/tips.js | 51 -- src/FSharp.Formatting.Common/HtmlModel.fs | 1 - src/FSharp.Formatting.Common/Menu.fs | 24 +- .../PageContentList.fs | 5 +- src/FSharp.Formatting.Common/Templating.fs | 4 + src/FSharp.Formatting.Literate/ParsePynb.fs | 5 +- .../Transformations.fs | 16 +- .../HtmlFormatting.fs | 13 +- .../MarkdownUtils.fs | 2 +- src/fsdocs-tool/DevServer.fs | 36 +- src/fsdocs-tool/DocContent.fs | 28 +- tests/FSharp.ApiDocs.Tests/ApiDocsTests.fs | 133 +++- .../files/FsLib2/Library2.fs | 15 + .../CodeFormatTests.fs | 298 ++++++++- tests/FSharp.Literate.Tests/EvalTests.fs | 23 +- tests/FSharp.Literate.Tests/LiterateTests.fs | 37 +- tests/FSharp.Markdown.Tests/Markdown.fs | 6 +- tests/fsdocs-tool.Tests/DocContentTests.fs | 93 ++- 50 files changed, 2555 insertions(+), 900 deletions(-) create mode 100644 src/FSharp.Formatting.ApiDocs/GenerateHtml.fsi create mode 100644 src/FSharp.Formatting.ApiDocs/GenerateMarkdown.fsi create mode 100644 src/FSharp.Formatting.CodeFormat/NiceSignaturePrint.fs create mode 100644 src/FSharp.Formatting.CodeFormat/NiceSignaturePrint.fsi delete mode 100644 src/FSharp.Formatting.CodeFormat/files/style.css delete mode 100644 src/FSharp.Formatting.CodeFormat/files/tilde.png delete mode 100644 src/FSharp.Formatting.CodeFormat/files/tips.js diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index fb29f0215..801b88fb9 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "fantomas": { - "version": "8.0.0-beta-002", + "version": "8.0.4", "commands": [ "fantomas" ], diff --git a/.editorconfig b/.editorconfig index 17e3f2898..5256e08da 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,7 +18,7 @@ fsharp_max_value_binding_width=120 # Specific settings for "View/HTML" related files # It makes the code more consistent in term of spacing and indentation -[GenerateHtml.fs] +[{GenerateHtml,GenerateMarkdown}.fs] fsharp_experimental_elmish = true fsharp_multi_line_lambda_closing_newline = true fsharp_multiline_bracket_style = aligned diff --git a/AGENTS.md b/AGENTS.md index a7ae35667..d008579f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ Update `RELEASE_NOTES.md` when making user-visible changes: - Add entries under the `## [Unreleased]` section +- A `## [x.y.z] - date` heading is a version that is already published. Never add an entry to one, not even the topmost, and never edit the entries it holds +- The release commit renames `## [Unreleased]` to the version it ships, so right after a release the file has no `## [Unreleased]` section. Add one above the topmost version heading rather than appending to that version - Use categories: `### Added`, `### Changed`, `### Fixed`, `### Removed` - Include PR links: `[#123](https://github.com/fsprojects/FSharp.Formatting/pull/123)` @@ -35,8 +37,14 @@ dotnet fsi build.fsx # Just lint and test dotnet fsi build.fsx -- -p Verify -# Run analyzers (G-Research.FSharp.Analyzers, Ionide.Analyzers) +# Run analyzers (G-Research.FSharp.Analyzers, Ionide.Analyzers) over the whole solution. +# This is what CI does, and it takes minutes. dotnet msbuild /t:AnalyzeSolution + +# The same analyzers over the files the working tree touched, which takes seconds. +# Use this while working; it cannot see a finding your change causes in a file you did +# not edit, so run the full one above before opening a pull request. +dotnet fsi build.fsx -- -p AnalyzeChanged ``` Individual steps: diff --git a/Directory.Packages.props b/Directory.Packages.props index 47ae32cce..7140247a6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,7 +16,7 @@ - + @@ -25,8 +25,8 @@ - - + + diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 85feb25bd..bfe6a8408 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -5,6 +5,75 @@ ### Changed * Avoid recomputing `List.length` on the shrinking remainder for every scanned character when splitting a Markdown pipe-table row into cells (`pipeTableFindSplits` in `MarkdownTableParser.fs`). The scan now tracks the number of consumed characters incrementally, turning an O(n²) split into an O(n) one for rows with many delimiters, with no behavior change. +## [23.0.0-alpha.8] - 2026-09-18 + +### Added +* Tool tips on F# snippets are syntax highlighted. The compiler hands a signature over as runs it has already classified, and `ToolTipReader` was flattening them into one string, so `val printfn: format: Printf.TextWriterFormat<'T> -> 'T` rendered as undifferentiated text. The runs keep their classification now and a tip is colored by the same `--code-*` rules as the snippet it describes, so a keyword, a type, a function and a type parameter look the same whether you read them in the code or in the tip. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336) + +* A signature too wide for its tool tip is laid out over several lines instead of wrapping wherever the line runs out. It breaks after the name, after each parameter and before the result, the parameters indented under the name and the result under them, the way a formatter would write it. The compiler's own parser decides where those points are, so the breaks land on syntax rather than a guess, and a line that fits, or that is not a signature, is left as it was. Nearly every line this touched was a member of a type: one with six optional parameters ran to 600 characters. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336) + +### Changed +* A tool tip opens with the doc comment and the signature follows it, where the signature used to come first. The doc text is shown in the color a comment has in a snippet. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336) + +* Code snippets are no longer laid out with a table. A snippet is a `
` holding one `
` for the line numbers and one `
` for the code, placed side by side with CSS grid. Markdown code blocks, notebook output and non-F# snippets use the same wrapper, so one selector covers every code block on the page. A stylesheet that targets `table.pre`, `td.lines`, `td.snippet`, or the `.code-block-wrapper` the copy button used to insert, needs updating, and a script that looks for a snippet, such as the Mermaid recipe in the documentation, should look for `.fsdocs-snippet` instead of `table.pre`. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)
+
+* Line numbers lost the `: ` suffix and sit in a column marked `aria-hidden`, so a screen reader reads the code and not the numbering. The code column scrolls on its own, so the line numbers stay in view while a wide snippet scrolls. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)
+
+* The default stylesheet ships a new syntax palette built from GitHub's light and dark hues. The previous one had grown a separate hue per token kind, eight of them teal or purple, on top of a dark green default text color in light mode. Tokens are grouped into roles instead: keywords, types, the modules and namespaces that hold them, callables, values, strings, and one mute for comments, fsi output, inactive code and line numbers. Modules take the green GitHub reserves for tags, so `List.map String.trim` reads as container then function rather than one orange run. Identifiers, operators and punctuation read as plain text. Every color is a `--code-*` custom property, so a site can restyle the whole scheme without touching a selector. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)
+
+* `ToolTipSpan` gained a `Token of kind: TokenKind * body: string` case for a classified run of tool tip text, and `ToolTipReader.stripParameterAttributes` now takes and returns those runs instead of a string. Code that matches on `ToolTipSpan` has to handle the new case. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)
+
+### Fixed
+* A type abbreviation in a tool tip was colored as an identifier rather than as a type, so `int` and `bool` were orange while `string`, `option`, `list`, `array` and `unit` were plain, in the same position of the same signature. The compiler tags an abbreviation apart from the type it stands for, and the tag was being grouped with parameters and locals. The `System.` qualifier of a fully written name takes the module color instead of plain for the same reason. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)
+
+* A tool tip lost every line break in the signature it was showing. The compiler does not hand the break over in a tag of its own, it arrives inside a `Text` or `Space` run with the indentation of the next line attached, and only the `Space` case was split on. A tip is flowing text, so the browser collapsed the rest and `type List<'T>` ran its cases, interfaces and members together on one line. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)
+
+* A doc comment reached the tool tip as the raw XML it was written as, so `` and `` tags showed up in the tip. The comment is read as XML now and a `` keeps the name it points at, rather than leaving a hole in the sentence it sits in. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)
+
+* Several syntax token classes had no matching rule in the default stylesheet and were rendered in the plain code color: union cases (`uc`), F# preprocessor directives (`pp`), escaped characters (`esc`), enumeration cases (`en`), mutable bindings (`mv`), and identifiers in every non-F# language (`i`), which is the bulk of a C#, JavaScript, TypeScript or HTML snippet. The stylesheet was matching `u`, `prep`, `e` and `v`, names the formatters stopped emitting. Compiler errors (`cerr`) and omitted code (`omitted`) are styled for the first time. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)
+
+* A code block rendered from an `OtherBlock` opened its wrapper element without ever closing it, leaving unbalanced HTML on the page whenever code snippet wrapping was on. [#1336](https://github.com/fsprojects/FSharp.Formatting/pull/1336)
+
+## [23.0.0-alpha.7] - 2026-09-15
+
+### Changed
+* The API Reference section of the menu leaves out a namespace nested in another documented namespace: `Fantomas.FCS.Syntax` folds into `Fantomas.FCS`, and the menu of a library such as Fantomas is six entries instead of twenty truncated ones. A page of a folded namespace marks the entry it sits in as the active one. [#1331](https://github.com/fsprojects/FSharp.Formatting/pull/1331)
+
+* A namespace page opens with a "Namespaces" table of the namespaces nested in it, the ones the menu folds away, each linking to its own page. The "On this page" menu and the `j` / `k` hotkeys walk them like the types and modules below. A namespace holding nothing but other namespaces gets that table too, where its page used to be empty. [#1331](https://github.com/fsprojects/FSharp.Formatting/pull/1331)
+
+## [23.0.0-alpha.6] - 2026-09-15
+
+### Fixed
+* The Markdown output marks the namespace of the page in the menu. The menu of the page was overwritten by the site-wide one, which marks nothing, the way the HTML output was until 23.0.0-alpha.5. [#1330](https://github.com/fsprojects/FSharp.Formatting/pull/1330)
+
+* API docs keep the comment of a member whose signature has a type abbreviation of a function type in the range of a function type, such as `(string -> IsPathIgnored) * (unit -> int)` with `type IsPathIgnored = string -> bool`. Reading the member threw `ArgumentOutOfRangeException` and the comment was dropped. The abbreviation is now shown under its own name, as it is everywhere else. [#1327](https://github.com/fsprojects/FSharp.Formatting/issues/1327)
+
+### Changed
+* The API Reference section of the menu lists the namespaces on every page of the site. The content pages got a single "All Namespaces" link instead, and only a collection named `FSharp.Core` got the list there, which made the menu of a content page a different menu from the one of an API page. No entry is marked active while the reader is on a content page. [#1330](https://github.com/fsprojects/FSharp.Formatting/pull/1330)
+
+* The header of that section reads "API Reference" on the API pages as well, where 23.0.0-alpha.5 made it "Namespaces". A menu template sees the same `{{fsdocs-menu-header-content}}` and `{{fsdocs-menu-header-id}}` (`api_reference`) on every page of the site now. The built-in menu keeps the header as the link to the index of all namespaces. [#1330](https://github.com/fsprojects/FSharp.Formatting/pull/1330)
+
+## [23.0.0-alpha.5] - 2026-09-15
+
+### Added
+* The API reference pages have an "On this page" menu, like the content pages do. A namespace page lists its types and modules, each linking to its own page; a type or module page lists its sections, which now carry anchors of their own (`Static members`, `Nested modules`, `Inherited members` had none). The index of all namespaces keeps the wide layout, since the page is already that list. The inline "Table of contents" and "Contents" blocks are gone, the menu replaces them. The menu marks the section the reader is in through the same scroll-driven animations as the content pages, over the section headings only: each tracked element costs three generated CSS rules, so a module with hundreds of members stays cheap. [#1328](https://github.com/fsprojects/FSharp.Formatting/pull/1328)
+
+* `_menu_template.html` and `_menu-item_template.html` are rendered with the substitutions of the page they go into, `{{root}}` among them, so a menu can link anywhere on the site and not only where fsdocs hands it a link. `{{root}}` is relative to the page being rendered, so `{{root}}reference/index.html` resolves from every depth. A key a menu template defines for itself still wins over a site-wide one of the same name. [#1328](https://github.com/fsprojects/FSharp.Formatting/pull/1328)
+
+* `_menu-item_template.html` can use `{{fsdocs-menu-item-title}}`, the hover text of an item. The API reference fills it with the full name of a namespace, where the menu shortened the entry to what tells it apart from its neighbours, and leaves it empty for an item whose text already says where it leads. [#1328](https://github.com/fsprojects/FSharp.Formatting/pull/1328)
+
+### Fixed
+* The API reference menu on the left lists the namespaces again. Every API page computed that menu and then had it silently replaced: the global substitutions define `fsdocs-list-of-namespaces` as well, with the single "All Namespaces" link meant for the content pages, and the last value for a key wins. The entries drop the prefix that all the namespaces share (`FSharp.Formatting.` here, `Microsoft.FSharp.` for FSharp.Core), which otherwise left a column of identically truncated names, and the full name is the `title` of the entry. The entries of the "On this page" menu carry one too, since that menu is narrow enough to cut them off. The header links to the index of all namespaces. The list of entities that used to expand under the current namespace is gone, the "On this page" menu covers it. [#1328](https://github.com/fsprojects/FSharp.Formatting/pull/1328)
+
+* The API reference menu built from `_menu_template.html` and `_menu-item_template.html` marks the namespace of the page the reader is on, and marks its own section as active so a template that folds a section away opens the one the reader is inside. It shortens an entry to the part that tells it apart from its neighbours, with the full name on the `title`. The Markdown output builds the same menu from the same templates and gained all of it too. Note that a templated menu renders its header as a label, where the built-in menu makes the "Namespaces" header the link to the index of all namespaces; see "Customizing menu items by template" for adding that link back. [#1328](https://github.com/fsprojects/FSharp.Formatting/pull/1328)
+
+* A menu built from `_menu-item_template.html` no longer escapes the text of a documentation page twice. `Menu.createMenu` encodes the content of an item, and the list of documents encoded it again beforehand, so a title carrying `&`, `<`, `>` or `"` reached the page as the escape sequence itself. [#1328](https://github.com/fsprojects/FSharp.Formatting/pull/1328)
+
+* The `j` / `k` hotkeys continue through the tables of the API reference: the namespaces on the index page and the types and modules on a namespace page, which have few headings of their own to stop at. The pages of a module or a type keep to their headings. The generator marks the links that take part with `data-fsdocs-nav`, so a link in your own content can join in as well. A marked link is only scrolled into view when it is off screen, a run of key presses walks the table instead of paging it. [#1328](https://github.com/fsprojects/FSharp.Formatting/pull/1328)
+
+### Changed
+* `Menu.createMenu` takes the substitutions of the page as its second argument, and `Menu.MenuItem` carries a `Title` field. Both are breaking for code that builds menus through `FSharp.Formatting.Common`. [#1328](https://github.com/fsprojects/FSharp.Formatting/pull/1328)
+
 ## [23.0.0-alpha.4] - 2026-09-13
 
 ### Added
diff --git a/build.fsx b/build.fsx
index 328887577..4fbca4f74 100755
--- a/build.fsx
+++ b/build.fsx
@@ -64,6 +64,233 @@ let buildStage =
         run $"dotnet build %s{solutionFile} --configuration %s{configuration} -tl"
     }
 
+// --------------------------------------------------------------------------------------
+// Analyzers
+//
+// `dotnet msbuild /t:AnalyzeSolution` is the full run, the one CI does, and it takes minutes.
+//
+// The `AnalyzeChanged` pipeline below runs the same analyzers over the files the working tree
+// touched, which takes seconds. It cannot see a finding your change causes in a file you did not
+// edit, so the full run is still the one to do before opening a pull request.
+// --------------------------------------------------------------------------------------
+
+/// Where the analyzer packages were restored to. `dotnet restore` writes a `Pkg*` property per
+/// package that asks for one, so no version is repeated here.
+let private analyzerPaths (ctx: Internal.StageContext) =
+    async {
+        let properties = [ "PkgG-Research_FSharp_Analyzers"; "PkgIonide_Analyzers" ]
+
+        let flags =
+            properties
+            |> List.map (fun property -> $"--getProperty:%s{property}")
+            |> String.concat " "
+
+        let! result =
+            ctx.RunCommandCaptureAll(
+                $"dotnet msbuild src/FSharp.Formatting/FSharp.Formatting.fsproj %s{flags}",
+                workingDir = root,
+                disablePrintCommand = true,
+                disablePrintOutput = true
+            )
+
+        if result.ExitCode <> 0 then
+            failwith $"Could not read the analyzer package paths.\n%s{result.StandardError}"
+
+        use json = Text.Json.JsonDocument.Parse result.StandardOutput
+        let values = json.RootElement.GetProperty "Properties"
+
+        return
+            properties
+            |> List.map (fun property ->
+                let path = values.GetProperty(property).GetString()
+
+                if String.IsNullOrEmpty path then
+                    failwith $"%s{property} is empty, restore the solution first."
+
+                path @@ "analyzers/dotnet/fs")
+    }
+
+/// The same projects Directory.Solution.targets analyzes.
+let private analyzerProjects =
+    !!(root @@ "src/**/*.fsproj")
+    |> Seq.map (fun project -> project.Replace('\\', '/'))
+    |> Seq.sort
+    |> List.ofSeq
+
+type private AnalysisTarget =
+    /// An empty file list asks for every file of the project.
+    | Project of project: string * files: string list
+    | Script of file: string
+
+/// Every project that owns a changed file, scoped to the changed files of its own, plus the build
+/// script when that changed. A changed project file asks for the whole project.
+let private targetsFor (files: string list) =
+    let endsWith extensions (path: string) =
+        extensions
+        |> List.exists (fun (extension: string) -> path.EndsWith(extension, StringComparison.Ordinal))
+
+    // `--include-files` only matches an absolute path, and silently reports a clean run otherwise.
+    let absolute (file: string) = (root @@ file).Replace('\\', '/')
+
+    let keep extensions =
+        List.choose
+            (fun file ->
+                if endsWith extensions file then
+                    Some(absolute file)
+                else
+                    None)
+            files
+
+    let sources = keep [ ".fs"; ".fsi" ]
+    let projectFiles = keep [ ".fsproj" ]
+
+    [
+        for project in analyzerProjects do
+            let folder = project.Substring(0, project.LastIndexOf '/' + 1)
+
+            let owns (file: string) =
+                file.StartsWith(folder, StringComparison.Ordinal)
+
+            if List.exists owns projectFiles then
+                yield Project(project, [])
+            else
+                match List.filter owns sources with
+                | [] -> ()
+                | owned -> yield Project(project, owned)
+
+        if List.contains "build.fsx" files then
+            yield Script(absolute "build.fsx")
+    ]
+
+/// The changed files git reports, relative to the repository root, without the deleted ones.
+let private changedFiles (ctx: Internal.StageContext) =
+    async {
+        let! result =
+            ctx.RunCommandCaptureAll(
+                "git status --porcelain --untracked-files=all",
+                workingDir = root,
+                disablePrintCommand = true,
+                disablePrintOutput = true
+            )
+
+        if result.ExitCode <> 0 then
+            failwith $"Could not read the git status.\n%s{result.StandardError}"
+
+        return
+            result.StandardOutput.Split('\n')
+            |> Array.choose (fun (line: string) ->
+                let line = line.TrimEnd('\r')
+
+                if line.Length < 4 || line[0] = 'D' || line[1] = 'D' then
+                    None
+                else
+                    let path = line.Substring 3
+
+                    let path =
+                        match path.IndexOf(" -> ", StringComparison.Ordinal) with
+                        | -1 -> path
+                        | arrow -> path.Substring(arrow + 4)
+
+                    Some(path.Trim('"').Replace('\\', '/')))
+            |> List.ofArray
+    }
+
+/// One process per target, several at a time, each printing its output in one piece as it
+/// finishes. No SARIF is written: a report of the files you touched is not one of the solution.
+let private runAnalyzers (ctx: Internal.StageContext) (targets: AnalysisTarget list) =
+    async {
+        let! analyzers = analyzerPaths ctx
+
+        let nameOf target =
+            match target with
+            | Script file -> Path.GetFileName file
+            | Project(project, _) -> Path.GetFileNameWithoutExtension project
+
+        printfn $"""Analyzing: %s{targets |> List.map nameOf |> String.concat ", "}"""
+
+        let analyze target =
+            async {
+                let started = DateTime.UtcNow
+
+                let arguments =
+                    [
+                        for analyzer in analyzers do
+                            "--analyzers-path"
+                            analyzer
+
+                        "--exclude-analyzers"
+                        "PartialAppAnalyzer"
+                        "ReturnStructPartialActivePatternAnalyzer"
+
+                        // These report at Info by default, which never fails a run.
+                        "--treat-as-error"
+                        "IONIDE-002"
+                        "IONIDE-010"
+                        "IONIDE-012"
+
+                        "-c"
+                        configuration
+                        "--code-root"
+                        root
+
+                        match target with
+                        | Script file ->
+                            // The scripts NuGet generates for a `#r "nuget: ..."` are not ours.
+                            "--exclude-files"
+                            "**/.packagemanagement/**"
+                            "--script"
+                            file
+                        | Project(project, files) ->
+                            match files with
+                            | [] -> ()
+                            | files ->
+                                "--include-files"
+                                yield! files
+
+                            "--project"
+                            project
+                    ]
+
+                let command =
+                    arguments |> List.map (fun argument -> $"\"%s{argument}\"") |> String.concat " "
+
+                let! result =
+                    ctx.RunCommandCaptureAll(
+                        $"dotnet fsharp-analyzers %s{command}",
+                        workingDir = root,
+                        disablePrintCommand = true,
+                        disablePrintOutput = true
+                    )
+
+                let elapsed = DateTime.UtcNow - started
+
+                let scope =
+                    match target with
+                    | Script _
+                    | Project(_, []) -> ""
+                    | Project(_, files) -> $" (%i{files.Length} files)"
+
+                let outcome =
+                    if result.ExitCode = 0 then
+                        "clean"
+                    else
+                        $"exit code %i{result.ExitCode}"
+
+                printfn $"\n=== %s{nameOf target}%s{scope}: %s{outcome} in %.1f{elapsed.TotalSeconds}s"
+                printf "%s" result.StandardOutput
+                eprintf "%s" result.StandardError
+
+                return result.ExitCode
+            }
+
+        // Every process type checks a whole project, so a handful at a time is what keeps the
+        // machine busy without the runs starving each other of memory.
+        let! exitCodes =
+            Async.Parallel(List.map analyze targets, max 2 (Environment.ProcessorCount / 2))
+
+        return Array.fold max 0 exitCodes
+    }
+
 let packStage =
     stage "NuGet" {
         run $"dotnet pack %s{solutionFile} --output \"%s{artifactsDir}\" --configuration %s{configuration} -tl"
@@ -117,6 +344,29 @@ pipeline "Verify" {
     runIfOnlySpecified true
 }
 
+// The analyzers, over the files the working tree touched, which is seconds rather than minutes.
+// `./build.fsx -p AnalyzeChanged`.
+pipeline "AnalyzeChanged" {
+    workingDir root
+    stage "RestoreTools" { run "dotnet tool restore" }
+    stage "Restore" { run $"dotnet restore %s{solutionFile} -tl" }
+
+    stage "Analyze" {
+        run (fun ctx ->
+            async {
+                let! files = changedFiles ctx
+
+                match targetsFor files with
+                | [] ->
+                    Console.WriteLine "No changed file is analyzed."
+                    return 0
+                | targets -> return! runAnalyzers ctx targets
+            })
+    }
+
+    runIfOnlySpecified true
+}
+
 // Start the documentation site in watch mode with the locally built fsdocs tool.
 // Runs until interrupted (Ctrl+C); the site is served on http://localhost:8901.
 // Every argument after the pipeline name is passed on to `fsdocs watch`, for example
@@ -130,7 +380,7 @@ pipeline "Docs" {
                 |> Array.skip 1
                 |> String.concat " "
 
-            $"dotnet run --project src/fsdocs-tool -- watch %s{extraArgs}")
+            $"dotnet watch --project src/fsdocs-tool -- watch %s{extraArgs}")
     }
 
     runIfOnlySpecified true
diff --git a/docs/_body.html b/docs/_body.html
index 8413ae22e..596e60379 100644
--- a/docs/_body.html
+++ b/docs/_body.html
@@ -5,10 +5,10 @@
   // as a syntax-highlighted code block. Promote those blocks to
   // 
elements so one plain fence works in both places. for (const code of document.querySelectorAll('code[lang="mermaid"]')) { - // fsdocs wraps the snippet in
...;
-    // replace the outermost wrapper so no table scaffolding is left around
+    // fsdocs wraps the snippet in 
...;
+    // replace the outermost wrapper so no snippet scaffolding is left around
     // the diagram.
-    const snippet = code.closest('table.pre') ?? code.closest('pre');
+    const snippet = code.closest('.fsdocs-snippet') ?? code.closest('pre');
     if (!snippet) continue;
     const diagram = document.createElement('div');
     diagram.className = 'mermaid';
diff --git a/docs/codeformat.fsx b/docs/codeformat.fsx
index dd998e44d..840f16c06 100644
--- a/docs/codeformat.fsx
+++ b/docs/codeformat.fsx
@@ -138,27 +138,23 @@ If the input contains multiple snippets separated using the `//[snippet:...]` co
 *)
 
 (**
-
-
-
-
-1: 
-2: 
-3: 
-4: 
-5: 
-6: 
-7: 
-
-
// [snippet: First sample]
-printf "The answer is: %A" 42
+
+
// [snippet: First sample]
+printf "The answer is: %A" 42
 // [/snippet]
 
 // [snippet: Second sample]
-printf "Hello world!"
+printf "Hello world!"
 // [/snippet]
 
-
+
*) (** diff --git a/docs/commandline.md b/docs/commandline.md index cfc192ab9..265877d37 100644 --- a/docs/commandline.md +++ b/docs/commandline.md @@ -177,6 +177,14 @@ The default template can be used from the keyboard: | `h` / `l` | Move the focus to the main menu / the content / the page menu | | `j` / `k` in a menu| Go to the next / previous link of the menu; `Enter` follows it | +On pages that have few headings of their own, `j` / `k` also stop at the links of the listing: the namespaces on the +API reference index, and the types and modules on a namespace page. Any link in your own content can join in by +carrying a `data-fsdocs-nav` attribute. + +The API reference pages carry an "On this page" menu of their own. A namespace page lists its types and modules, each +linking to its own page, and a type or module page lists its sections. The index of all namespaces has no menu, since +the page is already that list. + The hotkeys are ignored while typing in a text box. They are implemented in `fsdocs-hotkeys.js`, which expects the `main`, `#content`, `#fsdocs-main-menu` and `#fsdocs-page-menu` elements of the default template. To add them to your own `_template.html`, include the script: diff --git a/docs/content/fsdocs-copy-button.js b/docs/content/fsdocs-copy-button.js index d1e598291..1746caeae 100644 --- a/docs/content/fsdocs-copy-button.js +++ b/docs/content/fsdocs-copy-button.js @@ -42,26 +42,23 @@ function attachCopyHandler(button, getText) { } document.addEventListener('DOMContentLoaded', function () { - // table.pre blocks (F# highlighted code, sometimes with line numbers) - document.querySelectorAll('table.pre').forEach(function (table) { - const wrapper = document.createElement('div') - wrapper.className = 'code-block-wrapper' - table.parentNode.insertBefore(wrapper, table) - wrapper.appendChild(table) + // Snippets emitted by fsdocs:
holding an optional + // line number
 and the code 
. Copy only the code column.
+    document.querySelectorAll('.fsdocs-snippet').forEach(function (snippet) {
+        const code = snippet.querySelector('pre:not(.fsdocs-snippet-lines)')
+        if (!code) return
 
         const button = createCopyButton()
-        wrapper.appendChild(button)
+        snippet.appendChild(button)
 
-        const snippet = table.querySelector('.snippet pre')
         attachCopyHandler(button, function () {
-            return (snippet || table).innerText
+            return code.innerText
         })
     })
 
-    // Standard pre > code blocks (Markdown fenced code, standalone fssnip, etc.)
-    // Skip those already handled inside table.pre above.
+    // Any other pre > code block on the page, for example hand written HTML.
     document.querySelectorAll('pre > code').forEach(function (code) {
-        if (code.closest('table.pre')) return
+        if (code.closest('.fsdocs-snippet')) return
         const pre = code.parentElement
         pre.classList.add('has-copy-button')
 
diff --git a/docs/content/fsdocs-default.css b/docs/content/fsdocs-default.css
index e35633e4b..ffa30ef5e 100644
--- a/docs/content/fsdocs-default.css
+++ b/docs/content/fsdocs-default.css
@@ -64,8 +64,8 @@
     --text-color: light-dark(#020202, #F7F7F7);
     --text-hover: light-dark(#282828, #FFF);
     --heading-color: light-dark(#2e293a, rgb(229, 231, 235));
-    --code-background: light-dark(#ffffff, #28282D);
-    --code-color: light-dark(#1a4b0b, #f5f5f6);
+    --code-background: light-dark(#f6f8fa, #161b22);
+    --code-color: light-dark(#1f2328, #e6edf3);
     --header-background: light-dark(#f9fbfc, #0c1318);
     --header-border: light-dark(#e8ecf1, #3a3a42);
     --header-link-color: var(--text-color);
@@ -104,38 +104,60 @@
     --dialog-link-color: var(--link-color);
     --dialog-link-hover-color: var(--header-background);
     --dialog-link-hover-background-color: var(--link-hover);
-    --code-fence-border-color: transparent;
+    --code-fence-border-color: light-dark(#d1d9e0, #30363d);
+    --code-divider-color: light-dark(#d1d9e0, #30363d);
 
     --header-brand-text-transform: none;
 
-    --code-strings-color: light-dark(#0093A1, #86b4b9);
-    --code-printf-color: #6B2FBA;
-    --code-escaped-color: #EA8675;
-    --code-identifiers-color: light-dark(#6B2FBA, #d1b3f5);
-    --code-module-color: light-dark(#009999, #15e1e1);
-    --code-reference-color: light-dark(#4974D1, #40fddd);
-    --code-value-color: light-dark(#1B6600, #ffb4e9);
-    --code-interface-color: #43AEC6;
-    --code-typearg-color: #43AEC6;
-    --code-disposable-color: light-dark(#43AEC6, #6dd6f1);
-    --code-property-color: light-dark(#43AEC6, #6acfe7);
-    --code-punctuation-color: #43AEC6;
-    --code-punctuation2-color: var(--text-color);
-    --code-function-color: #6B2FBA;
-    --code-function2-color: light-dark(#6B2FBA, #cbda9d);
-    --code-activepattern-color: #4ec9b0;
-    --code-unioncase-color: #4ec9b0;
-    --code-enumeration-color: #8C6C41;
-    --code-keywords-color: light-dark(#0F54D6, #a7c2f8);
-    --code-comment-color: light-dark(#707070, #84d16e);
-    --code-operators-color: light-dark(#0F54D6, #b4c6ee);
-    --code-numbers-color: #009999;
-    --code-linenumbers-color: #80b0b0;
-    --code-mutable-color: #1b6600;
-    --code-inactive-color: #808080;
-    --code-preprocessor-color: #af75c1;
-    --code-fsioutput-color: #808080;
-    --code-tooltip-color: #d1d1d1;
+    /* Syntax colors, built from GitHub's Primer syntax hues.
+       Roles rather than one hue per token kind: keywords, types, the modules
+       and namespaces that hold them, callables, values, strings, and one mute
+       for anything the reader is not meant to focus on. Operators and
+       punctuation are left as plain text, the way GitHub renders them. */
+
+    /* keywords and preprocessor directives */
+    --code-keywords-color: light-dark(#cf222e, #ff7b72);
+    --code-preprocessor-color: light-dark(#cf222e, #ff7b72);
+
+    /* modules and namespaces, the containers rather than the types themselves */
+    --code-module-color: light-dark(#116329, #7ee787);
+
+    /* types */
+    --code-reference-color: light-dark(#953800, #ffa657);
+    --code-value-color: light-dark(#953800, #ffa657);
+    --code-interface-color: light-dark(#953800, #ffa657);
+    --code-typearg-color: light-dark(#953800, #ffa657);
+    --code-disposable-color: light-dark(#953800, #ffa657);
+
+    /* things you call: functions and active patterns */
+    --code-function-color: light-dark(#8250df, #d2a8ff);
+    --code-activepattern-color: light-dark(#8250df, #d2a8ff);
+
+    /* values: union cases, enum cases, properties, numbers, mutable bindings */
+    --code-unioncase-color: light-dark(#0550ae, #79c0ff);
+    --code-enumeration-color: light-dark(#0550ae, #79c0ff);
+    --code-property-color: light-dark(#0550ae, #79c0ff);
+    --code-numbers-color: light-dark(#0550ae, #79c0ff);
+    --code-mutable-color: light-dark(#0550ae, #79c0ff);
+
+    /* strings, and the escapes and format specifiers inside them */
+    --code-strings-color: light-dark(#0a3069, #a5d6ff);
+    --code-printf-color: light-dark(#953800, #ffa657);
+    --code-escaped-color: light-dark(#953800, #ffa657);
+
+    /* identifiers, operators and punctuation read as plain text */
+    --code-identifiers-color: var(--code-color);
+    --code-operators-color: var(--code-color);
+    --code-punctuation-color: var(--code-color);
+
+    /* muted: comments, fsi output, inactive code, line numbers */
+    --code-comment-color: light-dark(#59636e, #9198a1);
+    --code-fsioutput-color: light-dark(#59636e, #9198a1);
+    --code-inactive-color: light-dark(#818b98, #656c76);
+    --code-linenumbers-color: light-dark(#818b98, #656c76);
+
+    /* compiler errors */
+    --code-error-color: light-dark(#cf222e, #ff7b72);
 }
 
 /* Explicit color-scheme overrides for the manual theme toggle.
@@ -359,8 +381,10 @@ main {
         list-style: none;
         padding: 0;
         margin: 0;
+    }
 
-        & li {
+    > ul {
+        > li {
             border-left: 1px solid var(--panel-border);
             margin: 0;
 
@@ -418,6 +442,14 @@ main {
             &.level-4 {
                 padding-left: var(--spacing-300);
             }
+
+            /* The types and modules a section lists on an API reference page. They are plain
+             * links: the scroll-driven highlight above tracks the sections only, because each
+             * tracked element costs three generated CSS rules. */
+            & .fsdocs-menu-entries > li > a {
+                padding-left: var(--spacing-300);
+                font-size: var(--font-100);
+            }
         }
     }
 }
@@ -437,6 +469,21 @@ main {
         font-size: var(--font-200);
         font-weight: 500;
         color: var(--menu-color);
+
+        /* The "API Reference" header of that menu links to the index of all namespaces.
+         * It keeps the look of a header rather than taking on the link styling. */
+        & a {
+            color: inherit;
+            text-decoration: none;
+
+            &:hover {
+                text-decoration: underline;
+            }
+        }
+
+        &.active {
+            font-weight: 700;
+        }
     }
 }
 
@@ -558,18 +605,6 @@ main {
 @media screen and (min-width: 1200px) {
     body {
         grid-template-columns: var(--body-grid-template-columns-xl);
-
-        &.api-docs {
-            grid-template-columns: var(--body-grid-template-columns);
-
-            #content {
-                max-width: calc(var(--container-xl) - var(--main-menu-width) - var(--spacing-300) - var(--spacing-300));
-            }
-
-            #fsdocs-page-menu {
-                display: none;
-            }
-        }
     }
 
     header {
@@ -588,7 +623,7 @@ main {
 
 @media screen and (min-width: 1400px) {
     body {
-        &.api-docs, &:has(#fsdocs-page-menu .empty) {
+        &:has(#fsdocs-page-menu .empty) {
             #content {
                 max-width: calc(var(--container-xxl) - var(--main-menu-width) - var(--spacing-300) - var(--spacing-300));
             }
@@ -602,7 +637,7 @@ main {
 
 @media screen and (min-width: 1800px) {
     body {
-        &.api-docs, &:has(#fsdocs-page-menu .empty) {
+        &:has(#fsdocs-page-menu .empty) {
             #content {
                 max-width: calc(var(--container-xxxl) - var(--main-menu-width) - var(--spacing-300) - var(--spacing-300));
             }
@@ -790,7 +825,7 @@ pre {
     padding: 0;
 }
 
-code, table.pre, pre {
+code, pre {
     background-color: var(--code-background);
     color: var(--code-color);
     font-family: var(--monospace-font);
@@ -803,30 +838,64 @@ h1 code, h2 code, h3 code, h4 code, h5 code, h6 code {
     font-size: inherit;
 }
 
-table.pre, #content > pre.fssnip, .code-block-wrapper > table.pre {
-    border: 1px solid var(--code-fence-border-color);
+p > code, li > code {
+    padding: 2px var(--spacing-50);
+    border-radius: var(--radius);
 }
 
-table.pre, pre.fssnip.highlighted {
-    margin: var(--spacing-300) 0;
-    padding: var(--spacing-200);
-}
+/* A code snippet. FSharp.Formatting emits
 
-/* Copy code button */
-.code-block-wrapper {
+     
+ +
…
+
+ + where the line number column is optional. Two
 elements rather than one
+   keep the numbers out of anything the reader selects and copies. */
+.fsdocs-snippet {
     position: relative;
-    display: block;
+    display: grid;
+    grid-template-columns: auto minmax(0, 1fr);
+    align-items: stretch;
     margin: var(--spacing-300) 0;
+    padding: var(--spacing-200) 0;
+    border: 1px solid var(--code-fence-border-color);
+    border-radius: var(--radius);
+    background-color: var(--code-background);
 }
 
-.code-block-wrapper > table.pre {
-    margin: 0;
+/* without line numbers the code takes the whole width */
+.fsdocs-snippet > pre:only-child {
+    grid-column: 1 / -1;
 }
 
-pre.has-copy-button {
-    position: relative;
+.fsdocs-snippet-lines {
+    padding: 0 var(--spacing-200);
+    border-right: 1px solid var(--code-divider-color);
+    color: var(--code-linenumbers-color);
+    text-align: right;
+    user-select: none;
+    -webkit-user-select: none;
+}
+
+/* the code column scrolls on its own, so the line numbers stay in view */
+.fsdocs-snippet > pre:not(.fsdocs-snippet-lines) {
+    padding: 0 var(--spacing-200);
+    overflow-x: auto;
+}
+
+.fsdocs-snippet pre,
+.fsdocs-snippet code {
+    background-color: transparent;
+}
+
+.fsdocs-snippet code {
+    display: block;
+    max-width: initial;
+    margin-bottom: initial;
 }
 
+/* Copy code button */
 .copy-code-button {
     position: absolute;
     top: var(--spacing-100);
@@ -848,36 +917,19 @@ pre.has-copy-button {
     }
 }
 
-pre.has-copy-button:hover .copy-code-button,
-.code-block-wrapper:hover .copy-code-button {
-    opacity: 1;
-}
-
-table.pre .snippet pre.fssnip {
-    padding: 0;
-    margin: 0;
-}
-
-p > code, li > code {
-    padding: 2px var(--spacing-50);
-}
-
-table.pre, pre > code {
-    display: block;
-}
-
-pre.fssnip > code {
-    max-width: initial;
-    margin-bottom: initial;
+pre.has-copy-button {
+    position: relative;
 }
 
-/* avoid problems with transparent background colors */
-pre.fssnip.highlighted > code, table.pre .fssnip, table.pre .fssnip code {
-    background-color: transparent;
+.fsdocs-snippet:hover .copy-code-button,
+.fsdocs-snippet:focus-within .copy-code-button,
+pre.has-copy-button:hover .copy-code-button,
+pre.has-copy-button:focus-within .copy-code-button {
+    opacity: 1;
 }
 
 /* Custom scrollbar styling */
-body, aside, #menu-toggle .menu, table.pre, code,
+body, aside, #menu-toggle .menu, code, .fsdocs-snippet > pre,
 .fsdocs-entity-xmldoc > div > pre, div.fsdocs-summary > pre,
 dialog ul, div.fsdocs-tip, .fsdocs-xmldoc > pre {
     overflow-x: auto;
@@ -898,7 +950,7 @@ dialog ul, div.fsdocs-tip, .fsdocs-xmldoc > pre {
     }
 }
 
-body, aside, #menu-toggle .menu, table.pre, code,
+body, aside, #menu-toggle .menu, code, .fsdocs-snippet > pre,
 .fsdocs-entity-xmldoc > div > pre, div.fsdocs-summary > pre,
 dialog ul, div.fsdocs-tip, .fsdocs-xmldoc > pre {
     @media screen and (min-width: 768px) {
@@ -909,7 +961,7 @@ dialog ul, div.fsdocs-tip, .fsdocs-xmldoc > pre {
     }
 }
 
-table.pre, code, pre.fssnip {
+code {
     border-radius: var(--radius);
 }
 
@@ -923,8 +975,14 @@ table.pre, code, pre.fssnip {
     font-variant-ligatures: none;
 }
 
-.fssnip {
-    /* strings --- and stlyes for other string related formats */
+/* Syntax highlighting.
+   Class names are produced by FSharp.Formatting.CodeFormat.Constants.CSS for F#
+   snippets and by FSharp.Formatting.CSharpFormat for every other language.
+   Keep this list in sync with those two, a rule that matches nothing is invisible.
+   Tool tips carry the same classes, the compiler tags the text it hands us. */
+.fssnip,
+div.fsdocs-tip {
+    /* strings */
 
     & span.s {
         color: var(--code-strings-color);
@@ -938,14 +996,15 @@ table.pre, code, pre.fssnip {
 
     /* escaped chars */
 
-    & span.e {
+    & span.esc {
         color: var(--code-escaped-color);
     }
 
-    /* identifiers --- and styles for more specific identifier types */
+    /* identifiers: 'id' comes from the F# path, 'i' from the non-F# path */
 
-    & span.id {
-        color: var(--code-identifiers-color);;
+    & span.id,
+    & span.i {
+        color: var(--code-identifiers-color);
     }
 
     /* module */
@@ -992,22 +1051,14 @@ table.pre, code, pre.fssnip {
 
     /* punctuation */
 
-    & span.p {
-        color: var(--code-punctuation-color);
-    }
-
     & span.pn {
-        color: var(--code-punctuation2-color);
+        color: var(--code-punctuation-color);
     }
 
     /* function */
 
-    & span.f {
-        color: var(--code-function-color);
-    }
-
     & span.fn {
-        color: var(--code-function2-color);
+        color: var(--code-function-color);
     }
 
     /* active pattern */
@@ -1018,13 +1069,13 @@ table.pre, code, pre.fssnip {
 
     /* union case */
 
-    & span.u {
+    & span.uc {
         color: var(--code-unioncase-color);
     }
 
     /* enumeration */
 
-    & span.e {
+    & span.en {
         color: var(--code-enumeration-color);
     }
 
@@ -1054,27 +1105,22 @@ table.pre, code, pre.fssnip {
         color: var(--code-numbers-color);
     }
 
-    /* line number */
-
-    & span.l {
-        color: var(--code-linenumbers-color);
-    }
-
     /* mutable var or ref cell */
 
-    & span.v {
+    & span.mv {
         color: var(--code-mutable-color);
         font-weight: bold;
     }
 
-    /* inactive code */
+    /* inactive code, excluded by a conditional define */
 
     & span.inactive {
         color: var(--code-inactive-color);
     }
 
-    /* preprocessor */
+    /* preprocessor: 'pp' comes from the F# path, 'prep' from the non-F# path */
 
+    & span.pp,
     & span.prep {
         color: var(--code-preprocessor-color);
     }
@@ -1084,6 +1130,21 @@ table.pre, code, pre.fssnip {
     & span.fsi {
         color: var(--code-fsioutput-color);
     }
+
+    /* compiler error, the message is in the tooltip */
+
+    & span.cerr {
+        text-decoration: underline wavy var(--code-error-color);
+        text-underline-offset: 0.2em;
+    }
+
+    /* omitted code, the hidden lines are in the tooltip */
+
+    & span.omitted {
+        color: var(--code-inactive-color);
+        border-bottom: 1px dotted var(--code-inactive-color);
+        cursor: help;
+    }
 }
 
 /* tooltips */
@@ -1101,6 +1162,13 @@ div.fsdocs-tip {
     & code {
         color: var(--code-color);
     }
+
+    /* the doc comment under a signature, in the color a comment has in a snippet */
+
+    & em {
+        color: var(--code-comment-color);
+        font-style: italic;
+    }
 }
 
 @keyframes fsdocs-tip-fade-in {
@@ -1121,7 +1189,6 @@ div.fsdocs-tip:popover-open {
     cursor: pointer;
 }
 
-
 /* API docs */
 #content > div > h2:first-child {
     margin-top: 0;
diff --git a/docs/content/fsdocs-hotkeys.js b/docs/content/fsdocs-hotkeys.js
index 6f9d088bf..0c21a7309 100644
--- a/docs/content/fsdocs-hotkeys.js
+++ b/docs/content/fsdocs-hotkeys.js
@@ -1,6 +1,6 @@
 // Keyboard navigation for the default template.
 //
-//   j / k   next / previous heading in the content, or next / previous link when a menu has the focus
+//   j / k   next / previous stop in the content, or next / previous link when a menu has the focus
 //   h / l   move the focus between the main menu (left), the content and the page menu (right)
 //
 // The search script owns '/', Ctrl+K and Escape.
@@ -45,47 +45,56 @@ if (main && content) {
         return regions.find(region => region.contains(element)) ?? main;
     }
 
-    // Headings that are on the page (a heading inside a closed 
or a hidden tooltip does not count). - function headings() { - return Array.from(content.querySelectorAll("h1, h2, h3, h4, h5, h6")).filter(isVisible); + // Where j / k stop in the content: every heading, plus the links the page marked with + // data-fsdocs-nav (the namespace and type tables of the API reference, which have few headings of + // their own). querySelectorAll returns them in document order. A stop inside a closed
+ // or a hidden tooltip does not count. + function stops() { + return Array.from(content.querySelectorAll("h1, h2, h3, h4, h5, h6, [data-fsdocs-nav]")).filter(isVisible); + } + + function isHeading(element) { + return /^H[1-6]$/.test(element?.tagName); } function links(region) { return Array.from(region.querySelectorAll("a[href]")).filter(isVisible); } - // Distance between a heading and the place scrollIntoView puts it (the top of the content, minus its scroll margin). + // Distance between a stop and the place scrollIntoView puts it (the top of the content, minus its scroll margin). function topOf(element) { const scrollMargin = parseFloat(getComputedStyle(element).scrollMarginTop) || 0; return element.getBoundingClientRect().top - main.getBoundingClientRect().top - scrollMargin; } - // The heading the reader is looking at: the last one at or above the top of the content. - function currentHeading(all) { - return all.filter(heading => topOf(heading) <= 2).at(-1) ?? all[0]; + // The stop the reader is looking at: the last one at or above the top of the content. + function currentStop(all) { + return all.filter(stop => topOf(stop) <= 2).at(-1) ?? all[0]; } - function moveHeading(delta) { - const all = headings(); + function moveStop(delta) { + const all = stops(); if (all.length === 0) { return; } - // The first press selects the heading in view; the following ones move from it. + // The first press selects the stop in view; the following ones move from it. const focusedIndex = all.indexOf(document.activeElement); const target = focusedIndex < 0 - ? currentHeading(all) + ? currentStop(all) : all[Math.min(Math.max(focusedIndex + delta, 0), all.length - 1)]; - focusElement(target, { block: "start" }); + // A heading goes to the top, so its section is below it. A table row only scrolls when it is + // off screen, so a run of key presses walks down the table instead of paging it. + focusElement(target, { block: isHeading(target) ? "start" : "nearest" }); revealPageMenuEntry(target); updateAnchor(target); } // Put the anchor of the focused heading in the URL, as clicking its menu entry would, so the page URL // can be copied with the right anchor. replaceState keeps the history clean: one entry per page, not - // one per key press. - function updateAnchor(heading) { - const anchor = heading?.querySelector("a[href^='#']")?.getAttribute("href") ?? (heading?.id ? `#${heading.id}` : null); + // one per key press. A table row has no anchor of its own and leaves the URL alone. + function updateAnchor(stop) { + const anchor = stop?.querySelector("a[href^='#']")?.getAttribute("href") ?? (stop?.id ? `#${stop.id}` : null); if (anchor && anchor !== location.hash) { history.replaceState(history.state, "", anchor); } @@ -93,8 +102,8 @@ if (main && content) { // Keep the page menu entry of the focused heading in view when the menu has its own scroll bar. // Instant rather than smooth, so a quick series of key presses does not leave the menu lagging. - function revealPageMenuEntry(heading) { - const index = heading?.dataset.fsdocsHeading; + function revealPageMenuEntry(stop) { + const index = stop?.dataset.fsdocsHeading; const entry = index && pageMenu?.querySelector(`[data-fsdocs-heading="${index}"]`); if (entry && isVisible(entry)) { entry.scrollIntoView({ block: "nearest", behavior: "instant" }); @@ -113,7 +122,7 @@ if (main && content) { function enterRegion(region) { if (region === main) { - focusElement(currentHeading(headings()) ?? main); + focusElement(currentStop(stops()) ?? main); return; } const all = links(region); @@ -123,8 +132,8 @@ if (main && content) { let target = region.querySelector(".nav-item.active a[href]"); if (region === pageMenu) { // The entry of the heading currently in view, when the page menu has one. - const heading = currentHeading(headings()); - const anchor = heading?.querySelector("a[href^='#']")?.getAttribute("href") ?? (heading?.id ? `#${heading.id}` : null); + const stop = currentStop(stops()); + const anchor = stop?.querySelector("a[href^='#']")?.getAttribute("href") ?? (stop?.id ? `#${stop.id}` : null); target = anchor ? all.find(link => link.getAttribute("href") === anchor) : null; } focusElement(target && isVisible(target) ? target : all[0], { block: "nearest" }); @@ -155,10 +164,10 @@ if (main && content) { const region = regionOf(document.activeElement); switch (ev.key) { case "j": - region === main ? moveHeading(1) : moveLink(region, 1); + region === main ? moveStop(1) : moveLink(region, 1); break; case "k": - region === main ? moveHeading(-1) : moveLink(region, -1); + region === main ? moveStop(-1) : moveLink(region, -1); break; case "h": moveRegion(-1); diff --git a/docs/evaluation.fsx b/docs/evaluation.fsx index 4b5c929ae..de45721c0 100644 --- a/docs/evaluation.fsx +++ b/docs/evaluation.fsx @@ -230,15 +230,10 @@ followed by a nicely formatted ordered list:

Formatting demo

- - - -
-1: 
-
-
-let test = ["one";"two";"three"]
-
+
+
let test = ["one";"two";"three"]
+
  1. one

  2. two

  3. diff --git a/docs/mermaid.md b/docs/mermaid.md index ef11afb23..0c2bf861a 100644 --- a/docs/mermaid.md +++ b/docs/mermaid.md @@ -22,10 +22,10 @@ Create or edit a `_body.html` file in your `docs` folder. fsdocs injects it at t // as a syntax-highlighted code block. Promote those blocks to //
    elements so one plain fence works in both places. for (const code of document.querySelectorAll('code[lang="mermaid"]')) { - // fsdocs wraps the snippet in
    ...;
    -    // replace the outermost wrapper so no table scaffolding is left around
    +    // fsdocs wraps the snippet in 
    ...;
    +    // replace the outermost wrapper so no snippet scaffolding is left around
         // the diagram.
    -    const snippet = code.closest('table.pre') ?? code.closest('pre');
    +    const snippet = code.closest('.fsdocs-snippet') ?? code.closest('pre');
         if (!snippet) continue;
         const diagram = document.createElement('div');
         diagram.className = 'mermaid';
    diff --git a/docs/styling.md b/docs/styling.md
    index a30b35213..80671e1fd 100644
    --- a/docs/styling.md
    +++ b/docs/styling.md
    @@ -196,6 +196,57 @@ Do note that files must be added before running, or won't be generated.
     In case you want to get a unique identifier for a header or menu item, you can use `{{fsdocs-menu-header-id}}`
     and `{{fsdocs-menu-item-id}}`, respectively.
     
    +Both menu templates also get the substitutions of the page they are rendered into, `{{root}}` among them, so a
    +menu can link anywhere on the site and not only where fsdocs hands it a link. `{{root}}` is relative to the page
    +being rendered, so `{{root}}reference/index.html` resolves from every depth:
    +
    +```html
    +
    +
    +{{fsdocs-menu-items}}
    +```
    +
    +A key that a menu template defines for itself always wins over a site-wide one of the same name.
    +
    +One template renders every section of the menu: the documents, each of their categories, and the namespaces of the
    +API reference. The header tells them apart, through `{{fsdocs-menu-header-content}}` and its `{{fsdocs-menu-header-id}}`,
    +but the substitutions have no conditionals, so anything else a template adds appears in every section.
    +
    +That matters in one place. The built-in menu makes the "API Reference" header itself the link to the index of all
    +namespaces:
    +
    +```html
    +
    +```
    +
    +A templated header is a label, and a template cannot single out one section, so putting that link back is a job for
    +a few lines of script. Give the header its id and its root, both of which the template already has:
    +
    +```html
    +
    +```
    +
    +and turn the text of that one header into a link, the way the built-in menu does:
    +
    +```js
    +const header = document.getElementById("api_reference");
    +
    +if (header) {
    +    const link = document.createElement("a");
    +    link.href = `${header.dataset.root}reference/index.html`;
    +    link.textContent = header.textContent.trim();
    +    header.replaceChildren(link);
    +}
    +```
    +
    +The id comes from the header text, so "API Reference" gives `api_reference` and "Getting started" gives
    +`getting_started`.
    +The API reference menu lists every namespace already, so what the index adds over it is the description of each one.
    +
    +`{{fsdocs-menu-item-title}}` is the hover text of an item, meant for the `title` attribute of the link. The API
    +reference uses it for the full name of a namespace, where the menu shortened the entry to what tells it apart from
    +its neighbours. It is empty for an item whose text already says where it leads.
    +
     ## Injecting additional html into the default template
     
     Occasionally, you may find the need to make small customizations to the default template, such as adding a Google
    diff --git a/src/FSharp.Formatting.ApiDocs/FSharp.Formatting.ApiDocs.fsproj b/src/FSharp.Formatting.ApiDocs/FSharp.Formatting.ApiDocs.fsproj
    index e671e68bc..bb782f0ef 100644
    --- a/src/FSharp.Formatting.ApiDocs/FSharp.Formatting.ApiDocs.fsproj
    +++ b/src/FSharp.Formatting.ApiDocs/FSharp.Formatting.ApiDocs.fsproj
    @@ -1,4 +1,4 @@
    -
    +
     
       
         netstandard2.1
    @@ -18,7 +18,9 @@
         
         
         
    +    
         
    +    
         
         
         
    diff --git a/src/FSharp.Formatting.ApiDocs/GenerateHtml.fs b/src/FSharp.Formatting.ApiDocs/GenerateHtml.fs
    index 20210e45d..e875e7d52 100644
    --- a/src/FSharp.Formatting.ApiDocs/GenerateHtml.fs
    +++ b/src/FSharp.Formatting.ApiDocs/GenerateHtml.fs
    @@ -1,5 +1,3 @@
    -/// Internal module that generates HTML documentation output from an .
    -/// Produces one HTML file per namespace and per entity, plus an index file.
     module internal FSharp.Formatting.ApiDocs.GenerateHtml
     
     open System
    @@ -12,11 +10,8 @@ open FSharp.Formatting.Templating
     open FSharp.Formatting.HtmlModel
     open FSharp.Formatting.HtmlModel.Html
     
    -/// Embed some HTML generated in GenerateModel
     let embed (x: ApiDocHtml) = !!x.HtmlText
     
    -/// Wraps the HTML text of an  in a summary paragraph,
    -/// unless the content starts with a <pre> tag (which cannot be nested inside <p>).
     let fsdocsSummary (x: ApiDocHtml) =
         // the 
     tag is not allowed inside a 

    tag. if x.HtmlText.StartsWith("

    ", StringComparison.Ordinal) then
    @@ -24,19 +19,128 @@ let fsdocsSummary (x: ApiDocHtml) =
         else
             div [ Class "fsdocs-summary-contents" ] [ p [ Class "fsdocs-summary" ] [ embed x ] ]
     
    -/// Renders HTML API documentation for all namespaces and entities in an
    -/// . Writes one file per namespace
    -/// and per entity to the output directory using the supplied template.
    +/// How much two names have in common, character for character.
    +let sharedPrefixLength (a: string) (b: string) =
    +    let limit = min a.Length b.Length
    +    let mutable i = 0
    +
    +    while i < limit && a.[i] = b.[i] do
    +        i <- i + 1
    +
    +    i
    +
    +let commonNamespacePrefix (names: string list) =
    +    match names with
    +    | []
    +    | [ _ ] -> ""
    +    | first :: rest ->
    +        // What the first name shares with whichever of the others agrees with it least.
    +        let shared =
    +            rest
    +            |> List.fold (fun shared name -> min shared (sharedPrefixLength first name)) first.Length
    +
    +        // Back up to a dot: half of a segment is not a prefix worth hiding.
    +        let lastDot =
    +            if shared = 0 then
    +                -1
    +            else
    +                first.LastIndexOf('.', shared - 1)
    +
    +        if lastDot < 0 then "" else first.Substring(0, lastDot + 1)
    +
    +let menuNamespaceOf (documented: Set) (name: string) =
    +    let rec outermost (name: string) (found: string option) =
    +        match name.LastIndexOf '.' with
    +        | -1 -> found
    +        | i ->
    +            let parent = name.Substring(0, i)
    +            // Walking outwards, so the last documented ancestor seen is the outermost one.
    +            let found = if documented.Contains parent then Some parent else found
    +            outermost parent found
    +
    +    outermost name None |> Option.defaultValue name
    +
    +type internal SectionCollector() =
    +    let sections = ResizeArray()
    +    let entries = ResizeArray()
    +
    +    member _.Heading(level: int, id: string, title: string) =
    +        sections.Add(level, id, title)
    +
    +        let heading =
    +            match level with
    +            | 2 -> h2
    +            | 3 -> h3
    +            | _ -> h4
    +
    +        heading [ Id id; Custom("data-fsdocs-heading", string sections.Count) ] [ !!title ]
    +
    +    member _.Entry(href: string, text: string) =
    +        if sections.Count > 0 then
    +            entries.Add(sections.Count, href, text)
    +
    +    member _.Menu =
    +        if sections.Count = 0 then
    +            PageContentList.EmptyContent
    +        else
    +            // The entries of a section go in a nested list. The scroll-driven highlight styles the
    +            // items of the outer list, which stay exactly the sections.
    +            let items =
    +                [
    +                    for index in 1 .. sections.Count do
    +                        let level, id, title = sections.[index - 1]
    +
    +                        // The menu is narrow and cuts its entries off, so each carries its own text
    +                        // as a title for the reader to hover.
    +                        let ownEntries =
    +                            [
    +                                for (owner, href, text) in entries do
    +                                    if owner = index then
    +                                        li [] [ a [ Href href; HtmlProperties.Title text ] [ !!text ] ]
    +                            ]
    +
    +                        li [ Class $"level-%i{level}"; Custom("data-fsdocs-heading", string index) ] [
    +                            a [ Href($"#%s{id}"); HtmlProperties.Title title ] [ !!title ]
    +
    +                            if not ownEntries.IsEmpty then
    +                                ul [ Class "fsdocs-menu-entries" ] ownEntries
    +                        ]
    +                ]
    +
    +            string(ul [] items) + PageContentList.scrollSpyStyle sections.Count
    +
     type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) =
         let root = model.Root
         let collectionName = model.Collection.CollectionName
         let qualify = model.Qualify
     
    -    //let obsoleteMessage msg =
    -    //  div [Class "alert alert-warning"] [
    -    //      strong [] [!!"NOTE:"]
    -    //      p [] [!! ("This API is obsolete" + HttpUtility.HtmlEncode(msg))]
    -    //  ]
    +    // Grouping every namespace and entity by category is the same answer for the whole lifetime of
    +    // the renderer: the model it reads is a constructor argument and never changes. Rendering asks
    +    // for it once per page, so without this it ran once per page of the whole API reference.
    +    let categorised = lazy (Categorise.model model)
    +
    +    let documentedNamespaces = lazy (categorised.Value |> List.map (fun (_, ns) -> ns.Name) |> Set.ofList)
    +
    +    // The namespaces the menu lists. One nested in another documented namespace folds into it, so
    +    // a library such as Fantomas shows six entries instead of twenty. The API reference index is
    +    // still the list of every namespace.
    +    let menuNamespaces =
    +        lazy
    +            (categorised.Value
    +             |> List.filter (fun (_, ns) -> menuNamespaceOf documentedNamespaces.Value ns.Name = ns.Name))
    +
    +    /// Whether the menu entry for a namespace is the one the reader is inside: the namespace itself,
    +    /// or one nested in it that the menu folded away.
    +    let isActiveMenuNamespace (nsOpt: ApiDocNamespace option) (ns: ApiDocNamespace) =
    +        match nsOpt with
    +        | None -> false
    +        | Some current -> menuNamespaceOf documentedNamespaces.Value current.Name = ns.Name
    +
    +    /// The namespaces nested in this one, at any depth. The menu shows the outermost namespace only,
    +    /// so the page of that namespace is where the reader finds the ones it stands for.
    +    let nestedNamespaces (name: string) =
    +        categorised.Value
    +        |> List.filter (fun (_, ns: ApiDocNamespace) -> ns.Name.StartsWith(name + ".", StringComparison.Ordinal))
     
         let mutable uniqueNumber = 0
     
    @@ -111,10 +215,10 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) =
                 | _ -> ()
             ]
     
    -    let renderMembers header tableHeader (members: ApiDocMember list) =
    +    let renderMembers (sections: SectionCollector) sectionId header tableHeader (members: ApiDocMember list) =
             [
                 if members.Length > 0 then
    -                h3 [] [ !!header ]
    +                sections.Heading(3, sectionId, header)
     
                     table [ Class "table outer-list fsdocs-member-list" ] [
                         thead [] [
    @@ -328,7 +432,7 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) =
                     ]
             ]
     
    -    let renderEntities (entities: ApiDocEntity list) =
    +    let renderEntities (sections: SectionCollector) (entities: ApiDocEntity list) =
             [
                 if entities.Length > 0 then
                     let hasTypes = entities |> List.exists (fun e -> e.IsTypeDefinition)
    @@ -350,27 +454,36 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) =
                             let nameCounts = entities |> List.countBy (fun e -> e.Name) |> dict
     
                             for e in entities do
    -                            tr [] [
    -                                td [ Class "fsdocs-entity-name" ] [
    -                                    let nm = e.Name
    +                            let nm = e.Name
    +
    +                            let multi = nameCounts.[nm] > 1
    +
    +                            let nmWithSiffix =
    +                                if multi then
    +                                    (if e.IsTypeDefinition then
    +                                         nm + " (Type)"
    +                                     else
    +                                         nm + " (Module)")
    +                                else
    +                                    nm
     
    -                                    let multi = nameCounts.[nm] > 1
    +                            sections.Entry(
    +                                e.Url(root, collectionName, qualify, model.FileExtensions.InUrl),
    +                                nmWithSiffix
    +                            )
     
    -                                    let nmWithSiffix =
    -                                        if multi then
    -                                            (if e.IsTypeDefinition then
    -                                                 nm + " (Type)"
    -                                             else
    -                                                 nm + " (Module)")
    -                                        else
    -                                            nm
    +                            tr [] [
    +                                td [ Class "fsdocs-entity-name" ] [
     
                                         // This adds #EntityName anchor. These may currently be ambiguous
                                         p [] [
                                             a [ Name nm ] [
    -                                            a [ Href(e.Url(root, collectionName, qualify, model.FileExtensions.InUrl)) ] [
    -                                                !!nmWithSiffix
    -                                            ]
    +                                            // data-fsdocs-nav makes the j / k hotkeys stop here, so they walk the
    +                                            // types and modules of the namespace after the headings above them.
    +                                            a [
    +                                                Href(e.Url(root, collectionName, qualify, model.FileExtensions.InUrl))
    +                                                Custom("data-fsdocs-nav", "")
    +                                            ] [ !!nmWithSiffix ]
                                             ]
                                         ]
                                     ]
    @@ -389,7 +502,7 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) =
                     ]
             ]
     
    -    let entityContent (info: ApiDocEntityInfo) =
    +    let entityContent (sections: SectionCollector) (info: ApiDocEntityInfo) =
             // Get all the members & comment for the type
             let entity = info.Entity
     
    @@ -403,7 +516,12 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) =
                 | _ -> entity.Name
     
             [
    -            h2 [] [ !!(usageName + (if entity.IsTypeDefinition then " Type" else " Module")) ]
    +            sections.Heading(
    +                2,
    +                entity.UrlBaseName,
    +                usageName + (if entity.IsTypeDefinition then " Type" else " Module")
    +            )
    +
                 dl [ Class "fsdocs-metadata" ] [
                     dt [] [
                         !!"Namespace: "
    @@ -498,30 +616,24 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) =
     
                 ]
     
    -            if (byCategory.Length > 1) then
    -                // If there is more than 1 category in the type, generate TOC
    -                h3 [] [ !!"Table of contents" ]
    -
    -                ul [] [
    -                    for (index, _, name) in byCategory do
    -                        li [] [ a [ Href(sprintf "#section%d" index) ] [ !!name ] ]
    -                ]
    -
                 //
     
                 let nestedEntities = entity.NestedEntities |> List.filter (fun e -> not e.IsObsolete)
     
                 if (nestedEntities.Length > 0) then
                     div [] [
    -                    h3 [] [
    -                        !!(if nestedEntities |> List.forall (fun e -> not e.IsTypeDefinition) then
    -                               "Nested modules"
    -                           elif nestedEntities |> List.forall (fun e -> e.IsTypeDefinition) then
    -                               "Types"
    -                           else
    -                               "Types and nested modules")
    -                    ]
    -                    yield! renderEntities nestedEntities
    +                    sections.Heading(
    +                        3,
    +                        "nested-entities",
    +                        if nestedEntities |> List.forall (fun e -> not e.IsTypeDefinition) then
    +                            "Nested modules"
    +                        elif nestedEntities |> List.forall (fun e -> e.IsTypeDefinition) then
    +                            "Types"
    +                        else
    +                            "Types and nested modules"
    +                    )
    +
    +                    yield! renderEntities sections nestedEntities
                     ]
     
                 for (index, ms, name) in byCategory do
    @@ -529,8 +641,16 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) =
                     // categories, print the category heading (as 

    ) and add XML comment from the type // that is related to this specific category. if (byCategory.Length > 1) then - h2 [ Id(sprintf "section%d" index) ] [ !!name ] - // 

    + sections.Heading(2, sprintf "section%d" index, name) + + // A page can repeat a kind of member once per category, so the anchor of a section + // carries its category index. + let sectionId (name: string) = + if byCategory.Length > 1 then + sprintf "section%d-%s" index name + else + name + let functionsOrValues = ms |> List.filter (fun m -> m.Kind = ApiDocMemberKind.ValueOrFunction) let extensions = ms |> List.filter (fun m -> m.Kind = ApiDocMemberKind.TypeExtension) let activePatterns = ms |> List.filter (fun m -> m.Kind = ApiDocMemberKind.ActivePattern) @@ -540,15 +660,55 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) = let constructors = ms |> List.filter (fun m -> m.Kind = ApiDocMemberKind.Constructor) let instanceMembers = ms |> List.filter (fun m -> m.Kind = ApiDocMemberKind.InstanceMember) let staticMembers = ms |> List.filter (fun m -> m.Kind = ApiDocMemberKind.StaticMember) - div [] (renderMembers "Functions and values" "Function or value" functionsOrValues) - div [] (renderMembers "Type extensions" "Type extension" extensions) - div [] (renderMembers "Active patterns" "Active pattern" activePatterns) - div [] (renderMembers "Union cases" "Union case" unionCases) - div [] (renderMembers "Record fields" "Record Field" recordFields) - div [] (renderMembers "Static parameters" "Static parameters" staticParameters) - div [] (renderMembers "Constructors" "Constructor" constructors) - div [] (renderMembers "Instance members" "Instance member" instanceMembers) - div [] (renderMembers "Static members" "Static member" staticMembers) + + div + [] + (renderMembers + sections + (sectionId "functions-and-values") + "Functions and values" + "Function or value" + functionsOrValues) + + div + [] + (renderMembers sections (sectionId "type-extensions") "Type extensions" "Type extension" extensions) + + div + [] + (renderMembers + sections + (sectionId "active-patterns") + "Active patterns" + "Active pattern" + activePatterns) + + div [] (renderMembers sections (sectionId "union-cases") "Union cases" "Union case" unionCases) + div [] (renderMembers sections (sectionId "record-fields") "Record fields" "Record Field" recordFields) + + div + [] + (renderMembers + sections + (sectionId "static-parameters") + "Static parameters" + "Static parameters" + staticParameters) + + div [] (renderMembers sections (sectionId "constructors") "Constructors" "Constructor" constructors) + + div + [] + (renderMembers + sections + (sectionId "instance-members") + "Instance members" + "Instance member" + instanceMembers) + + div + [] + (renderMembers sections (sectionId "static-members") "Static members" "Static member" staticMembers) let inheritedMemberGroups = entity.InheritedMembers @@ -568,20 +728,58 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) = ) if not (List.isEmpty inheritedMemberGroups) then - h3 [] [ !!"Inherited members" ] + sections.Heading(3, "inherited-members", "Inherited members") + + for (i, (baseTypeHtml, instMembers, statMembers)) in List.indexed inheritedMemberGroups do + // The base type is HTML (it carries links), so the heading is written by hand and + // the menu gets the plain name. + h4 [ Id(sprintf "inherited-%d" i) ] [ !!"Inherited from "; embed baseTypeHtml ] + + div + [] + (renderMembers + sections + (sprintf "inherited-%d-instance-members" i) + "Instance members" + "Instance member" + instMembers) + + div + [] + (renderMembers + sections + (sprintf "inherited-%d-static-members" i) + "Static members" + "Static member" + statMembers) + ] - for (baseTypeHtml, instMembers, statMembers) in inheritedMemberGroups do - h4 [] [ !!"Inherited from "; embed baseTypeHtml ] - div [] (renderMembers "Instance members" "Instance member" instMembers) - div [] (renderMembers "Static members" "Static member" statMembers) + /// One row of a table of namespaces: the link and the summary. + let namespaceRow (ns: ApiDocNamespace) = + tr [] [ + td [] [ + // data-fsdocs-nav makes the j / k hotkeys stop here, so they walk the namespaces + // after the headings above them. + a [ + Href(ns.Url(root, collectionName, qualify, model.FileExtensions.InUrl)) + HtmlProperties.Title ns.Name + Custom("data-fsdocs-nav", "") + ] [ !!ns.Name ] + ] + td [] [ + match ns.NamespaceDocs with + | Some nsdocs -> embed nsdocs.Summary + | None -> () + ] ] - let namespaceContent (nsIndex, ns: ApiDocNamespace) = + let namespaceContent (sections: SectionCollector) (nsIndex, ns: ApiDocNamespace) = let allByCategory = Categorise.entities (nsIndex, ns, false) + let nested = nestedNamespaces ns.Name [ - if allByCategory.Length > 0 then - h2 [ Id ns.UrlHash ] [ !!(ns.Name + " Namespace") ] + if allByCategory.Length > 0 || not nested.IsEmpty then + sections.Heading(2, ns.UrlHash, ns.Name + " Namespace") div [ Class "fsdocs-xmldoc" ] [ match ns.NamespaceDocs with @@ -595,116 +793,87 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) = | None -> () ] - if (allByCategory.Length > 1) then - h3 [] [ !!"Contents" ] + // The menu lists this namespace and not the ones inside it, so they are listed here + // instead: without this the page is a dead end and the index is the only way on. + if not nested.IsEmpty then + sections.Heading(3, "namespaces", "Namespaces") + + table [ Class "table outer-list fsdocs-entity-list" ] [ + thead [] [ tr [] [ td [] [ !!"Namespace" ]; td [] [ !!"Description" ] ] ] + tbody [] [ + for _allByCategory, nestedNs in nested do + sections.Entry( + nestedNs.Url(root, collectionName, qualify, model.FileExtensions.InUrl), + nestedNs.Name + ) - ul [] [ - for category in allByCategory do - li [] [ a [ Href("#category-" + category.CategoryIndex) ] [ !!category.CategoryName ] ] + namespaceRow nestedNs + ] ] for category in allByCategory do if (allByCategory.Length > 1) then - h3 [] [ - a [ - Class "anchor" - Name("category-" + category.CategoryIndex) - Href("#category-" + category.CategoryIndex) - ] [ !!category.CategoryName ] - ] + sections.Heading(3, "category-" + category.CategoryIndex, category.CategoryName) - yield! renderEntities category.CategoryEntites + yield! renderEntities sections category.CategoryEntites ] let tableOfNamespacesAux () = + [ for _allByCategory, ns in categorised.Value -> namespaceRow ns ] + + let listOfNamespacesNavAux (root: string) otherDocs (nsOpt: ApiDocNamespace option) = [ - let categorise = Categorise.model model + let categorise = menuNamespaces.Value + + let someExist = categorise.Length > 0 + + if someExist then + // The header is the way back to the index of all namespaces, which no page + // links to otherwise. It is the current page on an API page showing no namespace; + // on a content page the reader is somewhere else entirely. + li [ + Class( + "nav-header" + + match nsOpt with + | None when not otherDocs -> " active" + | _ -> "" + ) + ] [ + a [ Href(model.IndexFileUrl(root, collectionName, qualify, model.FileExtensions.InUrl)) ] [ + !!"API Reference" + ] + ] + + let prefix = commonNamespacePrefix [ for _, ns in categorise -> ns.Name ] for _allByCategory, ns in categorise do // Generate the entry for the namespace - tr [] [ - td [] [ + li [ + Class( + "nav-item" + + + // add the 'active' class if this is the namespace of the thing being shown + if isActiveMenuNamespace nsOpt ns then " active" else "" + ) + ] [ + span [] [ a [ + Class( + "nav-link" + + + // add the 'active' class if this is the namespace of the thing being shown + if isActiveMenuNamespace nsOpt ns then " active" else "" + ) Href(ns.Url(root, collectionName, qualify, model.FileExtensions.InUrl)) - HtmlProperties.Title ns.Name - ] [ !!ns.Name ] - ] - td [] [ - match ns.NamespaceDocs with - | Some nsdocs -> embed nsdocs.Summary - | None -> () - ] - ] - ] - - let listOfNamespacesNavAux (root: string) otherDocs (nsOpt: ApiDocNamespace option) = - [ - // For FSharp.Core we make all entries available to other docs else there's not a lot else to show. - // - // For non-FSharp.Core we only show one link "API Reference" in the nav menu - if otherDocs && model.Collection.CollectionName <> "FSharp.Core" then - li [ Class "nav-header" ] [ !!"API Reference" ] - - li [ Class "nav-item" ] [ - a [ - Class "nav-link" - Href(model.IndexFileUrl(root, collectionName, qualify, model.FileExtensions.InUrl)) - ] [ !!"All Namespaces" ] - ] - else + // The entry drops the prefix it shares with its neighbours, so the + // full name is a hover away. + if prefix <> "" then + HtmlProperties.Title ns.Name + ] [ !!(ns.Name.Substring(prefix.Length)) ] - let categorise = Categorise.model model - - let someExist = categorise.Length > 0 - - if someExist then - li [ Class "nav-header" ] [ !!"Namespaces" ] - - for allByCategory, ns in categorise do - - // Generate the entry for the namespace - li [ - Class( - "nav-item" - + - // add the 'active' class if this is the namespace of the thing being shown - match nsOpt with - | Some ns2 when ns.Name = ns2.Name -> " active" - | _ -> "" - ) - ] [ - span [] [ - a [ - Class( - "nav-link" - + - // add the 'active' class if this is the namespace of the thing being shown - match nsOpt with - | Some ns2 when ns.Name = ns2.Name -> " active" - | _ -> "" - ) - Href(ns.Url(root, collectionName, qualify, model.FileExtensions.InUrl)) - ] [ !!ns.Name ] - - ] ] - - // In the navigation bar generate the expanded list of entities - // for the active namespace - match nsOpt with - | Some ns2 when ns.Name = ns2.Name -> - ul [ Custom("list-style-type", "none") (* Class "navbar-nav " *) ] [ - for category in allByCategory do - for e in category.CategoryEntites do - li [ Class "nav-item" ] [ - a [ - Class "nav-link" - Href(e.Url(root, collectionName, qualify, model.FileExtensions.InUrl)) - ] [ !!e.Name ] - ] - ] - | _ -> () + ] ] let listOfNamespacesNavWithRoot (root: string) otherDocs (nsOpt: ApiDocNamespace option) = @@ -713,6 +882,10 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) = |> List.map (fun html -> html.ToString()) |> String.concat " \n" + // What the page itself is rendered with, but with the root of this page, so a menu template + // can use {{root}} and the other site-wide substitutions to reach the rest of the site. + let menuSubstitutions = [ yield! model.Substitutions; yield ParamKeys.root, root ] + match menuTemplateFolder with | None -> noTemplatingFallback () | Some menuTemplateFolder -> @@ -720,78 +893,66 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) = if not isTemplatingAvailable then noTemplatingFallback () - else if otherDocs && model.Collection.CollectionName <> "FSharp.Core" then - let menuItems = - let title = "All Namespaces" - let link = model.IndexFileUrl(root, collectionName, qualify, model.FileExtensions.InUrl) - - // This menu is rendered on the other docs (otherDocs), never on the API reference pages, - // so the reader is not on the page this entry links to. - [ - { - Menu.MenuItem.Link = link - Menu.MenuItem.Content = title - Menu.MenuItem.IsActive = false - } - ] - - Menu.createMenu menuTemplateFolder false "API Reference" menuItems - else - let categorise = Categorise.model model + let categorise = menuNamespaces.Value if categorise.Length = 0 then "" else - let menuItems = - categorise - |> List.map (fun (_, ns) -> - let link = ns.Url(root, collectionName, qualify, model.FileExtensions.InUrl) - let name = ns.Name + let prefix = commonNamespacePrefix [ for _, ns in categorise -> ns.Name ] - { - Menu.MenuItem.Link = link - Menu.MenuItem.Content = name - Menu.MenuItem.IsActive = false - } - ) + let menuItems = + [ + for _, ns in categorise do + { + Menu.MenuItem.Link = + ns.Url(root, collectionName, qualify, model.FileExtensions.InUrl) + Menu.MenuItem.Content = ns.Name.Substring(prefix.Length) + Menu.MenuItem.Title = (if String.IsNullOrEmpty prefix then None else Some ns.Name) + Menu.MenuItem.IsActive = isActiveMenuNamespace nsOpt ns + } + ] - Menu.createMenu menuTemplateFolder false "Namespaces" menuItems + // A template can fold a section away, and the reader arriving on an API page is + // inside this one. Mark the category active so the section it renders is the one + // that opens. The other docs render the same list while the reader is elsewhere. + Menu.createMenu menuTemplateFolder menuSubstitutions (not otherDocs) "API Reference" menuItems let listOfNamespacesNav otherDocs (nsOpt: ApiDocNamespace option) = listOfNamespacesNavWithRoot root otherDocs nsOpt - /// The substitutions relevant to all pages, with the namespace links built for the given root - /// (a content page deeper in the site needs a different relative root than the API pages). member _.GlobalSubstitutionsFor(root: string) : Substitutions = let toc = listOfNamespacesNavWithRoot root true None [ yield (ParamKeys.``fsdocs-list-of-namespaces``, toc); yield ParamKeys.``fsdocs-body-class``, "api-docs" ] - /// Get the substitutions relevant to all member x.GlobalSubstitutions: Substitutions = x.GlobalSubstitutionsFor root - /// The pages of the API documentation: the output file relative to the output folder - /// (forward slashes) and a function rendering the page for a template and global substitutions. - /// Nothing is rendered until the function is called. member _.Pages(collectionName: string) : (string * (string option -> Substitutions -> string)) list = - let getSubstitutons parameters toc (content: HtmlElement) pageTitle globalParameters = + let getSubstitutons parameters toc (content: HtmlElement) pageContentList pageTitle globalParameters = [| yield! parameters - yield (ParamKeys.``fsdocs-list-of-namespaces``, toc) yield (ParamKeys.``fsdocs-content``, content.ToString()) yield (ParamKeys.``fsdocs-source``, String.Empty) yield (ParamKeys.``fsdocs-tooltips``, String.Empty) yield (ParamKeys.``fsdocs-page-title``, pageTitle) - yield (ParamKeys.``fsdocs-page-content-list``, PageContentList.EmptyContent) + yield (ParamKeys.``fsdocs-page-content-list``, pageContentList) yield (ParamKeys.``fsdocs-meta-tags``, String.Empty) yield! globalParameters + // Last one wins (the substitutions become a dictionary), so the namespace menu of the + // page goes after the global substitutions: those carry the same list with nothing + // marked active, which would otherwise take the place of the one marking this page. + yield (ParamKeys.``fsdocs-list-of-namespaces``, toc) |] - let page outFile parameters toc (content: unit -> HtmlElement) pageTitle = + let page outFile parameters toc (content: SectionCollector -> HtmlElement) pageTitle = let render (templateOpt: string option) (globalParameters: Substitutions) = - let substitutions = getSubstitutons parameters toc (content ()) pageTitle globalParameters + // The content has to be rendered before the menu: it is what fills the collector. + let sections = SectionCollector() + let element = content sections + + let substitutions = getSubstitutons parameters toc element sections.Menu pageTitle globalParameters SimpleTemplating.RenderWithFileTemplate(substitutions, templateOpt) @@ -800,7 +961,9 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) = let collection = model.Collection [ - (let content () = + // The index page is one table of namespaces, so it registers no sections and keeps the + // wide two column layout: a menu mirroring that table one for one would say nothing new. + (let content (_: SectionCollector) = div [] [ h1 [] [ !!"API Reference" ] h2 [] [ !!"Available Namespaces:" ] @@ -826,7 +989,9 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) = //printfn "Namespaces = %A" [ for ns in collection.Namespaces -> ns.Name ] for (nsIndex, ns) in Seq.indexed collection.Namespaces do - let content () = div [] (namespaceContent (nsIndex, ns)) + let content sections = + div [] (namespaceContent sections (nsIndex, ns)) + let pageTitle = ns.Name let toc = listOfNamespacesNav false (Some ns) @@ -835,7 +1000,7 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) = page outFile model.Substitutions toc content pageTitle for info in model.EntityInfos do - let content () = div [] (entityContent info) + let content sections = div [] (entityContent sections info) let pageTitle = sprintf "%s (%s)" info.Entity.Name collectionName @@ -846,8 +1011,6 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) = page outFile info.Entity.Substitutions toc content pageTitle ] - /// Writes all API documentation HTML files (index, one per namespace, one per entity) - /// to , applying to each page. member x.Generate(outDir: string, templateOpt, collectionName, globalParameters) = for (relativeFile, render) in x.Pages(collectionName) do let outFile = Path.Combine(outDir, relativeFile) diff --git a/src/FSharp.Formatting.ApiDocs/GenerateHtml.fsi b/src/FSharp.Formatting.ApiDocs/GenerateHtml.fsi new file mode 100644 index 000000000..60005d742 --- /dev/null +++ b/src/FSharp.Formatting.ApiDocs/GenerateHtml.fsi @@ -0,0 +1,71 @@ +/// Internal module that generates HTML documentation output from an . +/// Produces one HTML file per namespace and per entity, plus an index file. +module internal FSharp.Formatting.ApiDocs.GenerateHtml + +open System +open System.Collections.Generic +open System.IO +open System.Web +open FSharp.Formatting.Common +open FSharp.Compiler.Symbols +open FSharp.Formatting.Templating +open FSharp.Formatting.HtmlModel +open FSharp.Formatting.HtmlModel.Html + +/// Embed some HTML generated in GenerateModel +val embed: x: ApiDocHtml -> HtmlElement +/// Wraps the HTML text of an in a summary paragraph, +/// unless the content starts with a <pre> tag (which cannot be nested inside <p>). +val fsdocsSummary: x: ApiDocHtml -> HtmlElement +/// The part every namespace of a collection shares, up to a dot: "FSharp.Formatting." for the +/// namespaces of this repository, "Microsoft.FSharp." for FSharp.Core. Empty when they share nothing +/// or when there is only one. The menu drops it from the entries it shows, which otherwise read as a +/// column of identically truncated names. +val commonNamespacePrefix: names: string list -> string + +/// The namespace whose menu entry stands for this one: itself, or the outermost documented namespace +/// it is nested in. "Fantomas.FCS.Syntax" folds into "Fantomas.FCS", so the menu that every page +/// carries stays a list of top-level namespaces rather than one line per nested namespace. The API +/// reference index is unaffected and still lists every namespace. +val menuNamespaceOf: documented: Set -> name: string -> string + +/// Collects the sections of one API page while its content is rendered, so the "On this page" menu +/// can be built afterwards from the very same ids. Content and menu therefore cannot drift apart. +/// +/// A section is a heading that the reader can jump to. A section that lists types or modules also +/// gets an entry per row, because those lead to another page; the member tables of a type or a +/// module do not, since repeating them would only restate the page the reader is already on. +/// +/// Only the headings take part in the scroll-driven highlight of the menu (`data-fsdocs-heading`): +/// each tracked element costs three generated CSS rules, so the namespaces with many types stay +/// cheap. +type internal SectionCollector = + internal new: unit -> SectionCollector + /// Registers a section and returns its heading element, annotated for the scroll-driven highlight. + member internal Heading: level: int * id: string * title: string -> HtmlElement + /// Lists an entry under the section most recently registered. The link goes wherever the row it + /// stands for goes, which for a type or a module is its own page: jumping to the row instead + /// would leave the reader on the page they are already reading. + member internal Entry: href: string * text: string -> unit + /// The menu HTML, or the empty placeholder when the page has nothing worth listing. + member internal Menu: string + +/// Renders HTML API documentation for all namespaces and entities in an +/// . Writes one file per namespace +/// and per entity to the output directory using the supplied template. +type HtmlRender = + internal new: model: ApiDocModel * ?menuTemplateFolder: string -> HtmlRender + /// The substitutions relevant to all pages, with the namespace links built for the given root + /// (a content page deeper in the site needs a different relative root than the API pages). + member internal GlobalSubstitutionsFor: root: string -> Substitutions + /// Get the substitutions relevant to all + member internal GlobalSubstitutions: Substitutions + /// The pages of the API documentation: the output file relative to the output folder + /// (forward slashes) and a function rendering the page for a template and global substitutions. + /// Nothing is rendered until the function is called. + member internal Pages: collectionName: string -> (string * (string option -> Substitutions -> string)) list + + /// Writes all API documentation HTML files (index, one per namespace, one per entity) + /// to , applying to each page. + member internal Generate: + outDir: string * templateOpt: string option * collectionName: string * globalParameters: Substitutions -> unit diff --git a/src/FSharp.Formatting.ApiDocs/GenerateMarkdown.fs b/src/FSharp.Formatting.ApiDocs/GenerateMarkdown.fs index 31bbdcc92..d954bf79a 100644 --- a/src/FSharp.Formatting.ApiDocs/GenerateMarkdown.fs +++ b/src/FSharp.Formatting.ApiDocs/GenerateMarkdown.fs @@ -1,5 +1,3 @@ -/// Internal module that generates Markdown documentation output from an . -/// Produces one Markdown file per namespace and per entity, plus an index file. module internal FSharp.Formatting.ApiDocs.GenerateMarkdown open System @@ -10,32 +8,42 @@ open FSharp.Formatting.Markdown open FSharp.Formatting.Markdown.Dsl open FSharp.Formatting.Templating -/// HTML-encodes a string and additionally escapes pipe characters for safe use in Markdown tables. let encode (x: string) = HttpUtility.HtmlEncode(x).Replace("|", "|") -/// URL-encodes a string (for use in anchor hrefs). let urlEncode (x: string) = HttpUtility.UrlEncode x -/// Returns the trimmed HTML text of an value. let htmlString (x: ApiDocHtml) = (x.HtmlText.Trim()) -/// Returns the trimmed HTML text of an value, -/// with newlines replaced by <br /> and pipe characters escaped for Markdown tables. let htmlStringSafe (x: ApiDocHtml) = (x.HtmlText.Trim()).Replace("\n", "
    ").Replace("|", "|") -/// Wraps an value as a Markdown DSL node. let embed (x: ApiDocHtml) = !!(htmlString x) -/// Wraps an value as a Markdown DSL node, -/// escaping characters that would break Markdown table cells. let embedSafe (x: ApiDocHtml) = !!(htmlStringSafe x) -/// A Markdown DSL node representing an HTML line break. let br = !!"
    " -/// Renders Markdown API documentation for all namespaces and entities in an -/// . Writes one file per namespace -/// and per entity to using the supplied template. type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = + + // Same answer for the lifetime of the renderer, see the note in GenerateHtml. + let categorised = lazy (Categorise.model model) + + let documentedNamespaces = lazy (categorised.Value |> List.map (fun (_, ns) -> ns.Name) |> Set.ofList) + + // The menu folds a namespace into the documented one it is nested in, see GenerateHtml. + let menuNamespaces = + lazy + (categorised.Value + |> List.filter (fun (_, ns) -> GenerateHtml.menuNamespaceOf documentedNamespaces.Value ns.Name = ns.Name)) + + let isActiveMenuNamespace (nsOpt: ApiDocNamespace option) (ns: ApiDocNamespace) = + match nsOpt with + | None -> false + | Some current -> GenerateHtml.menuNamespaceOf documentedNamespaces.Value current.Name = ns.Name + + /// The namespaces nested in this one, at any depth, see GenerateHtml. + let nestedNamespaces (name: string) = + categorised.Value + |> List.filter (fun (_, ns: ApiDocNamespace) -> ns.Name.StartsWith(name + ".", StringComparison.Ordinal)) + let root = model.Root let collectionName = model.Collection.CollectionName let qualify = model.Qualify @@ -87,14 +95,13 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = match m.ReturnInfo.ReturnType with | None -> () | Some(_, returnTypeHtml) -> - p - [ - !!(if m.Kind <> ApiDocMemberKind.RecordField then - "Returns: " - else - "Field type: ") - embed returnTypeHtml - ] + p [ + !!(if m.Kind <> ApiDocMemberKind.RecordField then + "Returns: " + else + "Field type: ") + embed returnTypeHtml + ] match m.ReturnInfo.ReturnDocs with | None -> () @@ -117,13 +124,12 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = if not m.Comment.Exceptions.IsEmpty then for (nm, url, html) in m.Comment.Exceptions do - p - [ - match url with - | None -> () - | Some href -> link [ !!nm ] href - embed html - ] + p [ + match url with + | None -> () + | Some href -> link [ !!nm ] href + embed html + ] for e in m.Comment.Notes do ``#####`` [ !!"Note" ] @@ -132,15 +138,14 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = if not m.Comment.SeeAlso.IsEmpty then ``#####`` [ !!"See also" ] - ul - [ - for (nm, url, html) in m.Comment.SeeAlso do - [ - match url with - | Some href -> p [ link [ !!nm ] href ] - | None -> p [ embed html ] - ] - ] + ul [ + for (nm, url, html) in m.Comment.SeeAlso do + [ + match url with + | Some href -> p [ link [ !!nm ] href ] + | None -> p [ embed html ] + ] + ] for e in m.Comment.Examples do ``#####`` [ !!"Example" ] @@ -163,42 +168,38 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = table [ [ - p - [ - !!(if hasTypes && hasModules then "Type/Module" - elif hasTypes then "Type" - else "Modules") - ] + p [ + !!(if hasTypes && hasModules then "Type/Module" + elif hasTypes then "Type" + else "Modules") + ] p [ !!"Description" ] p [ !!"Source" ] ] - ] - [ AlignLeft; AlignLeft; AlignCenter ] - [ + ] [ AlignLeft; AlignLeft; AlignCenter ] [ let nameCounts = entities |> List.countBy (fun e -> e.Name) |> dict for e in entities do [ [ - p - [ - let nm = e.Name - - let multi = nameCounts.[nm] > 1 - - let nmWithSiffix = - if multi then - (if e.IsTypeDefinition then - nm + " (Type)" - else - nm + " (Module)") - else - nm - - link - [ !!nmWithSiffix ] - (e.Url(root, collectionName, qualify, model.FileExtensions.InUrl)) - ] + p [ + let nm = e.Name + + let multi = nameCounts.[nm] > 1 + + let nmWithSiffix = + if multi then + (if e.IsTypeDefinition then + nm + " (Type)" + else + nm + " (Module)") + else + nm + + link + [ !!nmWithSiffix ] + (e.Url(root, collectionName, qualify, model.FileExtensions.InUrl)) + ] ] [ p [ embedSafe e.Comment.Summary ] ] [ p [ yield! (sourceLink e.SourceLocation) ] ] @@ -223,25 +224,23 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = [ ``##`` [ !!(usageName + (if entity.IsTypeDefinition then " Type" else " Module")) ] - p - [ - !!"Namespace: " - link - [ !!info.Namespace.Name ] - (info.Namespace.Url(root, collectionName, qualify, model.FileExtensions.InUrl)) - ] + p [ + !!"Namespace: " + link + [ !!info.Namespace.Name ] + (info.Namespace.Url(root, collectionName, qualify, model.FileExtensions.InUrl)) + ] p [ !!("Assembly: " + entity.Assembly.Name + ".dll") ] match info.ParentModule with | None -> () | Some parentModule -> - p - [ - !!"Parent Module: " - link - [ !!parentModule.Name ] - (parentModule.Url(root, collectionName, qualify, model.FileExtensions.InUrl)) - ] + p [ + !!"Parent Module: " + link + [ !!parentModule.Name ] + (parentModule.Url(root, collectionName, qualify, model.FileExtensions.InUrl)) + ] match entity.AbbreviatedType with | Some(_, abbreviatedTyp) -> p [ !!"Abbreviation For: "; embed abbreviatedTyp ] @@ -254,15 +253,14 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = match entity.AllInterfaces with | [] -> () | l -> - p - [ - !!"All Interfaces: " - for (i, (_, interfaceTyHtml)) in Seq.indexed l do - if i <> 0 then - !!", " + p [ + !!"All Interfaces: " + for (i, (_, interfaceTyHtml)) in Seq.indexed l do + if i <> 0 then + !!", " - embed interfaceTyHtml - ] + embed interfaceTyHtml + ] if entity.Symbol.IsValueType then p [ !!("Kind: Struct") ] @@ -298,15 +296,14 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = if not entity.Comment.SeeAlso.IsEmpty then ``#####`` [ !!"See also" ] - ul - [ - for (nm, url, html) in entity.Comment.SeeAlso do - [ - match url with - | Some href -> p [ link [ !!nm ] href ] - | None -> p [ embed html ] - ] - ] + ul [ + for (nm, url, html) in entity.Comment.SeeAlso do + [ + match url with + | Some href -> p [ link [ !!nm ] href ] + | None -> p [ embed html ] + ] + ] for example in entity.Comment.Examples do ``#####`` [ !!"Example" ] @@ -316,11 +313,10 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = // If there is more than 1 category in the type, generate TOC ``###`` [ !!"Table of contents" ] - ul - [ - for (index, _, name) in byCategory do - [ p [ link [ !!(sprintf "#section%d" index) ] (name) ] ] - ] + ul [ + for (index, _, name) in byCategory do + [ p [ link [ !!(sprintf "#section%d" index) ] (name) ] ] + ] // @@ -328,15 +324,14 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = if (nestedEntities.Length > 0) then - ``###`` - [ - !!(if nestedEntities |> List.forall (fun e -> not e.IsTypeDefinition) then - "Nested modules" - elif nestedEntities |> List.forall (fun e -> e.IsTypeDefinition) then - "Types" - else - "Types and nested modules") - ] + ``###`` [ + !!(if nestedEntities |> List.forall (fun e -> not e.IsTypeDefinition) then + "Nested modules" + elif nestedEntities |> List.forall (fun e -> e.IsTypeDefinition) then + "Types" + else + "Types and nested modules") + ] yield! renderEntities nestedEntities @@ -381,7 +376,8 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = if not (List.isEmpty instMembers) || not (List.isEmpty statMembers) then Some(baseTypeHtml, instMembers, statMembers) else - None) + None + ) if not (List.isEmpty inheritedMemberGroups) then ``###`` [ !!"Inherited members" ] @@ -396,9 +392,10 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = /// and one section per category of entities. let namespaceContent (nsIndex, ns: ApiDocNamespace) = let allByCategory = Categorise.entities (nsIndex, ns, false) + let nested = nestedNamespaces ns.Name [ - if allByCategory.Length > 0 then + if allByCategory.Length > 0 || not nested.IsEmpty then ``##`` [ !!(ns.Name + " Namespace") ] match ns.NamespaceDocs with @@ -410,14 +407,31 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = | None -> () | None -> () + // The menu lists this namespace and not the ones inside it, so they are listed here + // instead: without this the page is a dead end and the index is the only way on. + if not nested.IsEmpty then + ``###`` [ !!"Namespaces" ] + + for _allByCategory, nestedNs in nested do + p [ + link + [ !!nestedNs.Name ] + (nestedNs.Url(root, collectionName, qualify, model.FileExtensions.InUrl)) + + match nestedNs.NamespaceDocs with + | Some nsdocs -> + !!" - " + embed nsdocs.Summary + | None -> () + ] + if (allByCategory.Length > 1) then ``###`` [ !!"Contents" ] - ul - [ - for category in allByCategory do - [ p [ link [ !!category.CategoryName ] ("#category-" + category.CategoryIndex) ] ] - ] + ul [ + for category in allByCategory do + [ p [ link [ !!category.CategoryName ] ("#category-" + category.CategoryIndex) ] ] + ] for category in allByCategory do if (allByCategory.Length > 1) then @@ -428,77 +442,61 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = /// Builds the list-of-namespaces Markdown fragment (for sidebar navigation or index page). /// When is true the active namespace is expanded to show its entities. - let listOfNamespacesAux (root: string) otherDocs nav (nsOpt: ApiDocNamespace option) = + let listOfNamespacesAux (root: string) nav (nsOpt: ApiDocNamespace option) = [ - // For FSharp.Core we make all entries available to other docs else there's not a lot else to show. - // - // For non-FSharp.Core we only show one link "API Reference" in the nav menu - if otherDocs && nav && model.Collection.CollectionName <> "FSharp.Core" then - p - [ - !!"API Reference" - link - [ !!"All Namespaces" ] - (model.IndexFileUrl(root, collectionName, qualify, model.FileExtensions.InUrl)) - ] - else + // The index page lists every namespace; the menu shows the ones the reader navigates by. + let categorise = if nav then menuNamespaces.Value else categorised.Value - let categorise = Categorise.model model + let someExist = categorise.Length > 0 - let someExist = categorise.Length > 0 + if someExist && nav then + p [ !!"API Reference" ] - if someExist && nav then - p [ !!"Namespaces" ] + for allByCategory, ns in categorise do - for allByCategory, ns in categorise do + // Generate the entry for the namespace + p [ + link [ !!ns.Name ] (ns.Url(root, collectionName, qualify, model.FileExtensions.InUrl)) - // Generate the entry for the namespace - p - [ - link [ !!ns.Name ] (ns.Url(root, collectionName, qualify, model.FileExtensions.InUrl)) + // If not in the navigation list then generate the summary text as well + if not nav then + !!" - " - // If not in the navigation list then generate the summary text as well - if not nav then - !!" - " + match ns.NamespaceDocs with + | Some nsdocs -> embed nsdocs.Summary + | None -> () + ] - match ns.NamespaceDocs with - | Some nsdocs -> embed nsdocs.Summary - | None -> () + // In the navigation bar generate the expanded list of entities + // for the active namespace + if nav then + match nsOpt with + | Some ns2 when ns.Name = ns2.Name -> + ul [ + for category in allByCategory do + for e in category.CategoryEntites do + [ + p [ + link + [ !!e.Name ] + (e.Url(root, collectionName, qualify, model.FileExtensions.InUrl)) + ] + ] ] - - // In the navigation bar generate the expanded list of entities - // for the active namespace - if nav then - match nsOpt with - | Some ns2 when ns.Name = ns2.Name -> - ul - [ - for category in allByCategory do - for e in category.CategoryEntites do - [ - p - [ - link - [ !!e.Name ] - (e.Url( - root, - collectionName, - qualify, - model.FileExtensions.InUrl - )) - ] - ] - ] - | _ -> () + | _ -> () ] /// Returns the list-of-namespaces string, using a menu template when available. let listOfNamespacesWithRoot (root: string) otherDocs nav (nsOpt: ApiDocNamespace option) = let noTemplatingFallback () = - listOfNamespacesAux root otherDocs nav nsOpt + listOfNamespacesAux root nav nsOpt |> List.map (fun html -> html.ToString()) |> String.concat " \n" + // What the page itself is rendered with, but with the root of this page, so a menu template + // can use {{root}} and the other site-wide substitutions to reach the rest of the site. + let menuSubstitutions = [ yield! model.Substitutions; yield ParamKeys.root, root ] + match menuTemplateFolder with | None -> noTemplatingFallback () | Some menuTemplateFolder -> @@ -506,67 +504,54 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = if not isTemplatingAvailable then noTemplatingFallback () - else if otherDocs && nav && model.Collection.CollectionName <> "FSharp.Core" then - let menuItems = - let title = "All Namespaces" - let link = model.IndexFileUrl(root, collectionName, qualify, model.FileExtensions.InUrl) - - [ - { - Menu.MenuItem.Link = link - Menu.MenuItem.Content = title - Menu.MenuItem.IsActive = false - } - ] - - Menu.createMenu menuTemplateFolder false "API Reference" menuItems - else - let categorise = Categorise.model model + let categorise = menuNamespaces.Value if categorise.Length = 0 then "" else - let menuItems = - categorise - |> List.map (fun (_, ns) -> - let link = ns.Url(root, collectionName, qualify, model.FileExtensions.InUrl) - let name = ns.Name + let prefix = GenerateHtml.commonNamespacePrefix [ for _, ns in categorise -> ns.Name ] - { - Menu.MenuItem.Link = link - Menu.MenuItem.Content = name - Menu.MenuItem.IsActive = false - }) + let menuItems = + [ + for _, ns in categorise do + { + Menu.MenuItem.Link = + ns.Url(root, collectionName, qualify, model.FileExtensions.InUrl) + Menu.MenuItem.Content = ns.Name.Substring(prefix.Length) + Menu.MenuItem.Title = (if String.IsNullOrEmpty prefix then None else Some ns.Name) + Menu.MenuItem.IsActive = isActiveMenuNamespace nsOpt ns + } + ] - Menu.createMenu menuTemplateFolder false "Namespaces" menuItems + // A template can fold a section away, and the reader arriving on an API page is + // inside this one. Mark the category active so the section it renders is the one + // that opens. The other docs render the same list while the reader is elsewhere. + Menu.createMenu menuTemplateFolder menuSubstitutions (not otherDocs) "API Reference" menuItems let listOfNamespaces otherDocs nav (nsOpt: ApiDocNamespace option) = listOfNamespacesWithRoot root otherDocs nav nsOpt - /// The substitutions relevant to all pages, with the namespace links built for the given root - /// (a content page deeper in the site needs a different relative root than the API pages). member _.GlobalSubstitutionsFor(root: string) : Substitutions = let toc = listOfNamespacesWithRoot root true true None [ yield (ParamKeys.``fsdocs-list-of-namespaces``, toc) ] - /// Get the substitutions relevant to all member x.GlobalSubstitutions: Substitutions = x.GlobalSubstitutionsFor root - /// The pages of the API documentation: the output file relative to the output folder - /// (forward slashes) and a function rendering the page for a template and global substitutions. - /// Nothing is rendered until the function is called. member _.Pages(collectionName: string) : (string * (string option -> Substitutions -> string)) list = let getSubstitutons parameters toc (content: MarkdownDocument) pageTitle globalParameters = [| yield! parameters - yield (ParamKeys.``fsdocs-list-of-namespaces``, toc) yield (ParamKeys.``fsdocs-content``, Markdown.ToMd(content)) yield (ParamKeys.``fsdocs-source``, "") yield (ParamKeys.``fsdocs-tooltips``, "") yield (ParamKeys.``fsdocs-page-title``, pageTitle) yield! globalParameters + // Last one wins (the substitutions become a dictionary), so the namespace menu of the + // page goes after the global substitutions: those carry the same list with nothing + // marked active, which would otherwise take the place of the one marking this page. + yield (ParamKeys.``fsdocs-list-of-namespaces``, toc) |] let page outFile parameters toc (content: unit -> MarkdownDocument) pageTitle = @@ -585,7 +570,7 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = [ ``#`` [ !!"API Reference" ] ``##`` [ !!"Available Namespaces" ] - ul [ (listOfNamespacesAux root false false None) ] + ul [ (listOfNamespacesAux root false None) ] ], Map.empty ) @@ -623,8 +608,6 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = page outFile info.Entity.Substitutions toc content pageTitle ] - /// Writes all API documentation Markdown files (index, one per namespace, one per entity) - /// to , applying to each page. member x.Generate(outDir: string, templateOpt, collectionName, globalParameters) = for (relativeFile, render) in x.Pages(collectionName) do let outFile = Path.Combine(outDir, relativeFile) diff --git a/src/FSharp.Formatting.ApiDocs/GenerateMarkdown.fsi b/src/FSharp.Formatting.ApiDocs/GenerateMarkdown.fsi new file mode 100644 index 000000000..fa5d4d314 --- /dev/null +++ b/src/FSharp.Formatting.ApiDocs/GenerateMarkdown.fsi @@ -0,0 +1,48 @@ +/// Internal module that generates Markdown documentation output from an . +/// Produces one Markdown file per namespace and per entity, plus an index file. +module internal FSharp.Formatting.ApiDocs.GenerateMarkdown + +open System +open System.IO +open System.Web +open FSharp.Formatting.Common +open FSharp.Formatting.Markdown +open FSharp.Formatting.Markdown.Dsl +open FSharp.Formatting.Templating + +/// HTML-encodes a string and additionally escapes pipe characters for safe use in Markdown tables. +val encode: x: string -> string +/// URL-encodes a string (for use in anchor hrefs). +val urlEncode: x: string -> string +/// Returns the trimmed HTML text of an value. +val htmlString: x: ApiDocHtml -> string +/// Returns the trimmed HTML text of an value, +/// with newlines replaced by <br /> and pipe characters escaped for Markdown tables. +val htmlStringSafe: x: ApiDocHtml -> string +/// Wraps an value as a Markdown DSL node. +val embed: x: ApiDocHtml -> MarkdownSpan +/// Wraps an value as a Markdown DSL node, +/// escaping characters that would break Markdown table cells. +val embedSafe: x: ApiDocHtml -> MarkdownSpan +/// A Markdown DSL node representing an HTML line break. +val br: MarkdownSpan + +/// Renders Markdown API documentation for all namespaces and entities in an +/// . Writes one file per namespace +/// and per entity to using the supplied template. +type MarkdownRender = + internal new: model: ApiDocModel * ?menuTemplateFolder: string -> MarkdownRender + /// The substitutions relevant to all pages, with the namespace links built for the given root + /// (a content page deeper in the site needs a different relative root than the API pages). + member internal GlobalSubstitutionsFor: root: string -> Substitutions + /// Get the substitutions relevant to all + member internal GlobalSubstitutions: Substitutions + /// The pages of the API documentation: the output file relative to the output folder + /// (forward slashes) and a function rendering the page for a template and global substitutions. + /// Nothing is rendered until the function is called. + member internal Pages: collectionName: string -> (string * (string option -> Substitutions -> string)) list + + /// Writes all API documentation Markdown files (index, one per namespace, one per entity) + /// to , applying to each page. + member internal Generate: + outDir: string * templateOpt: string option * collectionName: string * globalParameters: Substitutions -> unit diff --git a/src/FSharp.Formatting.ApiDocs/SymbolReader.fs b/src/FSharp.Formatting.ApiDocs/SymbolReader.fs index 326f57c97..43b1621ef 100644 --- a/src/FSharp.Formatting.ApiDocs/SymbolReader.fs +++ b/src/FSharp.Formatting.ApiDocs/SymbolReader.fs @@ -341,13 +341,6 @@ module internal SymbolReader = | None -> None | Some html -> Some(retType, html) - - //let signatureTooltip = - // match argInfos with - // | [] -> retTypeText - // | [[x]] when (v.IsPropertyGetterMethod || v.HasGetterMethod) && x.Name.IsNone && isUnitType x.Type -> retTypeText - // | _ -> (formatArgsUsageAsText true v argInfos) + " -> " + retTypeText - let extendedType = if v.IsExtensionMember then try @@ -419,15 +412,10 @@ module internal SymbolReader = Choice2Of2 fld, nm, html) let returnType = None - //if isUnitType retType then None else Some retTypeText let modifiers = List.empty let typeParams = List.empty - //let signatureTooltip = - // match fields with - // | [] -> retTypeText - // | _ -> (fields |> List.map (fun field -> formatTypeAsText field.FieldType) |> String.concat " * ") + " -> " + retTypeText let loc = tryGetLocation case let location = formatSourceLocation ctx.UrlRangeHighlight ctx.SourceFolderRepository loc @@ -458,7 +446,6 @@ module internal SymbolReader = ] let typeParams = List.empty - //let signatureTooltip = formatTypeAsText field.FieldType let paramTypes = [] let retType = field.FieldType @@ -521,7 +508,6 @@ module internal SymbolReader = let typeParams = List.empty let paramTypes = [] let returnType = None - //let signatureTooltip = formatTypeAsText staticParam.Kind + (if staticParam.IsOptional then sprintf " (optional, default = %A)" staticParam.DefaultValue else "") let loc = tryGetLocation staticParam diff --git a/src/FSharp.Formatting.ApiDocs/TypeFormatter.fs b/src/FSharp.Formatting.ApiDocs/TypeFormatter.fs index f1392d732..a8cdfb055 100644 --- a/src/FSharp.Formatting.ApiDocs/TypeFormatter.fs +++ b/src/FSharp.Formatting.ApiDocs/TypeFormatter.fs @@ -219,7 +219,11 @@ module internal TypeFormatter = bracketHtmlIf (prec <= 2) (formatTypesWithPrecAsHtml ctx 2 " * " tyargs) | _ when typ.IsFunctionType -> let rec loop soFar (typ: FSharpType) = - if typ.IsFunctionType then + // IsFunctionType strips abbreviations, so an abbreviation of a function type answers + // true while its GenericArguments are its own type arguments, not a domain and a + // range. Only a bare function type is taken apart, the rest is formatted under its + // own name like the cases above do. + if typ.IsFunctionType && not typ.HasTypeDefinition then let domainTyp, retType = typ.GenericArguments.[0], typ.GenericArguments.[1] loop (soFar @ [ formatTypeWithPrecAsHtml ctx 4 domainTyp; !!" -> " ]) retType diff --git a/src/FSharp.Formatting.CodeFormat/CodeFormat.fs b/src/FSharp.Formatting.CodeFormat/CodeFormat.fs index e87f7cf9c..318ed9fb0 100644 --- a/src/FSharp.Formatting.CodeFormat/CodeFormat.fs +++ b/src/FSharp.Formatting.CodeFormat/CodeFormat.fs @@ -44,8 +44,10 @@ type CodeFormat = let openTag = defaultArg openTag "
    "
     
             let closeTag = defaultArg closeTag "
    " - let openLinesTag = defaultArg openLinesTag openTag - let closeLinesTag = defaultArg closeLinesTag closeTag + + let openLinesTag = defaultArg openLinesTag "
    "
    +
    +        let closeLinesTag = defaultArg closeLinesTag "
    " let lineNumbers = defaultArg lineNumbers true let addErrors = defaultArg addErrors false let tokenKindToCss = defaultArg tokenKindToCss CodeFormatHelper.defaultTokenMap diff --git a/src/FSharp.Formatting.CodeFormat/CodeFormatAgent.fs b/src/FSharp.Formatting.CodeFormat/CodeFormatAgent.fs index 25870f1ce..925e8857e 100644 --- a/src/FSharp.Formatting.CodeFormat/CodeFormatAgent.fs +++ b/src/FSharp.Formatting.CodeFormat/CodeFormatAgent.fs @@ -470,16 +470,6 @@ module CodeFormatter = OtherOptions = [| yield! Helpers.parseOptions str; yield! opts.OtherOptions |] } | _ -> opts - //// add our file - //let opts = - // { opts with - // UseScriptResolutionRules = true - // //UnresolvedReferences = Some ( UnresolvedReferencesSet.UnresolvedAssemblyReference []) - // ProjectFileNames = [| filePath |] } - - //logger.Debugf "project options '%A', OtherOptions: \n\t%s" { opts with OtherOptions = [||] } (System.String.Join("\n\t", opts.OtherOptions)) - //let! results = fsChecker.ParseAndCheckProject(opts) - //let _errors = results.Errors for diagnostic in diagnostics do logger.Warnf "error from GetProjectOptionsFromScript '%s'" (formatDiagnostic diagnostic) diff --git a/src/FSharp.Formatting.CodeFormat/FSharp.Formatting.CodeFormat.fsproj b/src/FSharp.Formatting.CodeFormat/FSharp.Formatting.CodeFormat.fsproj index 9e9303eea..5f2af8e11 100644 --- a/src/FSharp.Formatting.CodeFormat/FSharp.Formatting.CodeFormat.fsproj +++ b/src/FSharp.Formatting.CodeFormat/FSharp.Formatting.CodeFormat.fsproj @@ -1,4 +1,4 @@ - + netstandard2.1 @@ -15,6 +15,8 @@ + + diff --git a/src/FSharp.Formatting.CodeFormat/HtmlFormatting.fs b/src/FSharp.Formatting.CodeFormat/HtmlFormatting.fs index 8a00c5359..2cb14245b 100644 --- a/src/FSharp.Formatting.CodeFormat/HtmlFormatting.fs +++ b/src/FSharp.Formatting.CodeFormat/HtmlFormatting.fs @@ -64,9 +64,27 @@ type FormattingContext = // -------------------------------------------------------------------------------------- /// Formats tool tip information and returns a string -let formatToolTipSpans spans = +let formatToolTipSpans (tokenKindToCss: TokenKind -> string) spans = let sb = StringBuilder() use wr = new StringWriter(sb) + + // A tool tip is laid out as flowing text rather than preformatted, so the browser + // would collapse the indentation of a continuation line. Keep it with nbsp, but only + // at the start of a line: doing it to every space would stop the tip wrapping at all. + let mutable atLineStart = true + + let writeText (text: string) = + if atLineStart then + let spaces = text.Length - text.TrimStart(' ').Length + + wr.Write(String.replicate spaces " ") + wr.Write(HttpUtility.HtmlEncode(text.Substring(spaces))) + else + wr.Write(HttpUtility.HtmlEncode(text)) + + if not (String.IsNullOrWhiteSpace text) then + atLineStart <- false + // Inner recursive function that does the formatting let rec format spans = spans @@ -75,12 +93,22 @@ let formatToolTipSpans spans = wr.Write("") format spans wr.Write("") - | Literal(string) -> - let spaces = string.Length - string.TrimStart(' ').Length - - wr.Write(String.replicate spaces " ") - wr.Write(HttpUtility.HtmlEncode(string.Substring(spaces))) - | HardLineBreak -> wr.Write("
    ")) + | Token(_, body) when String.IsNullOrWhiteSpace body -> writeText body + | Token(kind, body) -> + let css = tokenKindToCss kind + + if String.IsNullOrEmpty css then + writeText body + else + wr.Write("") + writeText body + wr.Write("") + | Literal(string) -> writeText string + | HardLineBreak -> + wr.Write("
    ") + atLineStart <- true) format spans sb.ToString() @@ -91,7 +119,7 @@ let rec formatTokenSpans (ctx: FormattingContext) = | TokenSpan.Error(_kind, message, body) when ctx.GenerateErrors -> let tip = ToolTipReader.formatMultilineString (message.Trim().Split('\n')) - let tipAttributes = ctx.FormatTip tip formatToolTipSpans + let tipAttributes = ctx.FormatTip tip (formatToolTipSpans ctx.TokenKindToCss) ctx.Writer.Write(" let tip = ToolTipReader.formatMultilineString (hidden.Trim().Split('\n')) - let tipAttributes = ctx.FormatTip tip formatToolTipSpans + let tipAttributes = ctx.FormatTip tip (formatToolTipSpans ctx.TokenKindToCss) ctx.Writer.Write(" ctx.FormatTip tip formatToolTipSpans + | Some(tip) -> ctx.FormatTip tip (formatToolTipSpans ctx.TokenKindToCss) | _ -> "" // Get CSS class name of the token @@ -161,26 +189,22 @@ let formatSnippets (ctx: FormattingContext) (snippets: Snippet array) = if String.IsNullOrEmpty(tag) |> not then ctx.Writer.Write(tag) - // If we're adding lines, then generate two column table - // (so that the body can be easily copied) - if ctx.GenerateLineNumbers then - ctx.Writer.Write("") - ctx.Writer.Write("") - ctx.Writer.Write("") - ctx.Writer.Write("") - ctx.Writer.WriteLine("") - ctx.Writer.Write("
    ") + // A snippet is a wrapper holding one or two
     elements, laid out as a
    +            // grid by the stylesheet. Line numbers get their own 
     so that they
    +            // stay out of any text the reader selects and copies.
    +            ctx.Writer.Write("
    ") - // Generate
     tag for the snippet
    +            if ctx.GenerateLineNumbers then
    +                // Generate 
     tag for the line numbers
                     emitTag ctx.OpenLinesTag
                     // Print all line numbers of the snippet
                     for index in 0 .. linesLength - 1 do
                         // Add line number to the beginning
                         let lineStr = (index + 1).ToString().PadLeft(numberLength)
     
    -                    ctx.Writer.WriteLine("{0}: ", lineStr)
    +                    ctx.Writer.WriteLine("{0}", lineStr)
     
                     emitTag ctx.CloseLinesTag
    -                ctx.Writer.WriteLine("
    ") - // Print all lines of the snippet inside
    ..
    emitTag ctx.OpenTag @@ -192,11 +216,7 @@ let formatSnippets (ctx: FormattingContext) (snippets: Snippet array) = emitTag ctx.CloseTag - if ctx.GenerateLineNumbers then - // Close the table if we are adding lines - ctx.Writer.WriteLine("
    ") + ctx.Writer.Write("
    ") ctx.Writer.Close() yield key, mainStr.ToString() diff --git a/src/FSharp.Formatting.CodeFormat/NiceSignaturePrint.fs b/src/FSharp.Formatting.CodeFormat/NiceSignaturePrint.fs new file mode 100644 index 000000000..f187062be --- /dev/null +++ b/src/FSharp.Formatting.CodeFormat/NiceSignaturePrint.fs @@ -0,0 +1,232 @@ +// -------------------------------------------------------------------------------------- +// F# CodeFormat (NiceSignaturePrint.fs) +// -------------------------------------------------------------------------------------- + +module internal FSharp.Formatting.CodeFormat.NiceSignaturePrint + +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Diagnostics +open FSharp.Compiler.Syntax +open FSharp.Compiler.Text +open FSharp.Formatting.Internal + +/// The parameters of a signature, and the result under them. +[] +let ArgumentIndent = 4 + +[] +let ResultIndent = 8 + +/// What the type is fed to the parser as. Only the length matters afterwards, to take the +/// reported columns back to offsets in the type itself. +[] +let FedPrefix = "val X: " + +let fedFile = "fsdocs-signature.fsi" + +let parsingOptions = + { FSharpParsingOptions.Default with + SourceFiles = [| fedFile |] + } + +/// The type the compiler read out of `text`, or None when it could not read one. +let parseType (text: string) : SynType option = + let results = + FSharpAssemblyHelper.checker.ParseFile( + fedFile, + SourceText.ofString ("module F\n" + FedPrefix + text), + parsingOptions + ) + |> Async.RunSynchronously + + let failed = + results.Diagnostics + |> Array.exists (fun diagnostic -> diagnostic.Severity = FSharpDiagnosticSeverity.Error) + + if failed then + None + else + match results.ParseTree with + | ParsedInput.SigFile(ParsedSigFileInput(contents = [ SynModuleOrNamespaceSig(decls = decls) ])) -> + decls + |> List.tryPick (function + | SynModuleSigDecl.Val(valSig = SynValSig(synType = synType)) -> Some synType + | _ -> None) + | _ -> None + +/// The index just past the colon that separates the name of a signature from its type, or -1 +/// when the line carries no such colon. +/// +/// The colon has to be the one at the top level: `member Foo: x: int -> int` has three, and only +/// the first ends the name. `:>` and `::` are not it either, and the `>` closing an arrow is not +/// a bracket. +let nameLength (text: string) = + let mutable depth = 0 + let mutable index = 0 + let mutable result = -1 + + while result < 0 && index < text.Length do + match text[index] with + | '-' when index + 1 < text.Length && text[index + 1] = '>' -> index <- index + 2 + | '(' + | '[' + | '<' + | '{' -> + depth <- depth + 1 + index <- index + 1 + | ')' + | ']' + | '>' + | '}' -> + depth <- depth - 1 + index <- index + 1 + | ':' when depth = 0 -> + if index + 1 < text.Length && (text[index + 1] = '>' || text[index + 1] = ':') then + index <- index + 2 + else + result <- index + 1 + | _ -> index <- index + 1 + + result + +type private Break = { Offset: int; Indent: int } + +/// The points a signature reads best when broken at: after each parameter of a tuple, after each +/// arrow, and after the colon that ends the name. Offsets are into the whole line. +let private breaksOf (typeStart: int) (synType: SynType) = + // the reported column is into the text handed to the parser, which is the type with a + // `val X: ` in front of it, on the line after a `module F` + let offsetOf (range: range) = + typeStart + range.EndColumn - FedPrefix.Length + + let rec arrows synType = + match synType with + | SynType.Fun(argType = argType; returnType = returnType; trivia = trivia) -> + (offsetOf trivia.ArrowRange, argType) :: arrows returnType + | _ -> [] + + let stars synType = + match synType with + | SynType.Tuple(path = segments) -> + segments + |> List.choose (function + | SynTupleTypeSegment.Star range -> Some(offsetOf range) + | _ -> None) + | _ -> [] + + let tupleBreaks synType = + stars synType + |> List.map (fun offset -> + { + Offset = offset + Indent = ArgumentIndent + }) + + match arrows synType with + | [] -> tupleBreaks synType + | chain -> + let lastArrow = fst (List.last chain) + + [ + for (arrow, argument) in chain do + yield! tupleBreaks argument + + // the result sits under the parameters, so the arrow that introduces it is the + // one that indents further + yield + { + Offset = arrow + Indent = if arrow = lastArrow then ResultIndent else ArgumentIndent + } + ] + +/// Cuts the runs into lines at the given offsets, indenting each line after the first. +let private cut (breaks: Break list) (runs: (TokenKind * string) list) = + let lines = ResizeArray<(TokenKind * string) list>() + let current = ResizeArray() + let mutable indent = 0 + let mutable position = 0 + + let closeLine () = + let line = + current + |> List.ofSeq + |> List.filter (fun (_, text) -> text <> "") + |> function + // the first line keeps whatever indentation it came with + | line when lines.Count = 0 -> line + | [] -> [] + | (kind, head) :: rest -> + // the space that followed the separator belongs to neither line + (kind, head.TrimStart(' ')) :: rest + + // two breaks can land on the same offset, and a line of nothing but indentation is not + // a line + let line = + match line with + | [] -> [] + | line when indent = 0 -> line + | line -> (TokenKind.Default, System.String(' ', indent)) :: line + + lines.Add line + current.Clear() + + for (kind, text) in runs do + let mutable start = 0 + + for br in breaks do + if br.Offset > position && br.Offset <= position + text.Length then + let at = br.Offset - position + current.Add(kind, text.Substring(start, at - start)) + closeLine () + indent <- br.Indent + start <- at + + current.Add(kind, text.Substring start) + position <- position + text.Length + + closeLine () + + lines |> List.ofSeq |> List.filter (List.isEmpty >> not) + +let layout (width: int) (runs: (TokenKind * string) list) : (TokenKind * string) list list = + let text = runs |> List.map snd |> String.concat "" + + if text.Length <= width then + [ runs ] + else + + match nameLength text with + | -1 -> [ runs ] + | nameLength -> + + let typePart = text.Substring nameLength + let trimmed = typePart.TrimStart ' ' + let typeStart = nameLength + (typePart.Length - trimmed.Length) + + match parseType trimmed with + | None -> [ runs ] + | Some synType -> + + // a member of a type is shown indented under it, and its continuation lines + // are indented from where it starts rather than from the margin + let leading = text.Length - text.TrimStart(' ').Length + + // the name keeps the line it is on, and the type starts under it + let breaks = + { + Offset = nameLength + Indent = ArgumentIndent + } + :: breaksOf typeStart synType + |> List.choose (fun br -> + if br.Offset < text.Length then + Some { br with Indent = br.Indent + leading } + else + None) + |> List.sortBy (fun br -> br.Offset) + + match breaks with + | [] + | [ _ ] -> [ runs ] + | breaks -> cut breaks runs diff --git a/src/FSharp.Formatting.CodeFormat/NiceSignaturePrint.fsi b/src/FSharp.Formatting.CodeFormat/NiceSignaturePrint.fsi new file mode 100644 index 000000000..64e53662b --- /dev/null +++ b/src/FSharp.Formatting.CodeFormat/NiceSignaturePrint.fsi @@ -0,0 +1,19 @@ +/// Lays a tool tip signature out over several lines when it is too wide to read on one. +/// +/// A tool tip is flowing text, so an over-long signature wraps wherever the line happens to run +/// out, which lands in the middle of a parameter as often as not. This picks the break points +/// instead: after the name, between the parameters, and before the result. +/// +/// The compiler decides where those points are. Every line a tip shows has the shape +/// `<prefix> <name>: <type>`, whether it is a `val`, a `member` or a `union case`, and while +/// none of the three parse as they stand, the type on its own always does. Feeding just that to +/// the parser as a `val` gives a whose ranges point +/// straight back into the text, so the breaks land on real syntax rather than on a guess about +/// where the brackets balance. +module internal FSharp.Formatting.CodeFormat.NiceSignaturePrint + +/// Lays `runs` out over several lines when the text they carry is wider than `width`. +/// +/// Returns the runs as one line when they already fit, and also when the compiler cannot read a +/// type out of them, which is the case for the lines of a tip that are not signatures at all. +val layout: width: int -> runs: (TokenKind * string) list -> (TokenKind * string) list list diff --git a/src/FSharp.Formatting.CodeFormat/SourceCode.fs b/src/FSharp.Formatting.CodeFormat/SourceCode.fs index 7d6d6a5b0..d2284ad2b 100644 --- a/src/FSharp.Formatting.CodeFormat/SourceCode.fs +++ b/src/FSharp.Formatting.CodeFormat/SourceCode.fs @@ -13,10 +13,14 @@ open FSharp.Formatting.CodeFormat.Constants /// A tool tip consists of a list of items reported from the compiler type ToolTipSpans = ToolTipSpan list -/// A tool tip span can be emphasized text, plain text Literal or a line brak +/// A tool tip span can be emphasized text, plain text Literal, a classified +/// Token or a line break type ToolTipSpan = | Emphasis of ToolTipSpans | Literal of string + /// A run of tool tip text the compiler classified, rendered with the same colors + /// as the equivalent token in a code snippet + | Token of kind: TokenKind * body: string | HardLineBreak /// Classifies tokens reported by the FCS diff --git a/src/FSharp.Formatting.CodeFormat/ToolTipReader.fs b/src/FSharp.Formatting.CodeFormat/ToolTipReader.fs index 19db88488..7a680391c 100644 --- a/src/FSharp.Formatting.CodeFormat/ToolTipReader.fs +++ b/src/FSharp.Formatting.CodeFormat/ToolTipReader.fs @@ -10,6 +10,7 @@ module internal FSharp.Formatting.CodeFormat.ToolTipReader open System.Text open System.Text.RegularExpressions +open System.Xml.Linq open FSharp.Collections open FSharp.Compiler.Symbols open FSharp.Compiler.EditorServices @@ -19,21 +20,79 @@ open FSharp.Compiler.Text // Implements formatting of tool tips // -------------------------------------------------------------------------------------- +/// How wide a signature is allowed to get before it is laid out over several lines. +[] +let private signatureWidth = 80 + +/// Maps the tag the compiler attached to a run of tool tip text onto the token kind +/// the snippet formatter already knows how to color, so a tool tip is highlighted by +/// the same rules as the code it describes. +let private tokenKindOfTextTag (tag: TextTag) = + match tag with + | TextTag.Keyword -> TokenKind.Keyword + | TextTag.Class + | TextTag.Record + | TextTag.Union + | TextTag.Delegate + | TextTag.Event -> TokenKind.ReferenceType + | TextTag.Struct -> TokenKind.ValueType + | TextTag.Interface -> TokenKind.Interface + | TextTag.Enum -> TokenKind.Enumeration + | TextTag.Module + | TextTag.Namespace + // the qualifier of a fully written name, `System.` in `System.DateTime` + | TextTag.UnknownEntity -> TokenKind.Module + | TextTag.TypeParameter -> TokenKind.TypeArgument + | TextTag.UnionCase + | TextTag.ActivePatternCase + | TextTag.ActivePatternResult -> TokenKind.UnionCase + | TextTag.Function + | TextTag.Method + | TextTag.Member + | TextTag.ModuleBinding -> TokenKind.Function + | TextTag.Property + | TextTag.Field + | TextTag.RecordField -> TokenKind.Property + | TextTag.NumericLiteral -> TokenKind.Number + | TextTag.StringLiteral -> TokenKind.String + | TextTag.Operator -> TokenKind.Operator + | TextTag.Punctuation -> TokenKind.Punctuation + | TextTag.Parameter + | TextTag.Local -> TokenKind.Identifier + // `string`, `list`, `option` and `array` are type abbreviations, which read as types even + // though the compiler tags them apart from the ones they abbreviate. Leaving them out of the + // type color puts `string` and `int` in two colors in the same signature. + | TextTag.Alias -> TokenKind.ReferenceType + | TextTag.Text + | TextTag.Space + | TextTag.LineBreak + | TextTag.UnknownType -> TokenKind.Default + /// Converts an array of values emitted by -/// the F# compiler into a sequence of plain strings, splitting on newline-tagged spaces. +/// the F# compiler into lines of runs. Each run keeps the token kind its tag maps to, so +/// the classification survives into the rendered tip. let linesFromTaggedText (tags: TaggedText array) = seq { - let content = StringBuilder() + let line = ResizeArray() for tag in tags do - if tag.Tag = TextTag.Space && tag.Text.Contains "\n" then - yield string content - content.Clear() |> ignore - else - content.Append tag.Text |> ignore + let kind = tokenKindOfTextTag tag.Tag + + // The compiler does not hand us a line break in a tag of its own. It arrives + // inside a Space or a Text run, often with the indentation of the next line + // attached, so split on the newline itself rather than on the tag. + let parts = tag.Text.Replace("\r\n", "\n").Split('\n') + + for index in 0 .. parts.Length - 1 do + if index > 0 then + yield List.ofSeq line + line.Clear() + + if parts[index] <> "" then + line.Add(kind, parts[index]) // yield any remaining text - if content.Length <> 0 then - yield string content + if line.Count <> 0 then + yield List.ofSeq line } /// Strips F# attribute annotations (e.g. [], []) @@ -41,8 +100,49 @@ let linesFromTaggedText (tags: TaggedText array) = /// particularly for methods with many attributed optional parameters. let private attributeAnnotationPattern = Regex(@"\s*\[<[^>]*>\]") -let internal stripParameterAttributes (line: string) = - attributeAnnotationPattern.Replace(line, "") +let internal stripParameterAttributes (runs: (TokenKind * string) list) = + let text = runs |> List.map snd |> String.concat "" + let matches = attributeAnnotationPattern.Matches(text) + + if matches.Count = 0 then + runs + else + // The pattern spans several runs, so mark the characters it removes and rebuild + // the runs around them. Every surviving character keeps the kind it came in with. + let dropped = Array.zeroCreate text.Length + + for m in matches do + for i in m.Index .. m.Index + m.Length - 1 do + dropped[i] <- true + + let mutable pos = 0 + + [ + for (kind, run) in runs do + let kept = StringBuilder() + + for ch in run do + if not dropped[pos] then + kept.Append(ch) |> ignore + + pos <- pos + 1 + + if kept.Length > 0 then + yield kind, string kept + ] + +/// Turn tagged lines into a sequence of tool tip spans interleaved with line breaks +let private formatTaggedLines (lines: (TokenKind * string) list list) = + [ + for line in lines do + yield HardLineBreak + + for (kind, text) in line do + yield Token(kind, text) + ] + |> function + | [] -> [] + | _ :: rest -> rest /// Turn string into a sequence of lines interleaved with line breaks let formatMultilineString (lines: string array) = @@ -53,10 +153,143 @@ let formatMultilineString (lines: string array) = ] |> List.tail +/// Splits text into lines and removes the indentation every line shares, which for a doc +/// comment is the space after the "///". +let private toLines (text: string) = + let lines = + text.Replace("\r\n", "\n").Split('\n') + |> Array.map (fun line -> line.TrimEnd()) + |> Array.skipWhile System.String.IsNullOrWhiteSpace + |> Array.rev + |> Array.skipWhile System.String.IsNullOrWhiteSpace + |> Array.rev + + let indent = + lines + |> Array.choose (fun line -> + if System.String.IsNullOrWhiteSpace line then + None + else + Some(line.Length - line.TrimStart(' ').Length)) + |> function + | [||] -> 0 + | indents -> Array.min indents + + lines + |> Array.map (fun line -> if line.Length <= indent then "" else line.Substring(indent)) + +/// The name a cref points at, without the "T:" kind prefix and without the namespace. +let private crefName (cref: string) = + let withoutKind = + if cref.Length > 1 && cref[1] = ':' then + cref.Substring(2) + else + cref + + withoutKind.Split('.') |> Array.last + +/// Reads the summary text and the per-parameter details out of one doc comment fragment. +/// Returns None when the fragment is not well formed XML, which a doc comment need not be. +let private tryReadXmlDoc (text: string) = + try + // a doc comment is a fragment of sibling elements rather than a single rooted + // document, so give it a root of its own before parsing + let root = XElement.Parse("" + text + "", LoadOptions.PreserveWhitespace) + + // a keeps its text in an attribute, so it would leave a + // hole in the sentence around it once we ask an element for its text + for reference in List.ofSeq (root.Descendants()) do + match reference.Name.LocalName with + | "see" + | "seealso" + | "paramref" + | "typeparamref" -> + let named = + [ "cref"; "name" ] + |> List.tryPick (fun name -> + match reference.Attribute(XName.Get name) with + | null -> None + | attribute -> Some attribute.Value) + + match named with + | Some value when System.String.IsNullOrWhiteSpace reference.Value -> + reference.ReplaceWith(XText(crefName value)) + | _ -> reference.ReplaceWith(XText(reference.Value)) + | _ -> () + + // Anything the author did not put in an element of its own reads as the summary, + // and most doc comments are written that way. + let summary = StringBuilder() + + for node in root.Nodes() do + match node with + | :? XElement as element -> + match element.Name.LocalName with + | "param" + | "typeparam" + | "returns" + | "exception" -> () + | _ -> summary.Append(element.Value) |> ignore + | :? XText as text -> summary.Append(text.Value) |> ignore + | _ -> () + + let details = + [| + for element in root.Elements() do + match element.Name.LocalName with + | "param" + | "typeparam" -> + let name = + match element.Attribute(XName.Get "name") with + | null -> element.Name.LocalName + | attribute -> attribute.Value + + yield sprintf "%s: %s" name (element.Value.Trim()) + | "returns" -> yield sprintf "returns: %s" (element.Value.Trim()) + | _ -> () + |] + + Some(string summary, details) + with _ -> + None + +/// Reads the text out of the doc comment elements a signature tool tip cares about. The +/// compiler hands the comment over as the XML the author wrote, and a tip is no place for +/// markup. A doc comment is not required to be well formed, so fall back to the raw lines. +let private xmlDocLines (xmlDoc: FSharp.Compiler.Xml.XmlDoc) = + // GetXmlText is no use here: it escapes the whole comment whenever it has to supply + // the wrapper itself, which turns a into literal text + let raw = xmlDoc.UnprocessedLines + + // The compiler applies that same escaping when it writes the xml documentation file, + // so markup can survive a round of parsing as text and needs unwrapping once more. + let rec read unwrapped text = + match tryReadXmlDoc text with + | Some(summary, details) when not unwrapped && summary.Contains "<" -> + match read true summary with + | Some(inner, extra) -> Some(inner, Array.append details extra) + | None -> Some(summary, details) + | result -> result + + match read false (String.concat "\n" raw) with + | Some(summary, details) -> + match toLines summary, details with + | [||], details -> details + | summary, [||] -> summary + | summary, details -> Array.concat [ summary; [| "" |]; details ] + | None -> + // An "unprocessed line" can itself span several lines, and a tool tip is flowing + // text, so split them here or the browser collapses the break away. + raw |> Array.collect (fun line -> line.Replace("\r\n", "\n").Split('\n')) + + /// Format comment in the tool tip let private formatComment xmlDoc = match xmlDoc with - | FSharpXmlDoc.FromXmlText(xmlDoc) -> [ Emphasis(formatMultilineString xmlDoc.UnprocessedLines); HardLineBreak ] + | FSharpXmlDoc.FromXmlText(xmlDoc) -> + match xmlDocLines xmlDoc with + | [||] -> [] + | lines -> [ Emphasis(formatMultilineString lines); HardLineBreak ] | _ -> // TODO: For 'XmlCommentSignature' we could get documentation // from 'xml' files, but we don't know where to get them... @@ -80,17 +313,27 @@ let private formatElement tooltip = [ for it in items do + // the doc comment reads first and the signature it describes follows it + yield! formatComment it.XmlDoc + yield! it.MainDescription |> linesFromTaggedText - |> Seq.choose (fun s -> - let stripped = stripParameterAttributes s - if stripped.Trim() <> "" then Some stripped else None) - |> Seq.toArray - |> formatMultilineString + |> Seq.choose (fun line -> + // a line left holding nothing but whitespace carries no information, + // and the tip reads better without it + match stripParameterAttributes line with + | stripped when + stripped + |> List.exists (fun (_, text) -> not (System.String.IsNullOrWhiteSpace text)) + -> + Some stripped + | _ -> None) + |> List.ofSeq + |> List.collect (NiceSignaturePrint.layout signatureWidth) + |> formatTaggedLines yield HardLineBreak - yield! formatComment it.XmlDoc // Add note with the number of omitted overloads if trimmed then diff --git a/src/FSharp.Formatting.CodeFormat/files/style.css b/src/FSharp.Formatting.CodeFormat/files/style.css deleted file mode 100644 index b2903ccfc..000000000 --- a/src/FSharp.Formatting.CodeFormat/files/style.css +++ /dev/null @@ -1,88 +0,0 @@ -/* identifier */ -span.i { color:#000000; } -/* comment */ -span.c { color:#008000; } -/* inactive code */ -span.inactive { color:#808080; } -/* keywords */ -span.k { color:#000080; } -/* numbers */ -span.n { color:#008000; } -/* operators */ -span.o { color:#800080; } -/* preprocessor */ -span.prep { color:#800080; } -/* string */ -span.s { color:#808000; } -/* line number */ -span.l { color:#80b0b0; } -/* fsi output */ -span.fsi { font-style:italic; color:#606060; } -/* omitted */ -span.omitted { - border:solid 1px #d8d8d8; - color:#808080; - padding:0px 0px 1px 0px; - background:#fafafa; -} -span.cerr { - background:url(tilde.png) repeat-x left bottom; -} - -div.tip -{ - font:8pt calibri; - padding:3px; - border:1px solid #606060; - background:#ffffd0; - display:none; - pointer-events:none; -} - -h2 -{ - font:bold 16pt 'calibri'; -} - -pre a.fssniplink -{ - font:bold 7.5pt calibry, arial, verdana; - position:absolute; - bottom:0.3em; - right:0.3em; - color:#a8a8a8; -} - -/* Optionally, also configure how the PRE element and TABLE.PRE look */ -table.pre pre { - padding:0px; - margin:0px; - border-style:none; -} - -pre, table.pre td { - padding:9.5px; - background:#f5f5f5; - border:solid 1px black; - border-radius:4px; - border-color:rgba(0,0,0,0.15); - margin:0px 0px 10px 0px; -} - -table.pre td.lines { - border-top-right-radius:0px; - border-bottom-right-radius:0px; - border-right-style: none; - padding-right:0px; -} -table.pre td.snippet { - border-top-left-radius:0px; - border-bottom-left-radius:0px; - border-left-style: none; - padding-left:4px; -} - -code, pre, pre code { - font-family:9pt consolas, monaco,'Lucida Console',monospace; - line-height:13pt; -} \ No newline at end of file diff --git a/src/FSharp.Formatting.CodeFormat/files/tilde.png b/src/FSharp.Formatting.CodeFormat/files/tilde.png deleted file mode 100644 index bf4af00095e96912ec9020231a27eeabef364c7a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2835 zcmV+u3+(iXP)pPPiaF#P*7-ZbZ>KLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&neh&#Q1i z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4 zfg=2N-7=cNnjjOr{yriy6mMFgG#l znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya? z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0 z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$ z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4 z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i& z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01 z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3| zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F} z0000!NkllDi5PK%sF+1k)kW99yKKjZR33F(44mp*3MGN?}g! lrFUxm*WTpJ^L&YT2LL{{H>=@Xl~@1(002ovPDHLkV1nNeM3Vpj diff --git a/src/FSharp.Formatting.CodeFormat/files/tips.js b/src/FSharp.Formatting.CodeFormat/files/tips.js deleted file mode 100644 index d722040c2..000000000 --- a/src/FSharp.Formatting.CodeFormat/files/tips.js +++ /dev/null @@ -1,51 +0,0 @@ -var currentTip = null; -var currentTipElement = null; - -function hideTip(evt, name, unique) -{ - var el = document.getElementById(name); - el.style.display = "none"; - currentTip = null; -} - -function findPos(obj) -{ - // no idea why, but it behaves differently in webbrowser component - if (window.location.search == "?inapp") - return [obj.offsetLeft + 10, obj.offsetTop + 30]; - - var curleft = 0; - var curtop = obj.offsetHeight; - while (obj) - { - curleft += obj.offsetLeft; - curtop += obj.offsetTop; - obj = obj.offsetParent; - }; - return [curleft, curtop]; -} - -function hideUsingEsc(e) -{ - if (!e) { e = event; } - hideTip(e, currentTipElement, currentTip); -} - -function showTip(evt, name, unique, owner) -{ - document.onkeydown = hideUsingEsc; - if (currentTip == unique) return; - currentTip = unique; - currentTipElement = name; - - var pos = findPos(owner ? owner : (evt.srcElement ? evt.srcElement : evt.target)); - var posx = pos[0]; - var posy = pos[1]; - - var el = document.getElementById(name); - var parent = (document.documentElement == null) ? document.body : document.documentElement; - el.style.position = "absolute"; - el.style.left = posx + "px"; - el.style.top = posy + "px"; - el.style.display = "block"; -} \ No newline at end of file diff --git a/src/FSharp.Formatting.Common/HtmlModel.fs b/src/FSharp.Formatting.Common/HtmlModel.fs index 229646a20..f902d7135 100644 --- a/src/FSharp.Formatting.Common/HtmlModel.fs +++ b/src/FSharp.Formatting.Common/HtmlModel.fs @@ -751,7 +751,6 @@ module internal Html = let stop (props: HtmlProperties list) (children: HtmlElement list) = HtmlElement.Stop(props, children) let text (props: HtmlProperties list) (children: HtmlElement list) = HtmlElement.Text(props, children) let tspan (props: HtmlProperties list) (children: HtmlElement list) = HtmlElement.Tspan(props, children) - //let string str = HtmlElement.String str let (!!) str = HtmlElement.String str let encode str = HtmlElement.EncodeString str diff --git a/src/FSharp.Formatting.Common/Menu.fs b/src/FSharp.Formatting.Common/Menu.fs index 0df22f691..632d9cd7c 100644 --- a/src/FSharp.Formatting.Common/Menu.fs +++ b/src/FSharp.Formatting.Common/Menu.fs @@ -5,11 +5,13 @@ open System open System.IO open FSharp.Formatting.Templating -/// A single navigation menu item with its link, display text, and active state +/// A single navigation menu item with its link, display text, hover text and active state type MenuItem = { Link: string Content: string + /// Shown on hover, where the display text alone does not say which page the item leads to. + Title: string option IsActive: bool } @@ -20,8 +22,18 @@ let private snakeCaseRegex = let private snakeCase (v: string) = 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 = +/// Renders an HTML navigation menu for the given header and items using template files in `input`. +/// +/// `substitutions` are the ones the page itself is rendered with, `{{root}}` among them, so a menu +/// template can reach the rest of the site and not only the links it is handed. They go in first, so +/// a menu template cannot lose its own keys to a project that happens to define one of them. +let createMenu + (input: string) + (substitutions: Substitutions) + (isCategoryActive: bool) + (header: string) + (items: MenuItem list) + : string = let pwd = Directory.GetCurrentDirectory() let menuTemplate = File.ReadAllText(Path.Combine(pwd, input, "_menu_template.html")) let menuItemTemplate = File.ReadAllText(Path.Combine(pwd, input, "_menu-item_template.html")) @@ -35,9 +47,14 @@ let createMenu (input: string) (isCategoryActive: bool) (header: string) (items: SimpleTemplating.ApplySubstitutionsInText [| + yield! substitutions ParamKeys.``fsdocs-menu-item-link``, link ParamKeys.``fsdocs-menu-item-content``, title ParamKeys.``fsdocs-menu-item-id``, id + ParamKeys.``fsdocs-menu-item-title``, + (match model.Title with + | Some title -> System.Web.HttpUtility.HtmlEncode title + | None -> "") ParamKeys.``fsdocs-menu-item-active-class``, (if model.IsActive then "active" else "") |] menuItemTemplate) @@ -45,6 +62,7 @@ let createMenu (input: string) (isCategoryActive: bool) (header: string) (items: SimpleTemplating.ApplySubstitutionsInText [| + yield! substitutions ParamKeys.``fsdocs-menu-header-content``, header ParamKeys.``fsdocs-menu-header-id``, snakeCase header ParamKeys.``fsdocs-menu-header-active-class``, (if isCategoryActive then "active" else "") diff --git a/src/FSharp.Formatting.Common/PageContentList.fs b/src/FSharp.Formatting.Common/PageContentList.fs index 454779c33..22aa2869d 100644 --- a/src/FSharp.Formatting.Common/PageContentList.fs +++ b/src/FSharp.Formatting.Common/PageContentList.fs @@ -31,7 +31,10 @@ let private headingSelector (index: int) = $"[data-fsdocs-heading=\"%i{index}\"] /// The scroll timeline of the content scroller, named in fsdocs-default.css. let private mainTimeline = "--fsdocs-main" -let private scrollSpyStyle (headingCount: int) = +/// The `