LLM Access to Documentation
The user docs site (docs.rat.gd) is legible to AI agents: every doc page's
raw Markdown source is published at the same URL as its HTML page (same
permalink, .md suffix), and a generated /llms.txt index lists all pages
with descriptions. Shipped and verified live 2026-07-11.
https://docs.rat.gd/docs/activity-templates ← HTML page (humans)
https://docs.rat.gd/docs/activity-templates.md ← raw Markdown (agents)
https://docs.rat.gd/llms.txt ← index of all .md URLs
Scope
- User docs only (
docs/user, built bywebsite/docusaurus.config.ts). The dev docs site (docusaurus.dev.config.ts) is deliberately excluded: it is gated behind Cloudflare Access, and publishing an AI-consumption index for gated content would defeat the gate. - Default locale (
en) only. No per-locale.mdtrees orllms.txtvariants — consuming agents can translate. (9 of 10 locales have no translateddocs/usercontent anyway; onlydocs/user/legalis translated today.) - No
llms-full.txt(single concatenated file). Easy to add later from the same doc list if a real consumer asks for it.
Architecture
Custom plugin: website/src/plugins/rawMarkdownPlugin.ts
A ~1-file in-house Docusaurus plugin. Third-party packages were evaluated
and rejected — docusaurus-plugin-llms (the most mature candidate) was
tested against this site's real config (docsDir: "../docs/user",
routeBasePath: "docs", 10 locales) and failed four ways: .md files
written at source paths instead of route paths (guaranteed 404s), ../
path-traversal fragments baked into llms.txt URLs, locale passes silently
overwriting each other, and stray output outside outDir. Root cause: it
reverse-engineers URLs from the filesystem instead of reading Docusaurus's
own route data.
The custom plugin avoids all of that by construction — it never derives a URL from a file path:
-
allContentLoaded— collects{ source, permalink, title, description }from everydocusaurus-plugin-content-docsinstance's loaded data.permalinkis Docusaurus's own already-correct route path (includesrouteBasePath);sourceis@site-aliased and resolved againstsiteDir. Note this is theallContentLoadedlifecycle, notcontentLoaded—allContent(other plugins' data) is only available there. Collected docs live in the plugin factory's closure, which is per-build-pass since Docusaurus re-instantiates plugins for each locale. -
postBuild— guarded withif (props.i18n.currentLocale !== props.i18n.defaultLocale) return;so only theenpass writes output. For each doc it copies the raw source file topath.join(outDir, permalink + ".md")(trailing-slash permalinks fall back to/index), then writesllms.txtfrom the same in-memory doc list — link URLs and file destinations both derive from the onemdPermalink()helper, so they cannot diverge.
llms.txt format
# ROPA Docs
> Record of Processing Activities tool — user documentation.
## Docs
- [Activity-Scoped Templates](https://docs.rat.gd/docs/activity-templates.md): How to configure and use...
## Legal
- [Privacy Policy](https://docs.rat.gd/docs/legal/privacy.md): How GDPR Labs collects...
Docs whose permalink contains /legal/ go in the Legal section, the
rest in Docs. Each line's description is the doc's description
metadata (frontmatter description, falling back to the first content
line), with the title as final fallback. Absolute URLs are built from
siteConfig.url.
Registration
Registered in the plugins: [] array of website/docusaurus.config.ts
only. Docusaurus calls each entry as a factory with (context, options),
matching the plugin's (context: LoadContext) signature. Do not add it
to docusaurus.dev.config.ts (see Scope).
Content-Type: _headers (written by the plugin)
Deploys go to Cloudflare Pages, which reads a _headers file from the
build output. The plugin writes it in the same postBuild step as the
.md files and llms.txt:
/*.md
Content-Type: text/markdown; charset=utf-8
/llms.txt
Content-Type: text/plain; charset=utf-8
Cloudflare's * matches across /, so nested paths like
/docs/legal/privacy.md are covered.
The file is deliberately not in website/static/ — static assets are
shared by both sites, and shipping the /*.md rule to the dev site made
Cloudflare serve the dev site's 404 page as text/markdown for .md
URLs (raw HTML in the browser instead of the styled Page Not Found).
Verifying
The plugin has a no-mock Vitest suite at website/tests/rawMarkdownPlugin.test.ts
(run with npm run docs:test; chained into test:run) — see
Docs Website Tests.
After cd website && npm run build:
build/docs/<slug>.mdexists next tobuild/docs/<slug>/index.html.build/llms.txtexists; link count matches thedocs/userdoc count; no../or double slashes in any URL.- No
.mdoutput under any locale directory (build/es/,build/fr/, …). - No stray output outside
build/(e.g. nowebsite/docs/created). npm run build:devproduces no.md/llms.txt/_headersinbuild-dev/.
Against the live site:
curl -s https://docs.rat.gd/llms.txt
curl -s https://docs.rat.gd/docs/activity-templates.md
curl -sI https://docs.rat.gd/docs/activity-templates.md # Content-Type: text/markdown
Maintenance
- New user docs need no wiring — the plugin reads whatever the
content-docs plugin loaded, so any page added under
docs/userappears inllms.txtand gets a.mdURL automatically. Give each doc a frontmatterdescription; it becomes thellms.txtlink description. - Rollback is fully additive-reverse: remove the plugin registration from
docusaurus.config.tsand deleterawMarkdownPlugin.ts(the_headersfile goes with it). No existing routes or files are touched.
Possible follow-ups (not planned)
llms-full.txt— single concatenated file, generated from the same doc list, if an actual consumer asks for it.- A "View as Markdown" link in the doc page toolbar (swizzle
DocItem/Layout) so humans can find the raw view GitHub-style. - Locale-specific
.md/llms.txtif a real use case for non-English raw content appears.