AI Export
On this page
Everything here serves one goal: making a docs site built with apiuikit readable by AI agents and crawlers, not just by people in a browser.
Every rendered document carries a floating Copy as Markdown button with two actions:
- Copy for LLM: writes the whole document, serialized as Markdown, to the clipboard.
- View as Markdown: opens that same Markdown in a new tab.
Hide the button entirely with show: { copyMarkdown: false }.
Why "View as Markdown" may want a URL#
By default, "View as Markdown" serializes in the browser and opens the result as a blob: URL. That works, but a blob URL is a dead end: it's revoked on reload, can't be shared with anyone, can't be bookmarked, and no crawler or AI agent can ever fetch it.
If you're publishing real documentation, you probably want the Markdown to live at a real URL, the way docs.example.com/api.md sits next to docs.example.com/api. The library can't create that URL itself, since it has no server and doesn't own your routes. What it can do is link to yours.
config.markdown.url#
<OpenAPI openapi={doc} config={{ markdown: { url: "/docs/api.md" } }} />With that set, "View as Markdown" opens /docs/api.md instead of generating a blob. Serialization is skipped entirely, so it also costs nothing on click.
| Value | What "View as Markdown" does |
|---|---|
| unset | Serializes in the browser and opens a blob: URL |
| a string | Opens that URL |
| a function returning a URL | Opens that URL |
a function returning null |
Falls back to the blob: URL |
The function form is for apps where only some documents have a hosted twin:
const config: ConfigInterface = {
markdown: {
url: ({ kind, method, path }) =>
kind === "operation" ? `/docs${path}/${method}.md` : "/docs/api.md",
},
};It receives a MarkdownTarget:
| Field | Type | Description |
|---|---|---|
kind |
"document" | "operation" |
"document" for the whole-page export. "operation" is reserved for per-endpoint exports, which don't offer a "view" action yet |
document |
AsyncAPIDocumentData | OpenAPIDocumentData |
The resolved document being rendered |
method |
string? |
OpenAPI operations only, e.g. "get" |
path |
string? |
OpenAPI operations only, e.g. "/pets/{petId}" |
id |
string? |
AsyncAPI operations only, e.g. "sendLightMeasurement" |
Returning null rather than a guessed URL matters: a link to a 404 is worse than a blob.
Producing the files to serve#
Pointing at a URL only helps if something serves Markdown there. Three exported helpers cover it, and all three are spec-agnostic: they dispatch on the document's own version key, so the same code handles AsyncAPI and OpenAPI without you branching.
| Helper | Returns |
|---|---|
listDocumentTargets(doc) |
Every linkable item: OpenAPI endpoints, AsyncAPI operations |
documentToMarkdown(doc, target?) |
Markdown for one target, or the whole document when omitted |
documentToLlmsTxt(doc, options?) |
An llms.txt index linking each target |
A complete build step, in full:
import { listDocumentTargets, documentToMarkdown, documentToLlmsTxt, targetSlug } from "apiuikit/markdown";
import { mkdir, writeFile } from "node:fs/promises";
import doc from "./openapi.json" with { type: "json" };
const BASE = "https://docs.acme.com";
await mkdir("public/docs", { recursive: true });
// One Markdown file per endpoint/operation.
for (const entry of listDocumentTargets(doc)) {
await writeFile(`public/docs/${targetSlug(entry.key)}.md`, documentToMarkdown(doc, entry.target));
}
// The whole document, plus the index pointing at every file above.
await writeFile("public/docs/api.md", documentToMarkdown(doc));
await writeFile("public/llms.txt", documentToLlmsTxt(doc, {
baseUrl: `${BASE}/docs`,
optional: [{ label: "OpenAPI spec", url: `${BASE}/openapi.yaml`, description: "the source document" }],
}));Swap openapi.json for an AsyncAPI document and that script is unchanged.
These are plain functions with no DOM dependencies. Importing them from apiuikit/markdown keeps browser renderer code out of build scripts, server routes, and edge workers. They remain available from the root entry for browser-side use.
DocumentTarget#
Each entry from listDocumentTargets carries what you need to name a file, label a link, and serialize it:
| Field | Type | Description |
|---|---|---|
key |
string |
Stable id, unique in the document: "get /pets", "sendLightMeasurement" |
label |
string |
Human-readable, for a link or heading: "GET /pets" |
summary |
string? |
The document's own one-line summary for the item, if it has one |
target |
MarkdownTarget |
Pass to documentToMarkdown, and the same shape config.markdown.url receives |
That last field is the point of connection: the target you generated a file from is the target the resolver gets asked about, so the two can't drift.
documentToLlmsTxt options#
| Option | Type | Description |
|---|---|---|
baseUrl |
string? |
Base for the default per-entry URL, ${baseUrl}/${targetSlug(key)}.md |
url |
(entry) => string | null |
Your real route for an entry. Return null to leave it out of the index |
title |
string? |
Overrides info.title |
summary |
string? |
Overrides info.description |
optional |
LlmsTxtLink[]? |
Trailing "Optional" links, e.g. the source spec |
The default URL is a guess at your routes, so pass url whenever they differ, which is most of the time.
Worth including the raw spec in optional, served at a stable path like /openapi.yaml. An agent can consume the source document directly rather than reading prose about it, and it costs you one copied file.
Serving it#
Markdown and llms.txt are usually served as text/markdown by default, which makes browsers download the file instead of displaying it. Overriding the content type to text/plain makes the links usable by a human too. On Netlify that's a _headers entry:
/llms.txt
Content-Type: text/plain; charset=utf-8
Access-Control-Allow-Origin: *Spec-specific serializers#
If you already know which spec you have, asyncApiToMarkdown and openApiToMarkdown (plus asyncApiOperationToMarkdown and openApiEndpointToMarkdown) are exported too. Each takes an optional trailing deref for resolving $ref pointers, defaulting to resolving against the document itself.
Adding a spec#
listDocumentTargets, documentToMarkdown, and documentToLlmsTxt all dispatch through one adapter per spec in src/helpers/specAdapters.ts. Supporting a new spec means adding one adapter there: no signature changes, and callers get it for free.
Worked example: the playground#
apiuikit.com is the reference implementation. Its build step renders every bundled example to /examples/<name>.md, and the app hands the renderer a resolver that returns the matching path when the editor holds that example verbatim, and null once you've edited it or pasted your own document:
markdown: {
url: () => exampleMarkdownPath, // a real path, or null
}That's the whole pattern: real URL when a real file exists behind it, blob when it doesn't. See packages/playground/scripts/generateDocsAssets.mjs and packages/playground/src/Playground.tsx.