Markdown for agents
Your documentation is markdown before it is HTML, and two audiences want it that way: a reader pasting a page into a chat window, and an agent reading the whole corpus on its own.
Both are served by one file.
Two files, one of them optional
import { createDocsRoute } from '@waveso/docs/next';
export const docs = createDocsRoute({
contentDir: 'content/docs',
siteUrl: 'https://example.com',
llms: {
title: 'Your product',
description: 'One sentence on what this documentation covers.',
},
});export const GET = docs.llmsFullTxt;
export const dynamic = 'force-static';That is the whole setup. llms-full.txt is every published page's markdown in
one file, each labelled with its own URL and separated by a ---.
llms.txt — an index of one line per page, in
llmstxt.org (opens in a new tab)'s format — is a second route and entirely
optional:
export const GET = docs.llmsTxt;
export const dynamic = 'force-static';The corpus is the one that earns its file. It carries every page and its URL, so an agent that finds it has everything the index would have told it. Add the index when you want the convention's entry point; skip it when you want six files instead of seven.
Both are prerendered, and neither ships a byte to a browser.
Copy page
A button at the top of every page puts that page's markdown on the reader's
clipboard. It is on by default once llms is configured, and it needs no
other setup — it reads /llms-full.txt and slices out the page it is on.
copyPage: false // off
copyPage: { label: 'Copy as MD' } // relabelledAny page can override it in its own frontmatter, in either direction:
---
title: Welcome
copyPage: false
---That is for the page with nothing to copy. A landing page that is a hero and a
row of cards has no prose behind it, so the button offers a reader a file of
headings and link text — present, working and pointless. true turns it back on
where the route turned it off. This site's own home page sets false.
The route-level default is deliberately "on if llms is set" rather than plain
true. docs.llmsFullTxt refuses to serve without an llms option, so a site
without one has no corpus by construction — and rendering a button there would
produce a control whose only possible outcome is to remove itself.
If the route file is missing, the button removes itself. That is the one
case the server cannot see: llms configured and
app/docs/llms-full.txt/route.ts never created. Only a fetch can tell, so the
first click that 404s takes the button away. A network failure is treated
differently — the wiring is fine and the next press may work, so it reports and
stays.
What it costs
Nothing on page load. The corpus is fetched on click and cached for the rest of the visit, so every reader who never presses the button pays zero bytes — page weight and first paint are unchanged. The button itself is 1.2 KB gzipped and holds no markdown of its own.
One download is around 40 KB gzipped for twenty pages, and roughly 2 MB at a thousand. Docs are read on desktops by people who deliberately pressed something and expect it to do work; while the fetch is in flight the label pulses, so the wait reads as progress rather than as a verdict.
Why not a .md URL per page
That was the first design, and in Next it costs five steps: a route file, a
rewrite, output: 'export' made conditional, a post-build script, and a
placeholder the export forces which the script then deletes. Two of them fail
silently.
All of it exists because *.md is a pattern the docs catch-all already owns —
a route in the App Router is a folder, and a segment holds either a page or a
route, never both. llms-full.txt is a fixed path, which needs none of it.
Frameworks whose routes can carry a file extension have no such problem, and the
button is ready for them: it takes a corpusUrl, so an adapter that can serve
/guides/links.md points at that instead and the component does not change.
The markdown is the author's
A hosted documentation service has to reconstruct markdown from whatever it rendered, and loses whatever the render dropped. The source is still on disk here, so these files are the original bodies with exactly two edits.
A # title when the body has none — a page whose title lives only in
frontmatter would otherwise arrive as an untitled fragment.
Link destinations resolved against the page they were written on. The
destination for this text is a chat window, where [auth](./api/auth.md) has
nothing to resolve against. The markdown extension comes off, because
/api/auth is the route and /api/auth.md is nothing — and only for your own
origin, since a raw file on GitHub is a real URL that ends in .md.
Everything else survives byte for byte: list markers, table alignment, trailing
whitespace. Destinations are found by parsing, not by matching ](…), so
the example URLs in your code fences are left exactly as written — which a
regex rewriter would edit, in the one place a reader is meant to copy verbatim.
Drafts never appear
The list comes from the same source the routes are built from, so what ships as
markdown is exactly what ships as HTML. A draft: true page is absent from
both.