From 748f6bbebe3d423430b29169814816ddade0700b Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Wed, 14 Oct 2026 07:16:01 +0200 Subject: [PATCH 01/11] fix(preview): keep the spaces in the missing-tab note --- src/ShellDocs.Components/Content/PreviewFrame.razor | 4 ++-- tests/ShellDocs.Tests/PreviewCodeVariantsTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ShellDocs.Components/Content/PreviewFrame.razor b/src/ShellDocs.Components/Content/PreviewFrame.razor index 22f3560..4fb53cf 100644 --- a/src/ShellDocs.Components/Content/PreviewFrame.razor +++ b/src/ShellDocs.Components/Content/PreviewFrame.razor @@ -123,8 +123,8 @@ } diff --git a/tests/ShellDocs.Tests/PreviewCodeVariantsTests.cs b/tests/ShellDocs.Tests/PreviewCodeVariantsTests.cs index 5631e8f..d440ce3 100644 --- a/tests/ShellDocs.Tests/PreviewCodeVariantsTests.cs +++ b/tests/ShellDocs.Tests/PreviewCodeVariantsTests.cs @@ -119,7 +119,7 @@ public async Task DemoPreview_CodeTabs_ReplaceTheRazorSource() Assert.Contains("<Button Content="Save" />", html); Assert.Contains("class=\"language-xml\"", html); // [^>]* allows for the scoped-CSS attribute. - Assert.Matches("data-tab-missing hidden[^>]*>\\s*Not available on ]*> yet", html); + Assert.Matches("data-tab-missing hidden[^>]*>\\s*]*>Not available on ]*> yet", html); Assert.Contains("class=\"preview-caption\" title=\"Rendered with ShellUI for Blazor\"", html); Assert.DoesNotContain(harness.Logs.Messages, m => m.Contains("skipped attribute 'ChildContent'")); } From c55e9a2b5b41c3ae8fd413fbc3ca261711f6656f Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Wed, 14 Oct 2026 07:16:02 +0200 Subject: [PATCH 02/11] fix(preview): render razor:preview fences that hold only HTML --- docs/ARCHITECTURE.md | 2 +- .../Content/PreviewFrame.razor | 3 ++- src/ShellDocs.Markdown/SlotExtractor.cs | 6 +++++- tests/ShellDocs.Tests/PreviewTests.cs | 18 +++++++++++++++--- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b9587c7..e9316f5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -112,7 +112,7 @@ Placeholders plus a slot list keep everything at render time: no generated Razor ### `razor:preview` fences -`PreviewParser` turns the fence into ordered nodes: component nodes (registered capitalised tags; their children kept raw and parsed the same way, as Razor, when the component renders), element nodes (HTML, including unregistered capitalised tags) and text. `@code { }` / `@functions { }` blocks, `@* *@` comments and directive lines (`@using`, `@inject`, …) are skipped for rendering but stay in the Code tab. If no registered component is found the slot carries an `Error`, rendered as a red panel instead of a silent code block. `razor:preview stretch` in the info string sets `PreviewSlot.Layout`. +`PreviewParser` turns the fence into ordered nodes: component nodes (registered capitalised tags; their children kept raw and parsed the same way, as Razor, when the component renders), element nodes (HTML, including unregistered capitalised tags) and text. `@code { }` / `@functions { }` blocks, `@* *@` comments and directive lines (`@using`, `@inject`, …) are skipped for rendering but stay in the Code tab. With no registered component, a fence holding an unregistered capitalised tag carries an `Error`, rendered as a red panel instead of a silent code block; one holding only HTML renders it as is (`ComponentType` is null). A fence with no tags at all stays a code block, with a warning. `razor:preview stretch` in the info string sets `PreviewSlot.Layout`. --- diff --git a/src/ShellDocs.Components/Content/PreviewFrame.razor b/src/ShellDocs.Components/Content/PreviewFrame.razor index 4fb53cf..105acfc 100644 --- a/src/ShellDocs.Components/Content/PreviewFrame.razor +++ b/src/ShellDocs.Components/Content/PreviewFrame.razor @@ -174,7 +174,8 @@ private string? ErrorMessage => Error - ?? (Content is null && Preview?.ComponentType is null ? Preview?.Error ?? "Unknown error." : null); + ?? (Content is null && Preview?.ComponentType is null && Preview?.Nodes is not { Count: > 0 } + ? Preview?.Error ?? "Unknown error." : null); private string? PartId(string part) => Id is null ? null : $"{Id}-{part}"; diff --git a/src/ShellDocs.Markdown/SlotExtractor.cs b/src/ShellDocs.Markdown/SlotExtractor.cs index dd6245a..8d071f7 100644 --- a/src/ShellDocs.Markdown/SlotExtractor.cs +++ b/src/ShellDocs.Markdown/SlotExtractor.cs @@ -168,7 +168,11 @@ private static bool TryFindOpeningTag(string text, int from, out RazorTag tag) parser.FirstUnknownChildRaw, Error: msg); } - warnings.Add("razor:preview fence must contain a component tag."); + // Plain HTML (no components) still renders live. + if (nodes.Any(n => n is PreviewElementNode)) + return new PreviewSlot(NewSlotId(), null, new Dictionary(), code, "razor", Nodes: nodes, Layout: layout); + + warnings.Add("razor:preview fence must contain a component or HTML tag."); return null; } diff --git a/tests/ShellDocs.Tests/PreviewTests.cs b/tests/ShellDocs.Tests/PreviewTests.cs index eb05130..ca10995 100644 --- a/tests/ShellDocs.Tests/PreviewTests.cs +++ b/tests/ShellDocs.Tests/PreviewTests.cs @@ -157,13 +157,25 @@ public void KnownAndUnknownSiblings_RenderKnown_WarnUnknown() } [Fact] - public void FenceWithoutComponentTags_RendersAsPlainCode() + public void FenceWithOnlyHtml_IsALivePreview() { var renderer = Renderer(); - var doc = renderer.Render("```razor:preview\n
plain
\n```"); + var doc = renderer.Render("```razor:preview\n
plain
\n```"); + var slot = Assert.IsType(Assert.Single(doc.Slots)); + Assert.Null(slot.ComponentType); + Assert.Null(slot.Error); + Assert.Contains(slot.Nodes!, n => n is PreviewElementNode { TagName: "div" }); + Assert.DoesNotContain("language-razor:preview", doc.Html); + } + + [Fact] + public void FenceWithoutTags_RendersAsPlainCode() + { + var renderer = Renderer(); + var doc = renderer.Render("```razor:preview\njust text\n```"); Assert.Empty(doc.Slots); Assert.Contains("language-razor:preview", doc.Html); - Assert.Contains(renderer.LastWarnings, w => w.Contains("must contain a component tag")); + Assert.Contains(renderer.LastWarnings, w => w.Contains("must contain a component or HTML tag")); } [Fact] From 98989dfd19071edfbbda704c4efee1029837e4ab Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Wed, 14 Oct 2026 07:16:02 +0200 Subject: [PATCH 03/11] fix(build): show the visitor's path in 404.html, not the probe URL --- docs/ARCHITECTURE.md | 2 +- src/ShellDocs.CLI/Commands/PrerenderRunner.cs | 11 +++++++++- .../Content/DocsNotFound.razor | 2 +- src/ShellDocs.Components/wwwroot/shelldocs.js | 15 +++++++++++++ tests/ShellDocs.Tests/NotFoundTests.cs | 21 +++++++++++++++++++ 5 files changed, 48 insertions(+), 3 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e9316f5..67663cd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -246,7 +246,7 @@ One neutral, shadcn-shaped palette of CSS variables in `ShellDocs.Tokens/tokens. 2. Builds the navigation graph and collects every URL (visible and hidden) plus `/`. 3. `PrerenderRunner` starts the published app on a free port, requests each URL and writes `//index.html`. A URL that answers 404, or renders ``, fails the build. 4. Reads the app's redirect map and writes a redirect page for each source URL without a prerendered page (meta refresh plus `location.replace`, relative to ``). -5. Requests `/docs/__shelldocs-not-found__` and, if it renders `` with a 404, writes it as `404.html`. +5. Requests `/docs/__shelldocs-not-found__` and, if it renders `` with a 404, writes it as `404.html`. The probe's path is blanked and its line hidden; `shelldocs.js` fills in the visitor's path. 6. Copies the app's search index to `_shelldocs/search.json`. 7. Merges the published `wwwroot/` into the output without overwriting prerendered HTML. 8. Optionally rewrites `` in every HTML file, copies `index.html` to `404.html` (replacing the not-found page), and with `--site-url` writes `sitemap.xml`, `robots.txt` and `og:*` meta. diff --git a/src/ShellDocs.CLI/Commands/PrerenderRunner.cs b/src/ShellDocs.CLI/Commands/PrerenderRunner.cs index 5e8bc14..ab1ebad 100644 --- a/src/ShellDocs.CLI/Commands/PrerenderRunner.cs +++ b/src/ShellDocs.CLI/Commands/PrerenderRunner.cs @@ -152,7 +152,7 @@ private static bool WriteNotFoundPage(HttpClient http, string outputDir) var response = http.GetAsync(NotFoundProbe).GetAwaiter().GetResult(); var html = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); if (response.StatusCode != HttpStatusCode.NotFound || !IsNotFoundPage(html)) return false; - File.WriteAllText(Path.Combine(outputDir, "404.html"), html); + File.WriteAllText(Path.Combine(outputDir, "404.html"), BlankProbePath(html)); return true; } catch (Exception ex) @@ -162,6 +162,15 @@ private static bool WriteNotFoundPage(HttpClient http, string outputDir) } } + private static readonly Regex ProbePathLine = new( + @"(]*\bdata-not-found-path\b[^>]*)>([\s\S]*?]*>)" + Regex.Escape(NotFoundProbe) + "", + RegexOptions.Compiled); + + // 404.html serves every unknown URL, so the probe's path is blanked and the line + // hidden; shelldocs.js fills in the visitor's path. + internal static string BlankProbePath(string html) => + ProbePathLine.Replace(html, "$1 hidden>$2", 1); + internal const string SearchIndexPath = "/_shelldocs/search.json"; // shelldocs.js fetches the index from this path, so the export keeps it there. diff --git a/src/ShellDocs.Components/Content/DocsNotFound.razor b/src/ShellDocs.Components/Content/DocsNotFound.razor index 478c12a..ad1d4f6 100644 --- a/src/ShellDocs.Components/Content/DocsNotFound.razor +++ b/src/ShellDocs.Components/Content/DocsNotFound.razor @@ -11,7 +11,7 @@ counts it as a failure; data-shelldocs-not-found marks it for the build too. *@

Page not found

-

The page @_path doesn't exist.

+

The page @_path doesn't exist.

@HomeLabel

