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
+```
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index bfbe14041..61347748e 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -3,13 +3,13 @@
## [Unreleased]
### Added
-
* 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)
+* 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)
### 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)
diff --git a/docs/styling.md b/docs/styling.md
index 3978c3f63..5716029c9 100644
--- a/docs/styling.md
+++ b/docs/styling.md
@@ -76,6 +76,25 @@ 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.
+
+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
+
+ 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 2625b273c..303b0ec87 100644
--- a/src/fsdocs-tool/BuildCommand.fs
+++ b/src/fsdocs-tool/BuildCommand.fs
@@ -647,7 +647,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, ".") ]
@@ -681,7 +681,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 ]
@@ -1301,6 +1308,127 @@ module Serve =
startWebServerAsync serverConfig app |> snd |> Async.Start
+/// 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",
+ System.StringComparison.Ordinal
+ )
+ ))
+ |> 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
+ |> 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.
+ /// When false, entries are listed as bullet-point links (index format).
+ /// 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
+ let sb = System.Text.StringBuilder()
+ sb.Append(sprintf "## %s\n\n" sectionTitle) |> ignore
+
+ 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 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 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).
+ /// 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 contentTransform
+ + buildSection "API Reference" apiIndexEntries false apiDocTransform
+
+ let llmsFullTxt =
+ header
+ + buildSection "Docs" contentEntries true contentTransform
+ + buildSection "API Reference" apiEntries true apiDocTransform
+
+ llmsTxt, llmsFullTxt
+
type CoreBuildOptions(watch) =
[