From a31baeaffd74d02dca1b38bedb86ea04ea791072 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 22 Feb 2026 19:48:35 +0000 Subject: [PATCH 01/15] Add --generatellmstxt flag for llms.txt/llms-full.txt generation Implements support for generating llms.txt and llms-full.txt files alongside the documentation output when --generatellmstxt is passed to fsdocs build or fsdocs watch. - llms.txt: title/link index of all docs and API reference entries - llms-full.txt: same with full page content included Files follow the llmstxt.org convention, making project documentation easily consumable by LLMs and AI coding assistants. Closes #951 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- RELEASE_NOTES.md | 3 ++ src/fsdocs-tool/BuildCommand.fs | 55 +++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f08cd6a2f..2ce6f2e9b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Added +* Add `--generatellmstxt` flag to generate `llms.txt` and `llms-full.txt` for LLM consumption. [#951](https://github.com/fsprojects/FSharp.Formatting/issues/951) + ### Changed * Update FCS to 43.10.100. [#935](https://github.com/fsprojects/FSharp.Formatting/pull/966) diff --git a/src/fsdocs-tool/BuildCommand.fs b/src/fsdocs-tool/BuildCommand.fs index 539786a96..53366aae4 100644 --- a/src/fsdocs-tool/BuildCommand.fs +++ b/src/fsdocs-tool/BuildCommand.fs @@ -1385,6 +1385,12 @@ type CoreBuildOptions(watch) = [] member val clean = false with get, set + [] + member val generatellmstxt = false with get, set + member this.Execute() = let onError msg = @@ -1675,6 +1681,48 @@ type CoreBuildOptions(watch) = File.WriteAllText(Path.Combine(rootOutputFolderAsGiven, "index.json"), indxTxt) + let generateLlmsTxt () = + if this.generatellmstxt then + let index = Array.append latestApiDocSearchIndexEntries latestDocContentSearchIndexEntries + + let contentEntries = index |> Array.filter (fun e -> e.``type`` = "content") + let apiEntries = index |> Array.filter (fun e -> e.``type`` = "apiDocs") + + let header = sprintf "# %s\n\n" collectionName + + let buildSection sectionTitle (entries: ApiDocsSearchIndexEntry array) withContent = + if entries.Length = 0 then + "" + else + let sb = System.Text.StringBuilder() + sb.Append(sprintf "## %s\n\n" sectionTitle) |> ignore + + for e in entries do + sb.Append(sprintf "- [%s](%s)\n" e.title e.uri) |> ignore + + if withContent && not (System.String.IsNullOrWhiteSpace(e.content)) then + sb.Append("\n") |> ignore + sb.Append(e.content) |> ignore + sb.Append("\n\n") |> ignore + + sb.ToString() + + // llms.txt — index only (titles and links) + let llmsTxt = + header + + buildSection "Docs" contentEntries false + + buildSection "API Reference" apiEntries false + + File.WriteAllText(Path.Combine(rootOutputFolderAsGiven, "llms.txt"), llmsTxt) + + // llms-full.txt — full content + let llmsFullTxt = + header + + buildSection "Docs" contentEntries true + + buildSection "API Reference" apiEntries true + + File.WriteAllText(Path.Combine(rootOutputFolderAsGiven, "llms-full.txt"), llmsFullTxt) + /// get the hot reload script if running in watch mode let getLatestWatchScript () = if watch then @@ -1949,6 +1997,7 @@ type CoreBuildOptions(watch) = // bespoke file for namespaces etc. let ok1 = ok1 && runDocContentPhase2 () regenerateSearchIndex () + generateLlmsTxt () ok1 && ok2 //----------------------------------------- @@ -2010,7 +2059,8 @@ type CoreBuildOptions(watch) = if runDocContentPhase1 () then if runDocContentPhase2 () then - regenerateSearchIndex ()) + regenerateSearchIndex () + generateLlmsTxt ()) Serve.refreshEvent.Trigger fileName } @@ -2031,7 +2081,8 @@ type CoreBuildOptions(watch) = if runGeneratePhase1 () then if runGeneratePhase2 () then - regenerateSearchIndex ()) + regenerateSearchIndex () + generateLlmsTxt ()) Serve.refreshEvent.Trigger "full" } From 468ac35ad9a7ce1854c771450642007c81267dbe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 02:20:50 +0000 Subject: [PATCH 02/15] Rename --generatellmstxt flag to --llms Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- RELEASE_NOTES.md | 2 +- src/fsdocs-tool/BuildCommand.fs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2ce6f2e9b..fcea800b5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,7 +3,7 @@ ## [Unreleased] ### Added -* Add `--generatellmstxt` flag to generate `llms.txt` and `llms-full.txt` for LLM consumption. [#951](https://github.com/fsprojects/FSharp.Formatting/issues/951) +* Add `--llms` flag to generate `llms.txt` and `llms-full.txt` for LLM consumption. [#951](https://github.com/fsprojects/FSharp.Formatting/issues/951) ### Changed * Update FCS to 43.10.100. [#935](https://github.com/fsprojects/FSharp.Formatting/pull/966) diff --git a/src/fsdocs-tool/BuildCommand.fs b/src/fsdocs-tool/BuildCommand.fs index 53366aae4..db6dd550d 100644 --- a/src/fsdocs-tool/BuildCommand.fs +++ b/src/fsdocs-tool/BuildCommand.fs @@ -1385,11 +1385,11 @@ type CoreBuildOptions(watch) = [] member val clean = false with get, set - [] - member val generatellmstxt = false with get, set + member val llms = false with get, set member this.Execute() = @@ -1682,7 +1682,7 @@ type CoreBuildOptions(watch) = File.WriteAllText(Path.Combine(rootOutputFolderAsGiven, "index.json"), indxTxt) let generateLlmsTxt () = - if this.generatellmstxt then + if this.llms then let index = Array.append latestApiDocSearchIndexEntries latestDocContentSearchIndexEntries let contentEntries = index |> Array.filter (fun e -> e.``type`` = "content") From a8c94e0dde2c210443ae57dca56cdf9de5e6a41f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 02:33:53 +0000 Subject: [PATCH 03/15] Add tests for --llms flag (LlmsTxt.buildContent) Extract llms.txt generation logic into LlmsTxt module and add 8 unit tests covering: header generation, empty entries, Docs/API Reference section separation, index-only vs full-content modes, blank content skipping, and conditional section omission. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/fsdocs-tool/BuildCommand.fs | 78 ++++++++++--------- .../FSharp.Literate.Tests/DocContentTests.fs | 78 +++++++++++++++++++ 2 files changed, 120 insertions(+), 36 deletions(-) diff --git a/src/fsdocs-tool/BuildCommand.fs b/src/fsdocs-tool/BuildCommand.fs index db6dd550d..f97a6ef02 100644 --- a/src/fsdocs-tool/BuildCommand.fs +++ b/src/fsdocs-tool/BuildCommand.fs @@ -1287,6 +1287,47 @@ module Serve = startWebServerAsync serverConfig app |> snd |> Async.Start +/// Helpers for generating llms.txt and llms-full.txt content. +module internal LlmsTxt = + + /// Build a section of llms.txt from a set of search index entries. + /// When withContent is true, entry content is appended after each link. + let buildSection sectionTitle (entries: ApiDocsSearchIndexEntry array) withContent = + if entries.Length = 0 then + "" + else + let sb = System.Text.StringBuilder() + sb.Append(sprintf "## %s\n\n" sectionTitle) |> ignore + + for e in entries do + sb.Append(sprintf "- [%s](%s)\n" e.title e.uri) |> ignore + + if withContent && not (System.String.IsNullOrWhiteSpace(e.content)) then + sb.Append("\n") |> ignore + sb.Append(e.content) |> ignore + sb.Append("\n\n") |> ignore + + sb.ToString() + + /// Generate the text content of llms.txt (index) and llms-full.txt (with content). + /// Returns a tuple of (llms.txt content, llms-full.txt content). + let buildContent (collectionName: string) (entries: ApiDocsSearchIndexEntry array) = + let contentEntries = entries |> Array.filter (fun e -> e.``type`` = "content") + let apiEntries = entries |> Array.filter (fun e -> e.``type`` = "apiDocs") + let header = sprintf "# %s\n\n" collectionName + + let llmsTxt = + header + + buildSection "Docs" contentEntries false + + buildSection "API Reference" apiEntries false + + let llmsFullTxt = + header + + buildSection "Docs" contentEntries true + + buildSection "API Reference" apiEntries true + + llmsTxt, llmsFullTxt + type CoreBuildOptions(watch) = [] @@ -1684,43 +1725,8 @@ type CoreBuildOptions(watch) = let generateLlmsTxt () = if this.llms then let index = Array.append latestApiDocSearchIndexEntries latestDocContentSearchIndexEntries - - let contentEntries = index |> Array.filter (fun e -> e.``type`` = "content") - let apiEntries = index |> Array.filter (fun e -> e.``type`` = "apiDocs") - - let header = sprintf "# %s\n\n" collectionName - - let buildSection sectionTitle (entries: ApiDocsSearchIndexEntry array) withContent = - if entries.Length = 0 then - "" - else - let sb = System.Text.StringBuilder() - sb.Append(sprintf "## %s\n\n" sectionTitle) |> ignore - - for e in entries do - sb.Append(sprintf "- [%s](%s)\n" e.title e.uri) |> ignore - - if withContent && not (System.String.IsNullOrWhiteSpace(e.content)) then - sb.Append("\n") |> ignore - sb.Append(e.content) |> ignore - sb.Append("\n\n") |> ignore - - sb.ToString() - - // llms.txt — index only (titles and links) - let llmsTxt = - header - + buildSection "Docs" contentEntries false - + buildSection "API Reference" apiEntries false - + let llmsTxt, llmsFullTxt = LlmsTxt.buildContent collectionName index File.WriteAllText(Path.Combine(rootOutputFolderAsGiven, "llms.txt"), llmsTxt) - - // llms-full.txt — full content - let llmsFullTxt = - header - + buildSection "Docs" contentEntries true - + buildSection "API Reference" apiEntries true - File.WriteAllText(Path.Combine(rootOutputFolderAsGiven, "llms-full.txt"), llmsFullTxt) /// get the hot reload script if running in watch mode diff --git a/tests/FSharp.Literate.Tests/DocContentTests.fs b/tests/FSharp.Literate.Tests/DocContentTests.fs index fac1e244e..3c7d30f18 100644 --- a/tests/FSharp.Literate.Tests/DocContentTests.fs +++ b/tests/FSharp.Literate.Tests/DocContentTests.fs @@ -336,3 +336,81 @@ let ``ipynb notebook evaluates`` () = ipynbOut |> shouldContainText "10007" *) + +// -------------------------------------------------------------------------------------- +// Tests for LlmsTxt module (--llms flag) +// -------------------------------------------------------------------------------------- + +open FSharp.Formatting.ApiDocs + +let makeEntry t title uri content = + { uri = uri + title = title + content = content + headings = [] + ``type`` = t } + +[] +let ``LlmsTxt buildContent produces correct header`` () = + let llmsTxt, llmsFullTxt = LlmsTxt.buildContent "MyProject" [||] + llmsTxt |> shouldContainText "# MyProject\n\n" + llmsFullTxt |> shouldContainText "# MyProject\n\n" + +[] +let ``LlmsTxt buildContent with no entries produces header only`` () = + let llmsTxt, llmsFullTxt = LlmsTxt.buildContent "MyProject" [||] + llmsTxt |> shouldEqual "# MyProject\n\n" + llmsFullTxt |> shouldEqual "# MyProject\n\n" + +[] +let ``LlmsTxt buildContent separates Docs and API Reference sections`` () = + let entries = + [| makeEntry "content" "Getting Started" "https://example.com/docs/getting-started" "Some intro text" + makeEntry "apiDocs" "MyModule.MyType" "https://example.com/reference/mytype" "Type docs" |] + + let llmsTxt, _ = LlmsTxt.buildContent "MyProject" entries + llmsTxt |> shouldContainText "## Docs" + llmsTxt |> shouldContainText "## API Reference" + llmsTxt |> shouldContainText "- [Getting Started](https://example.com/docs/getting-started)" + llmsTxt |> shouldContainText "- [MyModule.MyType](https://example.com/reference/mytype)" + +[] +let ``LlmsTxt llms.txt does not include content body`` () = + let entries = + [| makeEntry "content" "Getting Started" "https://example.com/docs/getting-started" "Detailed page content here" |] + + let llmsTxt, _ = LlmsTxt.buildContent "MyProject" entries + llmsTxt |> shouldNotContainText "Detailed page content here" + +[] +let ``LlmsTxt llms-full.txt includes content body`` () = + let entries = + [| makeEntry "content" "Getting Started" "https://example.com/docs/getting-started" "Detailed page content here" |] + + let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries + llmsFullTxt |> shouldContainText "Detailed page content here" + +[] +let ``LlmsTxt llms-full.txt skips blank content`` () = + let entries = + [| makeEntry "apiDocs" "MyModule" "https://example.com/reference/mymodule" " " |] + + let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries + // The entry link must appear, but no content after it + llmsFullTxt |> shouldContainText "- [MyModule](https://example.com/reference/mymodule)" + // Blank content should not produce extra blank lines beyond the link line + llmsFullTxt.Contains(" ") |> shouldEqual false + +[] +let ``LlmsTxt omits Docs section when no content entries exist`` () = + let entries = [| makeEntry "apiDocs" "MyModule" "https://example.com/reference/mymodule" "" |] + let llmsTxt, _ = LlmsTxt.buildContent "MyProject" entries + llmsTxt |> shouldNotContainText "## Docs" + llmsTxt |> shouldContainText "## API Reference" + +[] +let ``LlmsTxt omits API Reference section when no apiDocs entries exist`` () = + let entries = [| makeEntry "content" "Guide" "https://example.com/docs/guide" "" |] + let llmsTxt, _ = LlmsTxt.buildContent "MyProject" entries + llmsTxt |> shouldContainText "## Docs" + llmsTxt |> shouldNotContainText "## API Reference" From 761d83b1c203594a10cdcd4d3788652b13a52a6c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 14:59:35 +0000 Subject: [PATCH 04/15] Replace --llms CLI flag with FsDocsGenerateLlmsTxt MSBuild property (enabled by default) Instead of requiring users to pass --llms on every fsdocs build/watch invocation, read the new MSBuild property from the project file or Directory.Build.props. The property defaults to true, so llms.txt and llms-full.txt are generated automatically. Projects that want to opt out can set false. Closes #951 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- RELEASE_NOTES.md | 2 +- docs/styling.md | 15 +++++++++++++++ src/fsdocs-tool/BuildCommand.fs | 10 ++-------- src/fsdocs-tool/ProjectCracker.fs | 6 +++++- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index fcea800b5..c00da6324 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,7 +3,7 @@ ## [Unreleased] ### Added -* Add `--llms` flag to generate `llms.txt` and `llms-full.txt` for LLM consumption. [#951](https://github.com/fsprojects/FSharp.Formatting/issues/951) +* Generate `llms.txt` and `llms-full.txt` for LLM consumption by default; opt out via `false` in your project file. [#951](https://github.com/fsprojects/FSharp.Formatting/issues/951) ### Changed * Update FCS to 43.10.100. [#935](https://github.com/fsprojects/FSharp.Formatting/pull/966) diff --git a/docs/styling.md b/docs/styling.md index b24f1961c..c134a8dd3 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -75,6 +75,21 @@ For example: ``` +## LLM-Friendly Output + +By default, `fsdocs build` generates `llms.txt` and `llms-full.txt` in the output root, +following the [llmstxt.org](https://llmstxt.org/) convention. These files provide a structured +index (and full content) of all documentation pages and API reference entries, making it easy +to add documentation context to LLMs and AI coding assistants. + +To opt out, set the following property in your project file or `Directory.Build.props`: + +```xml + + false + +``` + As an example, here is [a page with alternative styling](templates/leftside/styling.html). ## Customizing via CSS diff --git a/src/fsdocs-tool/BuildCommand.fs b/src/fsdocs-tool/BuildCommand.fs index f97a6ef02..5eeab9512 100644 --- a/src/fsdocs-tool/BuildCommand.fs +++ b/src/fsdocs-tool/BuildCommand.fs @@ -1426,12 +1426,6 @@ type CoreBuildOptions(watch) = [] member val clean = false with get, set - [] - member val llms = false with get, set - member this.Execute() = let onError msg = @@ -1491,7 +1485,7 @@ type CoreBuildOptions(watch) = // See https://github.com/ionide/proj-info/issues/123 let prevDotnetHostPath = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") - let (root, collectionName, crackedProjects, paths, docsSubstitutions), _key = + let (root, collectionName, crackedProjects, paths, docsSubstitutions, generateLlmsTxt), _key = let projects = Seq.toList this.projects let cacheFile = ".fsdocs/cache" @@ -1723,7 +1717,7 @@ type CoreBuildOptions(watch) = File.WriteAllText(Path.Combine(rootOutputFolderAsGiven, "index.json"), indxTxt) let generateLlmsTxt () = - if this.llms then + if generateLlmsTxt then let index = Array.append latestApiDocSearchIndexEntries latestDocContentSearchIndexEntries let llmsTxt, llmsFullTxt = LlmsTxt.buildContent collectionName index File.WriteAllText(Path.Combine(rootOutputFolderAsGiven, "llms.txt"), llmsTxt) diff --git a/src/fsdocs-tool/ProjectCracker.fs b/src/fsdocs-tool/ProjectCracker.fs index 65821b483..675b13f32 100644 --- a/src/fsdocs-tool/ProjectCracker.fs +++ b/src/fsdocs-tool/ProjectCracker.fs @@ -228,6 +228,7 @@ module Crack = FsDocsFaviconSource: string option FsDocsTheme: string option FsDocsWarnOnMissingDocs: bool + FsDocsGenerateLlmsTxt: bool PackageProjectUrl: string option Authors: string option GenerateDocumentationFile: bool @@ -259,6 +260,7 @@ module Crack = "FsDocsSourceFolder" "FsDocsSourceRepository" "FsDocsWarnOnMissingDocs" + "FsDocsGenerateLlmsTxt" "RepositoryType" "RepositoryBranch" "PackageProjectUrl" @@ -343,6 +345,7 @@ module Crack = FsDocsFaviconSource = msbuildPropString "FsDocsFaviconSource" FsDocsTheme = msbuildPropString "FsDocsTheme" FsDocsWarnOnMissingDocs = msbuildPropBool "FsDocsWarnOnMissingDocs" |> Option.defaultValue false + FsDocsGenerateLlmsTxt = msbuildPropBool "FsDocsGenerateLlmsTxt" |> Option.defaultValue true UsesMarkdownComments = msbuildPropBool "UsesMarkdownComments" |> Option.defaultValue false PackageProjectUrl = msbuildPropString "PackageProjectUrl" Authors = msbuildPropString "Authors" @@ -581,6 +584,7 @@ module Crack = |> fallbackFromDirectoryProps "//RepositoryUrl" FsDocsTheme = projectInfos |> List.tryPick (fun info -> info.FsDocsTheme) FsDocsWarnOnMissingDocs = false + FsDocsGenerateLlmsTxt = projectInfos |> List.forall (fun i -> i.FsDocsGenerateLlmsTxt) PackageProjectUrl = projectInfos |> List.tryPick (fun info -> info.PackageProjectUrl) @@ -679,4 +683,4 @@ module Crack = |> List.choose (fun projectInfo -> projectInfo.TargetPath |> Option.map Path.GetDirectoryName) let docsParameters = parametersForProjectInfo projectInfoForDocs - root, collectionName, crackedProjects, paths, docsParameters + root, collectionName, crackedProjects, paths, docsParameters, projectInfoForDocs.FsDocsGenerateLlmsTxt From 404920466c74e18cdc35916ade5cd3171bfc2d0f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 15:33:17 +0000 Subject: [PATCH 05/15] Update test comment to reflect FsDocsGenerateLlmsTxt MSBuild property (on by default) The LlmsTxt tests were added when this was a CLI flag. Update the section comment to accurately describe the current implementation as an MSBuild property that is enabled by default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/FSharp.Literate.Tests/DocContentTests.fs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/FSharp.Literate.Tests/DocContentTests.fs b/tests/FSharp.Literate.Tests/DocContentTests.fs index 3c7d30f18..a7603fcf0 100644 --- a/tests/FSharp.Literate.Tests/DocContentTests.fs +++ b/tests/FSharp.Literate.Tests/DocContentTests.fs @@ -338,7 +338,7 @@ let ``ipynb notebook evaluates`` () = *) // -------------------------------------------------------------------------------------- -// Tests for LlmsTxt module (--llms flag) +// Tests for LlmsTxt module (FsDocsGenerateLlmsTxt MSBuild property, on by default) // -------------------------------------------------------------------------------------- open FSharp.Formatting.ApiDocs @@ -371,8 +371,12 @@ let ``LlmsTxt buildContent separates Docs and API Reference sections`` () = let llmsTxt, _ = LlmsTxt.buildContent "MyProject" entries llmsTxt |> shouldContainText "## Docs" llmsTxt |> shouldContainText "## API Reference" - llmsTxt |> shouldContainText "- [Getting Started](https://example.com/docs/getting-started)" - llmsTxt |> shouldContainText "- [MyModule.MyType](https://example.com/reference/mytype)" + + llmsTxt + |> shouldContainText "- [Getting Started](https://example.com/docs/getting-started)" + + llmsTxt + |> shouldContainText "- [MyModule.MyType](https://example.com/reference/mytype)" [] let ``LlmsTxt llms.txt does not include content body`` () = @@ -392,12 +396,12 @@ let ``LlmsTxt llms-full.txt includes content body`` () = [] let ``LlmsTxt llms-full.txt skips blank content`` () = - let entries = - [| makeEntry "apiDocs" "MyModule" "https://example.com/reference/mymodule" " " |] + let entries = [| makeEntry "apiDocs" "MyModule" "https://example.com/reference/mymodule" " " |] let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries // The entry link must appear, but no content after it - llmsFullTxt |> shouldContainText "- [MyModule](https://example.com/reference/mymodule)" + llmsFullTxt + |> shouldContainText "- [MyModule](https://example.com/reference/mymodule)" // Blank content should not produce extra blank lines beyond the link line llmsFullTxt.Contains(" ") |> shouldEqual false From 3e2b51eb6f5e42018caeca9eb357efebfb7a3095 Mon Sep 17 00:00:00 2001 From: nojaf Date: Mon, 23 Feb 2026 16:55:37 +0100 Subject: [PATCH 06/15] Update AGENTS.md with pointer to test locally. --- AGENTS.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index fe7459a14..a7ae35667 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,3 +46,15 @@ dotnet restore FSharp.Formatting.sln dotnet build FSharp.Formatting.sln --configuration Release dotnet test FSharp.Formatting.sln --configuration Release --no-build ``` + +## Testing Locally Against Another Project + +After building the repo with `dotnet build`, run the tool directly from the build output in your project's directory: + +```bash +# macOS / Linux +/path/to/FSharp.Formatting/src/fsdocs-tool/bin/Debug/net10.0/fsdocs build + +# Windows +\path\to\FSharp.Formatting\src\fsdocs-tool\bin\Debug\net10.0\fsdocs.exe build +``` From 4d3abb85fa10d4a8b65c8077f25832bb9a4647b8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 16:12:49 +0000 Subject: [PATCH 07/15] Improve llms.txt and llms-full.txt output quality - Decode HTML entities (e.g. " -> ", > -> >) in content written to llms-full.txt - Strip FSharp.Formatting --eval warning lines from content - Trim API Reference index in llms.txt to type/module level only (exclude per-member entries with '#' anchors) - Use heading format (### [title](url)) per entry in llms-full.txt for better navigation structure - Add tests for all new behaviours Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- RELEASE_NOTES.md | 3 + src/fsdocs-tool/BuildCommand.fs | 32 +++++++-- .../FSharp.Literate.Tests/DocContentTests.fs | 70 ++++++++++++++++++- 3 files changed, 95 insertions(+), 10 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 96185fc8f..8054ba0c9 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -4,6 +4,9 @@ ### Added * Generate `llms.txt` and `llms-full.txt` for LLM consumption by default; opt out via `false` in your project file. [#951](https://github.com/fsprojects/FSharp.Formatting/issues/951) +* `llms-full.txt` now uses heading-per-entry format (`### [title](url)`) for better navigation structure. [#980](https://github.com/fsprojects/FSharp.Formatting/pull/980) +* `llms.txt` index omits per-member API entries (individual properties/methods) to reduce size; `llms-full.txt` retains full member detail. [#980](https://github.com/fsprojects/FSharp.Formatting/pull/980) +* `llms-full.txt` content now has HTML entities decoded and `--eval` warning lines stripped for cleaner LLM consumption. [#980](https://github.com/fsprojects/FSharp.Formatting/pull/980) ### Fixed * Improve error message when a named code snippet is not found (e.g. `(*** include:name ***)` with undefined name now reports the missing name clearly). [#982](https://github.com/fsprojects/FSharp.Formatting/pull/982) diff --git a/src/fsdocs-tool/BuildCommand.fs b/src/fsdocs-tool/BuildCommand.fs index 5eeab9512..25aca4bfe 100644 --- a/src/fsdocs-tool/BuildCommand.fs +++ b/src/fsdocs-tool/BuildCommand.fs @@ -1290,8 +1290,22 @@ module Serve = /// Helpers for generating llms.txt and llms-full.txt content. module internal LlmsTxt = + /// Decode HTML entities (e.g. " → ", > → >) in a string. + let private decodeHtml (s: string) = System.Net.WebUtility.HtmlDecode(s) + + /// Strip FSharp.Formatting --eval warning lines from content. + let private stripEvalWarnings (s: string) = + s.Split('\n') + |> Array.filter (fun line -> + not (line.TrimStart().StartsWith("Warning: Output, it-value and value references require --eval"))) + |> String.concat "\n" + + /// Decode HTML entities and remove --eval noise from content. + let private cleanContent (s: string) = s |> decodeHtml |> stripEvalWarnings + /// Build a section of llms.txt from a set of search index entries. - /// When withContent is true, entry content is appended after each link. + /// When withContent is true, entry content is appended under a heading per entry. + /// When false, entries are listed as bullet-point links (index format). let buildSection sectionTitle (entries: ApiDocsSearchIndexEntry array) withContent = if entries.Length = 0 then "" @@ -1300,12 +1314,14 @@ module internal LlmsTxt = sb.Append(sprintf "## %s\n\n" sectionTitle) |> ignore for e in entries do - sb.Append(sprintf "- [%s](%s)\n" e.title e.uri) |> ignore + if withContent then + sb.Append(sprintf "### [%s](%s)\n\n" e.title e.uri) |> ignore - if withContent && not (System.String.IsNullOrWhiteSpace(e.content)) then - sb.Append("\n") |> ignore - sb.Append(e.content) |> ignore - sb.Append("\n\n") |> ignore + if not (System.String.IsNullOrWhiteSpace(e.content)) then + sb.Append(cleanContent e.content) |> ignore + sb.Append("\n\n") |> ignore + else + sb.Append(sprintf "- [%s](%s)\n" e.title e.uri) |> ignore sb.ToString() @@ -1314,12 +1330,14 @@ module internal LlmsTxt = let buildContent (collectionName: string) (entries: ApiDocsSearchIndexEntry array) = let contentEntries = entries |> Array.filter (fun e -> e.``type`` = "content") let apiEntries = entries |> Array.filter (fun e -> e.``type`` = "apiDocs") + // For the index, exclude per-member entries (identified by a '#' anchor in the URI). + let apiIndexEntries = apiEntries |> Array.filter (fun e -> not (e.uri.Contains("#"))) let header = sprintf "# %s\n\n" collectionName let llmsTxt = header + buildSection "Docs" contentEntries false - + buildSection "API Reference" apiEntries false + + buildSection "API Reference" apiIndexEntries false let llmsFullTxt = header diff --git a/tests/FSharp.Literate.Tests/DocContentTests.fs b/tests/FSharp.Literate.Tests/DocContentTests.fs index a7603fcf0..ce632a8b8 100644 --- a/tests/FSharp.Literate.Tests/DocContentTests.fs +++ b/tests/FSharp.Literate.Tests/DocContentTests.fs @@ -399,10 +399,10 @@ let ``LlmsTxt llms-full.txt skips blank content`` () = let entries = [| makeEntry "apiDocs" "MyModule" "https://example.com/reference/mymodule" " " |] let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries - // The entry link must appear, but no content after it + // Full file uses heading format per entry llmsFullTxt - |> shouldContainText "- [MyModule](https://example.com/reference/mymodule)" - // Blank content should not produce extra blank lines beyond the link line + |> shouldContainText "### [MyModule](https://example.com/reference/mymodule)" + // Blank content should not produce extra blank lines beyond the heading line llmsFullTxt.Contains(" ") |> shouldEqual false [] @@ -418,3 +418,67 @@ let ``LlmsTxt omits API Reference section when no apiDocs entries exist`` () = let llmsTxt, _ = LlmsTxt.buildContent "MyProject" entries llmsTxt |> shouldContainText "## Docs" llmsTxt |> shouldNotContainText "## API Reference" + +[] +let ``LlmsTxt llms-full.txt uses heading format per entry`` () = + let entries = [| makeEntry "content" "Getting Started" "https://example.com/docs/getting-started" "Some content" |] + + let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries + + llmsFullTxt + |> shouldContainText "### [Getting Started](https://example.com/docs/getting-started)" + + llmsFullTxt |> shouldNotContainText "- [Getting Started]" + +[] +let ``LlmsTxt llms-full.txt decodes HTML entities in content`` () = + let entries = + [| makeEntry + "content" + "Guide" + "https://example.com/docs/guide" + "use "double quotes" and > greater-than" |] + + let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries + llmsFullTxt |> shouldContainText "use \"double quotes\" and > greater-than" + llmsFullTxt |> shouldNotContainText """ + +[] +let ``LlmsTxt llms-full.txt strips eval warning lines from content`` () = + let content = "Some text\nWarning: Output, it-value and value references require --eval\nMore text" + + let entries = [| makeEntry "content" "Guide" "https://example.com/docs/guide" content |] + let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries + llmsFullTxt |> shouldNotContainText "--eval" + llmsFullTxt |> shouldContainText "Some text" + llmsFullTxt |> shouldContainText "More text" + +[] +let ``LlmsTxt llms.txt excludes per-member API entries (URIs with hash)`` () = + let entries = + [| makeEntry "apiDocs" "MyModule" "https://example.com/reference/mymodule.html" "module docs" + makeEntry + "apiDocs" + "MyModule.myFunction" + "https://example.com/reference/mymodule.html#myFunction" + "member docs" |] + + let llmsTxt, _ = LlmsTxt.buildContent "MyProject" entries + + llmsTxt + |> shouldContainText "- [MyModule](https://example.com/reference/mymodule.html)" + + llmsTxt |> shouldNotContainText "myFunction" + +[] +let ``LlmsTxt llms-full.txt includes per-member API entries`` () = + let entries = + [| makeEntry "apiDocs" "MyModule" "https://example.com/reference/mymodule.html" "module docs" + makeEntry + "apiDocs" + "MyModule.myFunction" + "https://example.com/reference/mymodule.html#myFunction" + "member docs" |] + + let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries + llmsFullTxt |> shouldContainText "myFunction" From 348d8ca372c236790803fc157eea3ede4b6d2b7e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:56:48 +0000 Subject: [PATCH 08/15] Fix llms-full.txt: normalise multi-line titles and collapse excessive blank lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trim and collapse internal whitespace in entry titles so link text is always on a single line (e.g. 'Fantomas\n' → 'Fantomas') - Collapse 3+ consecutive newlines to at most 2 in cleaned content, preventing large gaps between sections in llms-full.txt - Trim leading/trailing whitespace from cleaned content - Add two new tests covering both behaviours Addresses feedback from @nojaf on PR #980. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/fsdocs-tool/BuildCommand.fs | 21 +++++++++++++--- .../FSharp.Literate.Tests/DocContentTests.fs | 24 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/fsdocs-tool/BuildCommand.fs b/src/fsdocs-tool/BuildCommand.fs index 25aca4bfe..039fac342 100644 --- a/src/fsdocs-tool/BuildCommand.fs +++ b/src/fsdocs-tool/BuildCommand.fs @@ -1300,8 +1300,21 @@ module internal LlmsTxt = not (line.TrimStart().StartsWith("Warning: Output, it-value and value references require --eval"))) |> String.concat "\n" + /// Collapse three or more consecutive newlines into at most two. + let private collapseBlankLines (s: string) = + System.Text.RegularExpressions.Regex.Replace(s, @"\n{3,}", "\n\n") + + /// Normalise a title: trim and collapse internal whitespace/newlines to a single space. + let private normaliseTitle (s: string) = + System.Text.RegularExpressions.Regex.Replace(s.Trim(), @"\s+", " ") + /// Decode HTML entities and remove --eval noise from content. - let private cleanContent (s: string) = s |> decodeHtml |> stripEvalWarnings + let private cleanContent (s: string) = + s + |> decodeHtml + |> stripEvalWarnings + |> collapseBlankLines + |> fun t -> t.Trim() /// Build a section of llms.txt from a set of search index entries. /// When withContent is true, entry content is appended under a heading per entry. @@ -1314,14 +1327,16 @@ module internal LlmsTxt = sb.Append(sprintf "## %s\n\n" sectionTitle) |> ignore for e in entries do + let title = normaliseTitle e.title + if withContent then - sb.Append(sprintf "### [%s](%s)\n\n" e.title e.uri) |> ignore + sb.Append(sprintf "### [%s](%s)\n\n" title e.uri) |> ignore if not (System.String.IsNullOrWhiteSpace(e.content)) then sb.Append(cleanContent e.content) |> ignore sb.Append("\n\n") |> ignore else - sb.Append(sprintf "- [%s](%s)\n" e.title e.uri) |> ignore + sb.Append(sprintf "- [%s](%s)\n" title e.uri) |> ignore sb.ToString() diff --git a/tests/FSharp.Literate.Tests/DocContentTests.fs b/tests/FSharp.Literate.Tests/DocContentTests.fs index ce632a8b8..32b5e0b2b 100644 --- a/tests/FSharp.Literate.Tests/DocContentTests.fs +++ b/tests/FSharp.Literate.Tests/DocContentTests.fs @@ -482,3 +482,27 @@ let ``LlmsTxt llms-full.txt includes per-member API entries`` () = let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries llmsFullTxt |> shouldContainText "myFunction" + +[] +let ``LlmsTxt normalises multi-line titles to single-line`` () = + let entries = [| makeEntry "content" "Fantomas\n" "https://example.com/docs/index.html" "Some content" |] + + let llmsTxt, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries + // Title must be on a single line — no embedded newline in the link text + llmsTxt |> shouldContainText "- [Fantomas](https://example.com/docs/index.html)" + + llmsFullTxt + |> shouldContainText "### [Fantomas](https://example.com/docs/index.html)" + + llmsTxt |> shouldNotContainText "Fantomas\n" + +[] +let ``LlmsTxt collapses excessive blank lines in content`` () = + let content = "First paragraph\n\n\n\n\nSecond paragraph" + + let entries = [| makeEntry "content" "Guide" "https://example.com/docs/guide" content |] + let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries + // Should not contain 3 or more consecutive newlines + llmsFullTxt.Contains("\n\n\n") |> shouldEqual false + llmsFullTxt |> shouldContainText "First paragraph" + llmsFullTxt |> shouldContainText "Second paragraph" From 882ef162a620caceeda8bf3c291d9f9d8908f2b2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 08:25:26 +0000 Subject: [PATCH 09/15] Link llms.txt to .md files; generate markdown by default when FsDocsGenerateLlmsTxt is enabled - When FsDocsGenerateLlmsTxt is enabled (the default), prefer markdown templates for both API docs and doc content so that .md files are written alongside HTML output. - Bundle docs/_template.md as a package template so that markdown output is generated even when the user has not provided their own _template.md. - LlmsTxt.buildContent now accepts docContentUsesMarkdown and apiDocUsesMarkdown flags and transforms URIs accordingly: .html links become .md, and markdown API-doc URIs (InUrl='') get .md appended. - API docs template selection now prefers _template.md over _template.html when generateLlmsTxt is enabled. - DocContent.Convert accepts an optional defaultMdTemplate parameter that is passed as the initial markdown template to processFolder. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- RELEASE_NOTES.md | 1 + src/fsdocs-tool/BuildCommand.fs | 167 ++++++++++++++++++++++++----- src/fsdocs-tool/fsdocs-tool.fsproj | 1 + 3 files changed, 144 insertions(+), 25 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index b1cf66cb2..a35edcdf6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -7,6 +7,7 @@ * `llms-full.txt` now uses heading-per-entry format (`### [title](url)`) for better navigation structure. [#980](https://github.com/fsprojects/FSharp.Formatting/pull/980) * `llms.txt` index omits per-member API entries (individual properties/methods) to reduce size; `llms-full.txt` retains full member detail. [#980](https://github.com/fsprojects/FSharp.Formatting/pull/980) * `llms-full.txt` content now has HTML entities decoded and `--eval` warning lines stripped for cleaner LLM consumption. [#980](https://github.com/fsprojects/FSharp.Formatting/pull/980) +* When `FsDocsGenerateLlmsTxt` is enabled (the default), `llms.txt` and `llms-full.txt` link to `.md` files instead of `.html` when markdown output is available, as markdown is more useful for LLM consumption. A default `_template.md` is now bundled with the tool so that markdown output is generated automatically. [#980](https://github.com/fsprojects/FSharp.Formatting/pull/980) * Add `true` project file setting to include executable projects (OutputType=Exe/WinExe) in API documentation generation. [#918](https://github.com/fsprojects/FSharp.Formatting/issues/918) * Add `{{fsdocs-logo-alt}}` substitution (configurable via `` MSBuild property, defaults to `Logo`) for accessible alt text on the header logo image. [#626](https://github.com/fsprojects/FSharp.Formatting/issues/626) diff --git a/src/fsdocs-tool/BuildCommand.fs b/src/fsdocs-tool/BuildCommand.fs index 039fac342..523760d86 100644 --- a/src/fsdocs-tool/BuildCommand.fs +++ b/src/fsdocs-tool/BuildCommand.fs @@ -633,7 +633,7 @@ type internal DocContent (Path.Combine(outputFolderRelativeToRoot, subInputFolderName)) filesWithFrontMatter ] - member _.Convert(rootInputFolderAsGiven, htmlTemplate, extraInputs) = + member _.Convert(rootInputFolderAsGiven, htmlTemplate, extraInputs, ?defaultMdTemplate: string) = let inputDirectories = extraInputs @ [ (rootInputFolderAsGiven, ".") ] @@ -667,7 +667,14 @@ type internal DocContent [ for (rootInputFolderAsGiven, outputFolderRelativeToRoot) in inputDirectories do yield! processFolder - (htmlTemplate, None, None, None, None, false, Some rootInputFolderAsGiven, fullPathFileMap) + (htmlTemplate, + None, + None, + None, + defaultMdTemplate, + false, + Some rootInputFolderAsGiven, + fullPathFileMap) rootInputFolderAsGiven outputFolderRelativeToRoot filesWithFrontMatter ] @@ -1319,7 +1326,13 @@ module internal LlmsTxt = /// Build a section of llms.txt from a set of search index entries. /// When withContent is true, entry content is appended under a heading per entry. /// When false, entries are listed as bullet-point links (index format). - let buildSection sectionTitle (entries: ApiDocsSearchIndexEntry array) withContent = + /// uriTransform is applied to each entry's URI before rendering. + let buildSection + sectionTitle + (entries: ApiDocsSearchIndexEntry array) + withContent + (uriTransform: string -> string) + = if entries.Length = 0 then "" else @@ -1328,36 +1341,70 @@ module internal LlmsTxt = for e in entries do let title = normaliseTitle e.title + let uri = uriTransform e.uri if withContent then - sb.Append(sprintf "### [%s](%s)\n\n" title e.uri) |> ignore + sb.Append(sprintf "### [%s](%s)\n\n" title uri) |> ignore if not (System.String.IsNullOrWhiteSpace(e.content)) then sb.Append(cleanContent e.content) |> ignore sb.Append("\n\n") |> ignore else - sb.Append(sprintf "- [%s](%s)\n" title e.uri) |> ignore + sb.Append(sprintf "- [%s](%s)\n" title uri) |> ignore sb.ToString() + /// Returns a URI transformer that rewrites links to use .md when markdown output is available. + /// docContentUsesMarkdown – doc pages were generated with a _template.md. + /// apiDocUsesMarkdown – API reference was generated with GenerateMarkdownPhased + /// (URIs have no file extension; .md must be appended). + let buildUriTransform (docContentUsesMarkdown: bool) (apiDocUsesMarkdown: bool) (entryType: string) = + fun (uri: string) -> + match entryType with + | "content" when docContentUsesMarkdown -> + if uri.EndsWith(".html", System.StringComparison.OrdinalIgnoreCase) then + uri.[.. uri.Length - 6] + ".md" + else + uri + | "apiDocs" when apiDocUsesMarkdown -> + // In markdown mode InUrl="" so URIs have no extension; append .md. + // Strip any #anchor before appending, then re-attach it. + let hashIdx = uri.IndexOf('#') + + if hashIdx >= 0 then + uri.[.. hashIdx - 1] + ".md" + uri.[hashIdx..] + else + uri + ".md" + | _ -> uri + /// Generate the text content of llms.txt (index) and llms-full.txt (with content). /// Returns a tuple of (llms.txt content, llms-full.txt content). - let buildContent (collectionName: string) (entries: ApiDocsSearchIndexEntry array) = + /// When docContentUsesMarkdown is true, doc page links use .md extensions. + /// When apiDocUsesMarkdown is true, API reference links use .md extensions. + let buildContent + (collectionName: string) + (entries: ApiDocsSearchIndexEntry array) + (docContentUsesMarkdown: bool) + (apiDocUsesMarkdown: bool) + = let contentEntries = entries |> Array.filter (fun e -> e.``type`` = "content") let apiEntries = entries |> Array.filter (fun e -> e.``type`` = "apiDocs") // For the index, exclude per-member entries (identified by a '#' anchor in the URI). let apiIndexEntries = apiEntries |> Array.filter (fun e -> not (e.uri.Contains("#"))) let header = sprintf "# %s\n\n" collectionName + let contentTransform = buildUriTransform docContentUsesMarkdown apiDocUsesMarkdown "content" + let apiDocTransform = buildUriTransform docContentUsesMarkdown apiDocUsesMarkdown "apiDocs" + let llmsTxt = header - + buildSection "Docs" contentEntries false - + buildSection "API Reference" apiIndexEntries false + + buildSection "Docs" contentEntries false contentTransform + + buildSection "API Reference" apiIndexEntries false apiDocTransform let llmsFullTxt = header - + buildSection "Docs" contentEntries true - + buildSection "API Reference" apiEntries true + + buildSection "Docs" contentEntries true contentTransform + + buildSection "API Reference" apiEntries true apiDocTransform llmsTxt, llmsFullTxt @@ -1694,6 +1741,35 @@ type CoreBuildOptions(watch) = else None + // Default markdown template – used when generateLlmsTxt is enabled and no user _template.md exists. + // An empty (or minimal) _template.md causes the processor to emit just the document content, which + // is ideal for LLM consumption. + let defaultMdTemplateAttempt1 = + Path.GetFullPath(Path.Combine(dir, "..", "..", "..", "templates", "_template.md")) + + let defaultMdTemplateAttempt2 = + Path.GetFullPath(Path.Combine(dir, "..", "..", "..", "..", "..", "docs", "_template.md")) + + let defaultMdTemplate = + if this.nodefaultcontent then + None + else if + (try + File.Exists(defaultMdTemplateAttempt1) + with _ -> + false) + then + Some defaultMdTemplateAttempt1 + elif + (try + File.Exists(defaultMdTemplateAttempt2) + with _ -> + false) + then + Some defaultMdTemplateAttempt2 + else + None + let extraInputs = [ if not this.nodefaultcontent then // The "extras" content goes in "." @@ -1733,6 +1809,7 @@ type CoreBuildOptions(watch) = let mutable latestApiDocCodeReferenceResolver = (fun _ -> None) let mutable latestApiDocPhase2 = (fun _ -> ()) let mutable latestApiDocSearchIndexEntries = [||] + let mutable latestApiDocOutputKind = OutputKind.Html let mutable latestDocContentPhase2 = (fun _ -> ()) let mutable latestDocContentResults = Map.empty let mutable latestDocContentSearchIndexEntries = [||] @@ -1749,10 +1826,20 @@ type CoreBuildOptions(watch) = File.WriteAllText(Path.Combine(rootOutputFolderAsGiven, "index.json"), indxTxt) + // Capture the bool value before it is shadowed by the generateLlmsTxt function below. + let generateLlmsTxtEnabled = generateLlmsTxt + let generateLlmsTxt () = - if generateLlmsTxt then + if generateLlmsTxtEnabled then let index = Array.append latestApiDocSearchIndexEntries latestDocContentSearchIndexEntries - let llmsTxt, llmsFullTxt = LlmsTxt.buildContent collectionName index + // Doc content generates .md files alongside .html when _template.md is present. + let docContentUsesMarkdown = File.Exists(Path.Combine(this.input, "_template.md")) + + let apiDocUsesMarkdown = latestApiDocOutputKind = OutputKind.Markdown + + let llmsTxt, llmsFullTxt = + LlmsTxt.buildContent collectionName index docContentUsesMarkdown apiDocUsesMarkdown + File.WriteAllText(Path.Combine(rootOutputFolderAsGiven, "llms.txt"), llmsTxt) File.WriteAllText(Path.Combine(rootOutputFolderAsGiven, "llms-full.txt"), llmsFullTxt) @@ -1772,32 +1859,55 @@ type CoreBuildOptions(watch) = latestApiDocGlobalParameters <- [ ParamKeys.``fsdocs-list-of-namespaces``, "" ] elif crackedProjects.Length > 0 then let (outputKind, initialTemplate2) = + // When llms.txt generation is enabled, prefer a markdown template so that + // API reference pages are emitted as .md files and llms.txt can link to them. let templates = - [ OutputKind.Html, Path.Combine(this.input, "reference", "_template.html") - OutputKind.Html, Path.Combine(this.input, "_template.html") - OutputKind.Markdown, Path.Combine(this.input, "reference", "_template.md") - OutputKind.Markdown, Path.Combine(this.input, "_template.md") ] + if generateLlmsTxtEnabled then + [ OutputKind.Markdown, Path.Combine(this.input, "reference", "_template.md") + OutputKind.Markdown, Path.Combine(this.input, "_template.md") + OutputKind.Html, Path.Combine(this.input, "reference", "_template.html") + OutputKind.Html, Path.Combine(this.input, "_template.html") ] + else + [ OutputKind.Html, Path.Combine(this.input, "reference", "_template.html") + OutputKind.Html, Path.Combine(this.input, "_template.html") + OutputKind.Markdown, Path.Combine(this.input, "reference", "_template.md") + OutputKind.Markdown, Path.Combine(this.input, "_template.md") ] match templates |> List.tryFind (fun (_, path) -> path |> File.Exists) with | Some(kind, path) -> kind, Some path | None -> let templateFiles = templates |> Seq.map snd |> String.concat "', '" - match defaultTemplate with + // When llms.txt is enabled, prefer a markdown fallback template so that + // .md files are generated and linked from llms.txt. + let mdFallback = if generateLlmsTxtEnabled then defaultMdTemplate else None + + match mdFallback with | Some d -> printfn - "note, no template files: '%s' found, using default template %s" + "note, no template files: '%s' found, using default markdown template %s for llms.txt" templateFiles d - OutputKind.Html, Some d + OutputKind.Markdown, Some d | None -> - printfn - "note, no template file '%s' found, and no default template at '%s'" - templateFiles - defaultTemplateAttempt1 + match defaultTemplate with + | Some d -> + printfn + "note, no template files: '%s' found, using default template %s" + templateFiles + d - OutputKind.Html, None + OutputKind.Html, Some d + | None -> + printfn + "note, no template file '%s' found, and no default template at '%s'" + templateFiles + defaultTemplateAttempt1 + + OutputKind.Html, None + + latestApiDocOutputKind <- outputKind printfn "" printfn "API docs:" @@ -1890,7 +2000,14 @@ type CoreBuildOptions(watch) = onError ) - let docModels = docContent.Convert(this.input, defaultTemplate, extraInputs) + let docModels = + // When llms.txt generation is enabled, pass the default markdown template so that + // .md versions of content pages are written alongside .html files, enabling + // llms.txt to link to the more LLM-friendly markdown versions. + let mdTemplate = if generateLlmsTxtEnabled then defaultMdTemplate else None + + docContent.Convert(this.input, defaultTemplate, extraInputs, ?defaultMdTemplate = mdTemplate) + let actualDocModels = docModels |> List.map fst |> List.choose id let extrasForSearchIndex = docContent.GetSearchIndexEntries(actualDocModels) diff --git a/src/fsdocs-tool/fsdocs-tool.fsproj b/src/fsdocs-tool/fsdocs-tool.fsproj index 73d5540cd..de96bd556 100644 --- a/src/fsdocs-tool/fsdocs-tool.fsproj +++ b/src/fsdocs-tool/fsdocs-tool.fsproj @@ -25,6 +25,7 @@ + From 1cc32b4a1b7724c93cd95a6bf5c471118958b089 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 08:27:24 +0000 Subject: [PATCH 10/15] ci: trigger CI checks From c8d602b45c5b4a63ded0b160554e7065903d9573 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 14:04:47 +0000 Subject: [PATCH 11/15] Fix FsDocsGenerateLlmsTxt to generate markdown alongside HTML, not instead of it - Restore HTML-first template ordering for API docs (was incorrectly switched to markdown-first when FsDocsGenerateLlmsTxt was enabled, causing API docs to be emitted as .md instead of .html when _template.md existed) - Remove markdown fallback when no user templates found; use HTML default instead - Add warning when FsDocsGenerateLlmsTxt is enabled but no _template.md found, advising users to add one for better LLM-friendly output - Fix StringComparison.Ordinal in StartsWith call (code scanning alert) - Fix test compilation: update LlmsTxt.buildContent calls to pass required docContentUsesMarkdown and apiDocUsesMarkdown arguments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/fsdocs-tool/BuildCommand.fs | 64 +++++++++---------- .../FSharp.Literate.Tests/DocContentTests.fs | 30 ++++----- 2 files changed, 44 insertions(+), 50 deletions(-) diff --git a/src/fsdocs-tool/BuildCommand.fs b/src/fsdocs-tool/BuildCommand.fs index 87a06b76a..6e6fc9f8d 100644 --- a/src/fsdocs-tool/BuildCommand.fs +++ b/src/fsdocs-tool/BuildCommand.fs @@ -1314,7 +1314,14 @@ module internal LlmsTxt = let private stripEvalWarnings (s: string) = s.Split('\n') |> Array.filter (fun line -> - not (line.TrimStart().StartsWith("Warning: Output, it-value and value references require --eval"))) + not ( + line + .TrimStart() + .StartsWith( + "Warning: Output, it-value and value references require --eval", + System.StringComparison.Ordinal + ) + )) |> String.concat "\n" /// Collapse three or more consecutive newlines into at most two. @@ -1845,6 +1852,14 @@ type CoreBuildOptions(watch) = // Doc content generates .md files alongside .html when _template.md is present. let docContentUsesMarkdown = File.Exists(Path.Combine(this.input, "_template.md")) + if not docContentUsesMarkdown then + printfn "note: FsDocsGenerateLlmsTxt is enabled but no '_template.md' was found in '%s'." this.input + + printfn " Consider adding a '_template.md' to generate Markdown pages alongside HTML." + + printfn + " Markdown output is more suitable for LLM consumption (see https://fsprojects.github.io/FSharp.Formatting/styling.html)." + let apiDocUsesMarkdown = latestApiDocOutputKind = OutputKind.Markdown let llmsTxt, llmsFullTxt = @@ -1869,53 +1884,32 @@ type CoreBuildOptions(watch) = latestApiDocGlobalParameters <- [ ParamKeys.``fsdocs-list-of-namespaces``, "" ] elif crackedProjects.Length > 0 then let (outputKind, initialTemplate2) = - // When llms.txt generation is enabled, prefer a markdown template so that - // API reference pages are emitted as .md files and llms.txt can link to them. let templates = - if generateLlmsTxtEnabled then - [ OutputKind.Markdown, Path.Combine(this.input, "reference", "_template.md") - OutputKind.Markdown, Path.Combine(this.input, "_template.md") - OutputKind.Html, Path.Combine(this.input, "reference", "_template.html") - OutputKind.Html, Path.Combine(this.input, "_template.html") ] - else - [ OutputKind.Html, Path.Combine(this.input, "reference", "_template.html") - OutputKind.Html, Path.Combine(this.input, "_template.html") - OutputKind.Markdown, Path.Combine(this.input, "reference", "_template.md") - OutputKind.Markdown, Path.Combine(this.input, "_template.md") ] + [ OutputKind.Html, Path.Combine(this.input, "reference", "_template.html") + OutputKind.Html, Path.Combine(this.input, "_template.html") + OutputKind.Markdown, Path.Combine(this.input, "reference", "_template.md") + OutputKind.Markdown, Path.Combine(this.input, "_template.md") ] match templates |> List.tryFind (fun (_, path) -> path |> File.Exists) with | Some(kind, path) -> kind, Some path | None -> let templateFiles = templates |> Seq.map snd |> String.concat "', '" - // When llms.txt is enabled, prefer a markdown fallback template so that - // .md files are generated and linked from llms.txt. - let mdFallback = if generateLlmsTxtEnabled then defaultMdTemplate else None - - match mdFallback with + match defaultTemplate with | Some d -> printfn - "note, no template files: '%s' found, using default markdown template %s for llms.txt" + "note, no template files: '%s' found, using default template %s" templateFiles d - OutputKind.Markdown, Some d + OutputKind.Html, Some d | None -> - match defaultTemplate with - | Some d -> - printfn - "note, no template files: '%s' found, using default template %s" - templateFiles - d - - OutputKind.Html, Some d - | None -> - printfn - "note, no template file '%s' found, and no default template at '%s'" - templateFiles - defaultTemplateAttempt1 - - OutputKind.Html, None + printfn + "note, no template file '%s' found, and no default template at '%s'" + templateFiles + defaultTemplateAttempt1 + + OutputKind.Html, None latestApiDocOutputKind <- outputKind diff --git a/tests/FSharp.Literate.Tests/DocContentTests.fs b/tests/FSharp.Literate.Tests/DocContentTests.fs index 32b5e0b2b..8dbbe0509 100644 --- a/tests/FSharp.Literate.Tests/DocContentTests.fs +++ b/tests/FSharp.Literate.Tests/DocContentTests.fs @@ -352,13 +352,13 @@ let makeEntry t title uri content = [] let ``LlmsTxt buildContent produces correct header`` () = - let llmsTxt, llmsFullTxt = LlmsTxt.buildContent "MyProject" [||] + let llmsTxt, llmsFullTxt = LlmsTxt.buildContent "MyProject" [||] false false llmsTxt |> shouldContainText "# MyProject\n\n" llmsFullTxt |> shouldContainText "# MyProject\n\n" [] let ``LlmsTxt buildContent with no entries produces header only`` () = - let llmsTxt, llmsFullTxt = LlmsTxt.buildContent "MyProject" [||] + let llmsTxt, llmsFullTxt = LlmsTxt.buildContent "MyProject" [||] false false llmsTxt |> shouldEqual "# MyProject\n\n" llmsFullTxt |> shouldEqual "# MyProject\n\n" @@ -368,7 +368,7 @@ let ``LlmsTxt buildContent separates Docs and API Reference sections`` () = [| makeEntry "content" "Getting Started" "https://example.com/docs/getting-started" "Some intro text" makeEntry "apiDocs" "MyModule.MyType" "https://example.com/reference/mytype" "Type docs" |] - let llmsTxt, _ = LlmsTxt.buildContent "MyProject" entries + let llmsTxt, _ = LlmsTxt.buildContent "MyProject" entries false false llmsTxt |> shouldContainText "## Docs" llmsTxt |> shouldContainText "## API Reference" @@ -383,7 +383,7 @@ let ``LlmsTxt llms.txt does not include content body`` () = let entries = [| makeEntry "content" "Getting Started" "https://example.com/docs/getting-started" "Detailed page content here" |] - let llmsTxt, _ = LlmsTxt.buildContent "MyProject" entries + let llmsTxt, _ = LlmsTxt.buildContent "MyProject" entries false false llmsTxt |> shouldNotContainText "Detailed page content here" [] @@ -391,14 +391,14 @@ let ``LlmsTxt llms-full.txt includes content body`` () = let entries = [| makeEntry "content" "Getting Started" "https://example.com/docs/getting-started" "Detailed page content here" |] - let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries + let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries false false llmsFullTxt |> shouldContainText "Detailed page content here" [] let ``LlmsTxt llms-full.txt skips blank content`` () = let entries = [| makeEntry "apiDocs" "MyModule" "https://example.com/reference/mymodule" " " |] - let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries + let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries false false // Full file uses heading format per entry llmsFullTxt |> shouldContainText "### [MyModule](https://example.com/reference/mymodule)" @@ -408,14 +408,14 @@ let ``LlmsTxt llms-full.txt skips blank content`` () = [] let ``LlmsTxt omits Docs section when no content entries exist`` () = let entries = [| makeEntry "apiDocs" "MyModule" "https://example.com/reference/mymodule" "" |] - let llmsTxt, _ = LlmsTxt.buildContent "MyProject" entries + let llmsTxt, _ = LlmsTxt.buildContent "MyProject" entries false false llmsTxt |> shouldNotContainText "## Docs" llmsTxt |> shouldContainText "## API Reference" [] let ``LlmsTxt omits API Reference section when no apiDocs entries exist`` () = let entries = [| makeEntry "content" "Guide" "https://example.com/docs/guide" "" |] - let llmsTxt, _ = LlmsTxt.buildContent "MyProject" entries + let llmsTxt, _ = LlmsTxt.buildContent "MyProject" entries false false llmsTxt |> shouldContainText "## Docs" llmsTxt |> shouldNotContainText "## API Reference" @@ -423,7 +423,7 @@ let ``LlmsTxt omits API Reference section when no apiDocs entries exist`` () = let ``LlmsTxt llms-full.txt uses heading format per entry`` () = let entries = [| makeEntry "content" "Getting Started" "https://example.com/docs/getting-started" "Some content" |] - let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries + let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries false false llmsFullTxt |> shouldContainText "### [Getting Started](https://example.com/docs/getting-started)" @@ -439,7 +439,7 @@ let ``LlmsTxt llms-full.txt decodes HTML entities in content`` () = "https://example.com/docs/guide" "use "double quotes" and > greater-than" |] - let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries + let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries false false llmsFullTxt |> shouldContainText "use \"double quotes\" and > greater-than" llmsFullTxt |> shouldNotContainText """ @@ -448,7 +448,7 @@ let ``LlmsTxt llms-full.txt strips eval warning lines from content`` () = let content = "Some text\nWarning: Output, it-value and value references require --eval\nMore text" let entries = [| makeEntry "content" "Guide" "https://example.com/docs/guide" content |] - let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries + let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries false false llmsFullTxt |> shouldNotContainText "--eval" llmsFullTxt |> shouldContainText "Some text" llmsFullTxt |> shouldContainText "More text" @@ -463,7 +463,7 @@ let ``LlmsTxt llms.txt excludes per-member API entries (URIs with hash)`` () = "https://example.com/reference/mymodule.html#myFunction" "member docs" |] - let llmsTxt, _ = LlmsTxt.buildContent "MyProject" entries + let llmsTxt, _ = LlmsTxt.buildContent "MyProject" entries false false llmsTxt |> shouldContainText "- [MyModule](https://example.com/reference/mymodule.html)" @@ -480,14 +480,14 @@ let ``LlmsTxt llms-full.txt includes per-member API entries`` () = "https://example.com/reference/mymodule.html#myFunction" "member docs" |] - let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries + let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries false false llmsFullTxt |> shouldContainText "myFunction" [] let ``LlmsTxt normalises multi-line titles to single-line`` () = let entries = [| makeEntry "content" "Fantomas\n" "https://example.com/docs/index.html" "Some content" |] - let llmsTxt, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries + let llmsTxt, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries false false // Title must be on a single line — no embedded newline in the link text llmsTxt |> shouldContainText "- [Fantomas](https://example.com/docs/index.html)" @@ -501,7 +501,7 @@ let ``LlmsTxt collapses excessive blank lines in content`` () = let content = "First paragraph\n\n\n\n\nSecond paragraph" let entries = [| makeEntry "content" "Guide" "https://example.com/docs/guide" content |] - let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries + let _, llmsFullTxt = LlmsTxt.buildContent "MyProject" entries false false // Should not contain 3 or more consecutive newlines llmsFullTxt.Contains("\n\n\n") |> shouldEqual false llmsFullTxt |> shouldContainText "First paragraph" From c6e2c24363bce46d6fa37d762eb1bc16fb1710fb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 14:06:36 +0000 Subject: [PATCH 12/15] ci: trigger CI checks From 2cd794612b7653d38f1f25c353e7f9903e58edd1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 27 Feb 2026 07:50:28 +0000 Subject: [PATCH 13/15] Fix RELEASE_NOTES.md: consolidate llms.txt entries and remove duplicates Merge conflicts from multiple 'main' merges had created duplicate Added entries and two separate entries for the llms.txt feature. Consolidate into a single entry referencing both #951 and #980, and restore all other entries from main (IFsiEvaluator, project restore detection, etc.). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- RELEASE_NOTES.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 8c904e340..0a81814f2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -4,17 +4,16 @@ ### Added -* When `FsDocsGenerateLlmsTxt` is enabled (the default), `llms.txt` and `llms-full.txt` link to `.md` files instead of `.html` when markdown output is available, as markdown is more useful for LLM consumption. A default `_template.md` is now bundled with the tool so that markdown output is generated automatically. [#980](https://github.com/fsprojects/FSharp.Formatting/pull/980) -* Add "Copy" button to all code blocks in generated documentation, making it easy to copy code samples to the clipboard. [#72](https://github.com/fsprojects/FSharp.Formatting/issues/72) -* Add `true` project file setting to include executable projects (OutputType=Exe/WinExe) in API documentation generation. [#918](https://github.com/fsprojects/FSharp.Formatting/issues/918) -* Add `{{fsdocs-logo-alt}}` substitution (configurable via `` MSBuild property, defaults to `Logo`) for accessible alt text on the header logo image. [#626](https://github.com/fsprojects/FSharp.Formatting/issues/626) -* Generate `llms.txt` and `llms-full.txt` for LLM consumption by default; opt out via `false` in your project file. [#951](https://github.com/fsprojects/FSharp.Formatting/issues/951) +* Generate `llms.txt` and `llms-full.txt` for LLM consumption by default (opt out via `false`); links point to `.md` files when markdown output is available, and a default `_template.md` is bundled so markdown output is generated automatically. [#951](https://github.com/fsprojects/FSharp.Formatting/issues/951) [#980](https://github.com/fsprojects/FSharp.Formatting/pull/980) +* Add `///` documentation comments to all public types, modules and members, and succinct internal comments, as part of ongoing effort to document the codebase. [#1035](https://github.com/fsprojects/FSharp.Formatting/issues/1035) * Add "Copy" button to all code blocks in generated documentation, making it easy to copy code samples to the clipboard. [#72](https://github.com/fsprojects/FSharp.Formatting/issues/72) * Add `true` project file setting to include executable projects (OutputType=Exe/WinExe) in API documentation generation. [#918](https://github.com/fsprojects/FSharp.Formatting/issues/918) * Add `{{fsdocs-logo-alt}}` substitution (configurable via `` MSBuild property, defaults to `Logo`) for accessible alt text on the header logo image. [#626](https://github.com/fsprojects/FSharp.Formatting/issues/626) * Add `fsdocs init` command to scaffold a minimal `docs/index.md` (and optionally `_template.html`) for new projects. [#872](https://github.com/fsprojects/FSharp.Formatting/issues/872) +* `IFsiEvaluator` now inherits `IDisposable`; `FsiEvaluator` disposes its underlying FSI session when disposed, preventing session leaks in long-running processes. [#341](https://github.com/fsprojects/FSharp.Formatting/issues/341) ### Fixed +* Fix project restore detection for projects with nonstandard artifact locations (e.g. `` or the dotnet/fsharp repo layout): when the MSBuild call to locate `project.assets.json` fails, emit a warning and proceed instead of hard-failing. [#592](https://github.com/fsprojects/FSharp.Formatting/issues/592) * Fix doc generation failure for members with 5D/6D+ array parameters by correctly formatting array type signatures in XML doc format (e.g. `System.Double[0:,0:,0:,0:,0:]` for a 5D array). [#702](https://github.com/fsprojects/FSharp.Formatting/issues/702) * Fix `_menu_template.html` and `_menu-item_template.html` being copied to the output directory. [#803](https://github.com/fsprojects/FSharp.Formatting/issues/803) * Fix `ApiDocMember.Details.ReturnInfo.ReturnType` returning `None` for properties that have both a getter and a setter. [#734](https://github.com/fsprojects/FSharp.Formatting/issues/734) From 32017290c22973a6cfc8ebe02bccf3a9a792ffea Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 27 Feb 2026 07:51:51 +0000 Subject: [PATCH 14/15] ci: trigger CI checks From 33e102136d03feb69f6f56efa2b3ff5dfd266bef Mon Sep 17 00:00:00 2001 From: nojaf Date: Fri, 27 Feb 2026 09:11:37 +0100 Subject: [PATCH 15/15] Always generate markdown when FsDocsGenerateLlmsTxt is enabled Previously, markdown generation for doc content required a user-provided _template.md file. Now when FsDocsGenerateLlmsTxt is enabled, a markdown template is always available (falling back to a temporary empty file if the bundled default isn't found), ensuring .md files are produced alongside .html without requiring users to add _template.md manually. --- RELEASE_NOTES.md | 2 +- docs/styling.md | 4 ++++ src/fsdocs-tool/BuildCommand.fs | 28 ++++++++++++++++------------ 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0a81814f2..50c84cc01 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -4,7 +4,7 @@ ### Added -* Generate `llms.txt` and `llms-full.txt` for LLM consumption by default (opt out via `false`); links point to `.md` files when markdown output is available, and a default `_template.md` is bundled so markdown output is generated automatically. [#951](https://github.com/fsprojects/FSharp.Formatting/issues/951) [#980](https://github.com/fsprojects/FSharp.Formatting/pull/980) +* Generate `llms.txt` and `llms-full.txt` for LLM consumption by default (opt out via `false`); when enabled, markdown output is always generated alongside HTML (even without a user-provided `_template.md`) and `llms.txt` links point to the `.md` files. [#951](https://github.com/fsprojects/FSharp.Formatting/issues/951) [#980](https://github.com/fsprojects/FSharp.Formatting/pull/980) * Add `///` documentation comments to all public types, modules and members, and succinct internal comments, as part of ongoing effort to document the codebase. [#1035](https://github.com/fsprojects/FSharp.Formatting/issues/1035) * Add "Copy" button to all code blocks in generated documentation, making it easy to copy code samples to the clipboard. [#72](https://github.com/fsprojects/FSharp.Formatting/issues/72) * Add `true` project file setting to include executable projects (OutputType=Exe/WinExe) in API documentation generation. [#918](https://github.com/fsprojects/FSharp.Formatting/issues/918) diff --git a/docs/styling.md b/docs/styling.md index db3d3bfb5..5716029c9 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -83,6 +83,10 @@ following the [llmstxt.org](https://llmstxt.org/) convention. These files provid index (and full content) of all documentation pages and API reference entries, making it easy to add documentation context to LLMs and AI coding assistants. +When this feature is enabled, markdown (`.md`) files are automatically generated alongside HTML +for all documentation pages — even if you have not added a `_template.md` file. The `llms.txt` +links point to these markdown files, which are more suitable for LLM consumption. + To opt out, set the following property in your project file or `Directory.Build.props`: ```xml diff --git a/src/fsdocs-tool/BuildCommand.fs b/src/fsdocs-tool/BuildCommand.fs index 6e6fc9f8d..a323ca920 100644 --- a/src/fsdocs-tool/BuildCommand.fs +++ b/src/fsdocs-tool/BuildCommand.fs @@ -1849,16 +1849,9 @@ type CoreBuildOptions(watch) = let generateLlmsTxt () = if generateLlmsTxtEnabled then let index = Array.append latestApiDocSearchIndexEntries latestDocContentSearchIndexEntries - // Doc content generates .md files alongside .html when _template.md is present. - let docContentUsesMarkdown = File.Exists(Path.Combine(this.input, "_template.md")) - - if not docContentUsesMarkdown then - printfn "note: FsDocsGenerateLlmsTxt is enabled but no '_template.md' was found in '%s'." this.input - - printfn " Consider adding a '_template.md' to generate Markdown pages alongside HTML." - - printfn - " Markdown output is more suitable for LLM consumption (see https://fsprojects.github.io/FSharp.Formatting/styling.html)." + // When FsDocsGenerateLlmsTxt is enabled, markdown is always generated alongside HTML + // (using the bundled default markdown template if the user hasn't provided a _template.md). + let docContentUsesMarkdown = true let apiDocUsesMarkdown = latestApiDocOutputKind = OutputKind.Markdown @@ -2005,10 +1998,21 @@ type CoreBuildOptions(watch) = ) let docModels = - // When llms.txt generation is enabled, pass the default markdown template so that + // When llms.txt generation is enabled, ensure a markdown template is available so that // .md versions of content pages are written alongside .html files, enabling // llms.txt to link to the more LLM-friendly markdown versions. - let mdTemplate = if generateLlmsTxtEnabled then defaultMdTemplate else None + // An empty template causes the processor to emit just the document content, + // which is ideal for LLM consumption. + let mdTemplate = + if generateLlmsTxtEnabled then + match defaultMdTemplate with + | Some _ -> defaultMdTemplate + | None -> + let tempMdTemplate = Path.GetTempFileName() + File.WriteAllText(tempMdTemplate, "") + Some tempMdTemplate + else + None docContent.Convert(this.input, defaultTemplate, extraInputs, ?defaultMdTemplate = mdTemplate)