diff --git a/src/ShellDocs.Components/wwwroot/shelldocs.js b/src/ShellDocs.Components/wwwroot/shelldocs.js index 78ec4d3..9642758 100644 --- a/src/ShellDocs.Components/wwwroot/shelldocs.js +++ b/src/ShellDocs.Components/wwwroot/shelldocs.js @@ -1014,8 +1014,23 @@ window.shelldocsChrome = (function () { // The shell is already parsed here; restoring now avoids a flash of the open sidebar. restoreCollapse(document); + /* 404.html is one file for every unknown URL, so its path line ships blank and + hidden; fill in the visitor's path, relative to the site's base. */ + function fillNotFoundPath() { + document.querySelectorAll('[data-not-found-path][hidden]').forEach(function (line) { + var code = line.querySelector('code'); + if (!code || code.textContent) return; + var base = new URL(document.baseURI).pathname.replace(/\/$/, ''); + var path = location.pathname; + if (base && path.indexOf(base + '/') === 0) path = path.slice(base.length); + code.textContent = decodeURIComponent(path); + line.hidden = false; + }); + } + function initPage() { initToc(); + fillNotFoundPath(); restoreCollapse(document); restoreTabs(document); window.shelldocsIframes.init(document); diff --git a/tests/ShellDocs.Tests/NotFoundTests.cs b/tests/ShellDocs.Tests/NotFoundTests.cs index 5fceda7..4bcde6c 100644 --- a/tests/ShellDocs.Tests/NotFoundTests.cs +++ b/tests/ShellDocs.Tests/NotFoundTests.cs @@ -79,6 +79,27 @@ public async Task DocsNotFound_WithoutAnHttpContext_StillRenders() Assert.Matches("]*>.*Start", html); } + [Fact] + public async Task NotFoundPageFile_HidesTheProbePath_ForShelldocsJsToFill() + { + var html = await Harness().RenderAsync(new() { ["Path"] = "/docs/__shelldocs-not-found__" }); + var blank = (string)CliRunner("BlankProbePath").Invoke(null, [html])!; + + Assert.DoesNotContain("__shelldocs-not-found__", blank); + Assert.Matches("]*data-not-found-path[^>]* hidden>The page ]*>", blank); + + var js = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", + "src", "ShellDocs.Components", "wwwroot", "shelldocs.js")); + Assert.Contains("[data-not-found-path][hidden]", js); + } + + private static MethodInfo CliRunner(string method) + { + var cli = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name == "shelldocs") ?? Assembly.Load("shelldocs"); + return cli.GetType("ShellDocs.CLI.Commands.PrerenderRunner", throwOnError: true)! + .GetMethod(method, BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; + } + [Fact] public void InitTemplate_UsesDocsNotFound() { From 6440737f4e10a3889f379e3fea0ca7f52a34803f Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Wed, 14 Oct 2026 07:16:02 +0200 Subject: [PATCH 04/11] fix(chrome): size LogoSvg logos to LogoHeight --- src/ShellDocs.Components/Chrome/BrandLogo.razor.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ShellDocs.Components/Chrome/BrandLogo.razor.css b/src/ShellDocs.Components/Chrome/BrandLogo.razor.css index 2e13767..8bdc03e 100644 --- a/src/ShellDocs.Components/Chrome/BrandLogo.razor.css +++ b/src/ShellDocs.Components/Chrome/BrandLogo.razor.css @@ -15,7 +15,8 @@ flex-shrink: 0; color: var(--foreground); } -::deep .brand-logo-svg > svg { +/* The SVG is MarkupString content, so it has no scope attribute. */ +.brand-logo-svg ::deep > svg { height: 100%; width: auto; display: block; From df35749e28479189508f10357542655ed5a3eb6d Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Wed, 14 Oct 2026 07:16:02 +0200 Subject: [PATCH 05/11] fix(layout): include SearchDialog in HomeLayout --- docs/ARCHITECTURE.md | 2 +- src/ShellDocs.Components/Layouts/HomeLayout.razor | 3 +++ tests/ShellDocs.Tests/StaticSearchTests.cs | 7 +++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 67663cd..658da59 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -191,7 +191,7 @@ All answers are computed at render time and emitted as plain ``, so prer Searching happens in the browser, so it's the same on a static host. `DocsSearchIndexMiddleware` (added by an `IStartupFilter`, like the redirects) serves the index at `/_shelldocs/search.json`: `{ versions: [{ id, root, latest }], entries: [{ u, t, d, s, k, b, v }] }`, where `k` is `p` (page) or `h` (heading) and `v` is the id of the version that owns the entry. `shelldocs build` copies that file into the export. -`SearchDialog` renders a hidden template. On Cmd/Ctrl+K or a `[data-search-open]` button, `shelldocs.js` clones it onto `` (outside the DOM Blazor owns, so a circuit starting doesn't close it), fetches the index once, keeps the current version's entries plus those outside every version, and scores them: whole-query and per-token matches against title (strongest), section, description and body; every token must match somewhere. Body-only matches get a ~150-character snippet. Results are links, cloned from the template's `[data-search-row]` rows. `shelldocsSearch.open()` / `.close()` do the same from script. +`SearchDialog` renders a hidden template; `DocsLayout` and `HomeLayout` both include one. On Cmd/Ctrl+K or a `[data-search-open]` button, `shelldocs.js` clones it onto `` (outside the DOM Blazor owns, so a circuit starting doesn't close it), fetches the index once, keeps the current version's entries plus those outside every version, and scores them: whole-query and per-token matches against title (strongest), section, description and body; every token must match somewhere. Body-only matches get a ~150-character snippet. Results are links, cloned from the template's `[data-search-row]` rows. `shelldocsSearch.open()` / `.close()` do the same from script. --- diff --git a/src/ShellDocs.Components/Layouts/HomeLayout.razor b/src/ShellDocs.Components/Layouts/HomeLayout.razor index 304914e..4ff7c2b 100644 --- a/src/ShellDocs.Components/Layouts/HomeLayout.razor +++ b/src/ShellDocs.Components/Layouts/HomeLayout.razor @@ -6,3 +6,6 @@ @Body
+ +@* Ctrl+K and [data-search-open] in page content still open search. *@ + diff --git a/tests/ShellDocs.Tests/StaticSearchTests.cs b/tests/ShellDocs.Tests/StaticSearchTests.cs index 5b87cea..85352ae 100644 --- a/tests/ShellDocs.Tests/StaticSearchTests.cs +++ b/tests/ShellDocs.Tests/StaticSearchTests.cs @@ -157,6 +157,13 @@ public void AddShellDocs_PutsTheIndexMiddlewareInThePipeline() Assert.Contains(filters, f => f is DocsSearchStartupFilter); } + [Fact] + public async Task HomeLayout_HasTheSearchDialog() + { + var html = await Harness().RenderAsync(); + Assert.Contains("data-search-dialog", html); + } + [Fact] public void ShelldocsJs_SearchesTheIndexInTheBrowser() { From cabd7b0a444baf561605f3f83b20b58d9f267d70 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Wed, 14 Oct 2026 07:16:02 +0200 Subject: [PATCH 06/11] chore: bump to 0.1.17-alpha, changelog --- CHANGELOG.md | 12 ++++++++++++ Directory.Build.props | 2 +- docs/ROADMAP.md | 3 ++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49084af..6b9644e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht ## [Unreleased] +## [0.1.17-alpha] — 2026-10-14 + +Five fixes found while writing the ShellDocs docs site. + +### Fixed + +- **The missing-tab note lost its spaces.** It read "Not available onRazoryet": the note is a flex container, so the text around the label became separate flex items and the spaces were dropped. The sentence is now one inline box. +- **A `razor:preview` fence holding only HTML rendered as a code block.** It now renders live in the frame, like a fence with components. A fence with no tags at all is still a code block, with a warning. +- **`404.html` named the build's probe URL.** Every unknown URL showed "The page /docs/\_\_shelldocs-not-found\_\_ doesn't exist". `shelldocs build` now blanks and hides that line, and `shelldocs.js` fills in the visitor's path (relative to the site's base). +- **`LogoSvg` logos weren't sized.** The scoped rule meant to fit the SVG to `LogoHeight` never matched, so an SVG without its own size could collapse to nothing. Sites that added their own `.brand-logo-svg > svg` rule can drop it. +- **Search on home pages.** `HomeLayout` had no ``, so Cmd/Ctrl+K and `[data-search-open]` did nothing there unless the site added one. It's included now; a site that added its own can remove it. + ## [0.1.16-alpha] — 2026-10-14 Fixes `` showing the previous page's preview after navigating within a site. diff --git a/Directory.Build.props b/Directory.Build.props index 112348b..7208d2a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -17,7 +17,7 @@ - 0.1.16-alpha + 0.1.17-alpha ShellUI ShellUI Copyright © 2026 ShellUI diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9816644..f0a505e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -209,7 +209,7 @@ Ships to `ShellDocs.Components` + `ShellDocs.Templates` + `ShellDocs.CLI` + `She **Authoring fix (`ShellDocs.Markdown`)** - `SlotExtractor.ReplaceComponentTags` no longer `.Trim()`s the raw child content of inline component tags. The Trim was stripping the first line's indent and defeating `SlotRenderer.Dedent` — Markdig then interpreted the remaining 4-space-indented lines as an indented code block. Symptom was the same "literal `
` around placeholder divs" bug that had already been fixed for `razor:preview` fences; the inline-tag code path was still hitting it.
 
-### ✅ Hardening releases (`0.1.3`–`0.1.16-alpha`) — shipped
+### ✅ Hardening releases (`0.1.3`–`0.1.17-alpha`) — shipped
 Driven by building the ShellUI docs on ShellDocs. Highlights (details in the CHANGELOG):
 
 - `feat/build-static-prerender` (0.1.5) — `shelldocs build` prerenders every URL into a static site.
@@ -223,6 +223,7 @@ Driven by building the ShellUI docs on ShellDocs. Highlights (details in the CHA
 - `fix/not-found-and-sidebar-collapse` (0.1.14) — `` answers 404, `build` fails on not-found pages and writes `404.html`, desktop sidebar collapse in `shelldocs.js`.
 - `feat/static-search` (0.1.15) — search runs in `shelldocs.js` against `/_shelldocs/search.json`, which `build` copies into the export, so static sites have search.
 - `fix/iframe-preview-navigation` (0.1.16) — an `` reused for the next page after in-site navigation is unloaded and set up again.
+- `fix/docs-site-findings` (0.1.17) — HTML-only `razor:preview` fences render live, `404.html` shows the visitor's path, `LogoSvg` sizing, search on `HomeLayout`, spacing in the missing-tab note.
 - `fix/preview-fidelity`, `feat/generics-redirects-page-title` (0.1.10) — `not-prose` previews, Razor child content, stretch layout, generic components in markdown, redirects (middleware + static pages), `RenderPageTitle`, theme sync and a static-host `ThemeToggle`.
 
 ### `feat/animation-polish`

From a10bec0ed5e1eb6631373d62bd292d0ea5fdf054 Mon Sep 17 00:00:00 2001
From: Shewart 
Date: Wed, 14 Oct 2026 09:59:13 +0200
Subject: [PATCH 07/11] feat(components): serve llms.txt and every page as
 Markdown for AI agents

o.EnableLlmsTxt(l => ...) serves /llms.txt, /llms-full.txt, the same under
each version root, each page at its URL + ".md", and AGENTS.md, CLAUDE.md,
copilot-instructions.md and a Cursor rule under agents/. ShellDocs tags
become Markdown; links to docs pages become absolute .md links.
---
 .../Content/AutoTypeTable.razor               |   2 +-
 src/ShellDocs.Components/Llms/LlmsMarkdown.cs | 321 +++++++++++++++++
 src/ShellDocs.Components/Llms/LlmsTxtFiles.cs | 249 ++++++++++++++
 .../Llms/LlmsTxtMiddleware.cs                 |  80 +++++
 src/ShellDocs.Components/LlmsTxtOptions.cs    |  24 ++
 .../ServiceCollectionExtensions.cs            |   8 +
 src/ShellDocs.Components/ShellDocsOptions.cs  |  12 +
 src/ShellDocs.Markdown/SlotExtractor.cs       |   2 +-
 tests/ShellDocs.Tests/LlmsTxtTests.cs         | 323 ++++++++++++++++++
 9 files changed, 1019 insertions(+), 2 deletions(-)
 create mode 100644 src/ShellDocs.Components/Llms/LlmsMarkdown.cs
 create mode 100644 src/ShellDocs.Components/Llms/LlmsTxtFiles.cs
 create mode 100644 src/ShellDocs.Components/Llms/LlmsTxtMiddleware.cs
 create mode 100644 src/ShellDocs.Components/LlmsTxtOptions.cs
 create mode 100644 tests/ShellDocs.Tests/LlmsTxtTests.cs

diff --git a/src/ShellDocs.Components/Content/AutoTypeTable.razor b/src/ShellDocs.Components/Content/AutoTypeTable.razor
index b3ef9e0..ef8337b 100644
--- a/src/ShellDocs.Components/Content/AutoTypeTable.razor
+++ b/src/ShellDocs.Components/Content/AutoTypeTable.razor
@@ -70,7 +70,7 @@ else
         _rows = _target is null ? new() : BuildRows(_target);
     }
 
-    private static List BuildRows(Type target)
+    internal static List BuildRows(Type target)
     {
         var rows = new List();
 
diff --git a/src/ShellDocs.Components/Llms/LlmsMarkdown.cs b/src/ShellDocs.Components/Llms/LlmsMarkdown.cs
new file mode 100644
index 0000000..26c644c
--- /dev/null
+++ b/src/ShellDocs.Components/Llms/LlmsMarkdown.cs
@@ -0,0 +1,321 @@
+using System.Net;
+using System.Text;
+using System.Text.RegularExpressions;
+using ShellDocs.Components.Content;
+using ShellDocs.Core;
+using ShellDocs.Markdown;
+
+namespace ShellDocs.Components.Llms;
+
+/* A page's Markdown body as plain Markdown for AI agents. ShellDocs tags become
+   Markdown (a callout a blockquote, a demo its source in a fence, cards a list),
+   other registered components their text, and root-relative links absolute URLs,
+   with ".md" added for docs pages. Code fences and code spans are left alone. */
+internal sealed partial class LlmsMarkdown
+{
+    // Every built-in content component is handled here; a test checks the list stays complete.
+    internal static readonly IReadOnlySet HandledTypes = new HashSet
+    {
+        typeof(AutoTypeTable), typeof(Callout), typeof(Card), typeof(CardGrid), typeof(CodeGroup),
+        typeof(CodeTab), typeof(ComponentPreview), typeof(DemoPreview), typeof(FileTree),
+        typeof(FileTreeItem), typeof(IframePreview), typeof(LinkCard), typeof(Step), typeof(Steps),
+        typeof(SyncSwitch), typeof(Tab), typeof(Tabs), typeof(TypeRow), typeof(TypeTable),
+    };
+
+    // Preview attributes that configure the frame rather than the component shown.
+    private static readonly HashSet FrameAttributes = new(StringComparer.OrdinalIgnoreCase)
+    {
+        "Component", "Layout", "SyncKey", "Caption", "Title", "Id", "CodeTabs", "ChildContentSource",
+    };
+
+    private readonly TypeRegistry _registry;
+    private readonly NavigationGraph _graph;
+    private readonly string? _demoRoot;
+    private readonly string _base;
+    private readonly Dictionary _masks = new();
+    private readonly Stack _steps = new();
+
+    // `baseUrl` is put before root-relative links: an origin, an origin and path, or "".
+    public LlmsMarkdown(TypeRegistry registry, NavigationGraph graph, string? demoRoot, string baseUrl)
+    {
+        _registry = registry;
+        _graph = graph;
+        _demoRoot = demoRoot;
+        _base = baseUrl.TrimEnd('/');
+    }
+
+    public string Convert(string markdown)
+    {
+        var text = markdown.Replace("\r\n", "\n");
+        text = SlotExtractor.FenceBlock.Replace(text, m => Mask(AsPlainFence(m)));
+        text = SlotExtractor.InlineCode.Replace(text, m => Mask(m.Value));
+        text = Tags(text);
+        text = Links(text);
+        text = Unmask(text);
+        return BlankRun().Replace(text, "\n\n").Trim();
+    }
+
+    // Absolute URL for an app-relative path; docs pages get ".md".
+    public string Url(string path, bool markdown = true)
+    {
+        var p = UrlPath.Normalize(path);
+        var isPage = markdown && _graph.ResolveByUrl(p) is { Kind: NodeKind.Page };
+        return _base + (p == "/" && !isPage ? "/" : p) + (isPage ? ".md" : "");
+    }
+
+    // ```razor:preview becomes ```razor: the fence's body is the example's source.
+    private static string AsPlainFence(Match m)
+    {
+        var lang = m.Groups["lang"];
+        if (!lang.Value.TrimStart().StartsWith("razor:preview", StringComparison.Ordinal)) return m.Value;
+        var start = lang.Index - m.Index;
+        return m.Value[..start] + "razor" + m.Value[(start + lang.Length)..];
+    }
+
+    private string Tags(string text)
+    {
+        var sb = new StringBuilder(text.Length);
+        var i = 0;
+        while (i < text.Length)
+        {
+            var lt = text.IndexOf('<', i);
+            if (lt < 0) { sb.Append(text, i, text.Length - i); break; }
+            sb.Append(text, i, lt - i);
+
+            if (!RazorTagScanner.TryRead(text, lt, out var tag) || tag.IsClose || _registry.Resolve(tag.Name) is not { } type)
+            {
+                sb.Append('<');
+                i = lt + 1;
+                continue;
+            }
+
+            var inner = "";
+            var end = tag.End;
+            if (!tag.IsSelfClosing)
+            {
+                var (closeStart, closeEnd) = RazorTagScanner.FindMatchingClose(text, tag.Name, tag.End, StringComparison.Ordinal);
+                if (closeStart < 0) { sb.Append('<'); i = lt + 1; continue; }
+                inner = text[tag.End..closeStart];
+                end = closeEnd;
+            }
+            sb.Append(Render(type, tag, inner));
+            i = end;
+        }
+        return sb.ToString();
+    }
+
+    private string Render(Type type, RazorTag tag, string inner)
+    {
+        string? A(string name) => Attr(tag, name);
+
+        if (type == typeof(Callout))
+        {
+            var title = A("Title") ?? (A("Variant")?.ToLowerInvariant() switch
+            {
+                "warning" => "Warning",
+                "danger" or "error" => "Danger",
+                "tip" => "Tip",
+                "success" => "Success",
+                _ => "Note",
+            });
+            var body = Unmask(A("Text") is { } t && inner.Trim().Length == 0 ? t : Tags(inner)).Trim();
+            var quoted = body.Split('\n').Select(l => l.Trim().Length == 0 ? ">" : "> " + l);
+            return Block($"> **{title}**" + (body.Length == 0 ? "" : "\n>\n" + string.Join("\n", quoted)));
+        }
+
+        if (type == typeof(Tab) || type == typeof(CodeTab))
+            return Block($"**{A("Label")}**\n\n{Tags(inner).Trim()}");
+
+        if (type == typeof(Tabs) || type == typeof(CodeGroup))
+            return Block(Tags(inner));
+
+        if (type == typeof(CardGrid))
+            return Block(string.Join("\n", Tags(inner).Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0)));
+
+        if (type == typeof(Card) || type == typeof(LinkCard))
+        {
+            var title = A("Title") ?? "";
+            var line = "- " + (A("Href") is { Length: > 0 } href ? $"[{title}]({href})" : $"**{title}**");
+            var text = A("Description") ?? OneLine(Tags(inner));
+            if (text.Length > 0) line += ": " + text;
+            return "\n" + line + "\n";
+        }
+
+        if (type == typeof(Steps))
+        {
+            _steps.Push(0);
+            var steps = Tags(inner);
+            _steps.Pop();
+            return Block(steps);
+        }
+
+        if (type == typeof(Step))
+        {
+            var n = 0;
+            if (_steps.Count > 0) _steps.Push(n = _steps.Pop() + 1);
+            var heading = n > 0 ? $"{n}. {A("Title")}" : A("Title");
+            return Block($"**{heading}**\n\n{Tags(inner).Trim()}");
+        }
+
+        if (type == typeof(FileTree))
+            return Block(Mask("```text\n" + string.Join("\n", TreeLines(inner, 0)) + "\n```"));
+
+        if (type == typeof(FileTreeItem))
+            return FileTreeName(tag);
+
+        if (type == typeof(TypeTable))
+        {
+            var rows = Tags(inner).Split('\n').Select(l => l.Trim()).Where(l => l.StartsWith('|'));
+            return Block(TableHeader + string.Join("\n", rows));
+        }
+
+        if (type == typeof(TypeRow))
+            return "\n" + Row(A("Name") ?? "", A("Type") ?? "", A("Default"), A("Description"), IsTrue(A("Required"))) + "\n";
+
+        if (type == typeof(AutoTypeTable))
+        {
+            var target = A("Component") is { } c ? _registry.Resolve(c) : null;
+            if (target is null) return "";
+            var rows = AutoTypeTable.BuildRows(target).Select(r => Row(r.Name, r.Type, r.Default, r.Description, r.Required));
+            return Block(TableHeader + string.Join("\n", rows));
+        }
+
+        if (type == typeof(DemoPreview) || type == typeof(ComponentPreview) || type == typeof(IframePreview))
+        {
+            var title = A("Title") is { Length: > 0 } t ? $"**{t}**\n\n" : "";
+            if (HasChild(inner, typeof(CodeTab))) return Block(title + Tags(inner));
+            if (type == typeof(DemoPreview))
+            {
+                var source = A("Component") is { } name && _demoRoot is not null ? DemoSourceCache.Get(_demoRoot, name) : null;
+                return source is null ? Block(title) : Block(title + Fence("razor", source));
+            }
+            if (type == typeof(IframePreview))
+                return Block(title + $"[Live preview]({A("Src")})");
+            return Block(title + Fence("razor", PreviewMarkup(tag, Unmask(inner).Trim())));
+        }
+
+        if (type == typeof(SyncSwitch))
+            return "";
+
+        // Any other registered component: its text, or nothing when it has no body.
+        return Tags(inner);
+    }
+
+    // Save → 
+    private string PreviewMarkup(RazorTag tag, string body)
+    {
+        var name = Attr(tag, "Component") ?? "";
+        var attrs = string.Concat(tag.Attributes
+            .Where(a => !FrameAttributes.Contains(a.Key))
+            .Select(a => a.Value is null ? " " + a.Key : $" {a.Key}=\"{Unmask(a.Value)}\""));
+        return body.Length == 0 ? $"<{name}{attrs} />" : $"<{name}{attrs}>{body}";
+    }
+
+    private IEnumerable TreeLines(string inner, int depth)
+    {
+        foreach (var (tag, body) in Children(inner, typeof(FileTreeItem)))
+        {
+            yield return new string(' ', depth * 2) + FileTreeName(tag);
+            foreach (var line in TreeLines(body, depth + 1)) yield return line;
+        }
+    }
+
+    private string FileTreeName(RazorTag tag)
+    {
+        var name = (Attr(tag, "Name") ?? "") + (IsTrue(Attr(tag, "IsFolder")) ? "/" : "");
+        return Attr(tag, "Comment") is { Length: > 0 } comment ? $"{name}  # {comment}" : name;
+    }
+
+    private bool HasChild(string inner, Type type) => Children(inner, type).Any();
+
+    // Top-level tags in `inner` that resolve to `type`, with their bodies.
+    private IEnumerable<(RazorTag Tag, string Body)> Children(string inner, Type type)
+    {
+        var i = 0;
+        while (i < inner.Length)
+        {
+            var lt = inner.IndexOf('<', i);
+            if (lt < 0) yield break;
+            if (!RazorTagScanner.TryRead(inner, lt, out var tag) || tag.IsClose) { i = lt + 1; continue; }
+            var body = "";
+            var end = tag.End;
+            if (!tag.IsSelfClosing)
+            {
+                var (closeStart, closeEnd) = RazorTagScanner.FindMatchingClose(inner, tag.Name, tag.End, StringComparison.Ordinal);
+                if (closeStart >= 0) { body = inner[tag.End..closeStart]; end = closeEnd; }
+            }
+            if (_registry.Resolve(tag.Name) == type) yield return (tag, body);
+            i = end;
+        }
+    }
+
+    private const string TableHeader = "| Name | Type | Default | Description |\n|------|------|---------|-------------|\n";
+
+    private static string Row(string name, string type, string? def, string? description, bool required) =>
+        $"| `{Cell(name)}`{(required ? " (required)" : "")} | `{Cell(type)}` | {(string.IsNullOrEmpty(def) ? "" : $"`{Cell(def)}`")} | {Cell(description ?? "")} |";
+
+    private static string Cell(string s) => s.Replace("|", "\\|").Replace("\n", " ");
+
+    private string Fence(string lang, string code)
+    {
+        var body = code.Replace("\r\n", "\n").Trim('\n');
+        var ticks = new string('`', Math.Max(3, LongestBacktickRun(body) + 1));
+        return Mask($"{ticks}{lang}\n{body}\n{ticks}");
+    }
+
+    private static int LongestBacktickRun(string s)
+    {
+        int best = 0, run = 0;
+        foreach (var c in s) { run = c == '`' ? run + 1 : 0; best = Math.Max(best, run); }
+        return best;
+    }
+
+    private string? Attr(RazorTag tag, string name)
+    {
+        foreach (var (key, value) in tag.Attributes)
+            if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase))
+                return value is null ? "" : WebUtility.HtmlDecode(Unmask(value));
+        return null;
+    }
+
+    private static bool IsTrue(string? value) => value is "" || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
+
+    private string OneLine(string text) => Whitespace().Replace(Unmask(text), " ").Trim();
+
+    private static string Block(string text) => "\n\n" + text.Trim('\n') + "\n\n";
+
+    private string Links(string text) => Link().Replace(text, m =>
+        m.Groups["pre"].Value + Url(m.Groups["path"].Value) + m.Groups["rest"].Value);
+
+    private string Mask(string original)
+    {
+        var id = $"\uE000{_masks.Count}\uE001";
+        _masks[id] = original;
+        return id;
+    }
+
+    // Masks can hold masks (a fence moved into a callout), so this repeats until none are left.
+    private string Unmask(string text)
+    {
+        while (text.Contains('\uE000'))
+        {
+            var next = MaskToken().Replace(text, m => _masks.TryGetValue(m.Value, out var v) ? v : m.Value);
+            if (next == text) break;
+            text = next;
+        }
+        return text;
+    }
+
+    [GeneratedRegex("\uE000\\d+\uE001")]
+    private static partial Regex MaskToken();
+
+    // Markdown link targets and href / src attributes that start with a single "/".
+    [GeneratedRegex(@"(?
\]\(\s*|\b(?:href|Href|src|Src)\s*=\s*"")(?/(?!/)[^)\s""#?]*)(?[?#][^)\s""]*)?")]
+    private static partial Regex Link();
+
+    [GeneratedRegex(@"\n[ \t]*(?:\n[ \t]*)+\n")]
+    private static partial Regex BlankRun();
+
+    [GeneratedRegex(@"\s+")]
+    private static partial Regex Whitespace();
+}
diff --git a/src/ShellDocs.Components/Llms/LlmsTxtFiles.cs b/src/ShellDocs.Components/Llms/LlmsTxtFiles.cs
new file mode 100644
index 0000000..25b9976
--- /dev/null
+++ b/src/ShellDocs.Components/Llms/LlmsTxtFiles.cs
@@ -0,0 +1,249 @@
+using System.Text;
+using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Hosting;
+using ShellDocs.Core;
+using ShellDocs.Markdown;
+
+namespace ShellDocs.Components.Llms;
+
+internal sealed record LlmsFile(string Path, string Body, bool IsDownload);
+
+/* The llms.txt files (https://llmstxt.org) EnableLlmsTxt serves:
+     /llms.txt, /llms-full.txt                   index and full text of the pages outside every
+                                                 version plus the latest version's (all pages
+                                                 when the site has no versions)
+     {version root}/llms.txt, /llms-full.txt     the same for one version
+     {page URL}.md                               one page as Markdown
+     /agents/{file}, {version root}/agents/...   AGENTS.md, CLAUDE.md, copilot-instructions.md and
+                                                 a Cursor rule, each a "## {SiteName}" section
+   Links are absolute from `baseUrl` (an origin, an origin and path, or "" for root-relative). */
+internal sealed class LlmsTxtFiles
+{
+    public const string ManifestPath = "/_shelldocs/llms.json";
+
+    private readonly ShellDocsOptions _options;
+    private readonly NavigationGraph _graph;
+    private readonly TypeRegistry _registry;
+    private readonly DocsVersionResolver _versions;
+    private readonly string _contentRoot;
+
+    public LlmsTxtFiles(ShellDocsOptions options, NavigationGraph graph, TypeRegistry registry,
+        DocsVersionResolver versions, IWebHostEnvironment? env = null)
+    {
+        _options = options;
+        _graph = graph;
+        _registry = registry;
+        _versions = versions;
+        _contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
+    }
+
+    private LlmsTxtOptions Llms => _options.LlmsTxt ?? new LlmsTxtOptions();
+    private string SiteName => string.IsNullOrWhiteSpace(_options.SiteName) ? "Documentation" : _options.SiteName.Trim();
+
+    public IReadOnlyList AgentFileNames =>
+        ["AGENTS.md", "CLAUDE.md", "copilot-instructions.md", CursorRuleName];
+
+    private string CursorRuleName
+    {
+        get
+        {
+            var slug = Regex.Replace(SiteName.ToLowerInvariant(), "[^a-z0-9]+", "-").Trim('-');
+            return (slug.Length == 0 ? "docs" : slug) + ".mdc";
+        }
+    }
+
+    // A version's files live under its root; null is the latest / unversioned scope at "/".
+    private IEnumerable<(string Root, DocsVersion? Version)> Scopes()
+    {
+        yield return ("", null);
+        foreach (var v in _versions.Versions) yield return (UrlPath.Normalize(v.RootUrl).TrimEnd('/'), v);
+    }
+
+    // Every path Get answers, for `shelldocs build`.
+    public IEnumerable AllPaths()
+    {
+        foreach (var (root, _) in Scopes())
+        {
+            yield return root + "/llms.txt";
+            yield return root + "/llms-full.txt";
+            if (!Llms.AgentFiles) continue;
+            foreach (var name in AgentFileNames) yield return root + "/agents/" + name;
+        }
+        foreach (var url in _graph.AllUrls.Order(StringComparer.Ordinal))
+            if (IsMarkdownPage(_graph.ResolveByUrl(url))) yield return UrlPath.Normalize(url) + ".md";
+    }
+
+    public LlmsFile? Get(string path, string baseUrl)
+    {
+        if (string.IsNullOrEmpty(path) || !path.StartsWith('/')) return null;
+        foreach (var (root, version) in Scopes())
+        {
+            if (Is(path, root + "/llms.txt")) return new(path, Index(root, version, baseUrl), false);
+            if (Is(path, root + "/llms-full.txt")) return new(path, Full(root, version, baseUrl), false);
+            if (!Llms.AgentFiles || !path.StartsWith(root + "/agents/", StringComparison.OrdinalIgnoreCase)) continue;
+            var name = AgentFileNames.FirstOrDefault(n => Is(path, root + "/agents/" + n));
+            if (name is not null) return new(path, AgentFile(root, version, baseUrl, name), true);
+        }
+        if (path.EndsWith(".md", StringComparison.OrdinalIgnoreCase) && _graph.ResolveByUrl(path[..^3]) is { } node && IsMarkdownPage(node))
+            return new(path, Page(node, Converter(baseUrl)), false);
+        return null;
+    }
+
+    private static bool Is(string path, string candidate) => string.Equals(path, candidate, StringComparison.OrdinalIgnoreCase);
+
+    private static bool IsMarkdownPage(NavigationNode? node) =>
+        node is { Kind: NodeKind.Page, Path: not null } && !string.IsNullOrEmpty(node.Url) && File.Exists(node.Path);
+
+    private LlmsMarkdown Converter(string baseUrl) => new(_registry, _graph, _options.DemoSourceRoot, baseUrl);
+
+    private List Pages(DocsVersion? version)
+    {
+        var latest = _versions.Versions.FirstOrDefault(v => v.IsLatest) ?? _versions.Versions.FirstOrDefault();
+        return _graph.Flatten().Where(n => IsMarkdownPage(n) && (version is null
+            ? _versions.FindContaining(n.Url) is not { } owner || owner == latest
+            : _versions.FindContaining(n.Url) == version)).ToList();
+    }
+
+    private string Header(string root, DocsVersion? version, LlmsMarkdown md)
+    {
+        var sb = new StringBuilder($"# {SiteName}\n\n");
+        var summary = OneLine(Llms.Summary ?? _options.SiteTagline);
+        if (summary.Length > 0 && !".!?".Contains(summary[^1])) summary += ".";
+        var shown = version ?? _versions.Versions.FirstOrDefault(v => v.IsLatest);
+        if (shown is not null)
+        {
+            var others = _versions.Versions.Where(v => v != shown)
+                .Select(v => $"[{v.Label}]({md.Url(UrlPath.Normalize(v.RootUrl).TrimEnd('/') + "/llms.txt", markdown: false)})").ToList();
+            summary = $"{summary}{(summary.Length > 0 ? " " : "")}These docs are for {shown.Label}."
+                + (others.Count > 0 ? " Other versions: " + string.Join(", ", others) + "." : "");
+        }
+        if (summary.Length > 0) sb.Append($"> {summary}\n\n");
+        if (Rules() is { Length: > 0 } rules) sb.Append(rules).Append("\n\n");
+        return sb.ToString();
+    }
+
+    private string Index(string root, DocsVersion? version, string baseUrl)
+    {
+        var md = Converter(baseUrl);
+        var sb = new StringBuilder(Header(root, version, md));
+        sb.Append($"The [full text]({md.Url(root + "/llms-full.txt", markdown: false)}) has every page below in one file.\n");
+
+        var pages = Pages(version);
+        var top = CommonAncestor(pages);
+        var install = InstallPattern();
+        // Pages outside every version listed with the latest one: its groups carry its label.
+        var mixed = pages.Any(p => _versions.FindContaining(p.Url) is null) && pages.Any(p => _versions.FindContaining(p.Url) is not null);
+        string? group = null;
+        foreach (var page in pages)
+        {
+            var g = Group(page, top);
+            if (mixed && _versions.FindContaining(page.Url) is { } owner) g = g == "Pages" ? owner.Label : $"{owner.Label} / {g}";
+            if (g != group) { sb.Append($"\n## {g}\n\n"); group = g; }
+            sb.Append($"- [{page.Title}]({md.Url(page.Url)})");
+            if (OneLine(page.Description).TrimEnd('.') is { Length: > 0 } d) sb.Append(": ").Append(d).Append('.');
+            if (install is not null && InstallCommand(page, install) is { } cmd) sb.Append($" Install: `{cmd}`");
+            sb.Append('\n');
+        }
+        return sb.ToString();
+    }
+
+    private string Full(string root, DocsVersion? version, string baseUrl)
+    {
+        var md = Converter(baseUrl);
+        var sb = new StringBuilder(Header(root, version, md).TrimEnd('\n'));
+        foreach (var page in Pages(version)) sb.Append("\n\n---\n\n").Append(Page(page, md).TrimEnd('\n'));
+        return sb.Append('\n').ToString();
+    }
+
+    private static string Page(NavigationNode page, LlmsMarkdown md)
+    {
+        var body = md.Convert(FrontmatterParser.Parse(File.ReadAllText(page.Path!)).Body);
+        var h1 = $"# {page.Title}";
+        if (body.StartsWith("# ", StringComparison.Ordinal))
+        {
+            var nl = body.IndexOf('\n');
+            h1 = nl < 0 ? body : body[..nl];
+            body = nl < 0 ? "" : body[(nl + 1)..].TrimStart('\n');
+        }
+        var sb = new StringBuilder(h1).Append("\n\n");
+        if (OneLine(page.Description) is { Length: > 0 } d) sb.Append($"> {d}\n\n");
+        sb.Append($"Source: {md.Url(page.Url, markdown: false)}\n\n");
+        return sb.Append(body).Append('\n').ToString();
+    }
+
+    private string AgentFile(string root, DocsVersion? version, string baseUrl, string name)
+    {
+        var md = Converter(baseUrl);
+        var shown = version ?? _versions.Versions.FirstOrDefault(v => v.IsLatest);
+        var example = Pages(version).FirstOrDefault();
+        var intro = $"This project uses {SiteName}{(shown is null ? "" : " " + shown.Label)}. Before writing code that uses it, "
+            + $"read {md.Url(root + "/llms.txt", markdown: false)}: it lists every docs page"
+            + (Llms.InstallCommandPattern is not null ? " with its install command" : "")
+            + ". Every page is also available as Markdown at its URL plus `.md`"
+            + (example is null ? "." : $", for example {md.Url(example.Url)}.");
+        var section = $"## {SiteName}\n\n{intro}\n";
+        if (Rules() is { Length: > 0 } rules) section += "\n" + DemoteHeadings(rules) + "\n";
+
+        if (!name.EndsWith(".mdc", StringComparison.Ordinal)) return "\n" + section;
+        var description = OneLine(Llms.Summary ?? _options.SiteTagline) is { Length: > 0 } s ? s : $"{SiteName} docs for AI agents";
+        return $"---\ndescription: {description}\nalwaysApply: true\n---\n\n{section}";
+    }
+
+    private string? Rules()
+    {
+        if (string.IsNullOrWhiteSpace(Llms.RulesFile)) return null;
+        var path = Path.IsPathRooted(Llms.RulesFile) ? Llms.RulesFile : Path.Combine(_contentRoot, Llms.RulesFile);
+        return File.Exists(path) ? File.ReadAllText(path).Replace("\r\n", "\n").Trim() : null;
+    }
+
+    // The agent files are a "## Site" section, so the rules' headings move one level down.
+    private static string DemoteHeadings(string markdown)
+    {
+        var inFence = false;
+        return string.Join("\n", markdown.Split('\n').Select(line =>
+        {
+            var t = line.TrimStart();
+            if (t.StartsWith("```") || t.StartsWith("~~~")) inFence = !inFence;
+            return !inFence && t.StartsWith('#') ? "#" + t : line;
+        }));
+    }
+
+    private Regex? InstallPattern() =>
+        string.IsNullOrWhiteSpace(Llms.InstallCommandPattern) ? null : new Regex(Llms.InstallCommandPattern, RegexOptions.None, TimeSpan.FromSeconds(1));
+
+    // First match in the page's code blocks.
+    private static string? InstallCommand(NavigationNode page, Regex pattern)
+    {
+        foreach (Match fence in SlotExtractor.FenceBlock.Matches(File.ReadAllText(page.Path!).Replace("\r\n", "\n")))
+            if (pattern.Match(fence.Groups["body"].Value) is { Success: true } m) return m.Value.Trim();
+        return null;
+    }
+
+    /* The page's sidebar section below the scope's common folder ("Guides"); a section
+       inside a folder adds the next one ("Components / Form") and deeper ones fold in. */
+    private string Group(NavigationNode page, NavigationNode? top)
+    {
+        var sections = new List();
+        for (var n = page.Parent; n is not null && n != top && n.Parent is not null; n = n.Parent)
+            if (n.Kind == NodeKind.Section && !string.IsNullOrWhiteSpace(n.Title) && !_versions.IsVersionNode(n)) sections.Insert(0, n);
+        if (sections.Count == 0) return "Pages";
+        return sections.Count > 1 && sections[0].Path is not null ? $"{sections[0].Title} / {sections[1].Title}" : sections[0].Title;
+    }
+
+    private static NavigationNode? CommonAncestor(IReadOnlyList pages)
+    {
+        List? common = null;
+        foreach (var page in pages)
+        {
+            var chain = new List();
+            for (var n = page.Parent; n is not null; n = n.Parent) chain.Insert(0, n);
+            if (common is null) { common = chain; continue; }
+            var k = 0;
+            while (k < common.Count && k < chain.Count && common[k] == chain[k]) k++;
+            common = common[..k];
+        }
+        return common?.LastOrDefault();
+    }
+
+    private static string OneLine(string? s) => s is null ? "" : Regex.Replace(s, @"\s+", " ").Trim();
+}
diff --git a/src/ShellDocs.Components/Llms/LlmsTxtMiddleware.cs b/src/ShellDocs.Components/Llms/LlmsTxtMiddleware.cs
new file mode 100644
index 0000000..73450df
--- /dev/null
+++ b/src/ShellDocs.Components/Llms/LlmsTxtMiddleware.cs
@@ -0,0 +1,80 @@
+using System.Net;
+using System.Text;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Net.Http.Headers;
+
+namespace ShellDocs.Components.Llms;
+
+/* Serves LlmsTxtFiles before routing, so the paths never reach the docs page route.
+   Text is text/plain because some browsers download text/markdown instead of showing
+   it; the agent files are attachments. Links use SiteUrl, or the request's origin. */
+internal sealed class LlmsTxtMiddleware
+{
+    // Sent by `shelldocs build` (from the same machine) with the export's --site-url,
+    // and its --base-href for root-relative links when there is no site URL.
+    public const string ExportSiteUrlHeader = "X-ShellDocs-Site-Url";
+    public const string ExportBasePathHeader = "X-ShellDocs-Base-Path";
+
+    private readonly RequestDelegate _next;
+    private readonly LlmsTxtFiles _files;
+    private readonly ShellDocsOptions _options;
+
+    public LlmsTxtMiddleware(RequestDelegate next, LlmsTxtFiles files, ShellDocsOptions options)
+    {
+        _next = next;
+        _files = files;
+        _options = options;
+    }
+
+    public Task InvokeAsync(HttpContext context)
+    {
+        var request = context.Request;
+        if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method)) return _next(context);
+
+        var path = request.Path.Value ?? "";
+        if (string.Equals(path, LlmsTxtFiles.ManifestPath, StringComparison.OrdinalIgnoreCase))
+            return context.Response.WriteAsJsonAsync(_files.AllPaths());
+        if (!path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) && !path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
+            && !path.EndsWith(".mdc", StringComparison.OrdinalIgnoreCase))
+            return _next(context);
+
+        var file = _files.Get(path, BaseUrl(context));
+        if (file is null) return _next(context);
+
+        var bytes = Encoding.UTF8.GetBytes(file.Body);
+        var response = context.Response;
+        response.ContentType = "text/plain; charset=utf-8";
+        response.ContentLength = bytes.Length;
+        response.Headers[HeaderNames.CacheControl] = "no-cache";
+        if (file.IsDownload)
+            response.Headers[HeaderNames.ContentDisposition] = $"attachment; filename=\"{Path.GetFileName(file.Path)}\"";
+        return HttpMethods.IsHead(request.Method) ? Task.CompletedTask : response.Body.WriteAsync(bytes).AsTask();
+    }
+
+    private string BaseUrl(HttpContext context)
+    {
+        var request = context.Request;
+        var remote = context.Connection.RemoteIpAddress;
+        var local = remote is null || IPAddress.IsLoopback(remote);
+        if (local && (request.Headers.ContainsKey(ExportSiteUrlHeader) || request.Headers.ContainsKey(ExportBasePathHeader)))
+        {
+            var site = request.Headers[ExportSiteUrlHeader].ToString();
+            if (site.Length > 0) return site.TrimEnd('/');
+            if (!string.IsNullOrWhiteSpace(_options.SiteUrl)) return _options.SiteUrl.TrimEnd('/');
+            return request.Headers[ExportBasePathHeader].ToString().TrimEnd('/');
+        }
+        if (!string.IsNullOrWhiteSpace(_options.SiteUrl)) return _options.SiteUrl.TrimEnd('/');
+        return $"{request.Scheme}://{request.Host}{request.PathBase}";
+    }
+}
+
+internal sealed class LlmsTxtStartupFilter : IStartupFilter
+{
+    public Action Configure(Action next) => app =>
+    {
+        app.UseMiddleware();
+        next(app);
+    };
+}
diff --git a/src/ShellDocs.Components/LlmsTxtOptions.cs b/src/ShellDocs.Components/LlmsTxtOptions.cs
new file mode 100644
index 0000000..76f7701
--- /dev/null
+++ b/src/ShellDocs.Components/LlmsTxtOptions.cs
@@ -0,0 +1,24 @@
+namespace ShellDocs.Components;
+
+// Set through ShellDocsOptions.EnableLlmsTxt; see Llms/LlmsTxtFiles.cs for what is served.
+public sealed class LlmsTxtOptions
+{
+    // The "> " line under the site name in llms.txt and the Cursor rule's description.
+    // Defaults to SiteTagline.
+    public string? Summary { get; set; }
+
+    /* A Markdown file shown at the top of llms.txt and llms-full.txt and in the agent
+       instruction files, such as "## Rules for AI agents" and a list. A relative path is
+       resolved against the app's content root. */
+    public string? RulesFile { get; set; }
+
+    /* Regex for a page's install command, looked for in its code blocks and listed
+       after the page in llms.txt, for example @"shellui add [\w-]+". */
+    public string? InstallCommandPattern { get; set; }
+
+    // AGENTS.md, CLAUDE.md, copilot-instructions.md and a Cursor rule under agents/.
+    public bool AgentFiles { get; set; } = true;
+
+    // The page actions menu by the title: Copy page, View as Markdown, Open in Claude / ChatGPT.
+    public bool PageActions { get; set; } = true;
+}
diff --git a/src/ShellDocs.Components/ServiceCollectionExtensions.cs b/src/ShellDocs.Components/ServiceCollectionExtensions.cs
index 02dbc31..e2e2640 100644
--- a/src/ShellDocs.Components/ServiceCollectionExtensions.cs
+++ b/src/ShellDocs.Components/ServiceCollectionExtensions.cs
@@ -3,6 +3,7 @@
 using Microsoft.Extensions.Logging;
 using ShellDocs.Components.Chrome;
 using ShellDocs.Components.Content;
