From 44303ae331c7ae9bf575b7d02a184760f6f82ff4 Mon Sep 17 00:00:00 2001 From: Andrei Ivascu <7030530+aivascu@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:10:41 +0300 Subject: [PATCH 1/6] Add llms.txt and robots.txt for agent discovery. --- site/public/llms.txt | 69 ++++++++++++++++++++++++++++++++++++++++++ site/public/robots.txt | 4 +++ 2 files changed, 73 insertions(+) create mode 100644 site/public/llms.txt create mode 100644 site/public/robots.txt diff --git a/site/public/llms.txt b/site/public/llms.txt new file mode 100644 index 0000000..ee2ab78 --- /dev/null +++ b/site/public/llms.txt @@ -0,0 +1,69 @@ +# AutoFixture documentation +> AutoFixture creates anonymous test data so you write less arrange code in .NET unit tests. + +This file helps LLM agents discover readable documentation on https://autofixture.com. + +Prefer markdown URLs when you need exact C# signatures (generics stay intact). HTML pages are fine for how-to reading. + +## Start here + +- https://autofixture.com/docs/get-started/introduction +- https://autofixture.com/docs/get-started/installation +- https://autofixture.com/docs/get-started/first-test +- https://autofixture.com/docs/reference/cheat-sheet +- https://autofixture.com/docs/reference/faq + +## Guide HTML + +Guides live under `/docs/{section}/{page}`. + +- https://autofixture.com/docs/get-started/introduction +- https://autofixture.com/docs/fundamentals/fixture-and-create +- https://autofixture.com/docs/fundamentals/build-dsl +- https://autofixture.com/docs/fundamentals/customizations +- https://autofixture.com/docs/how-to/collections +- https://autofixture.com/docs/integrations/overview +- https://autofixture.com/docs/integrations/xunit3 +- https://autofixture.com/docs/integrations/automoq +- https://autofixture.com/docs/advanced/specimen-pipeline +- https://autofixture.com/docs/reference/packages +- https://autofixture.com/docs/reference/v4-to-v5-migration + +## Guide markdown (preferred for agents) + +Same content as HTML, as raw markdown: + + https://autofixture.com/docs-markdown/{section}/{page}.md + +Examples: + +- https://autofixture.com/docs-markdown/get-started/introduction.md +- https://autofixture.com/docs-markdown/fundamentals/customizations.md +- https://autofixture.com/docs-markdown/reference/cheat-sheet.md + +## API reference + +Browse HTML: https://autofixture.com/api (redirects to latest AutoFixture v5) + +Raw API markdown (best for signatures): + + https://autofixture.com/api-markdown/{packageId}/{versionSegment}/{slug}.md + +Example (Fixture class, v5): + +- https://autofixture.com/api-markdown/autofixture/5-0-0-rc-1/autofixture.fixture.md +- HTML twin: https://autofixture.com/api/autofixture/5-0-0-rc-1/autofixture.fixture/ + +Slugs and version segments are lowercase. Package ids match the catalog (autofixture, xunit3, automoq, idioms, …). + +## Machine-readable indexes + +- https://autofixture.com/api-catalog.json — packages and versions +- https://autofixture.com/api-meta/routes.json — all API HTML routes +- https://autofixture.com/api-meta/{packageId}/{versionSegment}/toc.json — package TOC +- https://autofixture.com/api-meta/{packageId}/{versionSegment}/search.json — searchable API snippets +- https://autofixture.com/sitemap.xml — HTML and markdown URLs + +## Optional + +- https://autofixture.com/robots.txt diff --git a/site/public/robots.txt b/site/public/robots.txt new file mode 100644 index 0000000..1a253a4 --- /dev/null +++ b/site/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://autofixture.com/sitemap.xml From a9ac71319717d6f6904a91835448efda850ffc97 Mon Sep 17 00:00:00 2001 From: Andrei Ivascu <7030530+aivascu@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:11:20 +0300 Subject: [PATCH 2/6] Add prepare-agent-assets script for docs-markdown and sitemap. --- site/scripts/prepare-agent-assets.mjs | 178 ++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 site/scripts/prepare-agent-assets.mjs diff --git a/site/scripts/prepare-agent-assets.mjs b/site/scripts/prepare-agent-assets.mjs new file mode 100644 index 0000000..dc60865 --- /dev/null +++ b/site/scripts/prepare-agent-assets.mjs @@ -0,0 +1,178 @@ +/** + * Prepare agent-facing static assets before `nuxt generate`: + * - Mirror guide markdown to public/docs-markdown/ (URL-aligned paths) + * - Write public/sitemap.xml (guides, docs-markdown, API routes when present) + * + * Run from site/: node scripts/prepare-agent-assets.mjs + * Or via npm pregenerate / just site-generate. + */ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const siteRoot = path.resolve(__dirname, '..') +const contentDocs = path.join(siteRoot, 'content', 'docs') +const publicDir = path.join(siteRoot, 'public') +const docsMarkdownDir = path.join(publicDir, 'docs-markdown') +const sitemapPath = path.join(publicDir, 'sitemap.xml') +const routesJsonPath = path.join(publicDir, 'api-meta', 'routes.json') + +const SITE_ORIGIN = 'https://autofixture.com' + +/** Strip Nuxt Content-style numeric prefixes: "1.get-started" → "get-started" */ +function stripNumericPrefix(segment) { + return segment.replace(/^\d+\./, '') +} + +/** + * Map content-relative path to public docs path without leading slash. + * e.g. "1.get-started/1.introduction.md" → "get-started/introduction.md" + */ +function toPublicDocsRel(relFromDocs) { + const parts = relFromDocs.split(/[/\\]/).filter(Boolean) + const mapped = parts.map((part, index) => { + if (index === parts.length - 1 && part.toLowerCase().endsWith('.md')) { + const base = part.slice(0, -3) + return `${stripNumericPrefix(base)}.md` + } + return stripNumericPrefix(part) + }) + return mapped.join('/') +} + +function walkMarkdownFiles(dir, base = dir) { + /** @type {string[]} */ + const files = [] + if (!fs.existsSync(dir)) return files + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + files.push(...walkMarkdownFiles(full, base)) + continue + } + if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) { + files.push(path.relative(base, full)) + } + } + return files +} + +function removeIfExists(target) { + if (fs.existsSync(target)) { + fs.rmSync(target, { recursive: true, force: true }) + } +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }) +} + +/** + * @returns {{ htmlPaths: string[], markdownPaths: string[] }} + */ +function mirrorDocsMarkdown() { + removeIfExists(docsMarkdownDir) + ensureDir(docsMarkdownDir) + + /** @type {string[]} */ + const htmlPaths = [] + /** @type {string[]} */ + const markdownPaths = [] + + const files = walkMarkdownFiles(contentDocs) + for (const rel of files) { + const publicRel = toPublicDocsRel(rel) + const dest = path.join(docsMarkdownDir, publicRel) + ensureDir(path.dirname(dest)) + fs.copyFileSync(path.join(contentDocs, rel), dest) + + const withoutExt = publicRel.replace(/\.md$/i, '') + htmlPaths.push(`/docs/${withoutExt}`) + markdownPaths.push(`/docs-markdown/${publicRel.replace(/\\/g, '/')}`) + } + + htmlPaths.sort() + markdownPaths.sort() + return { htmlPaths, markdownPaths } +} + +function readApiRoutes() { + if (!fs.existsSync(routesJsonPath)) return [] + try { + const data = JSON.parse(fs.readFileSync(routesJsonPath, 'utf8')) + if (!Array.isArray(data)) return [] + return data.filter((r) => typeof r === 'string' && r.startsWith('/')) + } catch { + return [] + } +} + +function escapeXml(value) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +/** + * @param {string[]} locs + */ +function writeSitemap(locs) { + const unique = [...new Set(locs)] + unique.sort() + + const body = unique + .map( + (loc) => ` + ${escapeXml(`${SITE_ORIGIN}${loc}`)} + `, + ) + .join('\n') + + const xml = ` + +${body} + +` + + fs.writeFileSync(sitemapPath, xml, 'utf8') + return unique.length +} + +function main() { + if (!fs.existsSync(contentDocs)) { + console.error(`Missing content docs at ${contentDocs}`) + process.exit(1) + } + + const { htmlPaths, markdownPaths } = mirrorDocsMarkdown() + const apiRoutes = readApiRoutes() + + const locs = [ + '/', + '/llms.txt', + '/robots.txt', + '/api-catalog.json', + ...htmlPaths, + ...markdownPaths, + ...apiRoutes, + ] + + // Prefer catalog when prepare-api has run; omit if missing (local docs-only). + if (!fs.existsSync(path.join(publicDir, 'api-catalog.json'))) { + const i = locs.indexOf('/api-catalog.json') + if (i >= 0) locs.splice(i, 1) + } + + const count = writeSitemap(locs) + + console.log( + `prepare-agent-assets: mirrored ${htmlPaths.length} guides → docs-markdown/; sitemap ${count} URLs` + + (apiRoutes.length ? ` (incl. ${apiRoutes.length} API routes)` : ' (no api-meta/routes.json yet)'), + ) +} + +main() From bf85b4268a9e75b62fc8136678908550f2adeac0 Mon Sep 17 00:00:00 2001 From: Andrei Ivascu <7030530+aivascu@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:11:48 +0300 Subject: [PATCH 3/6] Wire agent assets into generate, CI, and gitignore. --- .github/actions/build-site/action.yml | 5 +++++ .gitignore | 2 ++ justfile | 8 ++++++-- site/package.json | 2 ++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/actions/build-site/action.yml b/.github/actions/build-site/action.yml index fbd3aeb..9c33891 100644 --- a/.github/actions/build-site/action.yml +++ b/.github/actions/build-site/action.yml @@ -49,6 +49,11 @@ runs: run: npm ci working-directory: site + - name: Prepare agent assets + shell: bash + run: npm run prepare-agent-assets + working-directory: site + - name: Generate static site shell: bash run: npm run generate diff --git a/.gitignore b/.gitignore index 25b9ae1..7102b4e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ site/.data/ site/public/api-markdown/ site/public/api-meta/ site/public/api-catalog.json +site/public/docs-markdown/ +site/public/sitemap.xml ############### # OS / IDE diff --git a/justfile b/justfile index 1a850d2..1f110f7 100644 --- a/justfile +++ b/justfile @@ -29,8 +29,12 @@ site-install: site-dev: npm run dev --prefix site -# Statically generate the site (same as CI; run prepare-api first) -site-generate: +# Mirror guide markdown + write sitemap (also runs via npm pregenerate) +prepare-agent-assets: + npm run prepare-agent-assets --prefix site + +# Statically generate the site (same as CI; run prepare-api first so API routes enter the sitemap) +site-generate: prepare-agent-assets npm run generate --prefix site # Prepare API docs and generate the static site (CI parity) diff --git a/site/package.json b/site/package.json index 6cedbcd..c7b4e0a 100644 --- a/site/package.json +++ b/site/package.json @@ -5,6 +5,8 @@ "scripts": { "build": "nuxt build", "dev": "nuxt dev --port 3000", + "prepare-agent-assets": "node scripts/prepare-agent-assets.mjs", + "pregenerate": "node scripts/prepare-agent-assets.mjs", "generate": "nuxt generate", "preview": "nuxt preview" }, From 0f81dbde075e61d0585f1f10cd900178b9a67b6d Mon Sep 17 00:00:00 2001 From: Andrei Ivascu <7030530+aivascu@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:15:39 +0300 Subject: [PATCH 4/6] Preserve C# generics in API HTML for agent readers. --- site/server/utils/renderApiMarkdown.ts | 39 +++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/site/server/utils/renderApiMarkdown.ts b/site/server/utils/renderApiMarkdown.ts index e550131..95f4eb8 100644 --- a/site/server/utils/renderApiMarkdown.ts +++ b/site/server/utils/renderApiMarkdown.ts @@ -1,6 +1,13 @@ import MarkdownIt from 'markdown-it' import { getCodeHighlighter, normalizeLang, SHIKI_THEMES } from './highlightCode' +/** Private-use placeholders so markdown-it html_inline does not eat C# generics. */ +const LT = '\uE000' +const GT = '\uE001' + +const HTML_TAG_RE = + /^<\/?(?:a|abbr|b|br|code|div|em|h[1-6]|hr|i|img|li|ol|p|pre|span|strong|sub|sup|table|tbody|td|th|thead|tr|ul)(?:\s[\s\S]*)?>$/i + function stripHtml(value: string) { return value .replace(/<[^>]+>/g, '') @@ -55,6 +62,34 @@ function stripFrontmatter(markdown: string) { return markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '') } +/** + * Keep DocFX / intentional HTML tags; rewrite C# generics like Create<T>() + * so markdown-it with html:true does not treat them as tags. + * Fenced and inline code are left untouched for Shiki / default code rendering. + */ +export function protectNonHtmlAngleBrackets(markdown: string) { + const slots: string[] = [] + const park = (match: string) => { + const index = slots.length + slots.push(match) + return `\0SLOT${index}\0` + } + + let text = markdown.replace(/```[\s\S]*?```/g, park) + text = text.replace(/`[^`\n]+`/g, park) + + text = text.replace(/<[^>\n]+>/g, (match) => { + if (HTML_TAG_RE.test(match)) return match + return match.replaceAll('<', LT).replaceAll('>', GT) + }) + + return text.replace(/\0SLOT(\d+)\0/g, (_, index: string) => slots[Number(index)]!) +} + +export function restoreProtectedAngleBrackets(html: string) { + return html.replaceAll(LT, '<').replaceAll(GT, '>') +} + export async function renderApiMarkdown(markdown: string) { const highlighter = await getCodeHighlighter() const md = new MarkdownIt({ @@ -81,5 +116,7 @@ export async function renderApiMarkdown(markdown: string) { md.renderer.rules.table_open = () => '
' md.renderer.rules.table_close = () => '
' - return normalizeHeadingAnchors(md.render(stripFrontmatter(markdown))) + const prepared = protectNonHtmlAngleBrackets(stripFrontmatter(markdown)) + const html = md.render(prepared) + return restoreProtectedAngleBrackets(normalizeHeadingAnchors(html)) } From f690709d4504cc7e1ed2d8f7e7f4d0b56eba3caa Mon Sep 17 00:00:00 2001 From: Andrei Ivascu <7030530+aivascu@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:16:03 +0300 Subject: [PATCH 5/6] Document agent assets and prepare-api build order. --- readme.md | 2 ++ site/readme.md | 26 +++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index a11ddad..3c61e20 100644 --- a/readme.md +++ b/readme.md @@ -19,6 +19,8 @@ Or via just: `just prepare-api` API content is generated locally and not committed. Run `prepare` before starting the dev server. +For a full static build, run `prepare-api` before `site-generate` so the agent sitemap includes API routes. See [site/readme.md](./site/readme.md) for `/llms.txt`, docs-markdown mirroring, and sitemap details. + Needs [DocFX](https://dotnet.github.io/docfx/), [Node.js](https://nodejs.org/), and optionally [just](https://github.com/casey/just#installation). ## CI / deploy diff --git a/site/readme.md b/site/readme.md index 232e846..adc42d0 100644 --- a/site/readme.md +++ b/site/readme.md @@ -10,12 +10,36 @@ just prepare-api # generate + copy API markdown into public/api-markdown just site-dev ``` +For a production-like static build, run `prepare-api` before `generate` so API routes are included in the sitemap: + +```bash +just prepare-api +just site-generate # runs prepare-agent-assets, then nuxt generate +``` + ## Routes | Path | Content | |------|---------| | `/` | Home | -| `/docs/**` | Guides (Get started section; docs layout) | +| `/docs/**` | Guides (docs layout) | +| `/docs-markdown/**` | Same guides as raw markdown (for LLM agents; generated at build) | | `/api/{package}/{version}/**` | Generated API reference | +| `/api-markdown/**` | Raw API markdown (generated; not committed) | +| `/llms.txt` | Agent discovery index | +| `/robots.txt` | Crawler rules + sitemap pointer | +| `/sitemap.xml` | Generated URL list (guides, docs-markdown, API) | API markdown is generated into `public/api-markdown` (not Nuxt Content). On API pages, the header shows the package and version from the API catalog. + +## Agent assets + +`scripts/prepare-agent-assets.mjs` (also `npm run prepare-agent-assets` / `pregenerate`): + +1. Mirrors `content/docs/**/*.md` → `public/docs-markdown/` with Nuxt-style paths (numeric prefixes stripped). +2. Writes `public/sitemap.xml` from those guides plus `public/api-meta/routes.json` when present. + +Committed: `public/llms.txt`, `public/robots.txt`. +Generated (gitignored): `public/docs-markdown/`, `public/sitemap.xml`. + +Keep `llms.txt` in sync when you add major guide sections. From 503e1f5c524f6e74d7bfa801d1bbe1813935d947 Mon Sep 17 00:00:00 2001 From: Andrei Ivascu <7030530+aivascu@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:01:01 +0300 Subject: [PATCH 6/6] Unescape DocFX \> in generic declarations for clean markdown and HTML. --- api-gen/lib/postprocess.mjs | 16 +++++++++++++--- site/server/utils/renderApiMarkdown.ts | 12 +++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/api-gen/lib/postprocess.mjs b/api-gen/lib/postprocess.mjs index 937999d..558ef50 100644 --- a/api-gen/lib/postprocess.mjs +++ b/api-gen/lib/postprocess.mjs @@ -116,6 +116,14 @@ function repairBrokenMarkdownLinks(content) { ) } +/** DocFX escapes >, (, ) in signatures; undo so generics read as Create(). */ +export function unescapeDocFxMarkdown(content) { + return content + .replace(/\\>/g, '>') + .replace(/\\\(/g, '(') + .replace(/\\\)/g, ')') +} + function rewriteMarkdownLinks(content, packageId, versionSegment, packageDir, pageIndex) { function linkFor(file, hash = '') { const slug = String(file).replace(/\.md$/i, '').toLowerCase() @@ -179,9 +187,11 @@ function processPackageDir(outRoot, dir, pageIndex) { if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.md')) continue const filePath = path.join(dir, entry.name) const original = fs.readFileSync(filePath, 'utf8') - const updated = annotateMemberHeadingIds( - rewriteMarkdownLinks(original, packageId, versionSegment, dir, pageIndex), - ).replace(/See the \[table of contents\]\(\.\/toc\.yml\)\.\r?\n?/i, '') + const updated = unescapeDocFxMarkdown( + annotateMemberHeadingIds( + rewriteMarkdownLinks(original, packageId, versionSegment, dir, pageIndex), + ).replace(/See the \[table of contents\]\(\.\/toc\.yml\)\.\r?\n?/i, ''), + ) if (updated !== original) { fs.writeFileSync(filePath, updated) } diff --git a/site/server/utils/renderApiMarkdown.ts b/site/server/utils/renderApiMarkdown.ts index 95f4eb8..1bf42fa 100644 --- a/site/server/utils/renderApiMarkdown.ts +++ b/site/server/utils/renderApiMarkdown.ts @@ -62,6 +62,14 @@ function stripFrontmatter(markdown: string) { return markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '') } +/** DocFX escapes >, (, ) in signatures; undo so generics read as Create(). */ +export function unescapeDocFxMarkdown(markdown: string) { + return markdown + .replace(/\\>/g, '>') + .replace(/\\\(/g, '(') + .replace(/\\\)/g, ')') +} + /** * Keep DocFX / intentional HTML tags; rewrite C# generics like Create<T>() * so markdown-it with html:true does not treat them as tags. @@ -116,7 +124,9 @@ export async function renderApiMarkdown(markdown: string) { md.renderer.rules.table_open = () => '
' md.renderer.rules.table_close = () => '
' - const prepared = protectNonHtmlAngleBrackets(stripFrontmatter(markdown)) + const prepared = protectNonHtmlAngleBrackets( + unescapeDocFxMarkdown(stripFrontmatter(markdown)), + ) const html = md.render(prepared) return restoreProtectedAngleBrackets(normalizeHeadingAnchors(html)) }