From 4e2478819a27ca31bc53e678ebb63ba66ad86c26 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:45:17 +0000 Subject: [PATCH] Add support for top-level XML doc tags in API docs Renders a 'See also' section (list of links) for members and entities that have top-level XML doc tags, per the xmldoc recommended-tags convention. Previously these tags were silently ignored. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- RELEASE_NOTES.md | 3 ++ src/FSharp.Formatting.ApiDocs/ApiDocTypes.fs | 7 +++- src/FSharp.Formatting.ApiDocs/GenerateHtml.fs | 24 +++++++++++ .../GenerateMarkdown.fs | 18 ++++++++ src/FSharp.Formatting.ApiDocs/XmlDocReader.fs | 41 ++++++++++++++++++- tests/FSharp.ApiDocs.Tests/ApiDocsTests.fs | 23 +++++++++++ .../files/FsLib2/Library2.fs | 6 +++ 7 files changed, 119 insertions(+), 3 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index db104a87d..da400a40c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Added +* Support top-level `` XML doc tags in API documentation generation. Per the [xmldoc recommended tags](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/xmldoc/recommended-tags#seealso), `` is a top-level section (unlike the inline `` tag) and is now rendered as a "See also" list in both HTML and Markdown API doc output, for both members and entities. [#1256](https://github.com/fsprojects/FSharp.Formatting/issues/1256) + ### Removed * Remove `docs/Dockerfile` (used for mybinder.org Binder integration) and mybinder badge links from documentation pages. The Binder integration relied on a deprecated .NET 7 SDK image and a deprecated `Microsoft.dotnet-interactive` version; mybinder.org support is discontinued. * Remove `.ipynb` (Jupyter Notebook) "run in notebook" badge links from all documentation pages and delete `docs/img/badge-notebook.svg`. The links linked to `.ipynb` outputs generated by fsdocs, but .NET Interactive (which powered those notebooks) is deprecated and has no current replacement. The `.ipynb` output format itself is unchanged. diff --git a/src/FSharp.Formatting.ApiDocs/ApiDocTypes.fs b/src/FSharp.Formatting.ApiDocs/ApiDocTypes.fs index 2a88e2a31..afe16183a 100644 --- a/src/FSharp.Formatting.ApiDocs/ApiDocTypes.fs +++ b/src/FSharp.Formatting.ApiDocs/ApiDocTypes.fs @@ -129,7 +129,7 @@ type ApiDocHtml(html: string, id: string option) = member _.Id = id /// Represents a documentation comment attached to source code -type ApiDocComment(xmldoc, summary, remarks, parameters, returns, examples, notes, exceptions, rawData) = +type ApiDocComment(xmldoc, summary, remarks, parameters, returns, examples, notes, exceptions, seeAlso, rawData) = /// The XElement for the XML doc if available member _.Xml: XElement option = xmldoc @@ -155,10 +155,13 @@ type ApiDocComment(xmldoc, summary, remarks, parameters, returns, examples, note /// The notes sections of the comment member _.Exceptions: (string * string option * ApiDocHtml) list = exceptions + /// The top-level "seealso" sections of the comment, rendered as a "See also:" list + member _.SeeAlso: (string * string option * ApiDocHtml) list = seeAlso + /// The raw data of the comment member _.RawData: KeyValuePair list = rawData - static member internal Empty = ApiDocComment(None, ApiDocHtml("", None), None, [], None, [], [], [], []) + static member internal Empty = ApiDocComment(None, ApiDocHtml("", None), None, [], None, [], [], [], [], []) /// Represents a custom attribute attached to source code type ApiDocAttribute(name, fullName, constructorArguments, namedConstructorArguments) = diff --git a/src/FSharp.Formatting.ApiDocs/GenerateHtml.fs b/src/FSharp.Formatting.ApiDocs/GenerateHtml.fs index 0303f325b..cd132cfd7 100644 --- a/src/FSharp.Formatting.ApiDocs/GenerateHtml.fs +++ b/src/FSharp.Formatting.ApiDocs/GenerateHtml.fs @@ -284,6 +284,18 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) = p [ Class "fsdocs-note" ] [ embed e ] + if not m.Comment.SeeAlso.IsEmpty then + h5 [ Class "fsdocs-seealso-header" ] [ !!"See also" ] + + ul [ Class "fsdocs-seealso-list" ] [ + for (nm, link, html) in m.Comment.SeeAlso do + li [] [ + match link with + | Some href -> a [ Href href ] [ !!nm ] + | None -> embed html + ] + ] + for e in m.Comment.Examples do h5 [ Class "fsdocs-example-header" ] [ !!"Example" ] @@ -467,6 +479,18 @@ type HtmlRender(model: ApiDocModel, ?menuTemplateFolder: string) = p [ Class "fsdocs-note" ] [ embed note ] + if not entity.Comment.SeeAlso.IsEmpty then + h5 [ Class "fsdocs-seealso-header" ] [ !!"See also" ] + + ul [ Class "fsdocs-seealso-list" ] [ + for (nm, link, html) in entity.Comment.SeeAlso do + li [] [ + match link with + | Some href -> a [ Href href ] [ !!nm ] + | None -> embed html + ] + ] + for example in entity.Comment.Examples do h5 [ Class "fsdocs-example-header" ] [ !!"Example" ] diff --git a/src/FSharp.Formatting.ApiDocs/GenerateMarkdown.fs b/src/FSharp.Formatting.ApiDocs/GenerateMarkdown.fs index 5cc2dc388..f4bd063e6 100644 --- a/src/FSharp.Formatting.ApiDocs/GenerateMarkdown.fs +++ b/src/FSharp.Formatting.ApiDocs/GenerateMarkdown.fs @@ -122,6 +122,15 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = ``#####`` [ !!"Note" ] p [ embed e ] + 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 ] ] ] + for e in m.Comment.Examples do ``#####`` [ !!"Example" ] p [ embed e ] @@ -251,6 +260,15 @@ type MarkdownRender(model: ApiDocModel, ?menuTemplateFolder: string) = ``#####`` [ !!"Note" ] p [ embed note ] + 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 ] ] ] + for example in entity.Comment.Examples do ``#####`` [ !!"Example" ] p [ embed example ] diff --git a/src/FSharp.Formatting.ApiDocs/XmlDocReader.fs b/src/FSharp.Formatting.ApiDocs/XmlDocReader.fs index 979f1d3bc..03ab56726 100644 --- a/src/FSharp.Formatting.ApiDocs/XmlDocReader.fs +++ b/src/FSharp.Formatting.ApiDocs/XmlDocReader.fs @@ -110,6 +110,7 @@ module internal XmlDocReader = examples = examples, notes = notes, exceptions = [], + seeAlso = [], rawData = raw ) @@ -366,6 +367,41 @@ module internal XmlDocReader = readXmlElementAsHtml true urlMap cmds html e cname, None, ApiDocHtml(html.ToString(), None) ] + let seeAlso = + // Only top-level elements (direct children of the doc comment), per the + // xmldoc recommended-tags convention: is a section, is an inline link. + let seeAlsoNodes = doc.Elements(XName.Get "seealso") |> Seq.toList + + [ for e in seeAlsoNodes do + let cref = e.Attribute(XName.Get "cref") + + if not (isNull cref) then + if String.IsNullOrEmpty(cref.Value) || cref.Value.Length < 3 then + printfn "Warning: Invalid cref specified in: %A" doc + else + // Older FSharp.Core cref listings don't start with "T:", see https://github.com/dotnet/fsharp/issues/9805 + let cname = cref.Value + let cname = if cname.Contains(":") then cname else "T:" + cname + + match urlMap.ResolveCref cname with + | Some reference -> + let html = new StringBuilder() + readXmlElementAsHtml true urlMap cmds html e + reference.NiceName, Some reference.ReferenceLink, ApiDocHtml(html.ToString(), None) + | _ -> + let html = new StringBuilder() + readXmlElementAsHtml true urlMap cmds html e + cname, None, ApiDocHtml(html.ToString(), None) + else + // no cref: render the element's own content (e.g. text) + let html = new StringBuilder() + readXmlElementAsHtml true urlMap cmds html e + let href = e.Attribute(XName.Get "href") + + let link = if isNull href then None else Some href.Value + + (if isNull href then "" else href.Value), link, ApiDocHtml(html.ToString(), None) ] + let examples = let exampleNodes = doc.Elements(XName.Get "example") |> Seq.toList @@ -404,7 +440,8 @@ module internal XmlDocReader = && ln <> "example" && ln <> "note" && ln <> "returns" - && ln <> "remarks") + && ln <> "remarks" + && ln <> "seealso") |> Seq.groupBy (fun n -> n.Name.LocalName) |> Seq.iter (fun (n, lst) -> let lst = Seq.toList lst @@ -425,6 +462,7 @@ module internal XmlDocReader = examples = examples, notes = notes, exceptions = exceptions, + seeAlso = seeAlso, rawData = rawData ) @@ -459,6 +497,7 @@ module internal XmlDocReader = returns = combineHtmlOptions c1.Returns c2.Returns, notes = c1.Notes @ c2.Notes, exceptions = c1.Exceptions @ c2.Exceptions, + seeAlso = c1.SeeAlso @ c2.SeeAlso, rawData = c1.RawData @ c2.RawData ) diff --git a/tests/FSharp.ApiDocs.Tests/ApiDocsTests.fs b/tests/FSharp.ApiDocs.Tests/ApiDocsTests.fs index f4c2b2832..d4fa02a7d 100644 --- a/tests/FSharp.ApiDocs.Tests/ApiDocsTests.fs +++ b/tests/FSharp.ApiDocs.Tests/ApiDocsTests.fs @@ -1636,6 +1636,29 @@ let ``ApiDocs Markdown uses section-based member layout instead of tables`` () = // No markdown table header for members (the old layout had this) nestedContent |> shouldNotContainText "Function or value | Description | Source" +[] +let ``ApiDocs Markdown generates See also section for top-level seealso tags`` () = + let library = testBin "FsLib2.dll" |> fullpath + + let files = generateApiDocs [ library ] OutputFormat.Markdown false "FsLib2_markdown_seealso" + + let seeAlsoContent = files.["fslib-seealsoexamples.md"] + + seeAlsoContent |> shouldContainText "##### See also" + seeAlsoContent |> shouldContainText "dothing" + seeAlsoContent |> shouldContainText "dothing2" + +[] +let ``ApiDocs HTML generates See also section for top-level seealso tags`` () = + let library = testBin "FsLib2.dll" |> fullpath + + let files = generateApiDocs [ library ] OutputFormat.Html false "FsLib2_html_seealso" + + let seeAlsoContent = files.["fslib-seealsoexamples.html"] + + seeAlsoContent |> shouldContainText "See also" + seeAlsoContent |> shouldContainText "fsdocs-seealso-list" + [] let ``ApiDocs Markdown generates Example and Note section headings`` () = let library = testBin "FsLib2.dll" |> fullpath diff --git a/tests/FSharp.ApiDocs.Tests/files/FsLib2/Library2.fs b/tests/FSharp.ApiDocs.Tests/files/FsLib2/Library2.fs index 8348c9fee..5e2cc884b 100644 --- a/tests/FSharp.ApiDocs.Tests/files/FsLib2/Library2.fs +++ b/tests/FSharp.ApiDocs.Tests/files/FsLib2/Library2.fs @@ -173,6 +173,12 @@ module CommentExamples = /// let attribInCodeExample () = () +module SeeAlsoExamples = + /// this does the thing + /// + /// + let dothingwithseealso () = () + /// Base class for testing inherited members (issue #590) type BaseClassForInheritance() = /// A documented instance method on the base class