+using ShellDocs.Components.Llms;
 using ShellDocs.Core;
 using ShellDocs.Markdown;
 
@@ -45,6 +46,13 @@ public static IServiceCollection AddShellDocs(this IServiceCollection services,
         });
         services.AddSingleton(sp => SearchIndex.FromGraph(sp.GetRequiredService()));
         services.AddSingleton();
+        // Before the redirects, so a page's .md is never redirected.
+        if (options.LlmsTxt is not null)
+        {
+            services.AddSingleton(sp => new LlmsTxtFiles(options, sp.GetRequiredService(),
+                sp.GetRequiredService(), sp.GetRequiredService(), sp.GetService()));
+            services.AddTransient();
+        }
         services.AddSingleton();
         if (options.EnableRedirects)
             services.AddTransient();
diff --git a/src/ShellDocs.Components/ShellDocsOptions.cs b/src/ShellDocs.Components/ShellDocsOptions.cs
index 9ddc39c..3966ed4 100644
--- a/src/ShellDocs.Components/ShellDocsOptions.cs
+++ b/src/ShellDocs.Components/ShellDocsOptions.cs
@@ -49,6 +49,8 @@ written as redirect pages by `shelldocs build`. */
     public List Redirects { get; } = new();
     // Searched recursively for X.razor to show as 's source.
     public string? DemoSourceRoot { get; set; }
