` 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 9816644..ebce0b9 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) — 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.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 5e8bc14..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
@@ -152,7 +158,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 +168,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.
@@ -185,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/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;
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/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/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/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)
{
}
- @* String expressions keep the spaces Razor would trim around the span. *@
- @("Not available on ")@(" yet")
+ @* One inline box: as separate flex items the spaces around the label collapse. *@
+ @("Not available on ")@(" yet")
@@ -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.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/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}{name}>";
+ }
+
+ 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.Components/wwwroot/shelldocs.js b/src/ShellDocs.Components/wwwroot/shelldocs.js
index 78ec4d3..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,8 +1016,80 @@ 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() {
+ 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();
+ fillPageActions(document);
restoreCollapse(document);
restoreTabs(document);
window.shelldocsIframes.init(document);
diff --git a/src/ShellDocs.Markdown/SlotExtractor.cs b/src/ShellDocs.Markdown/SlotExtractor.cs
index dd6245a..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);
@@ -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/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
new file mode 100644
index 0000000..1cc69d2
--- /dev/null
+++ b/tests/ShellDocs.Tests/LlmsTxtTests.cs
@@ -0,0 +1,410 @@
+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\nHello
\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());
+ }
+
+ [Fact]
+ public async Task PageActions_AreStaticHostFriendly()
+ {
+ var html = await Harness().RenderAsync();
+ Assert.Contains("data-page-md=\"docs/introduction.md\"", html);
+ Assert.Matches("