+    // Null until EnableLlmsTxt is called.
+    public LlmsTxtOptions? LlmsTxt { get; private set; }
     public List RegisteredComponents { get; } = new();
     // ShellDocs' own content primitives, filled by AddShellDocs. Registered before
     // RegisteredComponents so a consumer component with the same name wins.
@@ -167,6 +169,16 @@ public ShellDocsOptions AddSyncSwitch(string syncKey, params string[] options)
         return this;
     }
 
+    /* llms.txt (https://llmstxt.org) for AI agents: /llms.txt, /llms-full.txt, every
+       page as Markdown at its URL + ".md", the same files under each version root,
+       agent instruction files and a page actions menu. `shelldocs build` exports them. */
+    public ShellDocsOptions EnableLlmsTxt(Action? configure = null)
+    {
+        LlmsTxt ??= new LlmsTxtOptions();
+        configure?.Invoke(LlmsTxt);
+        return this;
+    }
+
     public ShellDocsOptions AddNavLink(string label, string href)
     {
         PrimaryNav.Add(new NavLink(label, href));
diff --git a/src/ShellDocs.Markdown/SlotExtractor.cs b/src/ShellDocs.Markdown/SlotExtractor.cs
index 8d071f7..7de36e1 100644
--- a/src/ShellDocs.Markdown/SlotExtractor.cs
+++ b/src/ShellDocs.Markdown/SlotExtractor.cs
@@ -19,7 +19,7 @@ a run of the same character at least as long. A longer fence can show a
 
     /* CommonMark code span: a backtick run, content, and a closing run of the same
        length. May wrap lines but not cross a blank line. */
-    private static readonly Regex InlineCode = new(
+    internal static readonly Regex InlineCode = new(
         @"(?`+)(?!`)(?:[^`\r\n]|\r?\n(?![ \t]*\r?\n)|`+(?!`))*?(?(?!`)",
         RegexOptions.Compiled);
 
diff --git a/tests/ShellDocs.Tests/LlmsTxtTests.cs b/tests/ShellDocs.Tests/LlmsTxtTests.cs
new file mode 100644
index 0000000..177b93d
--- /dev/null
+++ b/tests/ShellDocs.Tests/LlmsTxtTests.cs
@@ -0,0 +1,323 @@
+using System.Net;
+using System.Reflection;
+using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using ShellDocs.Components;
+using ShellDocs.Components.Chrome;
+using ShellDocs.Components.Content;
+using ShellDocs.Components.Llms;
+using ShellDocs.Core;
+using ShellDocs.Markdown;
+using Xunit;
+
+namespace ShellDocs.Tests;
+
+public class Shout : ComponentBase
+{
+    [Parameter] public RenderFragment? ChildContent { get; set; }
+}
+
+public class LlmsTxtTests : IDisposable
+{
+    private const string Site = "https://shellui.dev";
+    private readonly string _root;
+
+    public LlmsTxtTests()
+    {
+        _root = Path.Combine(Path.GetTempPath(), "shelldocs-llms-" + Guid.NewGuid().ToString("N"));
+        Write("content/docs/introduction.md", "---\ntitle: Introduction\ndescription: Start here.\n---\nRead the [setup](/docs/guides/setup#install).\n");
+        Write("content/docs/guides/setup.md", "---\ntitle: Setup\ndescription: Set it up\n---\n# Setup\n\n```bash\nshellui add button\n```\n");
+        Write("content/docs/v2/button.md", "---\ntitle: Button\n---\n# Button\n\nThe new button.\n");
+        Write("content/docs/v1/button.md", "---\ntitle: Button\n---\n# Button\n\nThe old button.\n");
+        Write("rules.md", "## Rules for AI agents\n\n1. Use ShellUI.\n");
+        Write("demos/HelloDemo.razor", "

Hello

\n"); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { } + } + + private void Write(string relative, string content) + { + var path = Path.Combine(_root, relative.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + private ComponentRenderHarness Harness(Action? llms = null, bool enabled = true, string uri = "http://localhost/docs/introduction") => new(o => + { + o.ContentRoot = Path.Combine(_root, "content"); + o.SiteName = "ShellUI"; + o.SiteTagline = "ShellUI for Blazor"; + o.DemoSourceRoot = Path.Combine(_root, "demos"); + o.RegisterComponent(); + o.AddVersion("v2", "v2.0", "/docs/v2", latest: true); + o.AddVersion("v1", "v1.0", "/docs/v1"); + if (enabled) + o.EnableLlmsTxt(l => + { + l.RulesFile = Path.Combine(_root, "rules.md"); + l.InstallCommandPattern = @"shellui add [\w-]+"; + llms?.Invoke(l); + }); + }, uri); + + private LlmsMarkdown Converter(ComponentRenderHarness h) => new( + h.Services.GetRequiredService(), h.Services.GetRequiredService(), Path.Combine(_root, "demos"), Site); + + [Fact] + public void EveryBuiltInComponent_HasAMarkdownForm() + { + var builtIns = Harness().Services.GetRequiredService().BuiltInComponents; + Assert.NotEmpty(builtIns); + Assert.All(builtIns, t => Assert.Contains(t, LlmsMarkdown.HandledTypes)); + } + + [Fact] + public void Convert_TurnsEveryDocsTagIntoMarkdown() + { + const string page = """ + + Text with [link](/docs/guides/setup). + + ```bash + run it + ``` + + + + + + + + First. + + + Second. + + + + + + ```csharp + var x = 1; + ``` + + + + + + + Card body. + + + + Get it. + Go. + + + + + + + + + + + + + + + + + Hi + + + + + + ```razor:preview + + ``` + + ````markdown + + ```` + + Inline loud word, and `` in a code span. + """; + var md = Converter(Harness()).Convert(page); + + Assert.DoesNotMatch(" **Careful**\n>\n> Text with [link](https://shellui.dev/docs/guides/setup.md).\n>\n> ```bash\n> run it\n> ```", md); + Assert.Contains("> **Note**\n>\n> One line.", md); + Assert.Contains("**One**\n\nFirst.", md); + Assert.Contains("**Two**\n\nSecond.", md); + Assert.Contains("**C#**\n\n```csharp\nvar x = 1;\n```", md); + Assert.Contains("- [Setup](https://shellui.dev/docs/guides/setup.md): Set it up.\n- **Plain**: Card body.", md); + Assert.Contains("**1. Install**\n\nGet it.", md); + Assert.Contains("**2. Run**\n\nGo.", md); + Assert.Contains("```text\nsrc/\n App.razor # root\n```", md); + Assert.Contains("| `Title` (required) | `string` | `null` | The title. |", md); + Assert.Contains("| `Variant` | `string` |", md); + Assert.Contains("**Hello**\n\n```razor\n

Hello

\n```", md); + Assert.Contains("```razor\nHi\n```", md); + Assert.Contains("[Live preview](https://shellui.dev/preview/index.html)", md); + Assert.DoesNotContain("platform", md); + Assert.Contains("```razor\n\n```", md); + Assert.Contains("````markdown\n\n````", md); + Assert.Contains("Inline loud word, and `` in a code span.", md); + } + + // The text outside fences (quoted ones too) and code spans. + private static string OutsideCode(string md) + { + var kept = new List(); + string? fence = null; + foreach (var line in md.Split('\n')) + { + var t = line.TrimStart('>', ' '); + var run = Regex.Match(t, "^`{3,}").Value; + if (fence is null && run.Length > 0) { fence = run; continue; } + if (fence is not null) { if (t.StartsWith(fence) && t.Trim('`').Length == 0) fence = null; continue; } + kept.Add(Regex.Replace(line, "`[^`\n]*`", "")); + } + return string.Join("\n", kept); + } + + [Fact] + public void Index_ListsTheLatestPages_WithRulesAndInstallCommands() + { + var files = Harness().Services.GetRequiredService(); + var index = files.Get("/llms.txt", Site)!.Body; + + Assert.StartsWith("# ShellUI\n\n> ShellUI for Blazor. These docs are for v2.0. Other versions: [v1.0](https://shellui.dev/docs/v1/llms.txt).\n\n## Rules for AI agents\n\n1. Use ShellUI.\n\n", index); + Assert.Contains("The [full text](https://shellui.dev/llms-full.txt) has every page below in one file.", index); + Assert.Contains("- [Introduction](https://shellui.dev/docs/introduction.md): Start here.\n", index); + Assert.Contains("- [Setup](https://shellui.dev/docs/guides/setup.md): Set it up. Install: `shellui add button`\n", index); + Assert.Contains("- [Button](https://shellui.dev/docs/v2/button.md)", index); + Assert.DoesNotContain("/docs/v1/button", index); + + var v1 = files.Get("/docs/v1/llms.txt", Site)!.Body; + Assert.Contains("These docs are for v1.0. Other versions: [v2.0](https://shellui.dev/docs/v2/llms.txt).", v1); + Assert.Contains("- [Button](https://shellui.dev/docs/v1/button.md)", v1); + Assert.DoesNotContain("introduction", v1); + } + + [Fact] + public void FullText_AndPages_AreMarkdown() + { + var files = Harness().Services.GetRequiredService(); + + var page = files.Get("/docs/introduction.md", Site)!; + Assert.False(page.IsDownload); + Assert.Equal("# Introduction\n\n> Start here.\n\nSource: https://shellui.dev/docs/introduction\n\nRead the [setup](https://shellui.dev/docs/guides/setup.md#install).\n", page.Body); + // A page with its own heading keeps it. + Assert.StartsWith("# Setup\n\n> Set it up\n\nSource: https://shellui.dev/docs/guides/setup\n\n```bash", files.Get("/docs/guides/setup.md", Site)!.Body); + + var full = files.Get("/llms-full.txt", Site)!.Body; + Assert.Contains("\n\n---\n\n# Introduction\n\n> Start here.", full); + Assert.Contains("\n\n---\n\n# Button\n\n", full); + Assert.Contains("The new button.", full); + Assert.DoesNotContain("The old button.", full); + + Assert.Null(files.Get("/docs/nope.md", Site)); + Assert.Null(files.Get("/docs/introduction", Site)); + // Root-relative links when there is no base URL. + Assert.Contains("[setup](/docs/guides/setup.md#install)", files.Get("/docs/introduction.md", "")!.Body); + } + + [Fact] + public void AgentFiles_AreASectionToAppend() + { + var files = Harness().Services.GetRequiredService(); + + var claude = files.Get("/agents/CLAUDE.md", Site)!; + Assert.True(claude.IsDownload); + Assert.StartsWith("\n## ShellUI\n\nThis project uses ShellUI v2.0. Before writing code that uses it, read https://shellui.dev/llms.txt: it lists every docs page with its install command.", claude.Body); + Assert.Contains("### Rules for AI agents", claude.Body); + + Assert.StartsWith("---\ndescription: ShellUI for Blazor\nalwaysApply: true\n---\n\n## ShellUI\n\n", files.Get("/agents/shellui.mdc", Site)!.Body); + Assert.Contains("read https://shellui.dev/docs/v1/llms.txt", files.Get("/docs/v1/agents/AGENTS.md", Site)!.Body); + Assert.NotNull(files.Get("/docs/v2/agents/copilot-instructions.md", Site)); + + var off = Harness(l => l.AgentFiles = false).Services.GetRequiredService(); + Assert.Null(off.Get("/agents/CLAUDE.md", Site)); + Assert.DoesNotContain(off.AllPaths(), p => p.Contains("/agents/")); + } + + [Fact] + public void AllPaths_ListsEveryFileForTheBuild() + { + var paths = Harness().Services.GetRequiredService().AllPaths().ToList(); + foreach (var expected in new[] + { + "/llms.txt", "/llms-full.txt", "/agents/AGENTS.md", "/agents/shellui.mdc", + "/docs/v1/llms.txt", "/docs/v2/llms-full.txt", "/docs/v2/agents/CLAUDE.md", + "/docs/introduction.md", "/docs/guides/setup.md", "/docs/v1/button.md", + }) + Assert.Contains(expected, paths); + } + + [Fact] + public async Task Middleware_ServesTextAndDownloads_AndPassesOnTheRest() + { + var services = Harness().Services; + var passedOn = 0; + var middleware = new LlmsTxtMiddleware(_ => { passedOn++; return Task.CompletedTask; }, + services.GetRequiredService(), services.GetRequiredService()); + + var txt = Context("/llms.txt"); + await middleware.InvokeAsync(txt); + Assert.Equal("text/plain; charset=utf-8", txt.Response.ContentType); + Assert.Contains("(https://docs.test/docs/introduction.md)", Body(txt)); + Assert.False(txt.Response.Headers.ContainsKey("Content-Disposition")); + + var agent = Context("/agents/AGENTS.md"); + await middleware.InvokeAsync(agent); + Assert.Equal("attachment; filename=\"AGENTS.md\"", agent.Response.Headers.ContentDisposition.ToString()); + + // shelldocs build, from this machine: the export's site URL or base path. + var export = Context("/docs/introduction.md"); + export.Request.Headers[LlmsTxtMiddleware.ExportSiteUrlHeader] = ""; + export.Request.Headers[LlmsTxtMiddleware.ExportBasePathHeader] = "/sub"; + await middleware.InvokeAsync(export); + Assert.Contains("(/sub/docs/guides/setup.md#install)", Body(export)); + + // The same headers from anywhere else are ignored. + var remote = Context("/docs/introduction.md"); + remote.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.5"); + remote.Request.Headers[LlmsTxtMiddleware.ExportSiteUrlHeader] = "https://evil.test"; + await middleware.InvokeAsync(remote); + Assert.Contains("(https://docs.test/docs/guides/setup.md#install)", Body(remote)); + + await middleware.InvokeAsync(Context("/docs/introduction")); + await middleware.InvokeAsync(Context("/docs/nope.md")); + Assert.Equal(2, passedOn); + } + + [Fact] + public void Middleware_IsOnlyInThePipelineWhenEnabled() + { + Assert.Contains(Harness().Services.GetServices(), f => f is LlmsTxtStartupFilter); + Assert.DoesNotContain(Harness(enabled: false).Services.GetServices(), f => f is LlmsTxtStartupFilter); + Assert.Null(Harness(enabled: false).Services.GetService()); + } + + private static DefaultHttpContext Context(string path) + { + var ctx = new DefaultHttpContext(); + ctx.Request.Method = "GET"; + ctx.Request.Scheme = "https"; + ctx.Request.Host = new HostString("docs.test"); + ctx.Request.Path = path; + ctx.Response.Body = new MemoryStream(); + return ctx; + } + + private static string Body(HttpContext ctx) => System.Text.Encoding.UTF8.GetString(((MemoryStream)ctx.Response.Body).ToArray()); +} From aaaf0e04ceefaf7661ebfb663b52bfce0ead538d Mon Sep 17 00:00:00 2001 From: Shewart Date: Wed, 14 Oct 2026 09:59:21 +0200 Subject: [PATCH 08/11] feat(components): page actions menu by the title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With EnableLlmsTxt, a ⋯ menu next to the page title offers Copy page, View as Markdown, Open in Claude and Open in ChatGPT. shelldocs.js drives it, so it works in static exports. Links to .txt and .md files on the site now load the file instead of the docs' "Page not found". --- .../Chrome/DocsPageActions.razor | 59 ++++++++++++++ .../Chrome/DocsPageActions.razor.css | 78 +++++++++++++++++++ .../Content/MarkdownContent.razor | 1 + src/ShellDocs.Components/wwwroot/shelldocs.js | 73 +++++++++++++++-- tests/ShellDocs.Tests/LlmsTxtTests.cs | 43 ++++++++++ 5 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 src/ShellDocs.Components/Chrome/DocsPageActions.razor create mode 100644 src/ShellDocs.Components/Chrome/DocsPageActions.razor.css diff --git a/src/ShellDocs.Components/Chrome/DocsPageActions.razor b/src/ShellDocs.Components/Chrome/DocsPageActions.razor new file mode 100644 index 0000000..525b24b --- /dev/null +++ b/src/ShellDocs.Components/Chrome/DocsPageActions.razor @@ -0,0 +1,59 @@ +@using ShellIcons.Icons +@inject ShellDocsOptions Options +@inject NavigationManager Nav +@inject NavigationGraph Graph + +@* The ⋯ menu by the page title when EnableLlmsTxt is on. shelldocs.js opens it, + copies the page's Markdown ([data-page-copy]) and fills the chat links' prompt + with the page's absolute .md URL ([data-page-ai]), so it works in static exports. *@ +@if (_markdownHref is not null) +{ +
+} + +@code { + private const string ClaudeUrl = "https://claude.ai/new?q="; + private const string ChatGptUrl = "https://chatgpt.com/?q="; + + // Base-relative ("docs/intro.md"), so it follows in a sub-path export. + private string? _markdownHref; + + protected override void OnParametersSet() + { + var path = DocsVersionResolver.PathOf(Nav); + _markdownHref = Options.LlmsTxt is { PageActions: true } && Graph.ResolveByUrl(path) is { Kind: NodeKind.Page, Path: not null } + ? path.TrimStart('/') + ".md" + : null; + } + + // Absolute with SiteUrl; otherwise shelldocs.js puts the visitor's origin in on click. + private string ChatHref(string chat) + { + var url = string.IsNullOrWhiteSpace(Options.SiteUrl) ? "/" + _markdownHref : Options.SiteUrl.TrimEnd('/') + "/" + _markdownHref; + return chat + Uri.EscapeDataString($"Read {url} and help me with it."); + } +} diff --git a/src/ShellDocs.Components/Chrome/DocsPageActions.razor.css b/src/ShellDocs.Components/Chrome/DocsPageActions.razor.css new file mode 100644 index 0000000..6c26ddd --- /dev/null +++ b/src/ShellDocs.Components/Chrome/DocsPageActions.razor.css @@ -0,0 +1,78 @@ +/* Floats right of the page title that follows it; same menu as the preview ⋯ menu. */ +.page-actions { + position: relative; + float: right; + margin: 0.25rem 0 0.5rem 1rem; + z-index: 20; +} + +.page-actions-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + background: transparent; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 3px); + color: var(--muted-foreground); + cursor: pointer; + transition: color 150ms, background 150ms; +} +.page-actions-trigger:hover, +.page-actions[data-open="true"] .page-actions-trigger { + color: var(--foreground); + background: var(--muted); +} +.page-actions-trigger:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; } +.page-actions-trigger ::deep svg { width: 1rem; height: 1rem; } + +.page-actions-list { + position: absolute; + top: calc(100% + 0.35rem); + right: 0; + min-width: 12rem; + display: none; + flex-direction: column; + gap: 0.1rem; + padding: 0.3rem; + background: var(--popover); + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 2px); + box-shadow: 0 12px 32px -8px rgb(0 0 0 / 0.18), 0 4px 12px -4px rgb(0 0 0 / 0.06); + z-index: 30; +} +.page-actions[data-open="true"] .page-actions-list { display: flex; } + +.page-actions-item { + display: flex; + align-items: center; + gap: 0.55rem; + width: 100%; + padding: 0.45rem 0.55rem; + background: transparent; + border: 0; + border-radius: calc(var(--radius) - 3px); + color: var(--foreground); + font: inherit; + font-size: 0.8125rem; + text-align: left; + text-decoration: none; + white-space: nowrap; + cursor: pointer; + transition: background 150ms; +} +.page-actions-item:hover, +.page-actions-item:focus-visible { background: var(--muted); outline: none; } +.page-actions-item ::deep svg { width: 0.875rem; height: 0.875rem; color: var(--muted-foreground); flex-shrink: 0; } + +.page-actions-item ::deep .icon-check { display: none; } +.page-actions-item.copied ::deep .icon-copy { display: none; } +.page-actions-item.copied ::deep .icon-check { display: inline; color: var(--success, oklch(0.723 0.219 149.579)); } + +.page-actions-separator { + height: 1px; + margin: 0.2rem 0.25rem; + background: var(--border); +} diff --git a/src/ShellDocs.Components/Content/MarkdownContent.razor b/src/ShellDocs.Components/Content/MarkdownContent.razor index 86185fd..bd665ca 100644 --- a/src/ShellDocs.Components/Content/MarkdownContent.razor +++ b/src/ShellDocs.Components/Content/MarkdownContent.razor @@ -15,6 +15,7 @@ @* Ordinal ids keep anchors and saved tab state stable across renders. *@ var previewIndex = 0; var switchesShown = false; + @if (_title is not null) {
diff --git a/src/ShellDocs.Components/wwwroot/shelldocs.js b/src/ShellDocs.Components/wwwroot/shelldocs.js index 9642758..ec7e10d 100644 --- a/src/ShellDocs.Components/wwwroot/shelldocs.js +++ b/src/ShellDocs.Components/wwwroot/shelldocs.js @@ -669,13 +669,13 @@ window.shelldocsChrome = (function () { } // Dropdowns — .pkg (PackageSelector), .ver (VersionSelector), .preview-menu - // (preview ⋯ menu): the trigger toggles [data-open]; outside click, Escape, - // or picking an option closes it. - var DROPDOWN = '.pkg, .ver, .preview-menu'; - var OPEN_DROPDOWN = '.pkg[data-open="true"], .ver[data-open="true"], .preview-menu[data-open="true"]'; - var TRIGGER = '.pkg-trigger, .ver-trigger, .preview-menu-trigger'; - var MENU = '.pkg-menu, .ver-menu, .preview-menu-list'; - var OPTION = '.pkg-option, .ver-option, .preview-menu-item'; + // (preview ⋯ menu), .page-actions (page ⋯ menu): the trigger toggles [data-open]; + // outside click, Escape, or picking an option closes it. + var DROPDOWN = '.pkg, .ver, .preview-menu, .page-actions'; + var OPEN_DROPDOWN = '.pkg[data-open="true"], .ver[data-open="true"], .preview-menu[data-open="true"], .page-actions[data-open="true"]'; + var TRIGGER = '.pkg-trigger, .ver-trigger, .preview-menu-trigger, .page-actions-trigger'; + var MENU = '.pkg-menu, .ver-menu, .preview-menu-list, .page-actions-list'; + var OPTION = '.pkg-option, .ver-option, .preview-menu-item, .page-actions-item'; var CHEVRON = '.pkg-chevron, .ver-chevron'; function onDropdownClick(e) { @@ -690,6 +690,8 @@ window.shelldocsChrome = (function () { } var option = e.target.closest(OPTION); + // Copy page closes the menu itself, after showing that it copied. + if (option && option.hasAttribute('data-page-copy')) return; if (option) { var owner = option.closest(DROPDOWN); if (owner) setDropdown(owner, false); @@ -1014,6 +1016,62 @@ window.shelldocsChrome = (function () { // The shell is already parsed here; restoring now avoids a flash of the open sidebar. restoreCollapse(document); + /* Page ⋯ menu (EnableLlmsTxt): [data-page-md] holds the page's base-relative .md + URL. Copy page fetches it; the chat links get a prompt with its absolute URL. */ + function pageMarkdownUrl(el) { + var root = el.closest('[data-page-md]'); + return root ? new URL(root.getAttribute('data-page-md'), document.baseURI).href : null; + } + + function fillPageActions(scope) { + scope.querySelectorAll('[data-page-ai]').forEach(function (link) { + var url = pageMarkdownUrl(link); + if (url) link.href = link.getAttribute('data-page-ai') + encodeURIComponent('Read ' + url + ' and help me with it.'); + }); + } + + function onPageActionsClick(e) { + // Again on click: the circuit may have replaced the links filled in at load. + var chat = e.target.closest('[data-page-ai]'); + if (chat) fillPageActions(chat.parentElement); + var copy = e.target.closest('[data-page-copy]'); + if (!copy) return; + var url = pageMarkdownUrl(copy); + if (!url) return; + var text = fetch(url).then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.text(); + }); + var clipboard = navigator.clipboard; + // A ClipboardItem holding the pending text keeps Safari's user-gesture check happy. + var write = clipboard && clipboard.write && window.ClipboardItem + ? clipboard.write([new ClipboardItem({ 'text/plain': text.then(function (t) { return new Blob([t], { type: 'text/plain' }); }) })]) + .catch(function () { return text.then(function (t) { return clipboard.writeText(t); }); }) + : clipboard && clipboard.writeText + ? text.then(function (t) { return clipboard.writeText(t); }) + : Promise.reject(new Error('clipboard unavailable')); + write.then(function () { + copy.classList.add('copied'); + setTimeout(function () { + copy.classList.remove('copied'); + var menu = copy.closest(DROPDOWN); + if (menu) setDropdown(menu, false); + }, 900); + }).catch(function () { /* silent */ }); + } + + /* Links to .txt / .md files (llms.txt, a page's Markdown) load as files. Blazor's + router and enhanced navigation would show the docs' "Page not found" instead, so + the click stops here, before their document listeners, and the browser follows it. */ + window.addEventListener('click', function (e) { + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + var link = e.target.closest && e.target.closest('a[href]'); + if (!link || link.hasAttribute('download') || (link.target && link.target !== '_self')) return; + var url = new URL(link.href, document.baseURI); + if (url.origin === location.origin && /\.(txt|md|mdc)$/i.test(url.pathname)) e.stopPropagation(); + }, true); + document.addEventListener('click', onPageActionsClick); + /* 404.html is one file for every unknown URL, so its path line ships blank and hidden; fill in the visitor's path, relative to the site's base. */ function fillNotFoundPath() { @@ -1031,6 +1089,7 @@ window.shelldocsChrome = (function () { function initPage() { initToc(); fillNotFoundPath(); + fillPageActions(document); restoreCollapse(document); restoreTabs(document); window.shelldocsIframes.init(document); diff --git a/tests/ShellDocs.Tests/LlmsTxtTests.cs b/tests/ShellDocs.Tests/LlmsTxtTests.cs index 177b93d..7b582c2 100644 --- a/tests/ShellDocs.Tests/LlmsTxtTests.cs +++ b/tests/ShellDocs.Tests/LlmsTxtTests.cs @@ -308,6 +308,34 @@ public void Middleware_IsOnlyInThePipelineWhenEnabled() Assert.Null(Harness(enabled: false).Services.GetService()); } + [Fact] + public async Task PageActions_AreStaticHostFriendly() + { + var html = await Harness().RenderAsync(); + Assert.Contains("data-page-md=\"docs/introduction.md\"", html); + Assert.Matches("]*data-page-copy", html); + Assert.Matches("]*href=\"docs/introduction.md\"[^>]*target=\"_blank\"[^>]*>[\\s\\S]*?View as Markdown", html); + Assert.Contains("data-page-ai=\"https://claude.ai/new?q=\"", html); + Assert.Contains("data-page-ai=\"https://chatgpt.com/?q=\"", html); + Assert.Contains("href=\"https://claude.ai/new?q=Read%20%2Fdocs%2Fintroduction.md%20and%20help%20me%20with%20it.\"", html); + Assert.DoesNotContain("blazor:on", html); + + Assert.Contains("data-page-md", await Harness().RenderAsync(new() { ["Markdown"] = "# Hi" })); + Assert.DoesNotContain("page-actions", await Harness(enabled: false).RenderAsync()); + Assert.DoesNotContain("page-actions", await Harness(l => l.PageActions = false).RenderAsync()); + Assert.DoesNotContain("page-actions", await Harness(uri: "http://localhost/showcase").RenderAsync()); + } + + [Fact] + public void ShelldocsJs_DrivesThePageMenu_AndLoadsFileLinks() + { + var js = File.ReadAllText(FindAsset("shelldocs.js")); + Assert.Contains("'.pkg, .ver, .preview-menu, .page-actions'", js); + Assert.Contains("[data-page-copy]", js); + Assert.Contains("[data-page-ai]", js); + Assert.Contains("/\\.(txt|md|mdc)$/i", js); + } + private static DefaultHttpContext Context(string path) { var ctx = new DefaultHttpContext(); @@ -320,4 +348,19 @@ private static DefaultHttpContext Context(string path) } private static string Body(HttpContext ctx) => System.Text.Encoding.UTF8.GetString(((MemoryStream)ctx.Response.Body).ToArray()); + + private static string FindAsset(string name) + { + var testDir = Path.GetDirectoryName(typeof(LlmsTxtTests).Assembly.Location)!; + foreach (var candidate in new[] + { + Path.Combine(testDir, "wwwroot", "_content", "ShellDocs.Components", name), + Path.Combine(testDir, "..", "..", "..", "..", "..", "src", "ShellDocs.Components", "wwwroot", name) + }) + { + var full = Path.GetFullPath(candidate); + if (File.Exists(full)) return full; + } + throw new FileNotFoundException(name); + } } From 7e1c5bede78af6d2f20af06d4f30d358cfde9bf6 Mon Sep 17 00:00:00 2001 From: Shewart Date: Wed, 14 Oct 2026 09:59:30 +0200 Subject: [PATCH 09/11] feat(cli): write the llms.txt files into the static export shelldocs build reads the app's file list (/_shelldocs/llms.json) and writes llms.txt, llms-full.txt, every page's .md and the agent files. Links use --site-url, then SiteUrl, else they're root-relative under --base-href. --- src/ShellDocs.CLI/Commands/BuildCommand.cs | 2 +- src/ShellDocs.CLI/Commands/PrerenderRunner.cs | 54 ++++++++++++++++++- tests/ShellDocs.Tests/LlmsTxtTests.cs | 38 +++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/ShellDocs.CLI/Commands/BuildCommand.cs b/src/ShellDocs.CLI/Commands/BuildCommand.cs index 1030de7..7174dd1 100644 --- a/src/ShellDocs.CLI/Commands/BuildCommand.cs +++ b/src/ShellDocs.CLI/Commands/BuildCommand.cs @@ -66,7 +66,7 @@ public static int Run(string dir, string output, string? baseHref, bool spaFallb if (Directory.Exists(outputAbs)) Directory.Delete(outputAbs, recursive: true); Directory.CreateDirectory(outputAbs); - var result = PrerenderRunner.Run(publishStage, csproj, urls, outputAbs); + var result = PrerenderRunner.Run(publishStage, csproj, urls, outputAbs, siteUrl: normalizedSiteUrl, baseHref: baseHref); if (!result.Success) { AnsiConsole.MarkupLine("[red]error:[/] prerender did not complete cleanly. Output may be incomplete."); diff --git a/src/ShellDocs.CLI/Commands/PrerenderRunner.cs b/src/ShellDocs.CLI/Commands/PrerenderRunner.cs index ab1ebad..36ae9a6 100644 --- a/src/ShellDocs.CLI/Commands/PrerenderRunner.cs +++ b/src/ShellDocs.CLI/Commands/PrerenderRunner.cs @@ -14,7 +14,9 @@ public record Result(bool Success, int Rendered, int Failed); private static readonly Regex KestrelListening = new( @"Now listening on:\s*(?https?://\S+)", RegexOptions.Compiled); - public static Result Run(string publishDir, string csproj, IReadOnlyList urls, string outputDir, TimeSpan? readyTimeout = null) + // `siteUrl` and `baseHref` are the export's; the app puts them in its llms.txt links. + public static Result Run(string publishDir, string csproj, IReadOnlyList urls, string outputDir, + TimeSpan? readyTimeout = null, string? siteUrl = null, string? baseHref = null) { var assemblyName = InferAssemblyName(csproj) + ".dll"; var assemblyPath = Path.Combine(publishDir, assemblyName); @@ -84,6 +86,8 @@ void OnStreamLine(string line) AnsiConsole.MarkupLine($"[dim]prerender:[/] app listening on [cyan]{boundUrl}[/]"); using var http = new HttpClient { BaseAddress = new Uri(boundUrl), Timeout = TimeSpan.FromSeconds(30) }; + http.DefaultRequestHeaders.Add(LlmsSiteUrlHeader, siteUrl ?? ""); + http.DefaultRequestHeaders.Add(LlmsBasePathHeader, (baseHref ?? "").TrimEnd('/')); var rendered = 0; var failed = 0; @@ -129,6 +133,8 @@ void OnStreamLine(string line) AnsiConsole.MarkupLine($"[dim]search:[/] wrote [cyan]{SearchIndexPath.TrimStart('/')}[/] ({Math.Max(1, indexBytes / 1024)} KB)"); else if (indexBytes == 0) AnsiConsole.MarkupLine(" [yellow]warn:[/] the site serves no search index (its ShellDocs.Components predates static search), so search won't work in this export"); + var llms = WriteLlmsFiles(http, outputDir); + if (llms > 0) AnsiConsole.MarkupLine($"[dim]llms.txt:[/] wrote [cyan]{llms}[/] file(s)"); return new Result(failed == 0, rendered, failed); } finally @@ -194,6 +200,52 @@ internal static long WriteSearchIndex(HttpClient http, string outputDir) } } + internal const string LlmsManifestPath = "/_shelldocs/llms.json"; + internal const string LlmsSiteUrlHeader = "X-ShellDocs-Site-Url"; + internal const string LlmsBasePathHeader = "X-ShellDocs-Base-Path"; + + // llms.txt, llms-full.txt, each page's .md and the agent files, from the app's list + // (EnableLlmsTxt). Sites without it answer 404 and nothing is written. + internal static int WriteLlmsFiles(HttpClient http, string outputDir) + { + List? paths; + try + { + var response = http.GetAsync(LlmsManifestPath).GetAwaiter().GetResult(); + if (!response.IsSuccessStatusCode) return 0; + paths = JsonSerializer.Deserialize>(response.Content.ReadAsStringAsync().GetAwaiter().GetResult()); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($" [yellow]warn:[/] couldn't read the llms.txt file list: {ex.Message}"); + return 0; + } + + var written = 0; + foreach (var path in paths ?? new()) + { + if (!path.StartsWith('/') || path.Split('/').Any(s => s is "." or "..")) continue; + try + { + var response = http.GetAsync(path).GetAwaiter().GetResult(); + if (!response.IsSuccessStatusCode) + { + AnsiConsole.MarkupLine($" [yellow]warn:[/] [cyan]{path}[/] returned [yellow]{(int)response.StatusCode}[/]"); + continue; + } + var outPath = Path.Combine(outputDir, path.TrimStart('/').Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); + File.WriteAllBytes(outPath, response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()); + written++; + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($" [yellow]warn:[/] [cyan]{path}[/] failed: {ex.Message}"); + } + } + return written; + } + private record RedirectEntry(string From, string To); // The app serves its redirect map (DocsRedirectMiddleware); each source URL without diff --git a/tests/ShellDocs.Tests/LlmsTxtTests.cs b/tests/ShellDocs.Tests/LlmsTxtTests.cs index 7b582c2..2fb47e1 100644 --- a/tests/ShellDocs.Tests/LlmsTxtTests.cs +++ b/tests/ShellDocs.Tests/LlmsTxtTests.cs @@ -336,6 +336,44 @@ public void ShelldocsJs_DrivesThePageMenu_AndLoadsFileLinks() Assert.Contains("/\\.(txt|md|mdc)$/i", js); } + [Fact] + public void Build_WritesTheLlmsFilesIntoTheExport() + { + var cli = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name == "shelldocs") ?? Assembly.Load("shelldocs"); + var write = cli.GetType("ShellDocs.CLI.Commands.PrerenderRunner", throwOnError: true)! + .GetMethod("WriteLlmsFiles", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; + var output = Path.Combine(_root, "out"); + Directory.CreateDirectory(output); + + using var served = new HttpClient(new FilesHandler(new() + { + [LlmsTxtFiles.ManifestPath] = "[\"/llms.txt\",\"/docs/v2/agents/AGENTS.md\",\"/docs/introduction.md\",\"/../outside.txt\"]", + ["/llms.txt"] = "# ShellUI", + ["/docs/v2/agents/AGENTS.md"] = "## ShellUI", + ["/docs/introduction.md"] = "# Introduction", + })) { BaseAddress = new Uri("http://localhost") }; + Assert.Equal(3, (int)write.Invoke(null, [served, output])!); + Assert.Equal("# ShellUI", File.ReadAllText(Path.Combine(output, "llms.txt"))); + Assert.Equal("## ShellUI", File.ReadAllText(Path.Combine(output, "docs", "v2", "agents", "AGENTS.md"))); + Assert.Equal("# Introduction", File.ReadAllText(Path.Combine(output, "docs", "introduction.md"))); + Assert.False(File.Exists(Path.Combine(_root, "outside.txt"))); + + // A site without EnableLlmsTxt. + using var none = new HttpClient(new FilesHandler(new())) { BaseAddress = new Uri("http://localhost") }; + Assert.Equal(0, (int)write.Invoke(null, [none, Path.Combine(_root, "none")])!); + } + + private sealed class FilesHandler(Dictionary files) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(files.TryGetValue(request.RequestUri!.AbsolutePath, out var body) + ? new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) } + : new HttpResponseMessage(HttpStatusCode.NotFound)); + + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + => SendAsync(request, cancellationToken).GetAwaiter().GetResult(); + } + private static DefaultHttpContext Context(string path) { var ctx = new DefaultHttpContext(); From a35448e09daf27682755e21972937bce7497caa2 Mon Sep 17 00:00:00 2001 From: Shewart Date: Wed, 14 Oct 2026 09:59:31 +0200 Subject: [PATCH 10/11] docs: llms.txt for AI agents; init and the example site turn it on README and ARCHITECTURE sections, a page on the example site with its own rules file, and o.EnableLlmsTxt() in the Program.cs that shelldocs init writes. The example docs use the ShellUI and ShellDocs sites' real details. --- README.md | 25 +++++ docs/ARCHITECTURE.md | 23 +++- docs/ROADMAP.md | 2 +- examples/ShellDocs.Preview/Program.cs | 7 ++ .../ShellDocs.Preview.csproj | 2 + .../content/docs/components/code-block.md | 2 +- .../content/docs/installation.md | 4 +- .../content/docs/llms-txt.md | 105 ++++++++++++++++++ .../ShellDocs.Preview/content/docs/meta.json | 2 +- examples/ShellDocs.Preview/llms-rules.md | 9 ++ src/ShellDocs.Templates/ScaffoldTemplates.cs | 8 ++ tests/ShellDocs.Tests/LlmsTxtTests.cs | 6 + 12 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 examples/ShellDocs.Preview/content/docs/llms-txt.md create mode 100644 examples/ShellDocs.Preview/llms-rules.md diff --git a/README.md b/README.md index 7d83229..7342d4b 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Already have a Blazor project? Run `shelldocs init --attach` inside it: it adds - **Markdown-first authoring.** YAML frontmatter, Shiki-highlighted code fences, live `razor:preview` examples, inline component tags mid-prose. Set `o.RenderPageTitle = true` to render the frontmatter `title` and `description` as the page header instead of writing `# Title`. - **File-based navigation.** Drop a `.md` in `content/docs/` and it's a page. Sidebar, breadcrumb, prev/next and TOC come from the folder tree; `meta.json` controls order, dividers, subsections and hidden pages. - **`Cmd+K` search** across titles, headings and page text, with body snippets. The index is built at startup and searched in the browser, so there's no external service and it works in the static export too. +- **llms.txt for AI agents.** `o.EnableLlmsTxt()` serves `/llms.txt`, `/llms-full.txt` and every page as Markdown at its URL + `.md`, plus `AGENTS.md`, `CLAUDE.md`, Copilot and Cursor instruction files, and adds a **Copy page / View as Markdown / Open in Claude / Open in ChatGPT** menu by the title. See [AI agents](#ai-agents-llmstxt). - **Blazor-native.** Your components render as real Razor components, not iframes. - **Composable.** Bring your own component library (ShellUI, MudBlazor, Radzen, hand-rolled) and register it in one line: ```csharp @@ -91,6 +92,30 @@ o.AddRedirect("/docs/v0.3.0", "/docs/v0.3"); // also /docs/v0.3.0/x → /docs/ `AddShellDocs` registers middleware that answers these before routing, so a dotted version root works even though `/docs/{*Path:nonfile}` routes can't match it. `shelldocs build` writes the same redirects as static pages. Turn it off with `o.EnableRedirects = false`. +## AI agents (llms.txt) + +As on the ShellUI docs ([shellui.dev/llms.txt](https://shellui.dev/llms.txt)): + +```csharp +o.EnableLlmsTxt(l => +{ + l.Summary = "ShellUI is a shadcn/ui-style component library for Blazor."; // defaults to SiteTagline + l.RulesFile = "Llms/agent-rules.md"; // shown at the top, e.g. "Rules for AI agents" + l.InstallCommandPattern = @"shellui add [\w-]+"; // listed after each page that has one + // l.AgentFiles = false; l.PageActions = false; +}); +``` + +| URL | Contents | +|---|---| +| `/llms.txt` | Site name, summary and rules, then every page with its description, by sidebar section | +| `/llms-full.txt` | The same header, then every page as Markdown | +| `{page}.md` | One page as Markdown | +| `{version root}/llms.txt`, `/llms-full.txt` | One version (the bare files cover the latest plus unversioned pages) | +| `/agents/AGENTS.md`, `CLAUDE.md`, `copilot-instructions.md`, `{site}.mdc` | Instruction files to download into a project, each a `## {SiteName}` section that can be appended to an existing file (also under each version root) | + +The Markdown is the page source with ShellDocs tags turned into Markdown: demos become their source, callouts blockquotes, tabs a label per tab, cards a list, and links to docs pages absolute `.md` links. The files are `text/plain` (some browsers download `text/markdown`); links use `SiteUrl`, or the request's address. `shelldocs build` writes all of them into the export, and links to `.txt` / `.md` files load the file instead of the docs' not-found page. Copy the rules file to the output and publish folders (``). + ## Component previews - **Preview | Code toolbar on every example**, with copy and a ⋯ menu: *Open in new tab*, *Report a bug*, *Suggest something*. The issue links go to `https://github.com/{GitHubRepo}/issues/new`, pre-filled with the example and page URL; point them elsewhere with `o.IssueTrackerUrl`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 658da59..6e2bf8f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -195,6 +195,20 @@ Searching happens in the browser, so it's the same on a static host. `DocsSearch --- +## llms.txt + +`o.EnableLlmsTxt(l => …)` registers `LlmsTxtFiles` and `LlmsTxtMiddleware` (an `IStartupFilter` added before the redirects, so a page's `.md` is never redirected and no path reaches the docs page route). The middleware answers `/llms.txt`, `/llms-full.txt`, each version root's `llms.txt` / `llms-full.txt`, `{page URL}.md` and, with `AgentFiles`, `agents/AGENTS.md`, `CLAUDE.md`, `copilot-instructions.md` and `{site}.mdc` at `/` and under each version root. Everything is `text/plain` (some browsers download `text/markdown`); the agent files are attachments. `/_shelldocs/llms.json` lists every path for `shelldocs build`. + +- **Scope.** The bare files cover pages outside every version plus the latest version (every page on an unversioned site); a version root's cover that version. Index groups are the page's sidebar sections below the scope's common folder; when unversioned and latest pages are listed together, the latest version's groups carry its label. +- **Header.** `# {SiteName}`, a `>` line (`Summary`, defaulting to `SiteTagline`, plus which version this is and links to the others' `llms.txt`), then the `RulesFile` as written. The index adds each page's description and the first `InstallCommandPattern` match in its code blocks. +- **Agent files.** A `## {SiteName}` section (leading newline, so `>>` appends cleanly): which version the project uses, where `llms.txt` is, and the rules with their headings one level down. The Cursor rule adds `description` / `alwaysApply` frontmatter. +- **Markdown** (`LlmsMarkdown`). Fences and code spans are masked first and come back unchanged (`razor:preview` becomes `razor`). Registered tags are read with `RazorTagScanner` and matched to their type: `Callout` → a blockquote with a bold title (fences inside are quoted too), `Tabs`/`Tab` and `CodeGroup`/`CodeTab` → a bold label per tab, `CardGrid`/`Card`/`LinkCard` → a list, `Steps`/`Step` → numbered bold titles, `FileTree` → a `text` fence, `TypeTable`/`TypeRow`/`AutoTypeTable` → a table, `DemoPreview` → the demo's source, `ComponentPreview` → its markup without the frame attributes, `IframePreview` → a link, `SyncSwitch` → nothing; previews with `` children show those instead. Any other registered component becomes its text. Root-relative link targets and `href`/`src` attributes become absolute, with `.md` for docs pages. `LlmsMarkdown.HandledTypes` lists every built-in, and a test fails when a new built-in is missing from it. +- **Base URL.** `SiteUrl`, else the request's origin and path base. `shelldocs build` sends `X-ShellDocs-Site-Url` (its `--site-url`) and `X-ShellDocs-Base-Path` (its `--base-href`); the middleware honours them only from loopback, falling back to `SiteUrl`, then root-relative links under the base path. + +`DocsPageActions` (rendered first in `MarkdownContent`, floated right of the title, only on graph pages with `PageActions`) is a ⋯ menu: **Copy page** (`[data-page-copy]`), **View as Markdown** (the page's base-relative `.md`), **Open in Claude** / **Open in ChatGPT** (`[data-page-ai]`, the chat URL prefix). + +--- + ## Client-side chrome (`shelldocs.js`) Chrome that must work without a Blazor runtime follows one contract: @@ -206,7 +220,9 @@ Chrome that must work without a Blazor runtime follows one contract: | Feature | Markup | Behaviour | |---|---|---| | Sidebar sections | `.sidebar-section[data-open]` | toggle on click | -| Package / version selectors, preview ⋯ menu | `.pkg` / `.ver` / `.preview-menu` `[data-open]` | open on trigger; close on outside click, Escape or option | +| Package / version selectors, preview and page ⋯ menus | `.pkg` / `.ver` / `.preview-menu` / `.page-actions` `[data-open]` | open on trigger; close on outside click, Escape or option | +| Page actions | `.page-actions[data-page-md]`, `[data-page-copy]`, `[data-page-ai]` | Copy page fetches the `.md` (resolved against ``) into the clipboard through a `ClipboardItem` holding the pending text (Safari's gesture rule), then closes; the chat links get `Read {absolute .md URL} and help me with it.` at load and again on click | +| File links | any same-origin `` to a `.txt` / `.md` / `.mdc` path | a window capture listener stops the click before Blazor's router and enhanced navigation (which would show "Page not found"), so the browser loads the file | | Preview tabs | `.preview-frame[data-preview-tab]` | Preview / Code; arrow keys; copy reads the source | | Tabs, CodeGroup, preview code tabs, page switch | `[data-tabs]`, `[data-tab-target]`, `[data-tab-panel]` | switch on click / arrows; `[data-tabs-sync]` groups switch together and persist in `localStorage`; a `[data-tabs-missing]` group without the chosen value shows its `[data-tab-missing]` note; with nothing saved yet, groups follow a `[data-tabs-switch]` for their key | | Iframe previews | `[data-iframe-preview][data-src][data-lazy]` | sets the iframe's `src` (plus `?theme=`) when visible (IntersectionObserver), on `[data-iframe-run]` or at once; posts `{ type: "shelldocs-theme", theme }` to loaded frames on theme change; a host Blazor reuses for another page (`data-src` or `data-lazy` changed) is unloaded and set up again | @@ -248,8 +264,9 @@ One neutral, shadcn-shaped palette of CSS variables in `ShellDocs.Tokens/tokens. 4. Reads the app's redirect map and writes a redirect page for each source URL without a prerendered page (meta refresh plus `location.replace`, relative to ``). 5. Requests `/docs/__shelldocs-not-found__` and, if it renders `` with a 404, writes it as `404.html`. The probe's path is blanked and its line hidden; `shelldocs.js` fills in the visitor's path. 6. Copies the app's search index to `_shelldocs/search.json`. -7. Merges the published `wwwroot/` into the output without overwriting prerendered HTML. -8. Optionally rewrites `` in every HTML file, copies `index.html` to `404.html` (replacing the not-found page), and with `--site-url` writes `sitemap.xml`, `robots.txt` and `og:*` meta. +7. With `EnableLlmsTxt`, reads `/_shelldocs/llms.json` and writes each listed file (`llms.txt`, `llms-full.txt`, every page's `.md`, the agent files) at its path. Links use `--site-url`, then the app's `SiteUrl`, else they're root-relative under `--base-href`. +8. Merges the published `wwwroot/` into the output without overwriting prerendered HTML. +9. Optionally rewrites `` in every HTML file, copies `index.html` to `404.html` (replacing the not-found page), and with `--site-url` writes `sitemap.xml`, `robots.txt` and `og:*` meta. --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f0a505e..ebce0b9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -223,7 +223,7 @@ Driven by building the ShellUI docs on ShellDocs. Highlights (details in the CHA - `fix/not-found-and-sidebar-collapse` (0.1.14) — `` answers 404, `build` fails on not-found pages and writes `404.html`, desktop sidebar collapse in `shelldocs.js`. - `feat/static-search` (0.1.15) — search runs in `shelldocs.js` against `/_shelldocs/search.json`, which `build` copies into the export, so static sites have search. - `fix/iframe-preview-navigation` (0.1.16) — an `` reused for the next page after in-site navigation is unloaded and set up again. -- `fix/docs-site-findings` (0.1.17) — HTML-only `razor:preview` fences render live, `404.html` shows the visitor's path, `LogoSvg` sizing, search on `HomeLayout`, spacing in the missing-tab note. +- `fix/docs-site-findings` (0.1.17) — llms.txt for AI agents (`EnableLlmsTxt`: `/llms.txt`, `/llms-full.txt`, per-page `.md`, agent instruction files, page actions menu, static export); HTML-only `razor:preview` fences render live, `404.html` shows the visitor's path, `LogoSvg` sizing, search on `HomeLayout`, spacing in the missing-tab note. - `fix/preview-fidelity`, `feat/generics-redirects-page-title` (0.1.10) — `not-prose` previews, Razor child content, stretch layout, generic components in markdown, redirects (middleware + static pages), `RenderPageTitle`, theme sync and a static-host `ThemeToggle`. ### `feat/animation-polish` diff --git a/examples/ShellDocs.Preview/Program.cs b/examples/ShellDocs.Preview/Program.cs index 0322087..68cf4b8 100644 --- a/examples/ShellDocs.Preview/Program.cs +++ b/examples/ShellDocs.Preview/Program.cs @@ -55,6 +55,13 @@ o.RegisterComponentsFromAssembly("ShellDocs.Preview.Ui"); o.RegisterComponentsFromAssembly("ShellDocs.Preview.Demos"); o.DemoSourceRoot = Path.Combine(builder.Environment.ContentRootPath, "Demos"); + + // /llms.txt, /llms-full.txt, every page as Markdown, agent files and the page ⋯ menu. + o.EnableLlmsTxt(l => + { + l.RulesFile = "llms-rules.md"; + l.InstallCommandPattern = @"dotnet tool install [^\n]+"; + }); }); var app = builder.Build(); diff --git a/examples/ShellDocs.Preview/ShellDocs.Preview.csproj b/examples/ShellDocs.Preview/ShellDocs.Preview.csproj index 0aabee7..e3e99e4 100644 --- a/examples/ShellDocs.Preview/ShellDocs.Preview.csproj +++ b/examples/ShellDocs.Preview/ShellDocs.Preview.csproj @@ -17,6 +17,8 @@ + + diff --git a/examples/ShellDocs.Preview/content/docs/components/code-block.md b/examples/ShellDocs.Preview/content/docs/components/code-block.md index a227418..88c0a15 100644 --- a/examples/ShellDocs.Preview/content/docs/components/code-block.md +++ b/examples/ShellDocs.Preview/content/docs/components/code-block.md @@ -11,7 +11,7 @@ There's no component to call: every fenced code block in your markdown becomes a builder.Services.AddShellDocs(o => { o.SiteName = "My Docs"; - o.GitHubRepo = "acme/my-docs"; + o.GitHubRepo = "shellui-dev/shelldocs"; }); ``` diff --git a/examples/ShellDocs.Preview/content/docs/installation.md b/examples/ShellDocs.Preview/content/docs/installation.md index ffcf76d..a2d62ca 100644 --- a/examples/ShellDocs.Preview/content/docs/installation.md +++ b/examples/ShellDocs.Preview/content/docs/installation.md @@ -53,8 +53,8 @@ shelldocs build - `--base-href /my-repo/` rewrites `` for sites served from a subpath. - `--spa-fallback` copies `index.html` to `404.html`. Without it, `404.html` is the site's own not-found page, which GitHub Pages, Netlify and Cloudflare Pages serve for unknown URLs. -- `--site-url https://docs.example.com` emits `sitemap.xml`, `robots.txt` and `og:` meta tags. +- `--site-url https://shelldocs.shellui.dev` emits `sitemap.xml`, `robots.txt` and `og:` meta tags. -In the static output, navigation, sidebar sections and collapse, the mobile menu, the version and package selectors, tabs, preview tabs and menus, the table of contents, the theme toggle and code copy all work through `shelldocs.js`. Search and stateful demo components still need a running Blazor Server app (interactive server rendering). +In the static output, navigation, sidebar sections and collapse, the mobile menu, the version and package selectors, tabs, preview tabs and menus, the table of contents, the theme toggle, search and code copy all work through `shelldocs.js`. Stateful demo components still need a running Blazor Server app (interactive server rendering). With `EnableLlmsTxt`, the export also has `llms.txt`, `llms-full.txt` and every page as `.md`. URLs that aren't pages (folders, version roots, unversioned URLs that moved into a version) become redirect pages in the static output, the same redirects the running app answers. diff --git a/examples/ShellDocs.Preview/content/docs/llms-txt.md b/examples/ShellDocs.Preview/content/docs/llms-txt.md new file mode 100644 index 0000000..a81b2c5 --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/llms-txt.md @@ -0,0 +1,105 @@ +--- +title: llms.txt for AI agents +description: Publish your docs for Claude Code, Cursor, Copilot and other agents as llms.txt, Markdown pages and agent instruction files. +--- + +# llms.txt for AI agents + +`EnableLlmsTxt` publishes a site's docs in the [llms.txt](https://llmstxt.org) format, so a coding agent can read every page as Markdown instead of scraping HTML. It also serves instruction files an agent reads at startup, and adds a page menu with **Copy page**, **View as Markdown**, **Open in Claude** and **Open in ChatGPT** next to the title. + +## Turn it on + +The ShellUI docs at [shellui.dev](https://shellui.dev) turn it on like this: + +```csharp +builder.Services.AddShellDocs(o => +{ + o.SiteName = "ShellUI"; + o.SiteUrl = "https://shellui.dev"; + o.EnableLlmsTxt(l => + { + l.Summary = "ShellUI is a shadcn/ui-style component library for Blazor."; + l.RulesFile = "Llms/agent-rules.md"; + l.InstallCommandPattern = @"shellui add [\w-]+"; + }); +}); +``` + +| Option | Default | What it does | +|--------|---------|--------------| +| `Summary` | `SiteTagline` | The `>` line under the site name in `llms.txt`. | +| `RulesFile` | none | A Markdown file shown at the top of `llms.txt` and in the agent files, such as a "Rules for AI agents" list. A relative path is resolved against the app's content root; copy it to the output and publish folders. | +| `InstallCommandPattern` | none | A regex looked for in each page's code blocks; the first match is listed after the page in `llms.txt` as `Install: ...`. | +| `AgentFiles` | `true` | Serves `AGENTS.md`, `CLAUDE.md`, `copilot-instructions.md` and a Cursor rule. | +| `PageActions` | `true` | The page menu next to the title. | + +Links in the files are absolute: `SiteUrl` when it's set, otherwise the address the request came in on. + +## What agents can read + +| URL | Contents | +|-----|----------| +| [`/llms.txt`](/llms.txt) | The site name, summary and rules, then every page with its description, grouped by sidebar section | +| [`/llms-full.txt`](/llms-full.txt) | The same header, then every page as Markdown in one file | +| Any page + `.md` | One page as Markdown, for example [`/docs/installation.md`](/docs/installation.md) | +| `{version root}/llms.txt`, `/llms-full.txt` | One version's pages, for example [`/docs/v1.9.1/llms.txt`](/docs/v1.9.1/llms.txt) | +| `/agents/{file}`, `{version root}/agents/{file}` | The agent instruction files | + +The bare `/llms.txt` covers the pages outside every version plus the latest version. On a site without versions it covers every page. + +In the Markdown, `razor:preview` fences and `` become the example's source, `` a blockquote, `` a bold label per tab, cards a list, `` numbered steps, `` a table, and other components their text. Links to docs pages point at their `.md`. + +## Set up an agent + +Each instruction file is a `## {SiteName}` section that links `llms.txt` and includes the rules, so it can be appended to a file the project already has. + + + + +Claude Code reads `CLAUDE.md`. [Download CLAUDE.md](/agents/CLAUDE.md), or from the project root: + +```bash +curl -fsSL https://shelldocs.shellui.dev/agents/CLAUDE.md >> CLAUDE.md +``` + + + + +Cursor reads rules from `.cursor/rules`. [Download the rule](/agents/shelldocs.mdc), named after the site, into that folder: + +```bash +curl -fsSL --create-dirs -o .cursor/rules/shelldocs.mdc https://shelldocs.shellui.dev/agents/shelldocs.mdc +``` + + + + +Copilot reads `.github/copilot-instructions.md`. [Download copilot-instructions.md](/agents/copilot-instructions.md): + +```bash +mkdir -p .github && curl -fsSL https://shelldocs.shellui.dev/agents/copilot-instructions.md >> .github/copilot-instructions.md +``` + + + + +Codex and many other agents read `AGENTS.md`. [Download AGENTS.md](/agents/AGENTS.md): + +```bash +curl -fsSL https://shelldocs.shellui.dev/agents/AGENTS.md >> AGENTS.md +``` + + + + +## Static sites + +`shelldocs build` writes `llms.txt`, `llms-full.txt`, every page's `.md` and the agent files into the export. Their links use `--site-url`, then `SiteUrl`; with neither, they are root-relative (under `--base-href` when it's set). + + + +The running app serves the files as `text/plain` and the agent files as downloads. A static host uses its own types: many send `.md` as `text/markdown`, which some browsers download instead of showing. Agents read either. + + + +Links to `.txt` and `.md` files on the site load the file, in the running app and in the export, rather than going through the docs router. diff --git a/examples/ShellDocs.Preview/content/docs/meta.json b/examples/ShellDocs.Preview/content/docs/meta.json index 4d15bbe..4fe07ed 100644 --- a/examples/ShellDocs.Preview/content/docs/meta.json +++ b/examples/ShellDocs.Preview/content/docs/meta.json @@ -8,7 +8,7 @@ "---", { "title": "Concepts", - "pages": ["theming", "markdown-syntax"] + "pages": ["theming", "markdown-syntax", "llms-txt"] }, "---", "components" diff --git a/examples/ShellDocs.Preview/llms-rules.md b/examples/ShellDocs.Preview/llms-rules.md new file mode 100644 index 0000000..b7e5620 --- /dev/null +++ b/examples/ShellDocs.Preview/llms-rules.md @@ -0,0 +1,9 @@ +## Rules for AI agents + +Read these before changing a ShellDocs site. + +1. **Pages are Markdown.** They live in `content/docs/` with `title` and `description` frontmatter; `meta.json` in each folder sets the sidebar order. `shelldocs add page|guide|component ` scaffolds one. +2. **Use ShellDocs' components in Markdown; don't write HTML look-alikes.** ``, `` / ``, `` / ``, `` / ``, ``, ``, `` and ``. When the site's own library shadows a name, the `Docs` alias (``) still reaches the built-in. +3. **Live examples:** a ```` ```razor:preview ```` fence, ``, or `` for a demo component under `DemoSourceRoot`. A component used in Markdown must be registered (`RegisterComponent()` or `RegisterComponentsFromAssembly()`). +4. **Configuration is `AddShellDocs(o => …)` in `Program.cs`:** navigation, versions (`AddVersion`), redirects, sync switches and `EnableLlmsTxt`. +5. **Static sites come from `shelldocs build`.** Don't edit the `publish/` output by hand. diff --git a/src/ShellDocs.Templates/ScaffoldTemplates.cs b/src/ShellDocs.Templates/ScaffoldTemplates.cs index 7412423..d640240 100644 --- a/src/ShellDocs.Templates/ScaffoldTemplates.cs +++ b/src/ShellDocs.Templates/ScaffoldTemplates.cs @@ -175,6 +175,14 @@ public static string ProgramAddShellDocs(string siteName, string githubRepo) => o.AddNavLink("Docs", "/docs/introduction"); + // /llms.txt, every page as Markdown at its URL + ".md", agent instruction + // files and a Copy page / Open in Claude menu by the title, for AI agents. + o.EnableLlmsTxt(l => + { + // l.RulesFile = "llms-rules.md"; // shown at the top of llms.txt + // l.InstallCommandPattern = @"dotnet add package \S+"; + }); + // Register your own components so authors can drop them into // markdown as `` or inside a razor:preview fence: // o.RegisterComponent(); diff --git a/tests/ShellDocs.Tests/LlmsTxtTests.cs b/tests/ShellDocs.Tests/LlmsTxtTests.cs index 2fb47e1..1cc69d2 100644 --- a/tests/ShellDocs.Tests/LlmsTxtTests.cs +++ b/tests/ShellDocs.Tests/LlmsTxtTests.cs @@ -363,6 +363,12 @@ public void Build_WritesTheLlmsFilesIntoTheExport() Assert.Equal(0, (int)write.Invoke(null, [none, Path.Combine(_root, "none")])!); } + [Fact] + public void InitTemplate_TurnsItOn() + { + Assert.Contains("o.EnableLlmsTxt(", ShellDocs.Templates.ScaffoldTemplates.ProgramAddShellDocs("ShellUI", "shellui-dev/shellui")); + } + private sealed class FilesHandler(Dictionary files) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) From f80e85d8d93dc57b109cb86e74f9d4e3d9dc19ec Mon Sep 17 00:00:00 2001 From: Shewart Date: Wed, 14 Oct 2026 09:59:31 +0200 Subject: [PATCH 11/11] chore: llms.txt in the 0.1.17-alpha changelog --- CHANGELOG.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b9644e..f044720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,20 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht ## [0.1.17-alpha] — 2026-10-14 -Five fixes found while writing the ShellDocs docs site. +llms.txt for AI agents, and five fixes found while writing the ShellDocs docs site. + +### Added + +- **llms.txt for AI agents.** `o.EnableLlmsTxt(l => …)` serves `/llms.txt` (every page with its description, grouped by sidebar section), `/llms-full.txt` (every page as Markdown) and each page as Markdown at its URL + `.md`, the same under every version root. Options: `Summary`, `RulesFile` (Markdown shown at the top, such as rules for agents), `InstallCommandPattern` (a regex; the first match in a page's code blocks is listed after it) and `AgentFiles`. Files are `text/plain`; links are absolute from `SiteUrl` or the request. +- **Agent instruction files.** `AGENTS.md`, `CLAUDE.md`, `copilot-instructions.md` and a Cursor rule (`{site}.mdc`) under `/agents/` and each version root, served as downloads. Each is a `## {SiteName}` section, so it can be appended to an instructions file a project already has. +- **Page actions menu.** A ⋯ menu by the page title: Copy page (the page's Markdown), View as Markdown, Open in Claude and Open in ChatGPT (prefilled with "Read {page}.md and help me with it."). It works in static exports. Turn it off with `PageActions = false`. +- **Markdown for every ShellDocs tag.** Demos become their source in a fence, callouts blockquotes (code fences inside kept), tabs and code groups a bold label per tab, cards a list, steps numbered, file trees a text block, type tables a table, other registered components their text; links to docs pages become absolute `.md` links. Tags inside code fences are left alone. +- **`shelldocs build` exports them.** `llms.txt`, `llms-full.txt`, every page's `.md` and the agent files are written into the output; links use `--site-url`, then `SiteUrl`, else they're root-relative under `--base-href`. +- **`shelldocs init` turns it on** in the generated `Program.cs`. + +### Changed + +- **Links to `.txt` and `.md` files load the file.** Blazor's router and enhanced navigation showed the docs' "Page not found" for them; `shelldocs.js` now lets the browser follow them. ### Fixed