From fd522e25ac920372264c1de326c773401b08aaff Mon Sep 17 00:00:00 2001
From: Kaylee <65376239+KayleeWilliams@users.noreply.github.com>
Date: Thu, 2 Jul 2026 11:27:27 +0100
Subject: [PATCH 1/9] Add OpenAPI docs generation
---
.changeset/openapi-generated-docs.md | 5 +
apps/fumadocs-example/lib/mdx-components.tsx | 260 ++++
apps/fumadocs-example/lib/source.ts | 28 +-
docs/docs.config.ts | 9 +
docs/openapi/leadtype-api.yaml | 70 +
docs/reference/openapi.mdx | 233 ++++
packages/leadtype/package.json | 4 +
packages/leadtype/rollup.config.ts | 1 +
packages/leadtype/scripts/generate-docs.ts | 183 +--
packages/leadtype/src/cli/generate.ts | 38 +-
packages/leadtype/src/index.ts | 21 +
.../src/internal/package-surface.test.ts | 1 +
packages/leadtype/src/llm/llm.ts | 6 +
.../src/markdown/default-transforms.ts | 18 +
.../leadtype/src/markdown/plugins/openapi.ts | 260 ++++
packages/leadtype/src/mdx/index.ts | 9 +
packages/leadtype/src/mdx/tag-types.ts | 53 +
packages/leadtype/src/openapi/index.ts | 1208 +++++++++++++++++
packages/leadtype/src/openapi/openapi.test.ts | 103 ++
19 files changed, 2421 insertions(+), 89 deletions(-)
create mode 100644 .changeset/openapi-generated-docs.md
create mode 100644 docs/openapi/leadtype-api.yaml
create mode 100644 docs/reference/openapi.mdx
create mode 100644 packages/leadtype/src/markdown/plugins/openapi.ts
create mode 100644 packages/leadtype/src/openapi/index.ts
create mode 100644 packages/leadtype/src/openapi/openapi.test.ts
diff --git a/.changeset/openapi-generated-docs.md b/.changeset/openapi-generated-docs.md
new file mode 100644
index 00000000..6acf1bdb
--- /dev/null
+++ b/.changeset/openapi-generated-docs.md
@@ -0,0 +1,5 @@
+---
+"leadtype": minor
+---
+
+Add native OpenAPI page generation for API reference docs, including markdown flattening, MDX renderer prop types, and a `leadtype/openapi` package entry point.
diff --git a/apps/fumadocs-example/lib/mdx-components.tsx b/apps/fumadocs-example/lib/mdx-components.tsx
index 9ea808ca..334de788 100644
--- a/apps/fumadocs-example/lib/mdx-components.tsx
+++ b/apps/fumadocs-example/lib/mdx-components.tsx
@@ -7,6 +7,15 @@ import { Tab, Tabs } from "fumadocs-ui/components/tabs";
import defaultMdxComponents from "fumadocs-ui/mdx";
import type {
AccordionItemProps,
+ ApiAuthProps,
+ ApiCodeSamplesProps,
+ ApiEndpointProps,
+ ApiMediaType,
+ ApiParametersProps,
+ ApiRequestBodyProps,
+ ApiResponsesProps,
+ ApiSchemaProperty,
+ ApiTryItProps,
AudienceProps,
CommandTabsProps,
DetailsProps,
@@ -113,6 +122,250 @@ function TypeTable({
);
}
+function SchemaRows({ properties = [] }: { properties?: ApiSchemaProperty[] }) {
+ if (properties.length === 0) {
+ return null;
+ }
+ return (
+
+
+
+
+ | Property |
+ Type |
+ Required |
+ Description |
+
+
+
+ {properties.map((property) => (
+
+ | {property.name} |
+
+ {formatApiSchemaType(property)}
+ |
+
+ {property.required ? "Required" : "Optional"}
+ |
+ {property.description ?? "—"} |
+
+ ))}
+
+
+
+ );
+}
+
+function formatApiSchemaType(
+ schema?: Pick
+): string {
+ if (!schema) {
+ return "unknown";
+ }
+ return schema.format ? `${schema.type} (${schema.format})` : schema.type;
+}
+
+function ApiEndpoint({
+ method,
+ path,
+ operationId,
+ serverUrl,
+ deprecated,
+}: ApiEndpointProps) {
+ return (
+
+
+
+ {method}
+
+ {path}
+ {deprecated ? (
+
+ Deprecated
+
+ ) : null}
+
+ {operationId || serverUrl ? (
+
+ {operationId ? (
+
+
- Operation ID
+ - {operationId}
+
+ ) : null}
+ {serverUrl ? (
+
+
- Server
+ - {serverUrl}
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
+
+function ApiAuth({ requirements, schemes }: ApiAuthProps) {
+ if (requirements.length === 0 && schemes.length === 0) {
+ return No authentication required.
;
+ }
+ return (
+
+ {requirements.length > 0 ? (
+ <>
+
Requirements
+
+ {requirements.map((requirement) => {
+ const names = Object.keys(requirement);
+ return (
+ -
+ {names.length > 0 ? names.join(" + ") : "Anonymous"}
+
+ );
+ })}
+
+ >
+ ) : null}
+ {schemes.length > 0 ? (
+ <>
+
Schemes
+
+ {schemes.map((scheme) => (
+ -
+
{scheme.key}: {scheme.type}
+ {scheme.scheme ? ` / ${scheme.scheme}` : ""}
+ {scheme.description ? ` - ${scheme.description}` : ""}
+
+ ))}
+
+ >
+ ) : null}
+
+ );
+}
+
+function ApiParameters({ title, parameters }: ApiParametersProps) {
+ if (parameters.length === 0) {
+ return null;
+ }
+ return (
+
+ {title ?
{title}
: null}
+
+
+
+
+ | Name |
+ Type |
+ Required |
+ Description |
+
+
+
+ {parameters.map((parameter) => (
+
+ | {parameter.name} |
+
+ {formatApiSchemaType(parameter.schema)}
+ |
+
+ {parameter.required ? "Required" : "Optional"}
+ |
+ {parameter.description ?? "—"} |
+
+ ))}
+
+
+
+
+ );
+}
+
+function MediaType({ media }: { media: ApiMediaType }) {
+ return (
+
+
+ Content type {media.mediaType}
+
+
+ {media.example === undefined ? null : (
+
+ {JSON.stringify(media.example, null, 2)}
+
+ )}
+
+ );
+}
+
+function ApiRequestBody({ body }: ApiRequestBodyProps) {
+ return (
+
+
Request Body
+
+ {body.required ? "Required" : "Optional"}
+ {body.description ? ` - ${body.description}` : ""}
+
+ {body.content.map((media) => (
+
+ ))}
+
+ );
+}
+
+function ApiCodeSamples({ samples }: ApiCodeSamplesProps) {
+ if (samples.length === 0) {
+ return null;
+ }
+ return (
+
+ {samples.map((sample) => (
+
+
{sample.label}
+
+ {sample.code}
+
+
+ ))}
+
+ );
+}
+
+function ApiResponses({ responses }: ApiResponsesProps) {
+ if (responses.length === 0) {
+ return null;
+ }
+ return (
+
+ {responses.map((response) => (
+
+
+ {response.status}
+
+ {response.description}
+ {response.content.map((media) => (
+
+ ))}
+
+ ))}
+
+ );
+}
+
+function ApiTryIt({ operation }: ApiTryItProps) {
+ return (
+
+ Wire this component to your API proxy to execute{" "}
+
+ {operation.method.toUpperCase()} {operation.path}
+
+ .
+
+ );
+}
+
// Leadtype CommandTabs: render the command per package manager.
function CommandTabs(props: CommandTabsProps) {
const managers = ["npm", "pnpm", "yarn", "bun"] as const;
@@ -351,6 +604,13 @@ export const mdxComponents = {
TypeTable,
AutoTypeTable: TypeTable,
ExtractedTypeTable: TypeTable,
+ ApiAuth,
+ ApiCodeSamples,
+ ApiEndpoint,
+ ApiParameters,
+ ApiRequestBody,
+ ApiResponses,
+ ApiTryIt,
CommandTabs,
// Compatibility aliases for external docs that use the same component map.
PackageCommandTabs: CommandTabs,
diff --git a/apps/fumadocs-example/lib/source.ts b/apps/fumadocs-example/lib/source.ts
index 1548ef5b..d9c01645 100644
--- a/apps/fumadocs-example/lib/source.ts
+++ b/apps/fumadocs-example/lib/source.ts
@@ -1,21 +1,37 @@
-import { resolve } from "node:path";
+import { cp, mkdtemp } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
import { loader } from "fumadocs-core/source";
import { fumadocsSource } from "leadtype/fumadocs";
+import { normalizeOpenApiConfig, writeOpenApiPages } from "leadtype/openapi";
import docsConfig from "../../../docs/docs.config";
// process.cwd() is the app root when Next runs build/dev.
const repoRoot = resolve(process.cwd(), "..", "..");
const contentDir = resolve(repoRoot, "docs");
+const stagedRoot = await mkdtemp(join(tmpdir(), "leadtype-fumadocs-"));
+const stagedContentDir = join(stagedRoot, "docs");
+await cp(contentDir, stagedContentDir, { recursive: true });
+
+const generatedOpenApi =
+ docsConfig.openapi === undefined
+ ? { nav: [], pages: [] }
+ : await writeOpenApiPages({
+ configs: normalizeOpenApiConfig(docsConfig.openapi, contentDir),
+ docsDir: stagedContentDir,
+ });
+const nav = [...(docsConfig.navigation ?? []), ...generatedOpenApi.nav];
/**
- * fumadocs source backed by leadtype/fumadocs. It reads the repo-root
- * Leadtype docs, uses the same curated navigation as the other examples, and
- * resolves `` / `` relative to the repo root.
+ * fumadocs source backed by leadtype/fumadocs. It stages the repo-root
+ * Leadtype docs plus generated OpenAPI pages, uses the same curated navigation
+ * as the other examples, and resolves `` / ``
+ * relative to the repo root.
*/
const fumadocsSourceResult = await fumadocsSource({
- contentDir,
+ contentDir: stagedContentDir,
includeMetaJson: false,
- nav: docsConfig.navigation,
+ nav,
mounts: docsConfig.mounts,
typeTableBasePath: repoRoot,
});
diff --git a/docs/docs.config.ts b/docs/docs.config.ts
index b8cf4be8..1265dbca 100644
--- a/docs/docs.config.ts
+++ b/docs/docs.config.ts
@@ -24,6 +24,14 @@ const config: DocsConfig = {
email: "support@inth.com",
},
},
+ openapi: {
+ input: "./openapi/leadtype-api.yaml",
+ output: "rest-api",
+ title: "Leadtype REST API",
+ description:
+ "Generated from docs/openapi/leadtype-api.yaml to dogfood native API reference pages.",
+ groupByTags: true,
+ },
// The llms.txt body, rendered in order (was `product.blocks`).
llms: {
sections: [
@@ -246,6 +254,7 @@ const config: DocsConfig = {
"frontmatter-transformers",
"mdx",
"markdown",
+ "openapi",
"search",
"i18n",
"troubleshooting",
diff --git a/docs/openapi/leadtype-api.yaml b/docs/openapi/leadtype-api.yaml
new file mode 100644
index 00000000..0e2a1d85
--- /dev/null
+++ b/docs/openapi/leadtype-api.yaml
@@ -0,0 +1,70 @@
+openapi: 3.1.0
+info:
+ title: Leadtype Docs API
+ version: 0.1.0
+ description: Example API used to dogfood Leadtype's OpenAPI page generation.
+servers:
+ - url: https://leadtype.dev
+components:
+ securitySchemes:
+ docsToken:
+ type: http
+ scheme: bearer
+ description: Optional token for private docs deployments.
+paths:
+ /api/docs/search:
+ post:
+ operationId: searchDocs
+ summary: Search docs
+ description: Search a generated Leadtype docs index and return matching docs chunks.
+ tags:
+ - Operations
+ security:
+ - docsToken: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - query
+ properties:
+ query:
+ type: string
+ description: Search query entered by the user or agent.
+ limit:
+ type: integer
+ format: int32
+ description: Maximum number of results to return.
+ default: 8
+ responses:
+ "200":
+ description: Ranked docs search results.
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - results
+ properties:
+ results:
+ type: array
+ description: Matching docs chunks ordered by relevance.
+ items:
+ type: object
+ required:
+ - title
+ - urlPath
+ properties:
+ title:
+ type: string
+ description: Page or heading title.
+ urlPath:
+ type: string
+ description: Docs URL path for the result.
+ snippet:
+ type: string
+ description: Highlight-ready excerpt from the matched chunk.
+ "400":
+ description: The request body was malformed or the query was empty.
diff --git a/docs/reference/openapi.mdx b/docs/reference/openapi.mdx
new file mode 100644
index 00000000..6073eb7a
--- /dev/null
+++ b/docs/reference/openapi.mdx
@@ -0,0 +1,233 @@
+---
+title: OpenAPI
+description: Generate native MDX API reference pages from OpenAPI 3.x specs.
+---
+
+# OpenAPI
+
+Leadtype can turn OpenAPI 3.x JSON or YAML specs into native MDX pages before
+the normal docs pipeline runs. Generated API pages render through your docs UI,
+flatten into markdown mirrors, appear in `llms.txt`, feed search, and ship in
+package docs bundles.
+
+## Quickstart
+
+Put the spec somewhere in your docs source:
+
+
+
+
+
+
+
+
+Add an `openapi` block to the config. If the config lives at the project root,
+use `leadtype.config.ts`. If it lives inside the docs folder, use
+`docs.config.ts`; relative `input` paths resolve from the config file directory.
+
+```ts title="docs/docs.config.ts"
+import type { DocsConfig } from "leadtype";
+
+const config: DocsConfig = {
+ product: {
+ name: "Acme API",
+ tagline: "Programmable access to Acme.",
+ },
+ openapi: {
+ input: "./openapi/api.yaml",
+ output: "rest-api",
+ title: "REST API",
+ groupByTags: true,
+ },
+};
+
+export default config;
+```
+
+Run generation:
+
+```sh
+bunx leadtype generate --src . --out public --base-url https://example.com
+```
+
+This writes operation pages such as:
+
+```text
+public/docs/rest-api/users/read-user.md
+public/docs/llms.txt
+public/docs/search-index.json
+```
+
+The same generated pages are added to the docs navigation used by
+`llms.txt`, `AGENTS.md`, search, and Agent Readability artifacts.
+
+## Render in a docs app
+
+Generated pages use native API components instead of Swagger UI:
+
+```mdx
+
+
+
+
+
+
+```
+
+Leadtype exports the prop contracts from `leadtype/mdx`. Your renderer maps
+those names to local components the same way it maps `Callout`, `Tabs`, and
+`TypeTable`.
+
+```tsx title="lib/mdx-components.tsx"
+import type {
+ ApiEndpointProps,
+ ApiParametersProps,
+ ApiResponsesProps,
+} from "leadtype/mdx";
+
+export function ApiEndpoint({
+ method,
+ path,
+ operationId,
+}: ApiEndpointProps) {
+ return (
+
+
{method}{" "}
+
{path}
+ {operationId ?
Operation ID: {operationId}
: null}
+
+ );
+}
+
+export function ApiParameters({ title, parameters }: ApiParametersProps) {
+ return (
+
+ {title ? {title}
: null}
+
+
+ {parameters.map((parameter) => (
+
+
+ {parameter.name}
+ |
+ {parameter.schema?.type ?? "unknown"} |
+ {parameter.required ? "Required" : "Optional"} |
+ {parameter.description} |
+
+ ))}
+
+
+
+ );
+}
+
+export function ApiResponses({ responses }: ApiResponsesProps) {
+ return responses.map((response) => (
+
+ {response.status}
+ {response.description}
+
+ ));
+}
+
+export const mdxComponents = {
+ ApiEndpoint,
+ ApiParameters,
+ ApiResponses,
+};
+```
+
+For a fuller copyable implementation, see the Fumadocs example component map at
+`apps/fumadocs-example/lib/mdx-components.tsx`.
+
+## Try-it consoles
+
+Leadtype does not ship an API console. Set `includeTryIt: true` only when your
+renderer implements `` and routes requests through a server-side API
+proxy.
+
+```ts
+openapi: {
+ input: "./openapi/api.yaml",
+ output: "rest-api",
+ includeTryIt: true,
+}
+```
+
+Without `includeTryIt`, generated docs still include endpoint details, auth,
+parameters, request bodies, code samples, and responses.
+
+## Multiple specs
+
+Pass an array to generate pages from more than one spec:
+
+```ts title="docs.config.ts"
+export default {
+ product: {
+ name: "Acme",
+ tagline: "Acme developer docs.",
+ },
+ openapi: [
+ {
+ input: "./public-api.yaml",
+ output: "rest-api",
+ title: "REST API",
+ },
+ {
+ input: "https://example.com/admin-openapi.json",
+ output: "admin-api",
+ title: "Admin API",
+ includeTags: ["Admin"],
+ },
+ ],
+};
+```
+
+## Options
+
+| Option | Description |
+|---|---|
+| `input` | Local path or absolute URL to an OpenAPI 3.x JSON/YAML document. |
+| `output` | Docs-relative directory for generated pages. Defaults to `api`. |
+| `group` | Frontmatter group assigned to generated pages. |
+| `order` | Starting frontmatter order; each operation increments from it. |
+| `title` | Generated navigation section title. Defaults to `API Reference`. |
+| `description` | Generated navigation section description. |
+| `includeTags` | Only generate operations with one of these tags. |
+| `excludeTags` | Skip operations with one of these tags. |
+| `groupByTags` | Nest pages and generated nav by first operation tag. Defaults to `true`. |
+| `serverUrl` | Override the server URL used in examples and try-it metadata. |
+| `slugStrategy` | `"operation-id"` or `"method-path"`. Defaults to `"operation-id"`. |
+| `includeTryIt` | Emit `` metadata for renderers with an API console. Defaults to `false`. |
+
+## Library API
+
+```ts
+import {
+ normalizeOpenApiConfig,
+ writeOpenApiPages,
+} from "leadtype/openapi";
+
+const configs = normalizeOpenApiConfig(
+ { input: "./openapi.yaml", output: "rest-api" },
+ process.cwd()
+);
+
+const result = await writeOpenApiPages({
+ configs,
+ docsDir: "./docs",
+});
+```
+
+`writeOpenApiPages()` returns generated page metadata and a navigation node you
+can merge into a custom source pipeline.
+
+## Dogfooding
+
+Leadtype's own package docs generate a small API reference from
+`docs/openapi/leadtype-api.yaml`. The package build stages that spec, generates
+native MDX operation pages, converts them to markdown, and includes them in
+`packages/leadtype/docs`, `AGENTS.md`, search, and Agent Readability artifacts.
+The Fumadocs example stages the same generated MDX pages before calling
+`fumadocsSource()`, so the browser-rendered docs exercise the component
+contract too.
diff --git a/packages/leadtype/package.json b/packages/leadtype/package.json
index a4fda4cb..9b61a5ae 100644
--- a/packages/leadtype/package.json
+++ b/packages/leadtype/package.json
@@ -67,6 +67,10 @@
"types": "./dist/markdown/index.d.ts",
"import": "./dist/markdown/index.js"
},
+ "./openapi": {
+ "types": "./dist/openapi/index.d.ts",
+ "import": "./dist/openapi/index.js"
+ },
"./transformers": {
"types": "./dist/transformers.d.ts",
"import": "./dist/transformers.js"
diff --git a/packages/leadtype/rollup.config.ts b/packages/leadtype/rollup.config.ts
index 3f3596fb..dd1c14f5 100644
--- a/packages/leadtype/rollup.config.ts
+++ b/packages/leadtype/rollup.config.ts
@@ -15,6 +15,7 @@ const entries = {
"next/index": "src/next/index.ts",
"next/client": "src/next/client.ts",
"markdown/index": "src/markdown/index.ts",
+ "openapi/index": "src/openapi/index.ts",
transformers: "src/transformers.ts",
"convert/index": "src/convert/index.ts",
"llm/index": "src/llm/index.ts",
diff --git a/packages/leadtype/scripts/generate-docs.ts b/packages/leadtype/scripts/generate-docs.ts
index 2bd54dfd..2b7928cd 100644
--- a/packages/leadtype/scripts/generate-docs.ts
+++ b/packages/leadtype/scripts/generate-docs.ts
@@ -1,4 +1,5 @@
-import { rm } from "node:fs/promises";
+import { cp, mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import docsConfig from "../../../docs/docs.config";
@@ -12,12 +13,18 @@ import {
resolveDocsNavigation,
} from "../src/llm/index";
import { defaultMarkdownTransforms } from "../src/markdown/index";
+import {
+ normalizeOpenApiConfig,
+ writeOpenApiPages,
+} from "../src/openapi/index";
import { generateDocsSearchFiles } from "../src/search/node-index";
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const REPO_ROOT = resolve(PACKAGE_ROOT, "..", "..");
const SRC_DOCS_DIR = join(REPO_ROOT, "docs");
const OUT_DOCS_DIR = join(PACKAGE_ROOT, "docs");
+const STAGED_ROOT = await mkdtemp(join(tmpdir(), "leadtype-docs-"));
+const STAGED_DOCS_DIR = join(STAGED_ROOT, "docs");
// The output folder is entirely generated and gitignored — safe to nuke.
// Also clear the package-root AGENTS.md and any leftover `llms.txt` /
@@ -28,88 +35,106 @@ await rm(join(PACKAGE_ROOT, "SKILL.md"), { force: true });
await rm(join(PACKAGE_ROOT, "llms.txt"), { force: true });
await rm(join(PACKAGE_ROOT, "llms-full.txt"), { force: true });
-await convertAllMdx({
- srcDir: SRC_DOCS_DIR,
- outDir: OUT_DOCS_DIR,
- markdownTransforms: defaultMarkdownTransforms,
-});
+try {
+ await cp(SRC_DOCS_DIR, STAGED_DOCS_DIR, { recursive: true });
+
+ const openApiResult =
+ docsConfig.openapi === undefined
+ ? { nav: [], pages: [] }
+ : await writeOpenApiPages({
+ configs: normalizeOpenApiConfig(docsConfig.openapi, SRC_DOCS_DIR),
+ docsDir: STAGED_DOCS_DIR,
+ });
+ const docsNavigation = [
+ ...(docsConfig.navigation ?? []),
+ ...openApiResult.nav,
+ ];
+
+ await convertAllMdx({
+ srcDir: STAGED_DOCS_DIR,
+ outDir: OUT_DOCS_DIR,
+ markdownTransforms: defaultMarkdownTransforms,
+ });
-// Validate group references against docs.config.ts and fail fast on typos —
-// the lint rule covers this in CI, but the package build is also a gate so a
-// bad config can't ship.
-const agentInputs = resolveAgentInputs({
- product: docsConfig.product,
- organization: docsConfig.organization,
- llms: docsConfig.llms,
-});
+ // Validate group references against docs.config.ts and fail fast on typos —
+ // the lint rule covers this in CI, but the package build is also a gate so a
+ // bad config can't ship.
+ const agentInputs = resolveAgentInputs({
+ product: docsConfig.product,
+ organization: docsConfig.organization,
+ llms: docsConfig.llms,
+ });
-const navigation = await resolveDocsNavigation({
- srcDir: REPO_ROOT,
- nav: docsConfig.navigation,
- mounts: docsConfig.mounts,
-});
-if (navigation.unknown.length > 0) {
- for (const { urlPath, slug } of navigation.unknown) {
- logger.error({
- human: { message: `${urlPath} declares unknown group "${slug}"` },
- json: {
- event: "docs.unknown_group",
- fields: { urlPath, slug },
- },
- });
+ const navigation = await resolveDocsNavigation({
+ srcDir: STAGED_ROOT,
+ nav: docsNavigation,
+ mounts: docsConfig.mounts,
+ });
+ if (navigation.unknown.length > 0) {
+ for (const { urlPath, slug } of navigation.unknown) {
+ logger.error({
+ human: { message: `${urlPath} declares unknown group "${slug}"` },
+ json: {
+ event: "docs.unknown_group",
+ fields: { urlPath, slug },
+ },
+ });
+ }
+ process.exit(1);
}
- process.exit(1);
-}
-// Emit AGENTS.md at the package root. Every link inside is a relative path to
-// the bundled `.md` topic, so the docs remain valid after npm install at
-// node_modules/leadtype/ with no URL fetches required.
-const { outputPath } = await generateAgentsMd({
- srcDir: REPO_ROOT,
- outDir: PACKAGE_ROOT,
- product: agentInputs.product,
- nav: docsConfig.navigation,
-});
+ // Emit AGENTS.md at the package root. Every link inside is a relative path to
+ // the bundled `.md` topic, so the docs remain valid after npm install at
+ // node_modules/leadtype/ with no URL fetches required.
+ const { outputPath } = await generateAgentsMd({
+ srcDir: STAGED_ROOT,
+ outDir: PACKAGE_ROOT,
+ product: agentInputs.product,
+ nav: docsNavigation,
+ });
-// Also ship the MCP artifacts (search index + readability manifest) inside the
-// tarball — the `--bundle --mcp` story — so a consumer can run a version-matched
-// docs MCP server over our own docs: `leadtype mcp --package leadtype`.
-// URL-independent, so no base URL is needed.
-await generateDocsSearchFiles({
- outDir: PACKAGE_ROOT,
- mounts: docsConfig.mounts,
-});
-await generateAgentReadabilityArtifacts({
- outDir: PACKAGE_ROOT,
- product: {
- name: agentInputs.product.name,
- summary: agentInputs.product.summary,
- },
- nav: docsConfig.navigation,
- mounts: docsConfig.mounts,
- jsonLd: agentInputs.jsonLd,
-});
+ // Also ship the MCP artifacts (search index + readability manifest) inside the
+ // tarball — the `--bundle --mcp` story — so a consumer can run a version-matched
+ // docs MCP server over our own docs: `leadtype mcp --package leadtype`.
+ // URL-independent, so no base URL is needed.
+ await generateDocsSearchFiles({
+ outDir: PACKAGE_ROOT,
+ mounts: docsConfig.mounts,
+ });
+ await generateAgentReadabilityArtifacts({
+ outDir: PACKAGE_ROOT,
+ product: {
+ name: agentInputs.product.name,
+ summary: agentInputs.product.summary,
+ },
+ nav: docsNavigation,
+ mounts: docsConfig.mounts,
+ jsonLd: agentInputs.jsonLd,
+ });
-// Ship the docs-skill SKILL.md next to AGENTS.md so on-disk agents discover it the
-// same way they discover AGENTS.md (offline, version-matched). Bundle MCP is on.
-await generateSkillArtifacts({
- outDir: PACKAGE_ROOT,
- srcDir: REPO_ROOT,
- product: {
- name: agentInputs.product.name,
- summary: agentInputs.product.summary,
- },
- skills: docsConfig.agents?.skills,
- mode: "bundle",
- mcpEnabled: true,
-});
+ // Ship the docs-skill SKILL.md next to AGENTS.md so on-disk agents discover it the
+ // same way they discover AGENTS.md (offline, version-matched). Bundle MCP is on.
+ await generateSkillArtifacts({
+ outDir: PACKAGE_ROOT,
+ srcDir: REPO_ROOT,
+ product: {
+ name: agentInputs.product.name,
+ summary: agentInputs.product.summary,
+ },
+ skills: docsConfig.agents?.skills,
+ mode: "bundle",
+ mcpEnabled: true,
+ });
-logger.info({
- human: {
- message: `Generated ${outputPath} and ${OUT_DOCS_DIR}/*.md`,
- },
- json: {
- event: "docs.generate.done",
- fields: { outputPath, docsDir: OUT_DOCS_DIR },
- },
-});
+ logger.info({
+ human: {
+ message: `Generated ${outputPath} and ${OUT_DOCS_DIR}/*.md`,
+ },
+ json: {
+ event: "docs.generate.done",
+ fields: { outputPath, docsDir: OUT_DOCS_DIR },
+ },
+ });
+} finally {
+ await rm(STAGED_ROOT, { recursive: true, force: true });
+}
diff --git a/packages/leadtype/src/cli/generate.ts b/packages/leadtype/src/cli/generate.ts
index 2fbef3cd..e0170eb5 100644
--- a/packages/leadtype/src/cli/generate.ts
+++ b/packages/leadtype/src/cli/generate.ts
@@ -71,6 +71,11 @@ import {
generateNlwebArtifacts,
NLWEB_SCHEMA_MAP_PATH,
} from "../nlweb/artifacts";
+import {
+ type DocsOpenApiConfig,
+ normalizeOpenApiConfig,
+ writeOpenApiPages,
+} from "../openapi";
import type { GenerateDocsSearchFilesResult } from "../search/node";
import { generateDocsSearchFiles } from "../search/node";
import {
@@ -300,6 +305,7 @@ type ResolvedGenerateMetadata = {
mounts?: DocsPathMount[];
feeds?: DocsFeedConfig[];
git?: DocsConfig["git"];
+ openapi?: DocsOpenApiConfig;
transformers?: DocsTransformer[];
typeTableBasePath?: string;
typeTableStrict?: boolean;
@@ -1295,6 +1301,10 @@ function validateDocsConfig(value: unknown, configPath: string): DocsConfig {
const mounts = validateDocsMounts(value.mounts, configPath);
const feeds = validateDocsFeeds(value.feeds, configPath);
const git = validateGitConfig(value.git, configPath);
+ const openapi =
+ value.openapi === undefined
+ ? undefined
+ : (value.openapi as DocsOpenApiConfig);
if (value.flatteners !== undefined && !Array.isArray(value.flatteners)) {
throw new Error(
@@ -1312,6 +1322,7 @@ function validateDocsConfig(value: unknown, configPath: string): DocsConfig {
...(mounts ? { mounts } : {}),
...(feeds ? { feeds } : {}),
...(git ? { git } : {}),
+ ...(openapi ? { openapi } : {}),
...(value.frontmatterSchema === undefined
? {}
: {
@@ -1846,6 +1857,7 @@ async function resolveGenerateMetadata(
mounts: loaded.config.mounts,
feeds: loaded.config.feeds,
git: loaded.config.git,
+ openapi: loaded.config.openapi,
...agentInputs,
transformers: loaded.config.transformers,
typeTableBasePath: loaded.config.typeTableBasePath
@@ -2322,7 +2334,8 @@ async function writeI18nManifest(
async function createSourceMirror(
srcDir: string,
sources: ResolvedDocsSource[],
- args: GenerateArgs
+ args: GenerateArgs,
+ forceStaging = false
): Promise {
const filters = {
exclude: [...args.exclude],
@@ -2336,7 +2349,7 @@ async function createSourceMirror(
path.resolve(sources[0]?.docsDir ?? "") ===
path.resolve(srcDir, DEFAULT_DOCS_DIR);
- if (isDefaultSingleSource && !hasFilters) {
+ if (isDefaultSingleSource && !hasFilters && !forceStaging) {
const docsDir = sources[0]?.docsDir ?? path.join(srcDir, DEFAULT_DOCS_DIR);
return {
cleanup: async () => {
@@ -2613,7 +2626,22 @@ export async function runGenerateCommand(
args,
docsSources
);
- sourceMirror = await createSourceMirror(srcDir, docsSources, args);
+ sourceMirror = await createSourceMirror(
+ srcDir,
+ docsSources,
+ args,
+ metadata.openapi !== undefined
+ );
+ const generatedOpenApi =
+ metadata.openapi === undefined
+ ? { nav: [], pages: [] }
+ : await writeOpenApiPages({
+ configs: normalizeOpenApiConfig(
+ metadata.openapi,
+ metadata.configPath ? path.dirname(metadata.configPath) : docsDir
+ ),
+ docsDir: sourceMirror.docsDir,
+ });
const hasExplicitPathFilters =
args.include.length > 0 || args.exclude.length > 0;
// Collections-mode configs may omit per-collection `groups` and lean on
@@ -2634,7 +2662,9 @@ export async function runGenerateCommand(
}
: metadata;
const bundleMcpEnabled = args.mcp || metadata.agents?.mcp?.enabled === true;
- const effectiveNav = hasExplicitPathFilters ? undefined : nav;
+ const effectiveNav = hasExplicitPathFilters
+ ? undefined
+ : [...(nav ?? []), ...generatedOpenApi.nav];
const effectiveMounts = [...mounts, ...(metadata.mounts ?? [])];
const i18n = normalizeDocsI18nConfig(metadata.i18n);
const i18nManifest = buildI18nManifest(metadata.i18n);
diff --git a/packages/leadtype/src/index.ts b/packages/leadtype/src/index.ts
index 260c82b7..c8d39133 100644
--- a/packages/leadtype/src/index.ts
+++ b/packages/leadtype/src/index.ts
@@ -4,6 +4,7 @@
// - `leadtype/mdx` — tag types, source remark preset, include resolver
// - `leadtype/fumadocs` — adapter for fumadocs-core's Source interface
// - `leadtype/markdown` — agent/LLM flattening plugins
+// - `leadtype/openapi` — OpenAPI loading and generated API reference pages
// - `leadtype/convert` — MDX → markdown helpers
// - `leadtype/feed` — RSS/Atom renderers and artifact generation
// - `leadtype/llm` — TOC extraction, slug helpers, agent readability
@@ -70,6 +71,26 @@ export {
type SourceConfigInheritance,
type SourceConfigInheritField,
} from "./llm";
+export type {
+ DocsOpenApiConfig,
+ GeneratedOpenApiPage,
+ GenerateOpenApiPagesResult,
+ OpenApiCodeSample,
+ OpenApiHttpMethod,
+ OpenApiMediaType,
+ OpenApiOperation,
+ OpenApiParameter,
+ OpenApiRequestBody,
+ OpenApiResponse,
+ OpenApiSchemaProperty,
+ OpenApiSchemaSummary,
+ OpenApiSecurityRequirement,
+ OpenApiSecurityScheme,
+ OpenApiSlugStrategy,
+ OpenApiSourceConfig,
+ OpenApiSourceInput,
+ ResolvedOpenApiSourceConfig,
+} from "./openapi";
export {
type CreateDocsSourceConfig,
createDocsSource,
diff --git a/packages/leadtype/src/internal/package-surface.test.ts b/packages/leadtype/src/internal/package-surface.test.ts
index 3c09200b..47a360d6 100644
--- a/packages/leadtype/src/internal/package-surface.test.ts
+++ b/packages/leadtype/src/internal/package-surface.test.ts
@@ -91,6 +91,7 @@ describe("package surface", () => {
"./next",
"./next/client",
"./markdown",
+ "./openapi",
"./transformers",
"./convert",
"./llm",
diff --git a/packages/leadtype/src/llm/llm.ts b/packages/leadtype/src/llm/llm.ts
index d3744264..a240caaf 100644
--- a/packages/leadtype/src/llm/llm.ts
+++ b/packages/leadtype/src/llm/llm.ts
@@ -30,6 +30,7 @@ import {
} from "../internal/docs-url";
import { parseFrontmatter } from "../internal/frontmatter";
import { logger } from "../internal/logger";
+import type { DocsOpenApiConfig } from "../openapi";
import {
type DocsFrontmatterSchema,
type DocsLlmsTxtArtifact,
@@ -473,6 +474,11 @@ export type DocsConfig<
* its own source acquisition, URL prefix, frontmatter schema, and nav.
*/
collections?: Record;
+ /**
+ * OpenAPI specs to generate into the docs source before conversion. Generated
+ * pages use native MDX API components and flatten into agent-readable markdown.
+ */
+ openapi?: DocsOpenApiConfig;
i18n?: DocsI18nConfig;
/**
* Optional base directory for ExtractedTypeTable / AutoTypeTable path
diff --git a/packages/leadtype/src/markdown/default-transforms.ts b/packages/leadtype/src/markdown/default-transforms.ts
index 8f254898..21e2e40b 100644
--- a/packages/leadtype/src/markdown/default-transforms.ts
+++ b/packages/leadtype/src/markdown/default-transforms.ts
@@ -22,6 +22,7 @@ import { remarkDetailsToMarkdown } from "./plugins/details";
import { remarkExampleToMarkdown } from "./plugins/example";
import { remarkFileTreeToMarkdown } from "./plugins/file-tree";
import { remarkMermaidToMarkdown } from "./plugins/mermaid";
+import { openApiToMarkdown } from "./plugins/openapi";
import { remarkPromptToMarkdown } from "./plugins/prompt";
import { remarkSectionToMarkdown } from "./plugins/section";
import { remarkStepsToMarkdown } from "./plugins/steps";
@@ -55,6 +56,15 @@ tagFlattenerNames(remarkTopicSwitcherToMarkdown, ["TopicSwitcher"]);
tagFlattenerNames(remarkFileTreeToMarkdown, ["File", "FileTree", "Folder"]);
tagFlattenerNames(remarkPromptToMarkdown, ["Prompt"]);
tagFlattenerNames(remarkExampleToMarkdown, ["Example"]);
+tagFlattenerNames(openApiToMarkdown, [
+ "ApiAuth",
+ "ApiCodeSamples",
+ "ApiEndpoint",
+ "ApiParameters",
+ "ApiRequestBody",
+ "ApiResponses",
+ "ApiTryIt",
+]);
const resolvePlugins = [
remarkRemoveImports,
@@ -82,6 +92,7 @@ export const builtinMarkdownFlattenerTransforms = [
remarkFileTreeToMarkdown,
remarkPromptToMarkdown,
remarkExampleToMarkdown,
+ openApiToMarkdown,
];
/**
@@ -94,6 +105,13 @@ export const builtinMarkdownFlattenerTransforms = [
export const BUILTIN_FLATTENER_COMPONENT_NAMES = [
"Accordion",
"AccordionItem",
+ "ApiAuth",
+ "ApiCodeSamples",
+ "ApiEndpoint",
+ "ApiParameters",
+ "ApiRequestBody",
+ "ApiResponses",
+ "ApiTryIt",
"Audience",
"AutoTypeTable",
"Callout",
diff --git a/packages/leadtype/src/markdown/plugins/openapi.ts b/packages/leadtype/src/markdown/plugins/openapi.ts
new file mode 100644
index 00000000..b65b0a74
--- /dev/null
+++ b/packages/leadtype/src/markdown/plugins/openapi.ts
@@ -0,0 +1,260 @@
+import JSON5 from "json5";
+import type { Code, ListItem, Root, RootContent } from "mdast";
+import type { Transformer } from "unified";
+import type {
+ OpenApiCodeSample,
+ OpenApiMediaType,
+ OpenApiParameter,
+ OpenApiRequestBody,
+ OpenApiResponse,
+ OpenApiSchemaProperty,
+ OpenApiSecurityRequirement,
+ OpenApiSecurityScheme,
+} from "../../openapi";
+import {
+ createHeading,
+ createInlineCode,
+ createJsxComponentProcessor,
+ createListItem,
+ createParagraph,
+ createTable,
+ createUnorderedList,
+ getAttributeValue,
+} from "../libs";
+
+function createCodeBlock(value: string, lang: string): Code {
+ return { type: "code", lang, value };
+}
+
+function parseAttr(raw: string | null, fallback: T): T {
+ if (!raw) {
+ return fallback;
+ }
+ try {
+ return JSON5.parse(raw) as T;
+ } catch {
+ return fallback;
+ }
+}
+
+function requiredLabel(required: boolean | undefined): string {
+ return required ? "required" : "optional";
+}
+
+function schemaLabel(
+ schema: { type?: string; format?: string } | undefined
+): string {
+ if (!schema) {
+ return "";
+ }
+ return schema.format
+ ? `${schema.type ?? "unknown"} (${schema.format})`
+ : (schema.type ?? "");
+}
+
+function schemaRows(
+ properties: OpenApiSchemaProperty[] | undefined
+): (string | ReturnType[])[][] {
+ if (!properties || properties.length === 0) {
+ return [];
+ }
+ return properties.map((property) => [
+ [createInlineCode(property.name)],
+ schemaLabel(property),
+ requiredLabel(property.required),
+ property.description ?? "",
+ ]);
+}
+
+function renderParameterTable(parameters: OpenApiParameter[]): RootContent[] {
+ if (parameters.length === 0) {
+ return [createParagraph("No parameters.")];
+ }
+ return [
+ createTable(
+ ["Name", "Type", "Required", "Description"],
+ parameters.map((parameter) => [
+ [createInlineCode(parameter.name)],
+ schemaLabel(parameter.schema),
+ requiredLabel(parameter.required),
+ parameter.description ?? "",
+ ])
+ ),
+ ];
+}
+
+function renderMediaType(media: OpenApiMediaType): RootContent[] {
+ const nodes: RootContent[] = [
+ createParagraph(`Content type: ${media.mediaType}`),
+ ];
+ const rows = schemaRows(media.schema?.properties);
+ if (rows.length > 0) {
+ nodes.push(
+ createTable(["Property", "Type", "Required", "Description"], rows)
+ );
+ } else if (media.schema) {
+ nodes.push(createParagraph(`Schema: ${schemaLabel(media.schema)}`));
+ }
+ if (media.example !== undefined) {
+ nodes.push(createCodeBlock(JSON.stringify(media.example, null, 2), "json"));
+ }
+ return nodes;
+}
+
+function renderSecurityScheme(scheme: OpenApiSecurityScheme): ListItem {
+ const bits = [scheme.type];
+ if (scheme.scheme) {
+ bits.push(scheme.scheme);
+ }
+ if (scheme.name) {
+ bits.push(scheme.name);
+ }
+ const label = bits.filter(Boolean).join(" / ");
+ const description = scheme.description ? ` - ${scheme.description}` : "";
+ return createListItem([
+ createParagraph(`${scheme.key}: ${label}${description}`),
+ ]);
+}
+
+function renderSecurityRequirements(
+ requirements: OpenApiSecurityRequirement[]
+): RootContent[] {
+ if (requirements.length === 0) {
+ return [createParagraph("No authentication required.")];
+ }
+ const items = requirements.map((requirement) => {
+ const names = Object.keys(requirement);
+ return createListItem([
+ createParagraph(names.length > 0 ? names.join(" + ") : "Anonymous"),
+ ]);
+ });
+ return [createUnorderedList(items)];
+}
+
+function renderCodeSamples(samples: OpenApiCodeSample[]): RootContent[] {
+ const nodes: RootContent[] = [];
+ for (const sample of samples) {
+ nodes.push(createHeading(3, sample.label));
+ nodes.push(createCodeBlock(sample.code, sample.language));
+ }
+ return nodes;
+}
+
+function renderResponse(response: OpenApiResponse): RootContent[] {
+ const nodes: RootContent[] = [
+ createHeading(3, response.status),
+ createParagraph(response.description || "No description."),
+ ];
+ for (const media of response.content) {
+ nodes.push(...renderMediaType(media));
+ }
+ if (response.headers && response.headers.length > 0) {
+ nodes.push(createHeading(4, "Headers"));
+ nodes.push(...renderParameterTable(response.headers));
+ }
+ return nodes;
+}
+
+export function openApiToMarkdown(): Transformer {
+ const endpoint = createJsxComponentProcessor("ApiEndpoint", (node) => {
+ const method = (getAttributeValue(node, "method") ?? "").toUpperCase();
+ const apiPath = getAttributeValue(node, "path") ?? "";
+ const operationId = getAttributeValue(node, "operationId");
+ const serverUrl = getAttributeValue(node, "serverUrl");
+ const deprecated = getAttributeValue(node, "deprecated") === "true";
+ const lines = [
+ `${method} ${apiPath}`,
+ operationId ? `Operation ID: ${operationId}` : "",
+ serverUrl ? `Server: ${serverUrl}` : "",
+ deprecated ? "Deprecated: true" : "",
+ ].filter(Boolean);
+ return [createParagraph(lines.join("\n"))];
+ });
+
+ const auth = createJsxComponentProcessor("ApiAuth", (node) => {
+ const schemes = parseAttr(
+ getAttributeValue(node, "schemes"),
+ []
+ );
+ const requirements = parseAttr(
+ getAttributeValue(node, "requirements"),
+ []
+ );
+ const nodes: RootContent[] = [];
+ nodes.push(...renderSecurityRequirements(requirements));
+ if (schemes.length > 0) {
+ nodes.push(createHeading(3, "Schemes"));
+ nodes.push(createUnorderedList(schemes.map(renderSecurityScheme)));
+ }
+ return nodes;
+ });
+
+ const parameters = createJsxComponentProcessor("ApiParameters", (node) => {
+ const title = getAttributeValue(node, "title");
+ const parsed = parseAttr(
+ getAttributeValue(node, "parameters"),
+ []
+ );
+ const nodes: RootContent[] = [];
+ if (title) {
+ nodes.push(createHeading(3, title));
+ }
+ nodes.push(...renderParameterTable(parsed));
+ return nodes;
+ });
+
+ const requestBody = createJsxComponentProcessor("ApiRequestBody", (node) => {
+ const body = parseAttr(
+ getAttributeValue(node, "body"),
+ null
+ );
+ if (!body) {
+ return [createParagraph("No request body.")];
+ }
+ const nodes: RootContent[] = [
+ createHeading(3, "Request Body"),
+ createParagraph(
+ `${requiredLabel(body.required)}${body.description ? ` - ${body.description}` : ""}`
+ ),
+ ];
+ for (const media of body.content) {
+ nodes.push(...renderMediaType(media));
+ }
+ return nodes;
+ });
+
+ const responses = createJsxComponentProcessor("ApiResponses", (node) => {
+ const parsed = parseAttr(
+ getAttributeValue(node, "responses"),
+ []
+ );
+ if (parsed.length === 0) {
+ return [createParagraph("No responses documented.")];
+ }
+ return parsed.flatMap(renderResponse);
+ });
+
+ const codeSamples = createJsxComponentProcessor("ApiCodeSamples", (node) => {
+ const samples = parseAttr(
+ getAttributeValue(node, "samples"),
+ []
+ );
+ return renderCodeSamples(samples);
+ });
+
+ const tryIt = createJsxComponentProcessor("ApiTryIt", () => [
+ createParagraph(
+ "Interactive API console metadata is available to the docs renderer."
+ ),
+ ]);
+
+ return (tree) => {
+ endpoint(tree);
+ auth(tree);
+ parameters(tree);
+ requestBody(tree);
+ responses(tree);
+ codeSamples(tree);
+ tryIt(tree);
+ };
+}
diff --git a/packages/leadtype/src/mdx/index.ts b/packages/leadtype/src/mdx/index.ts
index 94356ef2..f508874d 100644
--- a/packages/leadtype/src/mdx/index.ts
+++ b/packages/leadtype/src/mdx/index.ts
@@ -52,6 +52,15 @@ export {
export type {
AccordionItemProps,
AccordionProps,
+ ApiAuthProps,
+ ApiCodeSamplesProps,
+ ApiEndpointProps,
+ ApiMediaType,
+ ApiParametersProps,
+ ApiRequestBodyProps,
+ ApiResponsesProps,
+ ApiSchemaProperty,
+ ApiTryItProps,
AudienceProps,
AudienceTarget,
CalloutProps,
diff --git a/packages/leadtype/src/mdx/tag-types.ts b/packages/leadtype/src/mdx/tag-types.ts
index d7f747f2..aafb4b44 100644
--- a/packages/leadtype/src/mdx/tag-types.ts
+++ b/packages/leadtype/src/mdx/tag-types.ts
@@ -19,6 +19,17 @@
* Every tag type is part of the 1.0 contract — bumping the prop shape is a
* breaking change.
*/
+import type {
+ OpenApiCodeSample,
+ OpenApiMediaType,
+ OpenApiOperation,
+ OpenApiParameter,
+ OpenApiRequestBody,
+ OpenApiResponse,
+ OpenApiSchemaProperty,
+ OpenApiSecurityRequirement,
+ OpenApiSecurityScheme,
+} from "../openapi";
// ---------------------------------------------------------------------------
// Callout
@@ -135,6 +146,48 @@ export type ExtractedTypeTableProps = {
description?: string;
};
+// ---------------------------------------------------------------------------
+// OpenAPI
+// ---------------------------------------------------------------------------
+
+export type ApiSchemaProperty = OpenApiSchemaProperty;
+export type ApiMediaType = OpenApiMediaType;
+
+export type ApiEndpointProps = {
+ method: OpenApiOperation["method"];
+ path: string;
+ operationId?: string;
+ serverUrl?: string;
+ deprecated?: boolean;
+};
+
+export type ApiAuthProps = {
+ requirements: OpenApiSecurityRequirement[];
+ schemes: OpenApiSecurityScheme[];
+};
+
+export type ApiParametersProps = {
+ location: OpenApiParameter["in"];
+ title?: string;
+ parameters: OpenApiParameter[];
+};
+
+export type ApiRequestBodyProps = {
+ body: OpenApiRequestBody;
+};
+
+export type ApiCodeSamplesProps = {
+ samples: OpenApiCodeSample[];
+};
+
+export type ApiResponsesProps = {
+ responses: OpenApiResponse[];
+};
+
+export type ApiTryItProps = {
+ operation: OpenApiOperation;
+};
+
// ---------------------------------------------------------------------------
// Mermaid
// ---------------------------------------------------------------------------
diff --git a/packages/leadtype/src/openapi/index.ts b/packages/leadtype/src/openapi/index.ts
new file mode 100644
index 00000000..3f3f204b
--- /dev/null
+++ b/packages/leadtype/src/openapi/index.ts
@@ -0,0 +1,1208 @@
+import { existsSync } from "node:fs";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import path from "node:path";
+import YAML from "yaml";
+import { normalizeDocsPath, stripDocsExtension } from "../internal/docs-url";
+import type { DocsNavNode } from "../llm";
+
+const HTTP_METHODS = [
+ "get",
+ "put",
+ "post",
+ "delete",
+ "options",
+ "head",
+ "patch",
+ "trace",
+] as const;
+
+const DEFAULT_OUTPUT_DIR = "api";
+const DEFAULT_NAV_TITLE = "API Reference";
+const DEFAULT_METHOD_ORDER = new Map(
+ HTTP_METHODS.map((method, index) => [method, index])
+);
+const CAMEL_CASE_BOUNDARY_PATTERN = /([a-z0-9])([A-Z])/g;
+const POINTER_ESCAPE_PATTERN = /~[01]/g;
+const NON_ALPHANUMERIC_PATTERN = /[^a-z0-9]+/gi;
+const DUPLICATE_DASH_PATTERN = /-+/g;
+const LEADING_TRAILING_DASH_PATTERN = /^-|-$/g;
+
+export type OpenApiHttpMethod = (typeof HTTP_METHODS)[number];
+
+export type OpenApiSourceInput = string | OpenApiSourceConfig;
+
+export type DocsOpenApiConfig = OpenApiSourceInput | OpenApiSourceInput[];
+
+export type OpenApiSlugStrategy = "operation-id" | "method-path";
+
+export type OpenApiSourceConfig = {
+ /**
+ * Local path or absolute URL to an OpenAPI 3.x JSON/YAML document.
+ * Relative paths resolve from the config file directory.
+ */
+ input: string;
+ /** Directory under the docs source where generated `.mdx` pages are written. */
+ output?: string;
+ /** Frontmatter group assigned to generated pages. */
+ group?: string | string[];
+ /** Starting frontmatter order. Each operation increments from this value. */
+ order?: number;
+ /** Parent navigation title for generated pages. */
+ title?: string;
+ /** Optional description for the generated navigation section. */
+ description?: string;
+ /** Include only operations with at least one matching tag. */
+ includeTags?: string[];
+ /** Exclude operations with any matching tag. */
+ excludeTags?: string[];
+ /** Split generated pages under tag folders and nav children. */
+ groupByTags?: boolean;
+ /** Override the server URL used in generated examples and try-it metadata. */
+ serverUrl?: string;
+ /** Prefer stable operation IDs or method/path slugs. Defaults to operation IDs. */
+ slugStrategy?: OpenApiSlugStrategy;
+ /** Emit an `ApiTryIt` component for renderers that support a native console. */
+ includeTryIt?: boolean;
+};
+
+export type ResolvedOpenApiSourceConfig = Required<
+ Pick<
+ OpenApiSourceConfig,
+ "groupByTags" | "includeTryIt" | "output" | "slugStrategy" | "title"
+ >
+> &
+ Omit<
+ OpenApiSourceConfig,
+ "groupByTags" | "includeTryIt" | "output" | "slugStrategy" | "title"
+ > & {
+ cwd: string;
+ };
+
+export type OpenApiSchemaObject = Record;
+
+export type OpenApiSchemaSummary = {
+ type: string;
+ description?: string;
+ required?: boolean;
+ deprecated?: boolean;
+ default?: unknown;
+ enum?: unknown[];
+ format?: string;
+ properties?: OpenApiSchemaProperty[];
+};
+
+export type OpenApiSchemaProperty = OpenApiSchemaSummary & {
+ name: string;
+};
+
+export type OpenApiParameter = {
+ name: string;
+ in: "path" | "query" | "header" | "cookie";
+ description?: string;
+ required: boolean;
+ deprecated?: boolean;
+ schema?: OpenApiSchemaSummary;
+ example?: unknown;
+};
+
+export type OpenApiMediaType = {
+ mediaType: string;
+ schema?: OpenApiSchemaSummary;
+ example?: unknown;
+ examples?: Record;
+};
+
+export type OpenApiRequestBody = {
+ description?: string;
+ required: boolean;
+ content: OpenApiMediaType[];
+};
+
+export type OpenApiResponse = {
+ status: string;
+ description: string;
+ headers?: OpenApiParameter[];
+ content: OpenApiMediaType[];
+};
+
+export type OpenApiSecurityScheme = {
+ key: string;
+ type: string;
+ name?: string;
+ in?: string;
+ scheme?: string;
+ bearerFormat?: string;
+ description?: string;
+ flows?: unknown;
+ openIdConnectUrl?: string;
+};
+
+export type OpenApiSecurityRequirement = Record;
+
+export type OpenApiCodeSample = {
+ label: string;
+ language: string;
+ code: string;
+};
+
+export type OpenApiOperation = {
+ method: OpenApiHttpMethod;
+ path: string;
+ operationId?: string;
+ title: string;
+ description: string;
+ summary?: string;
+ tags: string[];
+ deprecated: boolean;
+ serverUrl?: string;
+ parameters: OpenApiParameter[];
+ requestBody?: OpenApiRequestBody;
+ responses: OpenApiResponse[];
+ security: OpenApiSecurityRequirement[];
+ securitySchemes: OpenApiSecurityScheme[];
+ codeSamples: OpenApiCodeSample[];
+};
+
+export type GeneratedOpenApiPage = {
+ filePath: string;
+ relativePath: string;
+ title: string;
+ description: string;
+ operation: OpenApiOperation;
+};
+
+export type GenerateOpenApiPagesResult = {
+ pages: GeneratedOpenApiPage[];
+ nav: DocsNavNode[];
+};
+
+type OperationCandidate = {
+ method: OpenApiHttpMethod;
+ path: string;
+ operation: Record;
+ pathParameters: unknown[];
+};
+
+type LoadedDocument = {
+ document: Record;
+ source: string;
+};
+
+type RefContext = {
+ document: Record;
+ source: string;
+ cwd: string;
+ loaded: Map;
+};
+
+type RefTarget = {
+ value: unknown;
+ context: RefContext;
+};
+
+function isRecord(value: unknown): value is Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function asString(value: unknown): string | undefined {
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
+}
+
+function asStringArray(value: unknown): string[] {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+ return value
+ .filter((entry): entry is string => typeof entry === "string")
+ .map((entry) => entry.trim())
+ .filter(Boolean);
+}
+
+function normalizeOutputPath(input: string | undefined): string {
+ const raw = input?.trim() || DEFAULT_OUTPUT_DIR;
+ const normalized = normalizeDocsPath(raw).replace(/^\/+/, "");
+ return normalized || DEFAULT_OUTPUT_DIR;
+}
+
+function slugify(input: string): string {
+ const slug = input
+ .trim()
+ .replace(CAMEL_CASE_BOUNDARY_PATTERN, "$1-$2")
+ .toLowerCase()
+ .replace(NON_ALPHANUMERIC_PATTERN, "-")
+ .replace(DUPLICATE_DASH_PATTERN, "-")
+ .replace(LEADING_TRAILING_DASH_PATTERN, "");
+ return slug || "operation";
+}
+
+function titleize(input: string): string {
+ return input
+ .split(/[-_\s]+/)
+ .filter(Boolean)
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
+ .join(" ");
+}
+
+function methodPathSlug(method: OpenApiHttpMethod, apiPath: string): string {
+ const pathSlug = apiPath
+ .replace(/[{}]/g, "")
+ .split("/")
+ .filter(Boolean)
+ .map(slugify)
+ .join("-");
+ return slugify(`${method}-${pathSlug || "root"}`);
+}
+
+function escapePointerSegment(segment: string): string {
+ return segment.replace(POINTER_ESCAPE_PATTERN, (match) => {
+ if (match === "~1") {
+ return "/";
+ }
+ return "~";
+ });
+}
+
+function readPointer(document: unknown, pointer: string): unknown {
+ if (!pointer.startsWith("#/")) {
+ return;
+ }
+ const segments = pointer
+ .slice(2)
+ .split("/")
+ .map((segment) => escapePointerSegment(decodeURIComponent(segment)));
+ let current = document;
+ for (const segment of segments) {
+ if (Array.isArray(current)) {
+ const index = Number(segment);
+ current = Number.isInteger(index) ? current[index] : undefined;
+ continue;
+ }
+ if (isRecord(current)) {
+ current = current[segment];
+ continue;
+ }
+ return;
+ }
+ return current;
+}
+
+async function readInput(input: string, cwd: string): Promise {
+ if (/^https?:\/\//i.test(input)) {
+ const response = await fetch(input);
+ if (!response.ok) {
+ throw new Error(
+ `OpenAPI: failed to fetch "${input}" (${response.status} ${response.statusText})`
+ );
+ }
+ const raw = await response.text();
+ const parsed = YAML.parse(raw) as unknown;
+ if (!isRecord(parsed)) {
+ throw new Error(`OpenAPI: "${input}" did not parse to an object`);
+ }
+ return { document: parsed, source: input };
+ }
+
+ const filePath = path.isAbsolute(input) ? input : path.resolve(cwd, input);
+ if (!existsSync(filePath)) {
+ throw new Error(`OpenAPI: spec not found at "${filePath}"`);
+ }
+ const raw = await readFile(filePath, "utf8");
+ const parsed = YAML.parse(raw) as unknown;
+ if (!isRecord(parsed)) {
+ throw new Error(`OpenAPI: "${filePath}" did not parse to an object`);
+ }
+ return { document: parsed, source: filePath };
+}
+
+async function loadExternalRef(
+ ref: string,
+ context: RefContext
+): Promise {
+ const [targetInput = "", pointer = ""] = ref.split("#", 2);
+ const baseDir = /^https?:\/\//i.test(context.source)
+ ? context.source
+ : path.dirname(context.source);
+ let resolvedInput: string;
+ if (/^https?:\/\//i.test(targetInput)) {
+ resolvedInput = targetInput;
+ } else if (/^https?:\/\//i.test(baseDir)) {
+ resolvedInput = new URL(targetInput, baseDir).href;
+ } else {
+ resolvedInput = path.resolve(baseDir, targetInput);
+ }
+
+ let loaded = context.loaded.get(resolvedInput);
+ if (!loaded) {
+ loaded = await readInput(resolvedInput, context.cwd);
+ context.loaded.set(resolvedInput, loaded);
+ }
+
+ const externalContext: RefContext = {
+ cwd: context.cwd,
+ document: loaded.document,
+ loaded: context.loaded,
+ source: loaded.source,
+ };
+ if (!pointer) {
+ return { context: externalContext, value: loaded.document };
+ }
+ return {
+ context: externalContext,
+ value: readPointer(loaded.document, `#${pointer}`),
+ };
+}
+
+async function dereferenceValue(
+ value: unknown,
+ context: RefContext,
+ seen: Set
+): Promise {
+ if (Array.isArray(value)) {
+ const items: unknown[] = [];
+ for (const item of value) {
+ items.push(await dereferenceValue(item, context, seen));
+ }
+ return items;
+ }
+
+ if (!isRecord(value)) {
+ return value;
+ }
+
+ const ref = asString(value.$ref);
+ if (ref) {
+ const cacheKey = `${context.source}:${ref}`;
+ if (seen.has(cacheKey)) {
+ return value;
+ }
+ seen.add(cacheKey);
+ const target = ref.startsWith("#")
+ ? { context, value: readPointer(context.document, ref) }
+ : await loadExternalRef(ref, context);
+ if (target.value === undefined) {
+ throw new Error(`OpenAPI: unresolved $ref "${ref}" in ${context.source}`);
+ }
+ const resolved = await dereferenceValue(target.value, target.context, seen);
+ seen.delete(cacheKey);
+ return resolved;
+ }
+
+ const output: Record = {};
+ for (const [key, entry] of Object.entries(value)) {
+ output[key] = await dereferenceValue(entry, context, seen);
+ }
+ return output;
+}
+
+async function loadOpenApiDocument(
+ input: string,
+ cwd: string
+): Promise> {
+ const loaded = await readInput(input, cwd);
+ const context: RefContext = {
+ cwd,
+ document: loaded.document,
+ loaded: new Map([[loaded.source, loaded]]),
+ source: loaded.source,
+ };
+ const dereferenced = await dereferenceValue(
+ loaded.document,
+ context,
+ new Set()
+ );
+ if (!isRecord(dereferenced)) {
+ throw new Error(`OpenAPI: "${input}" did not resolve to an object`);
+ }
+ validateOpenApiDocument(dereferenced, input);
+ return dereferenced;
+}
+
+function validateOpenApiDocument(
+ document: Record,
+ input: string
+): void {
+ const version = asString(document.openapi);
+ if (!version?.startsWith("3.")) {
+ throw new Error(
+ `OpenAPI: "${input}" must be an OpenAPI 3.x document with an "openapi" field`
+ );
+ }
+ if (!isRecord(document.paths)) {
+ throw new Error(`OpenAPI: "${input}" must define a paths object`);
+ }
+}
+
+function resolveOperationTitle(
+ operation: Record,
+ method: OpenApiHttpMethod,
+ apiPath: string
+): string {
+ const summary = asString(operation.summary);
+ if (summary) {
+ return summary;
+ }
+ const operationId = asString(operation.operationId);
+ if (operationId) {
+ return titleize(operationId);
+ }
+ return `${method.toUpperCase()} ${apiPath}`;
+}
+
+function resolveOperationDescription(
+ operation: Record,
+ title: string
+): string {
+ return (
+ asString(operation.description) ?? asString(operation.summary) ?? title
+ );
+}
+
+function schemaType(schema: Record): string {
+ const ref = asString(schema.$ref);
+ if (ref) {
+ return ref.split("/").pop() ?? ref;
+ }
+
+ const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined;
+ if (enumValues && enumValues.length > 0) {
+ return enumValues.map((entry) => JSON.stringify(entry)).join(" | ");
+ }
+
+ const oneOf = Array.isArray(schema.oneOf) ? schema.oneOf : undefined;
+ if (oneOf && oneOf.length > 0) {
+ return oneOf.map((entry) => schemaTypeFromUnknown(entry)).join(" | ");
+ }
+
+ const anyOf = Array.isArray(schema.anyOf) ? schema.anyOf : undefined;
+ if (anyOf && anyOf.length > 0) {
+ return anyOf.map((entry) => schemaTypeFromUnknown(entry)).join(" | ");
+ }
+
+ const allOf = Array.isArray(schema.allOf) ? schema.allOf : undefined;
+ if (allOf && allOf.length > 0) {
+ return allOf.map((entry) => schemaTypeFromUnknown(entry)).join(" & ");
+ }
+
+ const type = schema.type;
+ if (Array.isArray(type)) {
+ return type.filter((entry) => typeof entry === "string").join(" | ");
+ }
+
+ if (type === "array") {
+ const items = schemaTypeFromUnknown(schema.items);
+ return `${items}[]`;
+ }
+
+ if (type === "object" || isRecord(schema.properties)) {
+ return "object";
+ }
+
+ return typeof type === "string" ? type : "unknown";
+}
+
+function schemaTypeFromUnknown(value: unknown): string {
+ return isRecord(value) ? schemaType(value) : "unknown";
+}
+
+function summarizeSchema(
+ value: unknown,
+ required = false
+): OpenApiSchemaSummary | undefined {
+ if (!isRecord(value)) {
+ return;
+ }
+
+ const requiredNames = new Set(asStringArray(value.required));
+ const properties: OpenApiSchemaProperty[] = [];
+ if (isRecord(value.properties)) {
+ for (const [name, property] of Object.entries(value.properties)) {
+ const summary = summarizeSchema(property, requiredNames.has(name));
+ if (!summary) {
+ continue;
+ }
+ properties.push({ name, ...summary });
+ }
+ }
+
+ return {
+ type: schemaType(value),
+ ...(asString(value.description)
+ ? { description: asString(value.description) }
+ : {}),
+ ...(required ? { required } : {}),
+ ...(value.deprecated === true ? { deprecated: true } : {}),
+ ...(value.default === undefined ? {} : { default: value.default }),
+ ...(Array.isArray(value.enum) ? { enum: value.enum } : {}),
+ ...(asString(value.format) ? { format: asString(value.format) } : {}),
+ ...(properties.length > 0 ? { properties } : {}),
+ };
+}
+
+function normalizeParameter(value: unknown): OpenApiParameter | undefined {
+ if (!isRecord(value)) {
+ return;
+ }
+ const name = asString(value.name);
+ const location = asString(value.in);
+ if (
+ !(
+ name &&
+ (location === "path" ||
+ location === "query" ||
+ location === "header" ||
+ location === "cookie")
+ )
+ ) {
+ return;
+ }
+ return {
+ name,
+ in: location,
+ required: value.required === true || location === "path",
+ ...(asString(value.description)
+ ? { description: asString(value.description) }
+ : {}),
+ ...(value.deprecated === true ? { deprecated: true } : {}),
+ ...(summarizeSchema(value.schema)
+ ? { schema: summarizeSchema(value.schema) }
+ : {}),
+ ...(value.example === undefined ? {} : { example: value.example }),
+ };
+}
+
+function normalizeMediaTypes(content: unknown): OpenApiMediaType[] {
+ if (!isRecord(content)) {
+ return [];
+ }
+ const mediaTypes: OpenApiMediaType[] = [];
+ for (const [mediaType, value] of Object.entries(content)) {
+ if (!isRecord(value)) {
+ continue;
+ }
+ mediaTypes.push({
+ mediaType,
+ ...(summarizeSchema(value.schema)
+ ? { schema: summarizeSchema(value.schema) }
+ : {}),
+ ...(value.example === undefined ? {} : { example: value.example }),
+ ...(isRecord(value.examples) ? { examples: value.examples } : {}),
+ });
+ }
+ return mediaTypes;
+}
+
+function normalizeRequestBody(value: unknown): OpenApiRequestBody | undefined {
+ if (!isRecord(value)) {
+ return;
+ }
+ return {
+ required: value.required === true,
+ ...(asString(value.description)
+ ? { description: asString(value.description) }
+ : {}),
+ content: normalizeMediaTypes(value.content),
+ };
+}
+
+function normalizeResponses(value: unknown): OpenApiResponse[] {
+ if (!isRecord(value)) {
+ return [];
+ }
+ const responses: OpenApiResponse[] = [];
+ for (const [status, response] of Object.entries(value)) {
+ if (!isRecord(response)) {
+ continue;
+ }
+ const headers: OpenApiParameter[] = [];
+ if (isRecord(response.headers)) {
+ for (const [name, header] of Object.entries(response.headers)) {
+ const normalized = normalizeParameter({
+ name,
+ in: "header",
+ ...asRecord(header),
+ });
+ if (normalized) {
+ headers.push(normalized);
+ }
+ }
+ }
+ responses.push({
+ status,
+ description: asString(response.description) ?? "",
+ ...(headers.length > 0 ? { headers } : {}),
+ content: normalizeMediaTypes(response.content),
+ });
+ }
+ return responses.sort((left, right) =>
+ left.status.localeCompare(right.status)
+ );
+}
+
+function asRecord(value: unknown): Record {
+ return isRecord(value) ? value : {};
+}
+
+function normalizeSecuritySchemes(
+ document: Record
+): OpenApiSecurityScheme[] {
+ const components = asRecord(document.components);
+ const schemes = asRecord(components.securitySchemes);
+ const normalized: OpenApiSecurityScheme[] = [];
+ for (const [key, value] of Object.entries(schemes)) {
+ if (!isRecord(value)) {
+ continue;
+ }
+ const type = asString(value.type);
+ if (!type) {
+ continue;
+ }
+ normalized.push({
+ key,
+ type,
+ ...(asString(value.name) ? { name: asString(value.name) } : {}),
+ ...(asString(value.in) ? { in: asString(value.in) } : {}),
+ ...(asString(value.scheme) ? { scheme: asString(value.scheme) } : {}),
+ ...(asString(value.bearerFormat)
+ ? { bearerFormat: asString(value.bearerFormat) }
+ : {}),
+ ...(asString(value.description)
+ ? { description: asString(value.description) }
+ : {}),
+ ...(value.flows === undefined ? {} : { flows: value.flows }),
+ ...(asString(value.openIdConnectUrl)
+ ? { openIdConnectUrl: asString(value.openIdConnectUrl) }
+ : {}),
+ });
+ }
+ return normalized;
+}
+
+function normalizeSecurity(value: unknown): OpenApiSecurityRequirement[] {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+ const requirements: OpenApiSecurityRequirement[] = [];
+ for (const entry of value) {
+ if (!isRecord(entry)) {
+ continue;
+ }
+ const requirement: OpenApiSecurityRequirement = {};
+ for (const [key, scopes] of Object.entries(entry)) {
+ requirement[key] = asStringArray(scopes);
+ }
+ requirements.push(requirement);
+ }
+ return requirements;
+}
+
+function firstServerUrl(
+ document: Record,
+ operation: Record,
+ config: ResolvedOpenApiSourceConfig
+): string | undefined {
+ if (config.serverUrl) {
+ return config.serverUrl;
+ }
+ const operationServers = Array.isArray(operation.servers)
+ ? operation.servers
+ : undefined;
+ const documentServers = Array.isArray(document.servers)
+ ? document.servers
+ : undefined;
+ const servers = operationServers ?? documentServers ?? [];
+ for (const server of servers) {
+ if (!isRecord(server)) {
+ continue;
+ }
+ const url = asString(server.url);
+ if (url) {
+ return url;
+ }
+ }
+ return;
+}
+
+function collectOperationCandidates(
+ document: Record
+): OperationCandidate[] {
+ const paths = asRecord(document.paths);
+ const candidates: OperationCandidate[] = [];
+ for (const [apiPath, pathItem] of Object.entries(paths)) {
+ if (!isRecord(pathItem)) {
+ continue;
+ }
+ const pathParameters = Array.isArray(pathItem.parameters)
+ ? pathItem.parameters
+ : [];
+ for (const method of HTTP_METHODS) {
+ const operation = pathItem[method];
+ if (!isRecord(operation)) {
+ continue;
+ }
+ candidates.push({ method, operation, path: apiPath, pathParameters });
+ }
+ }
+ return candidates.sort((left, right) => {
+ const pathCompare = left.path.localeCompare(right.path);
+ if (pathCompare !== 0) {
+ return pathCompare;
+ }
+ return (
+ (DEFAULT_METHOD_ORDER.get(left.method) ?? 99) -
+ (DEFAULT_METHOD_ORDER.get(right.method) ?? 99)
+ );
+ });
+}
+
+function shouldIncludeOperation(
+ operation: Record,
+ config: ResolvedOpenApiSourceConfig
+): boolean {
+ const tags = asStringArray(operation.tags);
+ if (config.includeTags && config.includeTags.length > 0) {
+ const include = tags.some((tag) => config.includeTags?.includes(tag));
+ if (!include) {
+ return false;
+ }
+ }
+ if (config.excludeTags && config.excludeTags.length > 0) {
+ return !tags.some((tag) => config.excludeTags?.includes(tag));
+ }
+ return true;
+}
+
+function codeSampleUrl(operation: OpenApiOperation): string {
+ const server = operation.serverUrl?.replace(/\/+$/, "") ?? "";
+ return `${server}${operation.path}`;
+}
+
+function buildCurlSample(operation: OpenApiOperation): string {
+ const lines = [
+ `curl -X ${operation.method.toUpperCase()} "${codeSampleUrl(operation)}"`,
+ ];
+ for (const parameter of operation.parameters) {
+ if (parameter.in !== "header" || parameter.required !== true) {
+ continue;
+ }
+ lines.push(` -H "${parameter.name}: "`);
+ }
+ const mediaType = operation.requestBody?.content[0]?.mediaType;
+ if (mediaType) {
+ lines.push(` -H "Content-Type: ${mediaType}"`);
+ }
+ if (operation.requestBody) {
+ lines.push(" -d ''");
+ }
+ return lines.join(" \\\n");
+}
+
+function buildFetchSample(operation: OpenApiOperation): string {
+ const headers: Record = {};
+ const mediaType = operation.requestBody?.content[0]?.mediaType;
+ if (mediaType) {
+ headers["Content-Type"] = mediaType;
+ }
+ const lines = [
+ `const response = await fetch("${codeSampleUrl(operation)}", {`,
+ ` method: "${operation.method.toUpperCase()}",`,
+ ];
+ if (Object.keys(headers).length > 0) {
+ lines.push(
+ ` headers: ${JSON.stringify(headers, null, 2).replaceAll("\n", "\n ")},`
+ );
+ }
+ if (operation.requestBody) {
+ lines.push(" body: JSON.stringify({ /* request body */ }),");
+ }
+ lines.push("});", "const data = await response.json();");
+ return lines.join("\n");
+}
+
+function normalizeOperation(
+ candidate: OperationCandidate,
+ document: Record,
+ config: ResolvedOpenApiSourceConfig
+): OpenApiOperation {
+ const { method, operation, path: apiPath, pathParameters } = candidate;
+ const title = resolveOperationTitle(operation, method, apiPath);
+ const description = resolveOperationDescription(operation, title);
+ const parameters: OpenApiParameter[] = [];
+ const seenParameters = new Set();
+ const allParameters = [
+ ...pathParameters,
+ ...(Array.isArray(operation.parameters) ? operation.parameters : []),
+ ];
+ for (const parameter of allParameters) {
+ const normalized = normalizeParameter(parameter);
+ if (!normalized) {
+ continue;
+ }
+ const key = `${normalized.in}:${normalized.name}`;
+ if (seenParameters.has(key)) {
+ continue;
+ }
+ seenParameters.add(key);
+ parameters.push(normalized);
+ }
+
+ const security = normalizeSecurity(operation.security ?? document.security);
+ const selectedSchemeNames = new Set(
+ security.flatMap((requirement) => Object.keys(requirement))
+ );
+ const securitySchemes = normalizeSecuritySchemes(document).filter(
+ (scheme) =>
+ selectedSchemeNames.size === 0 || selectedSchemeNames.has(scheme.key)
+ );
+
+ const normalized: OpenApiOperation = {
+ method,
+ path: apiPath,
+ title,
+ description,
+ tags: asStringArray(operation.tags),
+ deprecated: operation.deprecated === true,
+ parameters,
+ responses: normalizeResponses(operation.responses),
+ security,
+ securitySchemes,
+ codeSamples: [],
+ ...(asString(operation.operationId)
+ ? { operationId: asString(operation.operationId) }
+ : {}),
+ ...(asString(operation.summary)
+ ? { summary: asString(operation.summary) }
+ : {}),
+ ...(firstServerUrl(document, operation, config)
+ ? { serverUrl: firstServerUrl(document, operation, config) }
+ : {}),
+ ...(normalizeRequestBody(operation.requestBody)
+ ? { requestBody: normalizeRequestBody(operation.requestBody) }
+ : {}),
+ };
+ normalized.codeSamples = [
+ { code: buildCurlSample(normalized), label: "cURL", language: "bash" },
+ { code: buildFetchSample(normalized), label: "JavaScript", language: "ts" },
+ ];
+ return normalized;
+}
+
+export function normalizeOpenApiConfig(
+ config: DocsOpenApiConfig,
+ cwd: string
+): ResolvedOpenApiSourceConfig[] {
+ const inputs = Array.isArray(config) ? config : [config];
+ return inputs.map((input) => {
+ const source: OpenApiSourceConfig =
+ typeof input === "string" ? { input } : input;
+ return {
+ ...source,
+ cwd,
+ groupByTags: source.groupByTags ?? true,
+ includeTryIt: source.includeTryIt ?? false,
+ output: normalizeOutputPath(source.output),
+ slugStrategy: source.slugStrategy ?? "operation-id",
+ title: source.title ?? DEFAULT_NAV_TITLE,
+ };
+ });
+}
+
+function operationSlug(
+ operation: OpenApiOperation,
+ config: ResolvedOpenApiSourceConfig
+): string {
+ if (config.slugStrategy === "operation-id" && operation.operationId) {
+ return slugify(operation.operationId);
+ }
+ return methodPathSlug(operation.method, operation.path);
+}
+
+function operationRelativePath(
+ operation: OpenApiOperation,
+ config: ResolvedOpenApiSourceConfig,
+ usedPaths: Set
+): string {
+ const tag = operation.tags[0];
+ const tagPath = config.groupByTags && tag ? slugify(tag) : "";
+ const baseSlug = operationSlug(operation, config);
+ let relativePath = normalizeDocsPath(
+ path.posix.join(config.output, tagPath, `${baseSlug}.mdx`)
+ );
+ let counter = 2;
+ while (usedPaths.has(relativePath.toLowerCase())) {
+ relativePath = normalizeDocsPath(
+ path.posix.join(config.output, tagPath, `${baseSlug}-${counter}.mdx`)
+ );
+ counter += 1;
+ }
+ usedPaths.add(relativePath.toLowerCase());
+ return relativePath;
+}
+
+function serializeMdxExpression(value: unknown): string {
+ return JSON.stringify(value, null, 2);
+}
+
+function mdxProp(name: string, value: unknown): string {
+ if (typeof value === "string") {
+ return `${name}=${JSON.stringify(value)}`;
+ }
+ if (typeof value === "boolean") {
+ return value ? name : `${name}={false}`;
+ }
+ return `${name}={${serializeMdxExpression(value)}}`;
+}
+
+function yamlString(value: string): string {
+ return JSON.stringify(value);
+}
+
+function renderFrontmatter(
+ operation: OpenApiOperation,
+ config: ResolvedOpenApiSourceConfig,
+ index: number
+): string {
+ const lines = [
+ "---",
+ `title: ${yamlString(operation.title)}`,
+ `description: ${yamlString(operation.description)}`,
+ ];
+ if (config.group) {
+ lines.push(`group: ${JSON.stringify(config.group)}`);
+ }
+ if (config.order !== undefined) {
+ lines.push(`order: ${config.order + index}`);
+ }
+ lines.push("generated: true", "source: openapi", "---");
+ return lines.join("\n");
+}
+
+function renderMdxComponent(
+ name: string,
+ props: Record
+): string {
+ const renderedProps = Object.entries(props)
+ .filter(([, value]) => value !== undefined)
+ .map(([key, value]) => mdxProp(key, value))
+ .join("\n ");
+ return `<${name}\n ${renderedProps}\n/>`;
+}
+
+function parameterGroups(operation: OpenApiOperation): Array<{
+ location: OpenApiParameter["in"];
+ title: string;
+ parameters: OpenApiParameter[];
+}> {
+ const labels = {
+ cookie: "Cookie Parameters",
+ header: "Header Parameters",
+ path: "Path Parameters",
+ query: "Query Parameters",
+ } as const;
+ const locations: OpenApiParameter["in"][] = [
+ "path",
+ "query",
+ "header",
+ "cookie",
+ ];
+ const groups: Array<{
+ location: OpenApiParameter["in"];
+ parameters: OpenApiParameter[];
+ title: string;
+ }> = [];
+ for (const location of locations) {
+ const parameters = operation.parameters.filter(
+ (parameter) => parameter.in === location
+ );
+ if (parameters.length === 0) {
+ continue;
+ }
+ groups.push({ location, parameters, title: labels[location] });
+ }
+ return groups;
+}
+
+function renderOperationMdx(
+ operation: OpenApiOperation,
+ config: ResolvedOpenApiSourceConfig,
+ index: number
+): string {
+ const blocks = [
+ renderFrontmatter(operation, config, index),
+ "",
+ `# ${operation.title}`,
+ "",
+ renderMdxComponent("ApiEndpoint", {
+ deprecated: operation.deprecated,
+ method: operation.method,
+ operationId: operation.operationId,
+ path: operation.path,
+ serverUrl: operation.serverUrl,
+ }),
+ "",
+ operation.description,
+ ];
+
+ if (operation.securitySchemes.length > 0 || operation.security.length > 0) {
+ blocks.push(
+ "",
+ "## Authentication",
+ "",
+ renderMdxComponent("ApiAuth", {
+ requirements: operation.security,
+ schemes: operation.securitySchemes,
+ })
+ );
+ }
+
+ const groups = parameterGroups(operation);
+ if (groups.length > 0 || operation.requestBody) {
+ blocks.push("", "## Request");
+ for (const group of groups) {
+ blocks.push(
+ "",
+ renderMdxComponent("ApiParameters", {
+ location: group.location,
+ parameters: group.parameters,
+ title: group.title,
+ })
+ );
+ }
+ if (operation.requestBody) {
+ blocks.push(
+ "",
+ renderMdxComponent("ApiRequestBody", { body: operation.requestBody })
+ );
+ }
+ }
+
+ if (operation.codeSamples.length > 0) {
+ blocks.push(
+ "",
+ "## Code Examples",
+ "",
+ renderMdxComponent("ApiCodeSamples", { samples: operation.codeSamples })
+ );
+ }
+
+ if (operation.responses.length > 0) {
+ blocks.push(
+ "",
+ "## Responses",
+ "",
+ renderMdxComponent("ApiResponses", { responses: operation.responses })
+ );
+ }
+
+ if (config.includeTryIt) {
+ blocks.push(
+ "",
+ "## Try It",
+ "",
+ renderMdxComponent("ApiTryIt", { operation })
+ );
+ }
+
+ return `${blocks.join("\n")}\n`;
+}
+
+function buildGeneratedNav(
+ pages: GeneratedOpenApiPage[],
+ config: ResolvedOpenApiSourceConfig
+): DocsNavNode | undefined {
+ if (pages.length === 0) {
+ return;
+ }
+ const base = stripDocsExtension(config.output);
+ if (!config.groupByTags) {
+ return {
+ title: config.title,
+ ...(config.description ? { description: config.description } : {}),
+ base,
+ pages: pages.map((page) =>
+ stripDocsExtension(
+ path.posix.relative(config.output, page.relativePath)
+ )
+ ),
+ };
+ }
+
+ const byTag = new Map();
+ for (const page of pages) {
+ const tag = page.operation.tags[0] ?? "Operations";
+ const existing = byTag.get(tag) ?? [];
+ existing.push(page);
+ byTag.set(tag, existing);
+ }
+
+ return {
+ title: config.title,
+ ...(config.description ? { description: config.description } : {}),
+ base,
+ children: [...byTag.entries()].map(([tag, tagPages]) => ({
+ title: tag,
+ base: slugify(tag),
+ pages: tagPages.map((page) =>
+ stripDocsExtension(
+ path.posix.relative(
+ path.posix.join(config.output, slugify(tag)),
+ page.relativePath
+ )
+ )
+ ),
+ })),
+ };
+}
+
+export async function generateOpenApiPages(
+ config: ResolvedOpenApiSourceConfig
+): Promise {
+ const document = await loadOpenApiDocument(config.input, config.cwd);
+ const candidates = collectOperationCandidates(document).filter((candidate) =>
+ shouldIncludeOperation(candidate.operation, config)
+ );
+ const usedPaths = new Set();
+ const pages: GeneratedOpenApiPage[] = [];
+ for (const candidate of candidates) {
+ const operation = normalizeOperation(candidate, document, config);
+ const relativePath = operationRelativePath(operation, config, usedPaths);
+ pages.push({
+ description: operation.description,
+ filePath: relativePath,
+ operation,
+ relativePath,
+ title: operation.title,
+ });
+ }
+ const nav = buildGeneratedNav(pages, config);
+ return {
+ pages,
+ nav: nav ? [nav] : [],
+ };
+}
+
+export type WriteOpenApiPagesConfig = {
+ configs: ResolvedOpenApiSourceConfig[];
+ docsDir: string;
+};
+
+export async function writeOpenApiPages({
+ configs,
+ docsDir,
+}: WriteOpenApiPagesConfig): Promise {
+ const allPages: GeneratedOpenApiPage[] = [];
+ const nav: DocsNavNode[] = [];
+ for (const config of configs) {
+ const generated = await generateOpenApiPages(config);
+ for (const [index, page] of generated.pages.entries()) {
+ const outputPath = path.join(docsDir, page.relativePath);
+ await mkdir(path.dirname(outputPath), { recursive: true });
+ await writeFile(
+ outputPath,
+ renderOperationMdx(page.operation, config, index)
+ );
+ allPages.push({ ...page, filePath: outputPath });
+ }
+ nav.push(...generated.nav);
+ }
+ return { nav, pages: allPages };
+}
diff --git a/packages/leadtype/src/openapi/openapi.test.ts b/packages/leadtype/src/openapi/openapi.test.ts
new file mode 100644
index 00000000..3548d48f
--- /dev/null
+++ b/packages/leadtype/src/openapi/openapi.test.ts
@@ -0,0 +1,103 @@
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { normalizeOpenApiConfig, writeOpenApiPages } from "./index";
+
+const tempDirs: string[] = [];
+
+async function createTempDir(): Promise {
+ const dir = await mkdtemp(path.join(tmpdir(), "leadtype-openapi-"));
+ tempDirs.push(dir);
+ return dir;
+}
+
+afterEach(async () => {
+ await Promise.all(
+ tempDirs.splice(0).map(async (dir) => {
+ await rm(dir, { force: true, recursive: true });
+ })
+ );
+});
+
+describe("OpenAPI page generation", () => {
+ it("writes native MDX API reference pages and generated nav", async () => {
+ const dir = await createTempDir();
+ const docsDir = path.join(dir, "docs");
+ await mkdir(docsDir, { recursive: true });
+ await writeFile(
+ path.join(dir, "openapi.yaml"),
+ `
+openapi: 3.1.0
+info:
+ title: Fixture API
+ version: 1.0.0
+servers:
+ - url: https://api.example.com
+components:
+ securitySchemes:
+ bearer:
+ type: http
+ scheme: bearer
+paths:
+ /access-groups/{id}:
+ get:
+ operationId: readAccessGroup
+ summary: Reads an access group
+ tags: [Access Groups]
+ security:
+ - bearer: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ description: Access group ID.
+ responses:
+ "200":
+ description: The access group.
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [id]
+ properties:
+ id:
+ type: string
+ description: Unique ID.
+`
+ );
+
+ const configs = normalizeOpenApiConfig(
+ {
+ group: "api",
+ input: "openapi.yaml",
+ output: "rest-api",
+ },
+ dir
+ );
+ const result = await writeOpenApiPages({ configs, docsDir });
+
+ expect(result.pages).toHaveLength(1);
+ expect(result.nav[0]).toMatchObject({
+ base: "rest-api",
+ title: "API Reference",
+ });
+
+ const pagePath = path.join(
+ docsDir,
+ "rest-api",
+ "access-groups",
+ "read-access-group.mdx"
+ );
+ const page = await readFile(pagePath, "utf8");
+ expect(page).toContain('title: "Reads an access group"');
+ expect(page).toContain("
Date: Thu, 2 Jul 2026 12:48:33 +0100
Subject: [PATCH 2/9] Address OpenAPI docs generation review feedback
- Register Api* handlers in the native markdown dispatcher so generated
pages flatten into agent-readable markdown (llms.txt, search, bundles)
- Escape MDX-significant syntax in operation prose so CommonMark
descriptions ({id}, ) cannot break the docs build
- Summarize nested schemas (array items, allOf merge) and render dotted
property rows (results[].title) in both renderers
- Generate real code samples: auth headers from security schemes,
synthesized JSON bodies, required query params, x-codeSamples support
- Render named examples and synthesize request/response examples from
schemas for JSON media types
- Drop the duplicate body h1; frontmatter description uses the summary
- Validate openapi config in the CLI with descriptive errors
- Add stageOpenApiDocs() and accept openapi config in createDocsSource /
fumadocsSource; simplify the package build and fumadocs example
- Share slug collision state across specs, allow webhooks-only 3.1 docs,
reject Swagger 2.0 with a conversion hint
- Expand tests: flatten round-trip, MDX-unsafe prose, external/circular
refs, tag filters, slug collisions, code samples, staging, validation
---
.changeset/openapi-generated-docs.md | 6 +-
apps/fumadocs-example/lib/mdx-components.tsx | 97 ++-
apps/fumadocs-example/lib/source.ts | 32 +-
docs/reference/openapi.mdx | 74 +-
packages/leadtype/scripts/generate-docs.ts | 48 +-
packages/leadtype/src/cli/generate.ts | 9 +-
packages/leadtype/src/index.ts | 2 +
.../src/markdown/component-dispatcher.ts | 16 +
.../leadtype/src/markdown/plugins/openapi.ts | 276 +++++---
packages/leadtype/src/openapi/index.ts | 643 +++++++++++++++---
packages/leadtype/src/openapi/openapi.test.ts | 483 ++++++++++++-
packages/leadtype/src/source/index.ts | 42 +-
12 files changed, 1438 insertions(+), 290 deletions(-)
diff --git a/.changeset/openapi-generated-docs.md b/.changeset/openapi-generated-docs.md
index 6acf1bdb..a0b3e6c7 100644
--- a/.changeset/openapi-generated-docs.md
+++ b/.changeset/openapi-generated-docs.md
@@ -2,4 +2,8 @@
"leadtype": minor
---
-Add native OpenAPI page generation for API reference docs, including markdown flattening, MDX renderer prop types, and a `leadtype/openapi` package entry point.
+Add native OpenAPI page generation for API reference docs. OpenAPI 3.x specs generate MDX operation pages with endpoint, auth, parameter, request/response, and code-sample components that render through your docs UI and flatten into agent-readable markdown (llms.txt, search, package docs bundles).
+
+- `createDocsSource()` / `fumadocsSource()` accept `openapi` config directly and stage generated pages without touching authored docs; `stageOpenApiDocs()` exposes the same staging for custom pipelines.
+- Generated pages include synthesized JSON examples, nested schema property tables (`results[].title`), and cURL/fetch samples with auth headers and real payloads; `x-codeSamples` overrides are honored.
+- Operation prose is escaped for MDX safety, and `leadtype/openapi` plus `Api*` renderer prop types are part of the package surface.
diff --git a/apps/fumadocs-example/lib/mdx-components.tsx b/apps/fumadocs-example/lib/mdx-components.tsx
index 334de788..7b3643bb 100644
--- a/apps/fumadocs-example/lib/mdx-components.tsx
+++ b/apps/fumadocs-example/lib/mdx-components.tsx
@@ -122,8 +122,51 @@ function TypeTable({
);
}
+const MAX_SCHEMA_DEPTH = 6;
+
+interface FlattenedSchemaRow {
+ description?: string;
+ name: string;
+ required: boolean;
+ type: string;
+}
+
+// Flatten nested object/array-item properties into dotted rows
+// (`results[].title`) so deep schemas stay fully documented.
+function flattenSchemaRows(
+ properties: ApiSchemaProperty[],
+ prefix = "",
+ depth = 0
+): FlattenedSchemaRow[] {
+ if (depth > MAX_SCHEMA_DEPTH) {
+ return [];
+ }
+ const rows: FlattenedSchemaRow[] = [];
+ for (const property of properties) {
+ const name = `${prefix}${property.name}`;
+ rows.push({
+ description: property.description,
+ name,
+ required: property.required === true,
+ type: formatApiSchemaType(property),
+ });
+ if (property.properties) {
+ rows.push(
+ ...flattenSchemaRows(property.properties, `${name}.`, depth + 1)
+ );
+ }
+ if (property.items?.properties) {
+ rows.push(
+ ...flattenSchemaRows(property.items.properties, `${name}[].`, depth + 1)
+ );
+ }
+ }
+ return rows;
+}
+
function SchemaRows({ properties = [] }: { properties?: ApiSchemaProperty[] }) {
- if (properties.length === 0) {
+ const rows = flattenSchemaRows(properties);
+ if (rows.length === 0) {
return null;
}
return (
@@ -138,16 +181,14 @@ function SchemaRows({ properties = [] }: { properties?: ApiSchemaProperty[] }) {
- {properties.map((property) => (
-
- | {property.name} |
-
- {formatApiSchemaType(property)}
- |
+ {rows.map((row) => (
+
+ | {row.name} |
+ {row.type} |
- {property.required ? "Required" : "Optional"}
+ {row.required ? "Required" : "Optional"}
|
- {property.description ?? "—"} |
+ {row.description ?? "—"} |
))}
@@ -284,18 +325,44 @@ function ApiParameters({ title, parameters }: ApiParametersProps) {
);
}
+function JsonExample({ value }: { value: unknown }) {
+ return (
+
+
+ {typeof value === "string" ? value : JSON.stringify(value, null, 2)}
+
+
+ );
+}
+
+function MediaTypeExamples({ media }: { media: ApiMediaType }) {
+ const namedExamples = Object.entries(media.examples ?? {});
+ if (namedExamples.length > 0) {
+ return namedExamples.map(([name, value]) => (
+
+
+ Example: {name}
+
+
+
+ ));
+ }
+ if (media.example === undefined) {
+ return null;
+ }
+ return ;
+}
+
function MediaType({ media }: { media: ApiMediaType }) {
return (
Content type {media.mediaType}
-
- {media.example === undefined ? null : (
-
- {JSON.stringify(media.example, null, 2)}
-
- )}
+
+
);
}
diff --git a/apps/fumadocs-example/lib/source.ts b/apps/fumadocs-example/lib/source.ts
index d9c01645..6146b10c 100644
--- a/apps/fumadocs-example/lib/source.ts
+++ b/apps/fumadocs-example/lib/source.ts
@@ -1,38 +1,26 @@
-import { cp, mkdtemp } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join, resolve } from "node:path";
+import { resolve } from "node:path";
import { loader } from "fumadocs-core/source";
import { fumadocsSource } from "leadtype/fumadocs";
-import { normalizeOpenApiConfig, writeOpenApiPages } from "leadtype/openapi";
import docsConfig from "../../../docs/docs.config";
// process.cwd() is the app root when Next runs build/dev.
const repoRoot = resolve(process.cwd(), "..", "..");
const contentDir = resolve(repoRoot, "docs");
-const stagedRoot = await mkdtemp(join(tmpdir(), "leadtype-fumadocs-"));
-const stagedContentDir = join(stagedRoot, "docs");
-await cp(contentDir, stagedContentDir, { recursive: true });
-
-const generatedOpenApi =
- docsConfig.openapi === undefined
- ? { nav: [], pages: [] }
- : await writeOpenApiPages({
- configs: normalizeOpenApiConfig(docsConfig.openapi, contentDir),
- docsDir: stagedContentDir,
- });
-const nav = [...(docsConfig.navigation ?? []), ...generatedOpenApi.nav];
/**
- * fumadocs source backed by leadtype/fumadocs. It stages the repo-root
- * Leadtype docs plus generated OpenAPI pages, uses the same curated navigation
- * as the other examples, and resolves `` / ``
- * relative to the repo root.
+ * fumadocs source backed by leadtype/fumadocs. It reads the repo-root
+ * Leadtype docs, uses the same curated navigation as the other examples, and
+ * resolves `` / `` relative to the repo root.
+ *
+ * Passing `openapi` stages generated API reference pages into a temp copy of
+ * the docs and appends their navigation — the authored docs are untouched.
*/
const fumadocsSourceResult = await fumadocsSource({
- contentDir: stagedContentDir,
+ contentDir,
includeMetaJson: false,
- nav,
+ nav: docsConfig.navigation,
mounts: docsConfig.mounts,
+ openapi: docsConfig.openapi,
typeTableBasePath: repoRoot,
});
diff --git a/docs/reference/openapi.mdx b/docs/reference/openapi.mdx
index 6073eb7a..c66a9462 100644
--- a/docs/reference/openapi.mdx
+++ b/docs/reference/openapi.mdx
@@ -3,13 +3,15 @@ title: OpenAPI
description: Generate native MDX API reference pages from OpenAPI 3.x specs.
---
-# OpenAPI
-
Leadtype can turn OpenAPI 3.x JSON or YAML specs into native MDX pages before
the normal docs pipeline runs. Generated API pages render through your docs UI,
flatten into markdown mirrors, appear in `llms.txt`, feed search, and ship in
package docs bundles.
+Operation summaries and descriptions are treated as CommonMark (per the
+OpenAPI spec) — Leadtype escapes MDX-significant characters like `{` and `<`
+in prose, so arbitrary specs can't break the MDX build.
+
## Quickstart
Put the spec somewhere in your docs source:
@@ -61,6 +63,25 @@ public/docs/search-index.json
The same generated pages are added to the docs navigation used by
`llms.txt`, `AGENTS.md`, search, and Agent Readability artifacts.
+## What each page includes
+
+Every operation page carries the full request/response contract:
+
+- **Endpoint** — method, path, server URL, operation ID, deprecation.
+- **Authentication** — the operation's security requirements and only the
+ schemes those requirements reference.
+- **Parameters** — path/query/header/cookie tables. Nested object and
+ array-item properties flatten into dotted rows such as `results[].title`.
+- **Request body and responses** — property tables plus a JSON example. When
+ the spec provides `example`/`examples` they are used verbatim; otherwise a
+ representative payload is synthesized from the schema (defaults, enums, and
+ formats included).
+- **Code samples** — generated cURL and `fetch` snippets with real example
+ bodies, required query parameters, and auth headers derived from the
+ security scheme (`Authorization: Bearer `, API-key headers, and so
+ on). Specs that define Redocly-style `x-codeSamples` (or `x-code-samples`)
+ override the generated snippets with their hand-written ones.
+
## Render in a docs app
Generated pages use native API components instead of Swagger UI:
@@ -140,6 +161,28 @@ export const mdxComponents = {
For a fuller copyable implementation, see the Fumadocs example component map at
`apps/fumadocs-example/lib/mdx-components.tsx`.
+### fumadocs / createDocsSource
+
+`createDocsSource()` (and therefore `fumadocsSource()`) accepts the `openapi`
+config directly. Generated pages are staged into a temp copy of `contentDir` —
+your authored docs are never modified — and the generated navigation is
+appended to `nav` automatically:
+
+```ts title="lib/source.ts"
+import { fumadocsSource } from "leadtype/fumadocs";
+import docsConfig from "../../docs/docs.config";
+
+const source = await fumadocsSource({
+ contentDir: "../../docs",
+ nav: docsConfig.navigation,
+ openapi: docsConfig.openapi,
+});
+```
+
+Relative `input` paths resolve from `contentDir` by default; pass `openapiCwd`
+when your config lives elsewhere. Because staging snapshots the docs at
+construction time, restart the dev server to pick up spec or docs edits.
+
## Try-it consoles
Leadtype does not ship an API console. Set `includeTryIt: true` only when your
@@ -202,6 +245,24 @@ export default {
## Library API
+For custom pipelines, `stageOpenApiDocs()` does the staging dance in one call:
+copy the docs source to a temp directory, generate pages into it, and return
+the staged path plus generated nav.
+
+```ts
+import { stageOpenApiDocs } from "leadtype/openapi";
+
+const staged = await stageOpenApiDocs({
+ contentDir: "./docs",
+ openapi: { input: "./openapi.yaml", output: "rest-api" },
+});
+// staged.contentDir — temp docs copy including generated pages
+// staged.nav — navigation nodes to append to your curated nav
+await staged.cleanup();
+```
+
+The lower-level primitives are exported too:
+
```ts
import {
normalizeOpenApiConfig,
@@ -220,7 +281,9 @@ const result = await writeOpenApiPages({
```
`writeOpenApiPages()` returns generated page metadata and a navigation node you
-can merge into a custom source pipeline.
+can merge into a custom source pipeline. When multiple specs share an
+`output` directory, colliding slugs get numeric suffixes instead of
+overwriting each other.
## Dogfooding
@@ -228,6 +291,5 @@ Leadtype's own package docs generate a small API reference from
`docs/openapi/leadtype-api.yaml`. The package build stages that spec, generates
native MDX operation pages, converts them to markdown, and includes them in
`packages/leadtype/docs`, `AGENTS.md`, search, and Agent Readability artifacts.
-The Fumadocs example stages the same generated MDX pages before calling
-`fumadocsSource()`, so the browser-rendered docs exercise the component
-contract too.
+The Fumadocs example passes the same `openapi` config to `fumadocsSource()`,
+so the browser-rendered docs exercise the component contract too.
diff --git a/packages/leadtype/scripts/generate-docs.ts b/packages/leadtype/scripts/generate-docs.ts
index 2b7928cd..9a8c5930 100644
--- a/packages/leadtype/scripts/generate-docs.ts
+++ b/packages/leadtype/scripts/generate-docs.ts
@@ -1,5 +1,4 @@
-import { cp, mkdtemp, rm } from "node:fs/promises";
-import { tmpdir } from "node:os";
+import { rm } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import docsConfig from "../../../docs/docs.config";
@@ -13,18 +12,13 @@ import {
resolveDocsNavigation,
} from "../src/llm/index";
import { defaultMarkdownTransforms } from "../src/markdown/index";
-import {
- normalizeOpenApiConfig,
- writeOpenApiPages,
-} from "../src/openapi/index";
+import { stageOpenApiDocs } from "../src/openapi/index";
import { generateDocsSearchFiles } from "../src/search/node-index";
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const REPO_ROOT = resolve(PACKAGE_ROOT, "..", "..");
const SRC_DOCS_DIR = join(REPO_ROOT, "docs");
const OUT_DOCS_DIR = join(PACKAGE_ROOT, "docs");
-const STAGED_ROOT = await mkdtemp(join(tmpdir(), "leadtype-docs-"));
-const STAGED_DOCS_DIR = join(STAGED_ROOT, "docs");
// The output folder is entirely generated and gitignored — safe to nuke.
// Also clear the package-root AGENTS.md and any leftover `llms.txt` /
@@ -35,23 +29,25 @@ await rm(join(PACKAGE_ROOT, "SKILL.md"), { force: true });
await rm(join(PACKAGE_ROOT, "llms.txt"), { force: true });
await rm(join(PACKAGE_ROOT, "llms-full.txt"), { force: true });
-try {
- await cp(SRC_DOCS_DIR, STAGED_DOCS_DIR, { recursive: true });
-
- const openApiResult =
- docsConfig.openapi === undefined
- ? { nav: [], pages: [] }
- : await writeOpenApiPages({
- configs: normalizeOpenApiConfig(docsConfig.openapi, SRC_DOCS_DIR),
- docsDir: STAGED_DOCS_DIR,
- });
- const docsNavigation = [
- ...(docsConfig.navigation ?? []),
- ...openApiResult.nav,
- ];
+// Stage generated OpenAPI pages next to the authored docs (in a temp copy)
+// so they flow through conversion, nav, search, and agent artifacts.
+const staged =
+ docsConfig.openapi === undefined
+ ? undefined
+ : await stageOpenApiDocs({
+ contentDir: SRC_DOCS_DIR,
+ openapi: docsConfig.openapi,
+ });
+const docsDir = staged?.contentDir ?? SRC_DOCS_DIR;
+const srcRoot = staged ? dirname(staged.contentDir) : REPO_ROOT;
+const docsNavigation = [
+ ...(docsConfig.navigation ?? []),
+ ...(staged?.nav ?? []),
+];
+try {
await convertAllMdx({
- srcDir: STAGED_DOCS_DIR,
+ srcDir: docsDir,
outDir: OUT_DOCS_DIR,
markdownTransforms: defaultMarkdownTransforms,
});
@@ -66,7 +62,7 @@ try {
});
const navigation = await resolveDocsNavigation({
- srcDir: STAGED_ROOT,
+ srcDir: srcRoot,
nav: docsNavigation,
mounts: docsConfig.mounts,
});
@@ -87,7 +83,7 @@ try {
// the bundled `.md` topic, so the docs remain valid after npm install at
// node_modules/leadtype/ with no URL fetches required.
const { outputPath } = await generateAgentsMd({
- srcDir: STAGED_ROOT,
+ srcDir: srcRoot,
outDir: PACKAGE_ROOT,
product: agentInputs.product,
nav: docsNavigation,
@@ -136,5 +132,5 @@ try {
},
});
} finally {
- await rm(STAGED_ROOT, { recursive: true, force: true });
+ await staged?.cleanup();
}
diff --git a/packages/leadtype/src/cli/generate.ts b/packages/leadtype/src/cli/generate.ts
index e0170eb5..6ca1cb29 100644
--- a/packages/leadtype/src/cli/generate.ts
+++ b/packages/leadtype/src/cli/generate.ts
@@ -74,6 +74,7 @@ import {
import {
type DocsOpenApiConfig,
normalizeOpenApiConfig,
+ validateDocsOpenApiConfig,
writeOpenApiPages,
} from "../openapi";
import type { GenerateDocsSearchFilesResult } from "../search/node";
@@ -1301,10 +1302,10 @@ function validateDocsConfig(value: unknown, configPath: string): DocsConfig {
const mounts = validateDocsMounts(value.mounts, configPath);
const feeds = validateDocsFeeds(value.feeds, configPath);
const git = validateGitConfig(value.git, configPath);
- const openapi =
- value.openapi === undefined
- ? undefined
- : (value.openapi as DocsOpenApiConfig);
+ const openapi = validateDocsOpenApiConfig(
+ value.openapi,
+ `docs config at "${configPath}"`
+ );
if (value.flatteners !== undefined && !Array.isArray(value.flatteners)) {
throw new Error(
diff --git a/packages/leadtype/src/index.ts b/packages/leadtype/src/index.ts
index c8d39133..1fae92f8 100644
--- a/packages/leadtype/src/index.ts
+++ b/packages/leadtype/src/index.ts
@@ -90,6 +90,8 @@ export type {
OpenApiSourceConfig,
OpenApiSourceInput,
ResolvedOpenApiSourceConfig,
+ StagedOpenApiDocs,
+ StageOpenApiDocsConfig,
} from "./openapi";
export {
type CreateDocsSourceConfig,
diff --git a/packages/leadtype/src/markdown/component-dispatcher.ts b/packages/leadtype/src/markdown/component-dispatcher.ts
index e47305a0..057ecdab 100644
--- a/packages/leadtype/src/markdown/component-dispatcher.ts
+++ b/packages/leadtype/src/markdown/component-dispatcher.ts
@@ -16,6 +16,15 @@ import { detailsToMarkdown } from "./plugins/details";
import { exampleToMarkdown } from "./plugins/example";
import { fileTreeToMarkdown } from "./plugins/file-tree";
import { mermaidToMarkdown } from "./plugins/mermaid";
+import {
+ apiAuthToMarkdown,
+ apiCodeSamplesToMarkdown,
+ apiEndpointToMarkdown,
+ apiParametersToMarkdown,
+ apiRequestBodyToMarkdown,
+ apiResponsesToMarkdown,
+ apiTryItToMarkdown,
+} from "./plugins/openapi";
import { promptToMarkdown } from "./plugins/prompt";
import { sectionToMarkdown } from "./plugins/section";
import { compactStepTree, stepsToMarkdown } from "./plugins/steps";
@@ -74,6 +83,13 @@ function createHandlers(options: NativeMarkdownDispatcherOptions): Handler[] {
{ names: ["FileTree"], process: fileTreeToMarkdown },
{ names: ["Prompt"], process: promptToMarkdown },
{ names: ["Example"], process: exampleToMarkdown },
+ { names: ["ApiEndpoint"], process: apiEndpointToMarkdown },
+ { names: ["ApiAuth"], process: apiAuthToMarkdown },
+ { names: ["ApiParameters"], process: apiParametersToMarkdown },
+ { names: ["ApiRequestBody"], process: apiRequestBodyToMarkdown },
+ { names: ["ApiCodeSamples"], process: apiCodeSamplesToMarkdown },
+ { names: ["ApiResponses"], process: apiResponsesToMarkdown },
+ { names: ["ApiTryIt"], process: () => apiTryItToMarkdown() },
];
}
diff --git a/packages/leadtype/src/markdown/plugins/openapi.ts b/packages/leadtype/src/markdown/plugins/openapi.ts
index b65b0a74..c164b798 100644
--- a/packages/leadtype/src/markdown/plugins/openapi.ts
+++ b/packages/leadtype/src/markdown/plugins/openapi.ts
@@ -8,6 +8,7 @@ import type {
OpenApiRequestBody,
OpenApiResponse,
OpenApiSchemaProperty,
+ OpenApiSchemaSummary,
OpenApiSecurityRequirement,
OpenApiSecurityScheme,
} from "../../openapi";
@@ -20,8 +21,11 @@ import {
createTable,
createUnorderedList,
getAttributeValue,
+ type MdxNode,
} from "../libs";
+const MAX_SCHEMA_DEPTH = 6;
+
function createCodeBlock(value: string, lang: string): Code {
return { type: "code", lang, value };
}
@@ -52,18 +56,48 @@ function schemaLabel(
: (schema.type ?? "");
}
-function schemaRows(
- properties: OpenApiSchemaProperty[] | undefined
-): (string | ReturnType[])[][] {
- if (!properties || properties.length === 0) {
- return [];
+type SchemaRow = (string | ReturnType[])[];
+
+function appendSchemaRows(
+ rows: SchemaRow[],
+ properties: OpenApiSchemaProperty[],
+ prefix: string,
+ depth: number
+): void {
+ if (depth > MAX_SCHEMA_DEPTH) {
+ return;
}
- return properties.map((property) => [
- [createInlineCode(property.name)],
- schemaLabel(property),
- requiredLabel(property.required),
- property.description ?? "",
- ]);
+ for (const property of properties) {
+ const name = `${prefix}${property.name}`;
+ rows.push([
+ [createInlineCode(name)],
+ schemaLabel(property),
+ requiredLabel(property.required),
+ property.description ?? "",
+ ]);
+ if (property.properties) {
+ appendSchemaRows(rows, property.properties, `${name}.`, depth + 1);
+ }
+ if (property.items?.properties) {
+ appendSchemaRows(
+ rows,
+ property.items.properties,
+ `${name}[].`,
+ depth + 1
+ );
+ }
+ }
+}
+
+function schemaRows(schema: OpenApiSchemaSummary | undefined): SchemaRow[] {
+ const rows: SchemaRow[] = [];
+ if (schema?.properties) {
+ appendSchemaRows(rows, schema.properties, "", 0);
+ } else if (schema?.items?.properties) {
+ // Root-level arrays: render item fields with an `[]` prefix.
+ appendSchemaRows(rows, schema.items.properties, "[].", 0);
+ }
+ return rows;
}
function renderParameterTable(parameters: OpenApiParameter[]): RootContent[] {
@@ -83,11 +117,17 @@ function renderParameterTable(parameters: OpenApiParameter[]): RootContent[] {
];
}
+function renderJsonExample(value: unknown): Code {
+ const text =
+ typeof value === "string" ? value : JSON.stringify(value, null, 2);
+ return createCodeBlock(text, "json");
+}
+
function renderMediaType(media: OpenApiMediaType): RootContent[] {
const nodes: RootContent[] = [
createParagraph(`Content type: ${media.mediaType}`),
];
- const rows = schemaRows(media.schema?.properties);
+ const rows = schemaRows(media.schema);
if (rows.length > 0) {
nodes.push(
createTable(["Property", "Type", "Required", "Description"], rows)
@@ -95,8 +135,13 @@ function renderMediaType(media: OpenApiMediaType): RootContent[] {
} else if (media.schema) {
nodes.push(createParagraph(`Schema: ${schemaLabel(media.schema)}`));
}
- if (media.example !== undefined) {
- nodes.push(createCodeBlock(JSON.stringify(media.example, null, 2), "json"));
+ if (media.examples) {
+ for (const [name, value] of Object.entries(media.examples)) {
+ nodes.push(createParagraph(`Example: ${name}`));
+ nodes.push(renderJsonExample(value));
+ }
+ } else if (media.example !== undefined) {
+ nodes.push(renderJsonExample(media.example));
}
return nodes;
}
@@ -155,106 +200,129 @@ function renderResponse(response: OpenApiResponse): RootContent[] {
return nodes;
}
-export function openApiToMarkdown(): Transformer {
- const endpoint = createJsxComponentProcessor("ApiEndpoint", (node) => {
- const method = (getAttributeValue(node, "method") ?? "").toUpperCase();
- const apiPath = getAttributeValue(node, "path") ?? "";
- const operationId = getAttributeValue(node, "operationId");
- const serverUrl = getAttributeValue(node, "serverUrl");
- const deprecated = getAttributeValue(node, "deprecated") === "true";
- const lines = [
- `${method} ${apiPath}`,
- operationId ? `Operation ID: ${operationId}` : "",
- serverUrl ? `Server: ${serverUrl}` : "",
- deprecated ? "Deprecated: true" : "",
- ].filter(Boolean);
- return [createParagraph(lines.join("\n"))];
- });
+export function apiEndpointToMarkdown(node: MdxNode): RootContent[] {
+ const method = (getAttributeValue(node, "method") ?? "").toUpperCase();
+ const apiPath = getAttributeValue(node, "path") ?? "";
+ const operationId = getAttributeValue(node, "operationId");
+ const serverUrl = getAttributeValue(node, "serverUrl");
+ const deprecated = getAttributeValue(node, "deprecated") === "true";
+ const nodes: RootContent[] = [
+ createCodeBlock(`${method} ${apiPath}`, "http"),
+ ];
+ const details = [
+ serverUrl ? `Server: ${serverUrl}` : "",
+ operationId ? `Operation ID: ${operationId}` : "",
+ deprecated ? "Deprecated: true" : "",
+ ].filter(Boolean);
+ if (details.length > 0) {
+ nodes.push(createParagraph(details.join("\n")));
+ }
+ return nodes;
+}
- const auth = createJsxComponentProcessor("ApiAuth", (node) => {
- const schemes = parseAttr(
- getAttributeValue(node, "schemes"),
- []
- );
- const requirements = parseAttr(
- getAttributeValue(node, "requirements"),
- []
- );
- const nodes: RootContent[] = [];
- nodes.push(...renderSecurityRequirements(requirements));
- if (schemes.length > 0) {
- nodes.push(createHeading(3, "Schemes"));
- nodes.push(createUnorderedList(schemes.map(renderSecurityScheme)));
- }
- return nodes;
- });
+export function apiAuthToMarkdown(node: MdxNode): RootContent[] {
+ const schemes = parseAttr(
+ getAttributeValue(node, "schemes"),
+ []
+ );
+ const requirements = parseAttr(
+ getAttributeValue(node, "requirements"),
+ []
+ );
+ const nodes: RootContent[] = [];
+ nodes.push(...renderSecurityRequirements(requirements));
+ if (schemes.length > 0) {
+ nodes.push(createHeading(3, "Schemes"));
+ nodes.push(createUnorderedList(schemes.map(renderSecurityScheme)));
+ }
+ return nodes;
+}
- const parameters = createJsxComponentProcessor("ApiParameters", (node) => {
- const title = getAttributeValue(node, "title");
- const parsed = parseAttr(
- getAttributeValue(node, "parameters"),
- []
- );
- const nodes: RootContent[] = [];
- if (title) {
- nodes.push(createHeading(3, title));
- }
- nodes.push(...renderParameterTable(parsed));
- return nodes;
- });
+export function apiParametersToMarkdown(node: MdxNode): RootContent[] {
+ const title = getAttributeValue(node, "title");
+ const parsed = parseAttr(
+ getAttributeValue(node, "parameters"),
+ []
+ );
+ const nodes: RootContent[] = [];
+ if (title) {
+ nodes.push(createHeading(3, title));
+ }
+ nodes.push(...renderParameterTable(parsed));
+ return nodes;
+}
- const requestBody = createJsxComponentProcessor("ApiRequestBody", (node) => {
- const body = parseAttr(
- getAttributeValue(node, "body"),
- null
- );
- if (!body) {
- return [createParagraph("No request body.")];
- }
- const nodes: RootContent[] = [
- createHeading(3, "Request Body"),
- createParagraph(
- `${requiredLabel(body.required)}${body.description ? ` - ${body.description}` : ""}`
- ),
- ];
- for (const media of body.content) {
- nodes.push(...renderMediaType(media));
- }
- return nodes;
- });
+export function apiRequestBodyToMarkdown(node: MdxNode): RootContent[] {
+ const body = parseAttr(
+ getAttributeValue(node, "body"),
+ null
+ );
+ if (!body) {
+ return [createParagraph("No request body.")];
+ }
+ const nodes: RootContent[] = [
+ createHeading(3, "Request Body"),
+ createParagraph(
+ `${requiredLabel(body.required)}${body.description ? ` - ${body.description}` : ""}`
+ ),
+ ];
+ for (const media of body.content) {
+ nodes.push(...renderMediaType(media));
+ }
+ return nodes;
+}
- const responses = createJsxComponentProcessor("ApiResponses", (node) => {
- const parsed = parseAttr(
- getAttributeValue(node, "responses"),
- []
- );
- if (parsed.length === 0) {
- return [createParagraph("No responses documented.")];
- }
- return parsed.flatMap(renderResponse);
- });
+export function apiResponsesToMarkdown(node: MdxNode): RootContent[] {
+ const parsed = parseAttr(
+ getAttributeValue(node, "responses"),
+ []
+ );
+ if (parsed.length === 0) {
+ return [createParagraph("No responses documented.")];
+ }
+ return parsed.flatMap(renderResponse);
+}
- const codeSamples = createJsxComponentProcessor("ApiCodeSamples", (node) => {
- const samples = parseAttr(
- getAttributeValue(node, "samples"),
- []
- );
- return renderCodeSamples(samples);
- });
+export function apiCodeSamplesToMarkdown(node: MdxNode): RootContent[] {
+ const samples = parseAttr(
+ getAttributeValue(node, "samples"),
+ []
+ );
+ return renderCodeSamples(samples);
+}
- const tryIt = createJsxComponentProcessor("ApiTryIt", () => [
+export function apiTryItToMarkdown(): RootContent[] {
+ return [
createParagraph(
"Interactive API console metadata is available to the docs renderer."
),
- ]);
+ ];
+}
+
+export function openApiToMarkdown(): Transformer {
+ const processors = [
+ createJsxComponentProcessor("ApiEndpoint", (node) =>
+ apiEndpointToMarkdown(node)
+ ),
+ createJsxComponentProcessor("ApiAuth", (node) => apiAuthToMarkdown(node)),
+ createJsxComponentProcessor("ApiParameters", (node) =>
+ apiParametersToMarkdown(node)
+ ),
+ createJsxComponentProcessor("ApiRequestBody", (node) =>
+ apiRequestBodyToMarkdown(node)
+ ),
+ createJsxComponentProcessor("ApiResponses", (node) =>
+ apiResponsesToMarkdown(node)
+ ),
+ createJsxComponentProcessor("ApiCodeSamples", (node) =>
+ apiCodeSamplesToMarkdown(node)
+ ),
+ createJsxComponentProcessor("ApiTryIt", () => apiTryItToMarkdown()),
+ ];
return (tree) => {
- endpoint(tree);
- auth(tree);
- parameters(tree);
- requestBody(tree);
- responses(tree);
- codeSamples(tree);
- tryIt(tree);
+ for (const process of processors) {
+ process(tree);
+ }
};
}
diff --git a/packages/leadtype/src/openapi/index.ts b/packages/leadtype/src/openapi/index.ts
index 3f3f204b..0e97fa06 100644
--- a/packages/leadtype/src/openapi/index.ts
+++ b/packages/leadtype/src/openapi/index.ts
@@ -1,5 +1,6 @@
import { existsSync } from "node:fs";
-import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
import path from "node:path";
import YAML from "yaml";
import { normalizeDocsPath, stripDocsExtension } from "../internal/docs-url";
@@ -26,6 +27,15 @@ const POINTER_ESCAPE_PATTERN = /~[01]/g;
const NON_ALPHANUMERIC_PATTERN = /[^a-z0-9]+/gi;
const DUPLICATE_DASH_PATTERN = /-+/g;
const LEADING_TRAILING_DASH_PATTERN = /^-|-$/g;
+const CODE_FENCE_PATTERN = /^\s{0,3}(`{3,}|~{3,})/;
+const CODE_SPAN_PATTERN = /(`+)([\s\S]*?)\1/g;
+const AUTOLINK_PATTERN = /<(?:[a-z][a-z0-9+.-]*:|www\.)[^\s<>]*>/gi;
+const MDX_UNSAFE_CHARACTER_PATTERN = /[<{}]/g;
+const TRAILING_SLASHES_PATTERN = /\/+$/;
+const JSON_MEDIA_TYPE_PATTERN = /^application\/(?:.+\+)?json/i;
+const BLANK_LINE_PATTERN = /\n\s*\n/;
+const WHITESPACE_RUN_PATTERN = /\s+/g;
+const MAX_EXAMPLE_DEPTH = 6;
export type OpenApiHttpMethod = (typeof HTTP_METHODS)[number];
@@ -88,7 +98,10 @@ export type OpenApiSchemaSummary = {
default?: unknown;
enum?: unknown[];
format?: string;
+ example?: unknown;
properties?: OpenApiSchemaProperty[];
+ /** Item schema for arrays, so nested object items stay renderable. */
+ items?: OpenApiSchemaSummary;
};
export type OpenApiSchemaProperty = OpenApiSchemaSummary & {
@@ -243,6 +256,72 @@ function titleize(input: string): string {
.join(" ");
}
+function escapeMdxPlainText(text: string): string {
+ // Preserve CommonMark autolinks (``); escape every other `<`,
+ // `{`, and `}` so arbitrary spec prose cannot open a JSX tag or expression.
+ let output = "";
+ let lastIndex = 0;
+ for (const match of text.matchAll(AUTOLINK_PATTERN)) {
+ const index = match.index ?? 0;
+ output += text
+ .slice(lastIndex, index)
+ .replace(MDX_UNSAFE_CHARACTER_PATTERN, (char) => `\\${char}`);
+ output += match[0];
+ lastIndex = index + match[0].length;
+ }
+ output += text
+ .slice(lastIndex)
+ .replace(MDX_UNSAFE_CHARACTER_PATTERN, (char) => `\\${char}`);
+ return output;
+}
+
+function escapeMdxInline(line: string): string {
+ // Keep inline code spans verbatim; escape only the surrounding prose.
+ let output = "";
+ let lastIndex = 0;
+ for (const match of line.matchAll(CODE_SPAN_PATTERN)) {
+ const index = match.index ?? 0;
+ output += escapeMdxPlainText(line.slice(lastIndex, index));
+ output += match[0];
+ lastIndex = index + match[0].length;
+ }
+ output += escapeMdxPlainText(line.slice(lastIndex));
+ return output;
+}
+
+/**
+ * Make CommonMark text (OpenAPI summaries/descriptions) safe to embed in an
+ * MDX document body. Fenced code blocks, inline code spans, and autolinks are
+ * preserved; `<`, `{`, and `}` in prose are backslash-escaped so they cannot
+ * be parsed as JSX or expressions.
+ */
+export function escapeMarkdownForMdx(input: string): string {
+ const lines = input.split("\n");
+ const output: string[] = [];
+ let openFence: string | null = null;
+ for (const line of lines) {
+ const fenceMatch = line.match(CODE_FENCE_PATTERN);
+ if (openFence) {
+ output.push(line);
+ const closing = fenceMatch?.[1];
+ if (
+ closing?.startsWith(openFence[0] ?? "`") &&
+ closing.length >= openFence.length
+ ) {
+ openFence = null;
+ }
+ continue;
+ }
+ if (fenceMatch?.[1]) {
+ openFence = fenceMatch[1];
+ output.push(line);
+ continue;
+ }
+ output.push(escapeMdxInline(line));
+ }
+ return output.join("\n");
+}
+
function methodPathSlug(method: OpenApiHttpMethod, apiPath: string): string {
const pathSlug = apiPath
.replace(/[{}]/g, "")
@@ -253,7 +332,7 @@ function methodPathSlug(method: OpenApiHttpMethod, apiPath: string): string {
return slugify(`${method}-${pathSlug || "root"}`);
}
-function escapePointerSegment(segment: string): string {
+function unescapePointerSegment(segment: string): string {
return segment.replace(POINTER_ESCAPE_PATTERN, (match) => {
if (match === "~1") {
return "/";
@@ -269,7 +348,7 @@ function readPointer(document: unknown, pointer: string): unknown {
const segments = pointer
.slice(2)
.split("/")
- .map((segment) => escapePointerSegment(decodeURIComponent(segment)));
+ .map((segment) => unescapePointerSegment(decodeURIComponent(segment)));
let current = document;
for (const segment of segments) {
if (Array.isArray(current)) {
@@ -423,12 +502,20 @@ function validateOpenApiDocument(
): void {
const version = asString(document.openapi);
if (!version?.startsWith("3.")) {
+ if (asString(document.swagger)) {
+ throw new Error(
+ `OpenAPI: "${input}" is a Swagger 2.0 document. Convert it to OpenAPI 3.x (e.g. with swagger2openapi) before generating docs.`
+ );
+ }
throw new Error(
`OpenAPI: "${input}" must be an OpenAPI 3.x document with an "openapi" field`
);
}
- if (!isRecord(document.paths)) {
- throw new Error(`OpenAPI: "${input}" must define a paths object`);
+ // OpenAPI 3.1 allows webhooks-only documents; `paths` is optional there.
+ if (!(isRecord(document.paths) || isRecord(document.webhooks))) {
+ throw new Error(
+ `OpenAPI: "${input}" must define a paths (or webhooks) object`
+ );
}
}
@@ -504,6 +591,22 @@ function schemaTypeFromUnknown(value: unknown): string {
return isRecord(value) ? schemaType(value) : "unknown";
}
+function expandAllOfSources(
+ value: Record,
+ depth = 0
+): Record[] {
+ const sources: Record[] = [value];
+ if (depth >= MAX_EXAMPLE_DEPTH || !Array.isArray(value.allOf)) {
+ return sources;
+ }
+ for (const member of value.allOf) {
+ if (isRecord(member)) {
+ sources.push(...expandAllOfSources(member, depth + 1));
+ }
+ }
+ return sources;
+}
+
function summarizeSchema(
value: unknown,
required = false
@@ -512,29 +615,43 @@ function summarizeSchema(
return;
}
- const requiredNames = new Set(asStringArray(value.required));
- const properties: OpenApiSchemaProperty[] = [];
- if (isRecord(value.properties)) {
- for (const [name, property] of Object.entries(value.properties)) {
- const summary = summarizeSchema(property, requiredNames.has(name));
- if (!summary) {
+ // Merge `allOf` members so composed object schemas keep their full
+ // property set (`required` arrays union across members, per OpenAPI).
+ const sources = expandAllOfSources(value);
+ const requiredNames = new Set(
+ sources.flatMap((source) => asStringArray(source.required))
+ );
+ const mergedProperties = new Map();
+ for (const source of sources) {
+ if (!isRecord(source.properties)) {
+ continue;
+ }
+ for (const [name, property] of Object.entries(source.properties)) {
+ if (mergedProperties.has(name)) {
continue;
}
- properties.push({ name, ...summary });
+ const summary = summarizeSchema(property, requiredNames.has(name));
+ if (summary) {
+ mergedProperties.set(name, { name, ...summary });
+ }
}
}
+ const properties = [...mergedProperties.values()];
+ const items = summarizeSchema(value.items);
+ const description = asString(value.description);
+ const format = asString(value.format);
return {
type: schemaType(value),
- ...(asString(value.description)
- ? { description: asString(value.description) }
- : {}),
+ ...(description ? { description } : {}),
...(required ? { required } : {}),
...(value.deprecated === true ? { deprecated: true } : {}),
...(value.default === undefined ? {} : { default: value.default }),
...(Array.isArray(value.enum) ? { enum: value.enum } : {}),
- ...(asString(value.format) ? { format: asString(value.format) } : {}),
+ ...(format ? { format } : {}),
+ ...(value.example === undefined ? {} : { example: value.example }),
...(properties.length > 0 ? { properties } : {}),
+ ...(items ? { items } : {}),
};
}
@@ -555,21 +672,104 @@ function normalizeParameter(value: unknown): OpenApiParameter | undefined {
) {
return;
}
+ const description = asString(value.description);
+ const schema = summarizeSchema(value.schema);
return {
name,
in: location,
required: value.required === true || location === "path",
- ...(asString(value.description)
- ? { description: asString(value.description) }
- : {}),
+ ...(description ? { description } : {}),
...(value.deprecated === true ? { deprecated: true } : {}),
- ...(summarizeSchema(value.schema)
- ? { schema: summarizeSchema(value.schema) }
- : {}),
+ ...(schema ? { schema } : {}),
...(value.example === undefined ? {} : { example: value.example }),
};
}
+/**
+ * Build a representative example value from a schema summary. Explicit
+ * `example` / `default` / first `enum` values win; otherwise the value is
+ * synthesized from the type and format, matching what API reference UIs like
+ * Mintlify and Fumadocs render when a spec omits examples.
+ */
+export function buildSchemaExample(
+ schema: OpenApiSchemaSummary | undefined,
+ depth = 0
+): unknown {
+ if (!schema || depth > MAX_EXAMPLE_DEPTH) {
+ return null;
+ }
+ if (schema.example !== undefined) {
+ return schema.example;
+ }
+ if (schema.default !== undefined) {
+ return schema.default;
+ }
+ if (schema.enum && schema.enum.length > 0) {
+ return schema.enum[0];
+ }
+ if (schema.properties && schema.properties.length > 0) {
+ const output: Record = {};
+ for (const property of schema.properties) {
+ output[property.name] = buildSchemaExample(property, depth + 1);
+ }
+ return output;
+ }
+ if (schema.type.endsWith("[]") || schema.items) {
+ return schema.items ? [buildSchemaExample(schema.items, depth + 1)] : [];
+ }
+ const primary = schema.type.split(" | ")[0] ?? schema.type;
+ switch (primary) {
+ case "string":
+ return exampleStringForFormat(schema.format);
+ case "integer":
+ case "number":
+ return 0;
+ case "boolean":
+ return true;
+ case "null":
+ return null;
+ case "object":
+ case "unknown":
+ return {};
+ default:
+ // Named types (unresolvable/circular refs) fall back to an empty object.
+ return {};
+ }
+}
+
+function exampleStringForFormat(format: string | undefined): string {
+ switch (format) {
+ case "date-time":
+ return "2024-01-15T09:30:00Z";
+ case "date":
+ return "2024-01-15";
+ case "email":
+ return "user@example.com";
+ case "uuid":
+ return "123e4567-e89b-12d3-a456-426614174000";
+ case "uri":
+ case "url":
+ return "https://example.com";
+ case "binary":
+ case "byte":
+ return "";
+ default:
+ return "string";
+ }
+}
+
+function normalizeExamplesMap(
+ value: Record
+): Record {
+ // OpenAPI `examples` entries are Example objects — surface their `value`.
+ const output: Record = {};
+ for (const [name, entry] of Object.entries(value)) {
+ output[name] =
+ isRecord(entry) && entry.value !== undefined ? entry.value : entry;
+ }
+ return output;
+}
+
function normalizeMediaTypes(content: unknown): OpenApiMediaType[] {
if (!isRecord(content)) {
return [];
@@ -579,13 +779,25 @@ function normalizeMediaTypes(content: unknown): OpenApiMediaType[] {
if (!isRecord(value)) {
continue;
}
+ const schema = summarizeSchema(value.schema);
+ const examples = isRecord(value.examples)
+ ? normalizeExamplesMap(value.examples)
+ : undefined;
+ const hasNamedExamples = examples && Object.keys(examples).length > 0;
+ // Synthesize an example for JSON media types so every page ships a
+ // concrete payload even when the spec omits one.
+ const example =
+ value.example === undefined &&
+ !hasNamedExamples &&
+ schema &&
+ JSON_MEDIA_TYPE_PATTERN.test(mediaType)
+ ? buildSchemaExample(schema)
+ : value.example;
mediaTypes.push({
mediaType,
- ...(summarizeSchema(value.schema)
- ? { schema: summarizeSchema(value.schema) }
- : {}),
- ...(value.example === undefined ? {} : { example: value.example }),
- ...(isRecord(value.examples) ? { examples: value.examples } : {}),
+ ...(schema ? { schema } : {}),
+ ...(example === undefined ? {} : { example }),
+ ...(hasNamedExamples ? { examples } : {}),
});
}
return mediaTypes;
@@ -595,11 +807,10 @@ function normalizeRequestBody(value: unknown): OpenApiRequestBody | undefined {
if (!isRecord(value)) {
return;
}
+ const description = asString(value.description);
return {
required: value.required === true,
- ...(asString(value.description)
- ? { description: asString(value.description) }
- : {}),
+ ...(description ? { description } : {}),
content: normalizeMediaTypes(value.content),
};
}
@@ -656,22 +867,22 @@ function normalizeSecuritySchemes(
if (!type) {
continue;
}
+ const name = asString(value.name);
+ const location = asString(value.in);
+ const scheme = asString(value.scheme);
+ const bearerFormat = asString(value.bearerFormat);
+ const description = asString(value.description);
+ const openIdConnectUrl = asString(value.openIdConnectUrl);
normalized.push({
key,
type,
- ...(asString(value.name) ? { name: asString(value.name) } : {}),
- ...(asString(value.in) ? { in: asString(value.in) } : {}),
- ...(asString(value.scheme) ? { scheme: asString(value.scheme) } : {}),
- ...(asString(value.bearerFormat)
- ? { bearerFormat: asString(value.bearerFormat) }
- : {}),
- ...(asString(value.description)
- ? { description: asString(value.description) }
- : {}),
+ ...(name ? { name } : {}),
+ ...(location ? { in: location } : {}),
+ ...(scheme ? { scheme } : {}),
+ ...(bearerFormat ? { bearerFormat } : {}),
+ ...(description ? { description } : {}),
...(value.flows === undefined ? {} : { flows: value.flows }),
- ...(asString(value.openIdConnectUrl)
- ? { openIdConnectUrl: asString(value.openIdConnectUrl) }
- : {}),
+ ...(openIdConnectUrl ? { openIdConnectUrl } : {}),
});
}
return normalized;
@@ -771,37 +982,114 @@ function shouldIncludeOperation(
return true;
}
+function sampleParameterValue(parameter: OpenApiParameter): string {
+ const value =
+ parameter.example ??
+ parameter.schema?.example ??
+ parameter.schema?.default ??
+ parameter.schema?.enum?.[0];
+ if (value === undefined) {
+ return `<${parameter.name}>`;
+ }
+ return typeof value === "string" ? value : JSON.stringify(value);
+}
+
+function requiredQueryString(operation: OpenApiOperation): string {
+ const parts = operation.parameters
+ .filter((parameter) => parameter.in === "query" && parameter.required)
+ .map((parameter) => `${parameter.name}=${sampleParameterValue(parameter)}`);
+ return parts.length > 0 ? `?${parts.join("&")}` : "";
+}
+
function codeSampleUrl(operation: OpenApiOperation): string {
- const server = operation.serverUrl?.replace(/\/+$/, "") ?? "";
- return `${server}${operation.path}`;
+ const server =
+ operation.serverUrl?.replace(TRAILING_SLASHES_PATTERN, "") ?? "";
+ return `${server}${operation.path}${requiredQueryString(operation)}`;
}
-function buildCurlSample(operation: OpenApiOperation): string {
- const lines = [
- `curl -X ${operation.method.toUpperCase()} "${codeSampleUrl(operation)}"`,
- ];
+function authorizationHeader(scheme: OpenApiSecurityScheme): string[] | null {
+ if (scheme.type === "http" && scheme.scheme === "basic") {
+ return ["Authorization", "Basic "];
+ }
+ if (scheme.type === "http") {
+ return ["Authorization", `Bearer <${scheme.bearerFormat ?? "token"}>`];
+ }
+ if (scheme.type === "apiKey" && scheme.name) {
+ if (scheme.in === "header") {
+ return [scheme.name, ""];
+ }
+ if (scheme.in === "cookie") {
+ return ["Cookie", `${scheme.name}=`];
+ }
+ return null;
+ }
+ if (scheme.type === "oauth2" || scheme.type === "openIdConnect") {
+ return ["Authorization", "Bearer "];
+ }
+ return null;
+}
+
+function sampleHeaders(operation: OpenApiOperation): Map {
+ const headers = new Map();
+ // Auth for the first (preferred) security requirement.
+ const requirement = operation.security[0];
+ if (requirement) {
+ for (const key of Object.keys(requirement)) {
+ const scheme = operation.securitySchemes.find(
+ (candidate) => candidate.key === key
+ );
+ const header = scheme ? authorizationHeader(scheme) : null;
+ if (header && !headers.has(header[0] ?? "")) {
+ headers.set(header[0] ?? "", header[1] ?? "");
+ }
+ }
+ }
for (const parameter of operation.parameters) {
if (parameter.in !== "header" || parameter.required !== true) {
continue;
}
- lines.push(` -H "${parameter.name}: "`);
+ if (!headers.has(parameter.name)) {
+ headers.set(parameter.name, sampleParameterValue(parameter));
+ }
}
const mediaType = operation.requestBody?.content[0]?.mediaType;
- if (mediaType) {
- lines.push(` -H "Content-Type: ${mediaType}"`);
+ if (mediaType && !headers.has("Content-Type")) {
+ headers.set("Content-Type", mediaType);
+ }
+ return headers;
+}
+
+function sampleRequestBody(operation: OpenApiOperation): unknown {
+ const media = operation.requestBody?.content[0];
+ if (!media) {
+ return;
+ }
+ if (media.example !== undefined) {
+ return media.example;
+ }
+ if (media.schema) {
+ return buildSchemaExample(media.schema);
+ }
+ return;
+}
+
+function buildCurlSample(operation: OpenApiOperation): string {
+ const lines = [
+ `curl -X ${operation.method.toUpperCase()} "${codeSampleUrl(operation)}"`,
+ ];
+ for (const [name, value] of sampleHeaders(operation)) {
+ lines.push(` -H "${name}: ${value}"`);
}
- if (operation.requestBody) {
- lines.push(" -d ''");
+ const body = sampleRequestBody(operation);
+ if (body !== undefined) {
+ const json = JSON.stringify(body, null, 2).replaceAll("'", "'\\''");
+ lines.push(` -d '${json}'`);
}
return lines.join(" \\\n");
}
function buildFetchSample(operation: OpenApiOperation): string {
- const headers: Record = {};
- const mediaType = operation.requestBody?.content[0]?.mediaType;
- if (mediaType) {
- headers["Content-Type"] = mediaType;
- }
+ const headers = Object.fromEntries(sampleHeaders(operation));
const lines = [
`const response = await fetch("${codeSampleUrl(operation)}", {`,
` method: "${operation.method.toUpperCase()}",`,
@@ -811,13 +1099,43 @@ function buildFetchSample(operation: OpenApiOperation): string {
` headers: ${JSON.stringify(headers, null, 2).replaceAll("\n", "\n ")},`
);
}
- if (operation.requestBody) {
- lines.push(" body: JSON.stringify({ /* request body */ }),");
+ const body = sampleRequestBody(operation);
+ if (body !== undefined) {
+ const json = JSON.stringify(body, null, 2).replaceAll("\n", "\n ");
+ lines.push(` body: JSON.stringify(${json}),`);
}
lines.push("});", "const data = await response.json();");
return lines.join("\n");
}
+function vendorCodeSamples(
+ operation: Record
+): OpenApiCodeSample[] | undefined {
+ // Redocly-style `x-codeSamples` (also `x-code-samples`) override generated
+ // samples so spec authors can ship hand-written SDK snippets.
+ const raw = operation["x-codeSamples"] ?? operation["x-code-samples"];
+ if (!Array.isArray(raw)) {
+ return;
+ }
+ const samples: OpenApiCodeSample[] = [];
+ for (const entry of raw) {
+ if (!isRecord(entry)) {
+ continue;
+ }
+ const language = asString(entry.lang) ?? asString(entry.language);
+ const code = asString(entry.source);
+ if (!code) {
+ continue;
+ }
+ samples.push({
+ code,
+ label: asString(entry.label) ?? language ?? "Example",
+ language: (language ?? "text").toLowerCase(),
+ });
+ }
+ return samples.length > 0 ? samples : undefined;
+}
+
function normalizeOperation(
candidate: OperationCandidate,
document: Record,
@@ -849,11 +1167,17 @@ function normalizeOperation(
const selectedSchemeNames = new Set(
security.flatMap((requirement) => Object.keys(requirement))
);
- const securitySchemes = normalizeSecuritySchemes(document).filter(
- (scheme) =>
- selectedSchemeNames.size === 0 || selectedSchemeNames.has(scheme.key)
+ // Only ship the schemes this operation's requirements reference. Operations
+ // without security requirements are public — no auth section for them.
+ const securitySchemes = normalizeSecuritySchemes(document).filter((scheme) =>
+ selectedSchemeNames.has(scheme.key)
);
+ const operationId = asString(operation.operationId);
+ const summary = asString(operation.summary);
+ const serverUrl = firstServerUrl(document, operation, config);
+ const requestBody = normalizeRequestBody(operation.requestBody);
+
const normalized: OpenApiOperation = {
method,
path: apiPath,
@@ -866,20 +1190,12 @@ function normalizeOperation(
security,
securitySchemes,
codeSamples: [],
- ...(asString(operation.operationId)
- ? { operationId: asString(operation.operationId) }
- : {}),
- ...(asString(operation.summary)
- ? { summary: asString(operation.summary) }
- : {}),
- ...(firstServerUrl(document, operation, config)
- ? { serverUrl: firstServerUrl(document, operation, config) }
- : {}),
- ...(normalizeRequestBody(operation.requestBody)
- ? { requestBody: normalizeRequestBody(operation.requestBody) }
- : {}),
+ ...(operationId ? { operationId } : {}),
+ ...(summary ? { summary } : {}),
+ ...(serverUrl ? { serverUrl } : {}),
+ ...(requestBody ? { requestBody } : {}),
};
- normalized.codeSamples = [
+ normalized.codeSamples = vendorCodeSamples(operation) ?? [
{ code: buildCurlSample(normalized), label: "cURL", language: "bash" },
{ code: buildFetchSample(normalized), label: "JavaScript", language: "ts" },
];
@@ -891,9 +1207,14 @@ export function normalizeOpenApiConfig(
cwd: string
): ResolvedOpenApiSourceConfig[] {
const inputs = Array.isArray(config) ? config : [config];
- return inputs.map((input) => {
+ return inputs.map((input, index) => {
const source: OpenApiSourceConfig =
typeof input === "string" ? { input } : input;
+ if (typeof source.input !== "string" || source.input.trim() === "") {
+ throw new Error(
+ `OpenAPI: config entry ${index} must set "input" to a spec path or URL`
+ );
+ }
return {
...source,
cwd,
@@ -906,6 +1227,91 @@ export function normalizeOpenApiConfig(
});
}
+/**
+ * Validate an untyped `openapi` block from a loaded docs config. Returns the
+ * typed config, throwing descriptive errors so config typos fail fast.
+ */
+export function validateDocsOpenApiConfig(
+ value: unknown,
+ label: string
+): DocsOpenApiConfig | undefined {
+ if (value === undefined) {
+ return;
+ }
+ const entries = Array.isArray(value) ? value : [value];
+ for (const [index, entry] of entries.entries()) {
+ validateOpenApiEntry(entry, index, label);
+ }
+ return value as DocsOpenApiConfig;
+}
+
+function validateOpenApiEntry(
+ entry: unknown,
+ index: number,
+ label: string
+): void {
+ const where = `${label}: openapi entry ${index}`;
+ if (typeof entry === "string") {
+ if (entry.trim() === "") {
+ throw new Error(`${where} must be a non-empty spec path or URL`);
+ }
+ return;
+ }
+ if (!isRecord(entry)) {
+ throw new Error(`${where} must be a string or { input, … } object`);
+ }
+ if (typeof entry.input !== "string" || entry.input.trim() === "") {
+ throw new Error(`${where} must set "input" to a spec path or URL`);
+ }
+ const stringKeys = ["output", "serverUrl", "description"] as const;
+ for (const key of stringKeys) {
+ if (entry[key] !== undefined && typeof entry[key] !== "string") {
+ throw new Error(`${where}: "${key}" must be a string`);
+ }
+ }
+ if (entry.title !== undefined && typeof entry.title !== "string") {
+ throw new Error(`${where}: "title" must be a string`);
+ }
+ if (
+ entry.group !== undefined &&
+ typeof entry.group !== "string" &&
+ !(
+ Array.isArray(entry.group) &&
+ entry.group.every((item) => typeof item === "string")
+ )
+ ) {
+ throw new Error(`${where}: "group" must be a string or string array`);
+ }
+ if (entry.order !== undefined && typeof entry.order !== "number") {
+ throw new Error(`${where}: "order" must be a number`);
+ }
+ const tagKeys = ["includeTags", "excludeTags"] as const;
+ for (const key of tagKeys) {
+ const tags = entry[key];
+ if (
+ tags !== undefined &&
+ !(Array.isArray(tags) && tags.every((tag) => typeof tag === "string"))
+ ) {
+ throw new Error(`${where}: "${key}" must be a string array`);
+ }
+ }
+ const booleanKeys = ["groupByTags", "includeTryIt"] as const;
+ for (const key of booleanKeys) {
+ if (entry[key] !== undefined && typeof entry[key] !== "boolean") {
+ throw new Error(`${where}: "${key}" must be a boolean`);
+ }
+ }
+ if (
+ entry.slugStrategy !== undefined &&
+ entry.slugStrategy !== "operation-id" &&
+ entry.slugStrategy !== "method-path"
+ ) {
+ throw new Error(
+ `${where}: "slugStrategy" must be "operation-id" or "method-path"`
+ );
+ }
+}
+
function operationSlug(
operation: OpenApiOperation,
config: ResolvedOpenApiSourceConfig
@@ -956,6 +1362,18 @@ function yamlString(value: string): string {
return JSON.stringify(value);
}
+/**
+ * Short, single-line description for frontmatter, nav, and search snippets.
+ * Prefers the operation summary, then the first paragraph of the description.
+ */
+function shortDescription(operation: OpenApiOperation): string {
+ const source =
+ operation.summary ??
+ operation.description.split(BLANK_LINE_PATTERN)[0] ??
+ operation.title;
+ return source.replace(WHITESPACE_RUN_PATTERN, " ").trim();
+}
+
function renderFrontmatter(
operation: OpenApiOperation,
config: ResolvedOpenApiSourceConfig,
@@ -964,7 +1382,7 @@ function renderFrontmatter(
const lines = [
"---",
`title: ${yamlString(operation.title)}`,
- `description: ${yamlString(operation.description)}`,
+ `description: ${yamlString(shortDescription(operation))}`,
];
if (config.group) {
lines.push(`group: ${JSON.stringify(config.group)}`);
@@ -1026,20 +1444,21 @@ function renderOperationMdx(
config: ResolvedOpenApiSourceConfig,
index: number
): string {
+ // No body `#` heading: the docs renderer prints the frontmatter title, and a
+ // second h1 would duplicate it. Description text is CommonMark from the
+ // spec, so escape MDX-significant syntax before embedding it in the body.
const blocks = [
renderFrontmatter(operation, config, index),
"",
- `# ${operation.title}`,
- "",
renderMdxComponent("ApiEndpoint", {
- deprecated: operation.deprecated,
+ deprecated: operation.deprecated || undefined,
method: operation.method,
operationId: operation.operationId,
path: operation.path,
serverUrl: operation.serverUrl,
}),
"",
- operation.description,
+ escapeMarkdownForMdx(operation.description),
];
if (operation.securitySchemes.length > 0 || operation.security.length > 0) {
@@ -1154,19 +1573,19 @@ function buildGeneratedNav(
}
export async function generateOpenApiPages(
- config: ResolvedOpenApiSourceConfig
+ config: ResolvedOpenApiSourceConfig,
+ usedPaths: Set = new Set()
): Promise {
const document = await loadOpenApiDocument(config.input, config.cwd);
const candidates = collectOperationCandidates(document).filter((candidate) =>
shouldIncludeOperation(candidate.operation, config)
);
- const usedPaths = new Set();
const pages: GeneratedOpenApiPage[] = [];
for (const candidate of candidates) {
const operation = normalizeOperation(candidate, document, config);
const relativePath = operationRelativePath(operation, config, usedPaths);
pages.push({
- description: operation.description,
+ description: shortDescription(operation),
filePath: relativePath,
operation,
relativePath,
@@ -1191,8 +1610,11 @@ export async function writeOpenApiPages({
}: WriteOpenApiPagesConfig): Promise {
const allPages: GeneratedOpenApiPage[] = [];
const nav: DocsNavNode[] = [];
+ // One shared set so multiple specs targeting the same output directory get
+ // collision suffixes instead of silently overwriting each other.
+ const usedPaths = new Set();
for (const config of configs) {
- const generated = await generateOpenApiPages(config);
+ const generated = await generateOpenApiPages(config, usedPaths);
for (const [index, page] of generated.pages.entries()) {
const outputPath = path.join(docsDir, page.relativePath);
await mkdir(path.dirname(outputPath), { recursive: true });
@@ -1206,3 +1628,52 @@ export async function writeOpenApiPages({
}
return { nav, pages: allPages };
}
+
+export type StageOpenApiDocsConfig = {
+ /** Docs source directory to copy before writing generated pages. */
+ contentDir: string;
+ /** OpenAPI config, matching `DocsConfig["openapi"]`. */
+ openapi: DocsOpenApiConfig;
+ /** Base directory for relative `input` paths. Defaults to `contentDir`. */
+ cwd?: string;
+};
+
+export type StagedOpenApiDocs = {
+ /** Temp copy of `contentDir` with generated API pages written into it. */
+ contentDir: string;
+ /** Generated navigation nodes — append to your curated nav. */
+ nav: DocsNavNode[];
+ pages: GeneratedOpenApiPage[];
+ /** Remove the staged copy. Call when the consuming build is done with it. */
+ cleanup: () => Promise;
+};
+
+/**
+ * Stage a docs source directory with generated OpenAPI pages, without
+ * touching the original source. This is the shared building block behind
+ * `createDocsSource({ openapi })` and the package docs build — use it directly
+ * when wiring a custom pipeline.
+ */
+export async function stageOpenApiDocs(
+ config: StageOpenApiDocsConfig
+): Promise {
+ const sourceDir = path.resolve(config.contentDir);
+ if (!existsSync(sourceDir)) {
+ throw new Error(`OpenAPI: contentDir does not exist at "${sourceDir}"`);
+ }
+ const stagedRoot = await mkdtemp(path.join(tmpdir(), "leadtype-openapi-"));
+ const stagedContentDir = path.join(stagedRoot, path.basename(sourceDir));
+ await cp(sourceDir, stagedContentDir, { recursive: true });
+ const result = await writeOpenApiPages({
+ configs: normalizeOpenApiConfig(config.openapi, config.cwd ?? sourceDir),
+ docsDir: stagedContentDir,
+ });
+ return {
+ cleanup: async () => {
+ await rm(stagedRoot, { force: true, recursive: true });
+ },
+ contentDir: stagedContentDir,
+ nav: result.nav,
+ pages: result.pages,
+ };
+}
diff --git a/packages/leadtype/src/openapi/openapi.test.ts b/packages/leadtype/src/openapi/openapi.test.ts
index 3548d48f..e0e2d6fd 100644
--- a/packages/leadtype/src/openapi/openapi.test.ts
+++ b/packages/leadtype/src/openapi/openapi.test.ts
@@ -1,8 +1,18 @@
+import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
-import { normalizeOpenApiConfig, writeOpenApiPages } from "./index";
+import { convertMdxToMarkdown } from "../convert/convert";
+import { defaultMarkdownTransforms } from "../markdown/index";
+import {
+ buildSchemaExample,
+ escapeMarkdownForMdx,
+ normalizeOpenApiConfig,
+ stageOpenApiDocs,
+ validateDocsOpenApiConfig,
+ writeOpenApiPages,
+} from "./index";
const tempDirs: string[] = [];
@@ -20,14 +30,7 @@ afterEach(async () => {
);
});
-describe("OpenAPI page generation", () => {
- it("writes native MDX API reference pages and generated nav", async () => {
- const dir = await createTempDir();
- const docsDir = path.join(dir, "docs");
- await mkdir(docsDir, { recursive: true });
- await writeFile(
- path.join(dir, "openapi.yaml"),
- `
+const FIXTURE_SPEC = `
openapi: 3.1.0
info:
title: Fixture API
@@ -66,18 +69,34 @@ paths:
id:
type: string
description: Unique ID.
-`
- );
+`;
- const configs = normalizeOpenApiConfig(
- {
- group: "api",
- input: "openapi.yaml",
- output: "rest-api",
- },
- dir
- );
- const result = await writeOpenApiPages({ configs, docsDir });
+async function writeFixture(
+ dir: string,
+ name: string,
+ contents: string
+): Promise {
+ const filePath = path.join(dir, name);
+ await writeFile(filePath, contents);
+ return filePath;
+}
+
+async function generateFixturePages(spec: string, output = "rest-api") {
+ const dir = await createTempDir();
+ const docsDir = path.join(dir, "docs");
+ await mkdir(docsDir, { recursive: true });
+ await writeFixture(dir, "openapi.yaml", spec);
+ const configs = normalizeOpenApiConfig(
+ { input: "openapi.yaml", output },
+ dir
+ );
+ const result = await writeOpenApiPages({ configs, docsDir });
+ return { dir, docsDir, result };
+}
+
+describe("OpenAPI page generation", () => {
+ it("writes native MDX API reference pages and generated nav", async () => {
+ const { docsDir, result } = await generateFixturePages(FIXTURE_SPEC);
expect(result.pages).toHaveLength(1);
expect(result.nav[0]).toMatchObject({
@@ -99,5 +118,429 @@ paths:
expect(page).toContain(" {
+ const { result } = await generateFixturePages(FIXTURE_SPEC);
+ const converted = await convertMdxToMarkdown(
+ result.pages[0]?.filePath ?? "",
+ defaultMarkdownTransforms
+ );
+
+ // Every Api* component must flatten — no raw JSX in agent markdown.
+ expect(converted.markdown).not.toContain("");
+ // Synthesized response example from the schema.
+ expect(converted.markdown).toContain('"id": "string"');
+ });
+
+ it("escapes MDX-unsafe CommonMark descriptions", async () => {
+ const spec = `
+openapi: 3.1.0
+info: { title: Unsafe, version: 1.0.0 }
+paths:
+ /users/{id}:
+ get:
+ operationId: readUser
+ summary: Read a user
+ description: |
+ Returns the user for {id}. Set the header to when calling.
+ responses:
+ "200":
+ description: ok
+`;
+ const { result } = await generateFixturePages(spec);
+ const converted = await convertMdxToMarkdown(
+ result.pages[0]?.filePath ?? "",
+ defaultMarkdownTransforms
+ );
+ expect(converted.markdown).toContain("{id}");
+ expect(converted.markdown).toContain("");
+ });
+
+ it("renders nested array item properties as dotted rows", async () => {
+ const spec = `
+openapi: 3.1.0
+info: { title: Nested, version: 1.0.0 }
+paths:
+ /search:
+ post:
+ operationId: search
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ results:
+ type: array
+ items:
+ type: object
+ properties:
+ title:
+ type: string
+ description: Result title.
+ nested:
+ type: object
+ properties:
+ deep:
+ type: string
+`;
+ const { result } = await generateFixturePages(spec);
+ const page = await readFile(result.pages[0]?.filePath ?? "", "utf8");
+ expect(page).toContain('"name": "title"');
+
+ const converted = await convertMdxToMarkdown(
+ result.pages[0]?.filePath ?? "",
+ defaultMarkdownTransforms
+ );
+ expect(converted.markdown).toContain("`results[].title`");
+ expect(converted.markdown).toContain("`results[].nested.deep`");
+ });
+
+ it("resolves external $refs relative to the spec file", async () => {
+ const dir = await createTempDir();
+ const docsDir = path.join(dir, "docs");
+ await mkdir(docsDir, { recursive: true });
+ await writeFixture(
+ dir,
+ "schemas.yaml",
+ `
+User:
+ type: object
+ properties:
+ name:
+ type: string
+`
+ );
+ await writeFixture(
+ dir,
+ "openapi.yaml",
+ `
+openapi: 3.1.0
+info: { title: Refs, version: 1.0.0 }
+paths:
+ /users:
+ get:
+ operationId: listUsers
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ $ref: "./schemas.yaml#/User"
+`
+ );
+ const configs = normalizeOpenApiConfig({ input: "openapi.yaml" }, dir);
+ const result = await writeOpenApiPages({ configs, docsDir });
+ const page = await readFile(result.pages[0]?.filePath ?? "", "utf8");
+ expect(page).toContain('"name": "name"');
+ });
+
+ it("degrades circular $refs to the referenced type name", async () => {
+ const spec = `
+openapi: 3.1.0
+info: { title: Cycle, version: 1.0.0 }
+components:
+ schemas:
+ Node:
+ type: object
+ properties:
+ children:
+ type: array
+ items:
+ $ref: "#/components/schemas/Node"
+paths:
+ /nodes:
+ get:
+ operationId: listNodes
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Node"
+`;
+ const { result } = await generateFixturePages(spec);
+ const page = await readFile(result.pages[0]?.filePath ?? "", "utf8");
+ expect(page).toContain('"type": "Node[]"');
+ });
+
+ it("filters operations with includeTags and excludeTags", async () => {
+ const spec = `
+openapi: 3.1.0
+info: { title: Tags, version: 1.0.0 }
+paths:
+ /a:
+ get:
+ operationId: opA
+ tags: [Public]
+ responses: { "200": { description: ok } }
+ /b:
+ get:
+ operationId: opB
+ tags: [Internal]
+ responses: { "200": { description: ok } }
+`;
+ const dir = await createTempDir();
+ const docsDir = path.join(dir, "docs");
+ await mkdir(docsDir, { recursive: true });
+ await writeFixture(dir, "openapi.yaml", spec);
+ const configs = normalizeOpenApiConfig(
+ {
+ excludeTags: ["Internal"],
+ includeTags: ["Public", "Internal"],
+ input: "openapi.yaml",
+ },
+ dir
+ );
+ const result = await writeOpenApiPages({ configs, docsDir });
+ expect(result.pages).toHaveLength(1);
+ expect(result.pages[0]?.operation.operationId).toBe("opA");
+ });
+
+ it("supports method-path slugs and suffixes collisions across specs", async () => {
+ const spec = `
+openapi: 3.1.0
+info: { title: Slugs, version: 1.0.0 }
+paths:
+ /users/{id}:
+ get:
+ operationId: readUser
+ responses: { "200": { description: ok } }
+`;
+ const dir = await createTempDir();
+ const docsDir = path.join(dir, "docs");
+ await mkdir(docsDir, { recursive: true });
+ await writeFixture(dir, "openapi.yaml", spec);
+ // Two specs targeting the same output directory must not overwrite.
+ const configs = normalizeOpenApiConfig(
+ [
+ {
+ groupByTags: false,
+ input: "openapi.yaml",
+ output: "api",
+ slugStrategy: "method-path",
+ },
+ {
+ groupByTags: false,
+ input: "openapi.yaml",
+ output: "api",
+ slugStrategy: "method-path",
+ },
+ ],
+ dir
+ );
+ const result = await writeOpenApiPages({ configs, docsDir });
+ expect(result.pages.map((page) => page.relativePath).sort()).toEqual([
+ "api/get-users-id-2.mdx",
+ "api/get-users-id.mdx",
+ ]);
+ });
+
+ it("prefers x-codeSamples over generated snippets", async () => {
+ const spec = `
+openapi: 3.1.0
+info: { title: Samples, version: 1.0.0 }
+paths:
+ /things:
+ get:
+ operationId: listThings
+ x-codeSamples:
+ - lang: Python
+ label: Python SDK
+ source: client.things.list()
+ responses: { "200": { description: ok } }
+`;
+ const { result } = await generateFixturePages(spec);
+ const samples = result.pages[0]?.operation.codeSamples ?? [];
+ expect(samples).toHaveLength(1);
+ expect(samples[0]).toMatchObject({
+ code: "client.things.list()",
+ label: "Python SDK",
+ language: "python",
+ });
+ });
+
+ it("builds cURL samples with query params and request bodies", async () => {
+ const spec = `
+openapi: 3.1.0
+info: { title: Curl, version: 1.0.0 }
+servers:
+ - url: https://api.example.com
+paths:
+ /search:
+ post:
+ operationId: search
+ parameters:
+ - name: limit
+ in: query
+ required: true
+ schema:
+ type: integer
+ default: 8
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [query]
+ properties:
+ query:
+ type: string
+ responses: { "200": { description: ok } }
+`;
+ const { result } = await generateFixturePages(spec);
+ const curl = result.pages[0]?.operation.codeSamples[0]?.code ?? "";
+ expect(curl).toContain(
+ 'curl -X POST "https://api.example.com/search?limit=8"'
+ );
+ expect(curl).toContain('-H "Content-Type: application/json"');
+ expect(curl).toContain('"query": "string"');
+ });
+
+ it("accepts webhooks-only OpenAPI 3.1 documents", async () => {
+ const spec = `
+openapi: 3.1.0
+info: { title: Hooks, version: 1.0.0 }
+webhooks:
+ ping:
+ post:
+ operationId: pingHook
+ responses: { "200": { description: ok } }
+`;
+ const { result } = await generateFixturePages(spec);
+ expect(result.pages).toHaveLength(0);
+ expect(result.nav).toHaveLength(0);
+ });
+
+ it("rejects Swagger 2.0 documents with a conversion hint", async () => {
+ const dir = await createTempDir();
+ const docsDir = path.join(dir, "docs");
+ await mkdir(docsDir, { recursive: true });
+ await writeFixture(dir, "swagger.yaml", 'swagger: "2.0"\npaths: {}\n');
+ const configs = normalizeOpenApiConfig({ input: "swagger.yaml" }, dir);
+ await expect(writeOpenApiPages({ configs, docsDir })).rejects.toThrow(
+ /Swagger 2\.0/
+ );
+ });
+});
+
+describe("stageOpenApiDocs", () => {
+ it("stages a docs copy with generated pages and cleans up", async () => {
+ const dir = await createTempDir();
+ const docsDir = path.join(dir, "docs");
+ await mkdir(docsDir, { recursive: true });
+ await writeFile(
+ path.join(docsDir, "intro.mdx"),
+ "---\ntitle: Intro\n---\n"
+ );
+ await writeFixture(docsDir, "openapi.yaml", FIXTURE_SPEC);
+
+ const staged = await stageOpenApiDocs({
+ contentDir: docsDir,
+ openapi: { input: "./openapi.yaml", output: "rest-api" },
+ });
+ expect(staged.contentDir).not.toBe(docsDir);
+ expect(existsSync(path.join(staged.contentDir, "intro.mdx"))).toBe(true);
+ expect(
+ existsSync(
+ path.join(
+ staged.contentDir,
+ "rest-api",
+ "access-groups",
+ "read-access-group.mdx"
+ )
+ )
+ ).toBe(true);
+ expect(staged.nav).toHaveLength(1);
+ // The authored source is untouched.
+ expect(existsSync(path.join(docsDir, "rest-api"))).toBe(false);
+
+ await staged.cleanup();
+ expect(existsSync(staged.contentDir)).toBe(false);
+ });
+});
+
+describe("config validation", () => {
+ it("throws when input is missing", () => {
+ expect(() => normalizeOpenApiConfig({ input: "" }, process.cwd())).toThrow(
+ /must set "input"/
+ );
+ });
+
+ it("validates untyped docs config openapi blocks", () => {
+ expect(validateDocsOpenApiConfig(undefined, "config")).toBeUndefined();
+ expect(() =>
+ validateDocsOpenApiConfig({ output: "api" }, "config")
+ ).toThrow(/must set "input"/);
+ expect(() =>
+ validateDocsOpenApiConfig(
+ { input: "a.yaml", slugStrategy: "nope" },
+ "config"
+ )
+ ).toThrow(/slugStrategy/);
+ expect(() =>
+ validateDocsOpenApiConfig([{ input: "a.yaml", order: "1" }], "config")
+ ).toThrow(/"order" must be a number/);
+ expect(
+ validateDocsOpenApiConfig({ input: "a.yaml" }, "config")
+ ).toMatchObject({ input: "a.yaml" });
+ });
+});
+
+describe("escapeMarkdownForMdx", () => {
+ it("escapes JSX and expression openers in prose", () => {
+ expect(escapeMarkdownForMdx("use {id} and ")).toBe(
+ "use \\{id\\} and \\"
+ );
+ });
+
+ it("preserves inline code, fences, and autolinks", () => {
+ expect(escapeMarkdownForMdx("keep `{id}` and ")).toBe(
+ "keep `{id}` and "
+ );
+ const fenced = "```ts\nconst a = {id: 1};\n```";
+ expect(escapeMarkdownForMdx(fenced)).toBe(fenced);
+ });
+});
+
+describe("buildSchemaExample", () => {
+ it("synthesizes nested payloads from schema summaries", () => {
+ expect(
+ buildSchemaExample({
+ properties: [
+ { name: "query", type: "string" },
+ { default: 8, name: "limit", type: "integer" },
+ {
+ items: {
+ properties: [
+ { format: "uuid", name: "id", type: "string" },
+ { enum: ["a", "b"], name: "kind", type: '"a" | "b"' },
+ ],
+ type: "object",
+ },
+ name: "results",
+ type: "object[]",
+ },
+ ],
+ type: "object",
+ })
+ ).toEqual({
+ limit: 8,
+ query: "string",
+ results: [{ id: "123e4567-e89b-12d3-a456-426614174000", kind: "a" }],
+ });
});
});
diff --git a/packages/leadtype/src/source/index.ts b/packages/leadtype/src/source/index.ts
index cbbf0157..8ccb78e7 100644
--- a/packages/leadtype/src/source/index.ts
+++ b/packages/leadtype/src/source/index.ts
@@ -46,6 +46,7 @@ import type {
import { extractDocsTableOfContents, resolveDocsNavigation } from "../llm";
import type { DocsNavigation } from "../llm/readability";
import { createMdxSourcePlugins } from "../mdx/source-preset";
+import { type DocsOpenApiConfig, stageOpenApiDocs } from "../openapi";
import {
type IncludeResolution,
type ResolveIncludeOptions,
@@ -142,6 +143,17 @@ export type CreateDocsSourceConfig<
/** Optional locale configuration. When present, `locale` selects the active docs language. */
i18n?: DocsI18nConfig;
locale?: LocaleCode;
+ /**
+ * OpenAPI specs to generate API reference pages from. Pages are staged into
+ * a temp copy of `contentDir` (the source directory is never modified) and
+ * their navigation nodes are appended to `nav`.
+ */
+ openapi?: DocsOpenApiConfig;
+ /**
+ * Base directory for relative `openapi` input paths — typically the
+ * directory containing your docs config. Defaults to `contentDir`.
+ */
+ openapiCwd?: string;
};
export type DocsSource =
@@ -380,17 +392,35 @@ export async function createDocsSource<
>(
config: CreateDocsSourceConfig
): Promise> {
- const contentDir = path.resolve(config.contentDir);
- if (!existsSync(contentDir)) {
+ const sourceContentDir = path.resolve(config.contentDir);
+ if (!existsSync(sourceContentDir)) {
throw new Error(
- `createDocsSource: contentDir does not exist at "${contentDir}"`
+ `createDocsSource: contentDir does not exist at "${sourceContentDir}"`
);
}
+ // OpenAPI generation stages a temp copy of the source so generated pages
+ // exist on disk without polluting the authored docs. Nav nodes for the
+ // generated pages are appended to the curated nav.
+ let contentDir = sourceContentDir;
+ let nav = config.nav;
+ if (config.openapi !== undefined) {
+ const staged = await stageOpenApiDocs({
+ contentDir: sourceContentDir,
+ cwd: config.openapiCwd,
+ openapi: config.openapi,
+ });
+ contentDir = staged.contentDir;
+ nav = [...(config.nav ?? []), ...staged.nav];
+ }
+
const baseUrl = normalizeBaseUrl(config.baseUrl);
- const contentParentDir = path.dirname(contentDir);
+ // Type-table and include resolution stay anchored to the *original* source
+ // tree so relative references outside the docs dir keep working when the
+ // content is staged.
+ const contentParentDir = path.dirname(sourceContentDir);
const defaultTypeTableBasePath =
- contentParentDir === contentDir ? contentDir : contentParentDir;
+ contentParentDir === sourceContentDir ? sourceContentDir : contentParentDir;
const typeTableBasePath = path.resolve(
config.typeTableBasePath ?? defaultTypeTableBasePath
);
@@ -488,7 +518,7 @@ export async function createDocsSource<
docsDirName: path.basename(contentDir),
baseUrl: config.baseUrl,
groups: config.groups ?? [],
- nav: config.nav,
+ nav,
mounts: config.mounts,
i18n: config.i18n,
locale: config.locale,
From 9ab31ab86ea76a4c8d2ba5bb08cfe6b858c63d72 Mon Sep 17 00:00:00 2001
From: Kaylee <65376239+KayleeWilliams@users.noreply.github.com>
Date: Thu, 2 Jul 2026 12:51:51 +0100
Subject: [PATCH 3/9] Polish OpenAPI page metadata and flattened endpoint
details
- Frontmatter description prefers the description's first paragraph over
the summary (which usually mirrors the title), capped at 250 chars
- Render server URL and operation ID as inline code in flattened
markdown so URLs avoid escaping artifacts
---
.../public/feeds/schema.jsonl | 50 +++++
apps/fumadocs-example/public/mcp.json | 44 ++++
apps/fumadocs-example/public/robots.txt | 211 ++++++++++++++++++
apps/fumadocs-example/public/schema-map.xml | 8 +
apps/fumadocs-example/public/sitemap.md | 98 ++++++++
apps/fumadocs-example/public/sitemap.xml | 203 +++++++++++++++++
.../leadtype/src/markdown/plugins/openapi.ts | 24 +-
packages/leadtype/src/openapi/index.ts | 19 +-
8 files changed, 644 insertions(+), 13 deletions(-)
create mode 100644 apps/fumadocs-example/public/feeds/schema.jsonl
create mode 100644 apps/fumadocs-example/public/mcp.json
create mode 100644 apps/fumadocs-example/public/robots.txt
create mode 100644 apps/fumadocs-example/public/schema-map.xml
create mode 100644 apps/fumadocs-example/public/sitemap.md
create mode 100644 apps/fumadocs-example/public/sitemap.xml
diff --git a/apps/fumadocs-example/public/feeds/schema.jsonl b/apps/fumadocs-example/public/feeds/schema.jsonl
new file mode 100644
index 00000000..9aa9d0ec
--- /dev/null
+++ b/apps/fumadocs-example/public/feeds/schema.jsonl
@@ -0,0 +1,50 @@
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/concepts/methodology","url":"http://localhost:3000/docs/concepts/methodology","name":"Methodology","description":"Where leadtype fits alongside custom docs apps and frameworks like Fumadocs and Starlight — the portable content and agent-readability layer underneath the host you choose.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/concepts/architecture","url":"http://localhost:3000/docs/concepts/architecture","name":"Architecture","description":"The core / adapter boundary — what ships where, and the rules adapters must follow.","dateModified":"2026-06-29T18:28:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/concepts/evals","url":"http://localhost:3000/docs/concepts/evals","name":"Evals","description":"How Leadtype measures whether bundling agent docs actually helps coding agents — and how the llms.txt defaults were chosen.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/configure-sources","url":"http://localhost:3000/docs/pipeline/configure-sources","name":"Configure docs sources","description":"Choose where Leadtype reads MDX from: one local docs folder, multiple mounted folders, or remote git collections pinned to a branch, tag, or commit.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/collections","url":"http://localhost:3000/docs/pipeline/collections","name":"Collections reference","description":"Detailed defineCollection behavior for multi-source docs: local folders, git repos, filters, schemas, and per-collection navigation.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/sync-docs-across-repos","url":"http://localhost:3000/docs/pipeline/sync-docs-across-repos","name":"Sync docs across repositories","description":"Keep a separate docs UI repository pinned to a reviewed package-docs source revision.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/build-a-docs-site","url":"http://localhost:3000/docs/pipeline/build-a-docs-site","name":"Build an agent-ready docs site","description":"Pick the right Leadtype integration shape for a hosted docs site with rendered pages, markdown mirrors, llms.txt, search, and agent metadata.","dateModified":"2026-06-13T02:08:36.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/use-the-source-primitive","url":"http://localhost:3000/docs/pipeline/use-the-source-primitive","name":"Use the source primitive","description":"Wire createDocsSource into Next, TanStack Start, Nuxt, Astro, SvelteKit, or any MDX-aware bundler. Same primitive, multiple host shapes.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/agent-setup-prompts","url":"http://localhost:3000/docs/pipeline/agent-setup-prompts","name":"Agent setup prompts","description":"Copyable prompts that let a coding agent wire Leadtype into your app — local docs, external/multi-repo docs, or a package bundle — adapting to your real layout.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/generate-static-artifacts","url":"http://localhost:3000/docs/pipeline/generate-static-artifacts","name":"Generate static artifacts","description":"Run leadtype generate from your build pipeline to write llms.txt, markdown mirrors, search index, sitemap, and agent-readability files to disk.","dateModified":"2026-07-02T10:14:54.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/generate-rss-atom-feeds","url":"http://localhost:3000/docs/pipeline/generate-rss-atom-feeds","name":"Generate RSS and Atom feeds","description":"Configure Leadtype to emit RSS and Atom feeds for changelogs, blogs, release notes, or any URL-prefixed generated docs content.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/deploy-generated-artifacts","url":"http://localhost:3000/docs/pipeline/deploy-generated-artifacts","name":"Deploy generated artifacts","description":"Serve Leadtype output on common framework and hosting combinations.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/validate-in-ci","url":"http://localhost:3000/docs/pipeline/validate-in-ci","name":"Validate in CI","description":"Run leadtype lint in CI so frontmatter, navigation, and link issues fail PRs before publish.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/localize-docs","url":"http://localhost:3000/docs/pipeline/localize-docs","name":"Localize docs","description":"Author multi-locale MDX, generate per-locale llms.txt and markdown mirrors, and serve locale-prefixed docs with alternate-locale links.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/aeo/overview","url":"http://localhost:3000/docs/aeo/overview","name":"AEO & Agent Readability overview","description":"Every agent-facing artifact leadtype emits, how they map to the agent-readability spec and AEO scoring rubrics, and how to audit a site.","dateModified":"2026-06-13T02:08:36.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/aeo/optimize-docs-for-agents","url":"http://localhost:3000/docs/aeo/optimize-docs-for-agents","name":"Optimize docs for agents","description":"Generate llms.txt, markdown mirrors, JSON-LD inputs, sitemaps, robots.txt, and agent-readability.json from one CLI run.","dateModified":"2026-06-13T02:08:36.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/aeo/generate-artifacts-without-docs","url":"http://localhost:3000/docs/aeo/generate-artifacts-without-docs","name":"Generate artifacts without a docs tree","description":"Emit llms.txt, markdown mirrors, sitemaps, robots.txt, and the agent-readability manifest from an in-memory page list — no .mdx source files required.","dateModified":"2026-06-13T02:08:36.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/aeo/serve-agent-responses","url":"http://localhost:3000/docs/aeo/serve-agent-responses","name":"Serve agent responses","description":"Wire markdown responses, JSON-LD, sitemap, and robots into your framework using the generated agent-readability.json manifest.","dateModified":"2026-06-13T02:08:36.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/skills","url":"http://localhost:3000/docs/reference/skills","name":"Agent skills","description":"Emit a discoverable SKILL.md surface (agentskills.io) from docs.config.ts — an auto docs-skill plus any you declare — to /.well-known/agent-skills and the package bundle.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/mcp","url":"http://localhost:3000/docs/reference/mcp","name":"MCP server","description":"Serve docs to MCP clients over stdio or Streamable HTTP — the gate for when it's worth it, the optional-peer-dep and stateless-HTTP gotchas, and how edge/bundled hosts skip the disk path.","dateModified":"2026-06-12T22:00:22.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/webmcp","url":"http://localhost:3000/docs/reference/webmcp","name":"WebMCP","description":"Register generated docs as browser-side WebMCP tools with document.modelContext / navigator.modelContext — separate from the server MCP endpoint.","dateModified":"2026-06-07T19:40:27.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/nlweb","url":"http://localhost:3000/docs/reference/nlweb","name":"NLWeb","description":"Serve an NLWeb /ask endpoint over your generated docs and publish the schema feeds + robots.txt Schemamap directive that make the site conversational for agents.","dateModified":"2026-06-29T16:57:13.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/writing/write-for-agents","url":"http://localhost:3000/docs/writing/write-for-agents","name":"Write for agents & GEO","description":"Authoring for agents and the answer engines that cite you, in two halves: what to write (the non-obvious, not restatement) and how to structure it (lead with the answer, question-form headings, self-contained sections).","dateModified":"2026-06-29T15:47:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/writing/frontmatter","url":"http://localhost:3000/docs/writing/frontmatter","name":"Frontmatter","description":"Required fields, optional taxonomy metadata, and how authored MDX becomes a navigation tree.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/writing/components","url":"http://localhost:3000/docs/writing/components","name":"Components","description":"MDX components the pipeline knows how to flatten into agent-readable markdown.","dateModified":"2026-06-29T18:28:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/search/add-search","url":"http://localhost:3000/docs/search/add-search","name":"Add search","description":"Generate a static docs search index, query it at runtime, and wire a search UI with the React, Vue, or Svelte hooks.","dateModified":"2026-06-04T16:15:13.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/search/ai-answers","url":"http://localhost:3000/docs/search/ai-answers","name":"Stream AI answers","description":"Source-grounded answer streaming over the static index — Vercel AI SDK, TanStack AI, or Cloudflare Workers AI — behind a hardened endpoint.","dateModified":"2026-06-29T16:57:13.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/search/agent-tools","url":"http://localhost:3000/docs/search/agent-tools","name":"Agent search tools","description":"Expose docs as a read-only virtual filesystem so an agent can explore with ls, cat, find, grep, and rg instead of receiving pre-selected chunks.","dateModified":"2026-06-29T16:57:13.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/package-docs/bundle","url":"http://localhost:3000/docs/package-docs/bundle","name":"Bundle docs into a package","description":"Ship agent-readable docs inside an npm tarball — AGENTS.md at the package root plus per-topic .md files.","dateModified":"2026-06-29T16:57:13.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/integrations/framework-matrix","url":"http://localhost:3000/docs/integrations/framework-matrix","name":"Framework integration matrix","description":"Use Leadtype with native-feeling recipes for Next, TanStack Start, Nuxt, Astro, SvelteKit, and Fumadocs.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/integrations/integrate-with-fumadocs","url":"http://localhost:3000/docs/integrations/integrate-with-fumadocs","name":"Integrate with Fumadocs","description":"Wire leadtype's content layer into a fumadocs app for nav, search, and includes.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/cli","url":"http://localhost:3000/docs/reference/cli","name":"CLI","description":"leadtype init, generate, sync, lint, mcp, and score — flags, exit codes, and JSON output.","dateModified":"2026-06-29T18:28:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/source","url":"http://localhost:3000/docs/reference/source","name":"createDocsSource","description":"Framework-neutral docs source primitive — navigation, page loader, search index, and include resolver.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/llm","url":"http://localhost:3000/docs/reference/llm","name":"LLM files","description":"Generate llms.txt for hosted websites and AGENTS.md for npm-bundled offline reading.","dateModified":"2026-06-29T15:47:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/convert","url":"http://localhost:3000/docs/reference/convert","name":"Convert","description":"MDX-to-markdown conversion APIs from leadtype/convert.","dateModified":"2026-06-29T18:28:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/lint","url":"http://localhost:3000/docs/reference/lint","name":"Lint rules","description":"Schema, link, and navigation checks. CLI and library API.","dateModified":"2026-06-29T18:28:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/frontmatter-transformers","url":"http://localhost:3000/docs/reference/frontmatter-transformers","name":"Frontmatter transformers","description":"Define typed custom frontmatter and lifecycle hooks for Leadtype pipeline data.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/mdx","url":"http://localhost:3000/docs/reference/mdx","name":"leadtype/mdx","description":"Tag type contracts and the build-time source preset for consumers rendering MDX themselves.","dateModified":"2026-06-29T15:47:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/markdown","url":"http://localhost:3000/docs/reference/markdown","name":"Markdown transforms","description":"The default transform stack that flattens MDX components into markdown.","dateModified":"2026-06-29T19:15:26.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/openapi","url":"http://localhost:3000/docs/reference/openapi","name":"OpenAPI","description":"Generate native MDX API reference pages from OpenAPI 3.x specs.","dateModified":"2026-07-02T11:48:33.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/search","url":"http://localhost:3000/docs/reference/search","name":"Search","description":"API surface for leadtype/search: index generation, runtime query, framework hooks, answer streaming, bash tools, and endpoint guards.","dateModified":"2026-06-04T16:15:13.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/i18n","url":"http://localhost:3000/docs/reference/i18n","name":"i18n","description":"Localization config, locale-aware URL helpers, alternate-locale links, and the per-locale artifact manifest from leadtype/i18n.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/troubleshooting","url":"http://localhost:3000/docs/reference/troubleshooting","name":"Troubleshooting","description":"Common Leadtype errors — missing manifests, unknown groups, broken includes, content negotiation, and the base-url audit mismatch — with fixes.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/changelog/0-4","url":"http://localhost:3000/changelog/0-4","name":"Leadtype 0.4","description":"Release notes in progress for the next Leadtype minor release.","dateModified":"2026-07-02T10:14:54.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/changelog/0-3","url":"http://localhost:3000/changelog/0-3","name":"Leadtype 0.3","description":"Release notes for Leadtype 0.3, focused on browser-side WebMCP docs tools, RSS and Atom feed generation, and URL-prefixed docs content.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/changelog/0-2","url":"http://localhost:3000/changelog/0-2","name":"Leadtype 0.2","description":"Release notes for Leadtype 0.2, released June 3, 2026.","dateModified":"2026-06-07T09:11:55.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/rest-api/operations/search-docs","url":"http://localhost:3000/docs/rest-api/operations/search-docs","name":"Search docs","description":"Search a generated Leadtype docs index and return matching docs chunks.","dateModified":"2026-07-02T11:51:12.267Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs","url":"http://localhost:3000/docs","name":"Leadtype","description":"Build agent-ready docs from MDX: rendered pages, llms.txt, markdown mirrors, search output, and package-bundled AGENTS.md from the same source.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/quickstart","url":"http://localhost:3000/docs/quickstart","name":"Quickstart","description":"Build an agent-ready docs site from one MDX page: render it, generate llms.txt and markdown mirrors, then verify the output.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
+{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/how-it-works","url":"http://localhost:3000/docs/how-it-works","name":"How it works","description":"The mental model: one MDX source, a markdown transform pipeline, two output modes, three audiences.","dateModified":"2026-06-29T18:28:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
diff --git a/apps/fumadocs-example/public/mcp.json b/apps/fumadocs-example/public/mcp.json
new file mode 100644
index 00000000..b5450870
--- /dev/null
+++ b/apps/fumadocs-example/public/mcp.json
@@ -0,0 +1,44 @@
+{
+ "$schema": "https://static.modelcontextprotocol.io/schemas/mcp-server-card/v1.json",
+ "version": "1.0",
+ "protocolVersion": "2025-06-18",
+ "name": "leadtype-docs",
+ "description": "A docs pipeline that turns one MDX source into a website, agent-readable artifacts, and a search index.",
+ "serverUrl": "http://localhost:3000/mcp",
+ "tools": [
+ {
+ "name": "search-docs",
+ "title": "Search documentation",
+ "description": "Search the documentation and return ranked results ({ title, urlPath, snippet }). Use get-page to read a full result.",
+ "annotations": {
+ "idempotentHint": true,
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "get-page",
+ "title": "Get a documentation page",
+ "description": "Return the full Markdown of one documentation page by its urlPath (e.g. the urlPath from a search-docs result).",
+ "annotations": {
+ "idempotentHint": true,
+ "readOnlyHint": true
+ }
+ }
+ ],
+ "serverInfo": {
+ "name": "leadtype-docs",
+ "version": "1.0.0",
+ "description": "A docs pipeline that turns one MDX source into a website, agent-readable artifacts, and a search index.",
+ "instructions": "Search and read the documentation for A docs pipeline that turns one MDX source into a website, agent-readable artifacts, and a search index."
+ },
+ "transport": {
+ "type": "streamable-http",
+ "endpoint": "http://localhost:3000/mcp"
+ },
+ "capabilities": {
+ "tools": {}
+ },
+ "authentication": {
+ "required": false
+ }
+}
diff --git a/apps/fumadocs-example/public/robots.txt b/apps/fumadocs-example/public/robots.txt
new file mode 100644
index 00000000..59f5fefe
--- /dev/null
+++ b/apps/fumadocs-example/public/robots.txt
@@ -0,0 +1,211 @@
+User-agent: *
+Content-Signal: ai-train=no, search=yes, ai-input=yes
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: OAI-SearchBot
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: ChatGPT-User
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: PerplexityBot
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: Perplexity-User
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: ClaudeBot
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: Claude-SearchBot
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: Claude-User
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: Claude-Web
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: Gemini-Deep-Research
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: DeepSeekBot
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: Meta-ExternalFetcher
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: AmazonBot
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: Amazonbot
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: Bingbot
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: MistralBot
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: AppleBot
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: YouBot
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: GPTBot
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: Google-Extended
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: CCBot
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: ByteSpider
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: Bytespider
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: anthropic-ai
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: MetaExternalAgent
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+User-agent: Applebot-Extended
+Allow: /
+Allow: /docs/
+Allow: /llms.txt
+Allow: /docs/llms.txt
+Allow: /sitemap.xml
+Allow: /sitemap.md
+
+Sitemap: http://localhost:3000/sitemap.xml
+Schemamap: http://localhost:3000/schema-map.xml
diff --git a/apps/fumadocs-example/public/schema-map.xml b/apps/fumadocs-example/public/schema-map.xml
new file mode 100644
index 00000000..74254644
--- /dev/null
+++ b/apps/fumadocs-example/public/schema-map.xml
@@ -0,0 +1,8 @@
+
+
+
+ http://localhost:3000/feeds/schema.jsonl
+ application/jsonl
+ https://schema.org/TechArticle
+
+
diff --git a/apps/fumadocs-example/public/sitemap.md b/apps/fumadocs-example/public/sitemap.md
new file mode 100644
index 00000000..27278727
--- /dev/null
+++ b/apps/fumadocs-example/public/sitemap.md
@@ -0,0 +1,98 @@
+# Sitemap
+
+Structured documentation sitemap for Leadtype.
+
+## Concepts
+
+- [Methodology](/docs/concepts/methodology): Where leadtype fits alongside custom docs apps and frameworks like Fumadocs and Starlight — the portable content and agent-readability layer underneath the host you choose.
+- [Architecture](/docs/concepts/architecture): The core / adapter boundary — what ships where, and the rules adapters must follow.
+- [Evals](/docs/concepts/evals): How Leadtype measures whether bundling agent docs actually helps coding agents — and how the llms.txt defaults were chosen.
+
+## Docs Pipeline
+
+### Sources
+
+- [Configure docs sources](/docs/pipeline/configure-sources): Choose where Leadtype reads MDX from: one local docs folder, multiple mounted folders, or remote git collections pinned to a branch, tag, or commit.
+- [Collections reference](/docs/pipeline/collections): Detailed defineCollection behavior for multi-source docs: local folders, git repos, filters, schemas, and per-collection navigation.
+- [Sync docs across repositories](/docs/pipeline/sync-docs-across-repos): Keep a separate docs UI repository pinned to a reviewed package-docs source revision.
+
+### Build
+
+- [Build an agent-ready docs site](/docs/pipeline/build-a-docs-site): Pick the right Leadtype integration shape for a hosted docs site with rendered pages, markdown mirrors, llms.txt, search, and agent metadata.
+- [Use the source primitive](/docs/pipeline/use-the-source-primitive): Wire createDocsSource into Next, TanStack Start, Nuxt, Astro, SvelteKit, or any MDX-aware bundler. Same primitive, multiple host shapes.
+- [Agent setup prompts](/docs/pipeline/agent-setup-prompts): Copyable prompts that let a coding agent wire Leadtype into your app — local docs, external/multi-repo docs, or a package bundle — adapting to your real layout.
+
+### Generate & operate
+
+- [Generate static artifacts](/docs/pipeline/generate-static-artifacts): Run leadtype generate from your build pipeline to write llms.txt, markdown mirrors, search index, sitemap, and agent-readability files to disk.
+- [Generate RSS and Atom feeds](/docs/pipeline/generate-rss-atom-feeds): Configure Leadtype to emit RSS and Atom feeds for changelogs, blogs, release notes, or any URL-prefixed generated docs content.
+- [Deploy generated artifacts](/docs/pipeline/deploy-generated-artifacts): Serve Leadtype output on common framework and hosting combinations.
+- [Validate in CI](/docs/pipeline/validate-in-ci): Run leadtype lint in CI so frontmatter, navigation, and link issues fail PRs before publish.
+- [Localize docs](/docs/pipeline/localize-docs): Author multi-locale MDX, generate per-locale llms.txt and markdown mirrors, and serve locale-prefixed docs with alternate-locale links.
+
+## AEO & Agent Readability
+
+- [AEO & Agent Readability overview](/docs/aeo/overview): Every agent-facing artifact leadtype emits, how they map to the agent-readability spec and AEO scoring rubrics, and how to audit a site.
+- [Optimize docs for agents](/docs/aeo/optimize-docs-for-agents): Generate llms.txt, markdown mirrors, JSON-LD inputs, sitemaps, robots.txt, and agent-readability.json from one CLI run.
+- [Generate artifacts without a docs tree](/docs/aeo/generate-artifacts-without-docs): Emit llms.txt, markdown mirrors, sitemaps, robots.txt, and the agent-readability manifest from an in-memory page list — no .mdx source files required.
+- [Serve agent responses](/docs/aeo/serve-agent-responses): Wire markdown responses, JSON-LD, sitemap, and robots into your framework using the generated agent-readability.json manifest.
+- [Agent skills](/docs/reference/skills): Emit a discoverable SKILL.md surface (agentskills.io) from docs.config.ts — an auto docs-skill plus any you declare — to /.well-known/agent-skills and the package bundle.
+- [MCP server](/docs/reference/mcp): Serve docs to MCP clients over stdio or Streamable HTTP — the gate for when it's worth it, the optional-peer-dep and stateless-HTTP gotchas, and how edge/bundled hosts skip the disk path.
+- [WebMCP](/docs/reference/webmcp): Register generated docs as browser-side WebMCP tools with document.modelContext / navigator.modelContext — separate from the server MCP endpoint.
+- [NLWeb](/docs/reference/nlweb): Serve an NLWeb /ask endpoint over your generated docs and publish the schema feeds + robots.txt Schemamap directive that make the site conversational for agents.
+
+## Writing for Agents
+
+- [Write for agents & GEO](/docs/writing/write-for-agents): Authoring for agents and the answer engines that cite you, in two halves: what to write (the non-obvious, not restatement) and how to structure it (lead with the answer, question-form headings, self-contained sections).
+- [Frontmatter](/docs/writing/frontmatter): Required fields, optional taxonomy metadata, and how authored MDX becomes a navigation tree.
+- [Components](/docs/writing/components): MDX components the pipeline knows how to flatten into agent-readable markdown.
+
+## Search & AI Answers
+
+- [Add search](/docs/search/add-search): Generate a static docs search index, query it at runtime, and wire a search UI with the React, Vue, or Svelte hooks.
+- [Stream AI answers](/docs/search/ai-answers): Source-grounded answer streaming over the static index — Vercel AI SDK, TanStack AI, or Cloudflare Workers AI — behind a hardened endpoint.
+- [Agent search tools](/docs/search/agent-tools): Expose docs as a read-only virtual filesystem so an agent can explore with ls, cat, find, grep, and rg instead of receiving pre-selected chunks.
+
+## Package Docs
+
+- [Bundle docs into a package](/docs/package-docs/bundle): Ship agent-readable docs inside an npm tarball — AGENTS.md at the package root plus per-topic .md files.
+
+## Integrations
+
+- [Framework integration matrix](/docs/integrations/framework-matrix): Use Leadtype with native-feeling recipes for Next, TanStack Start, Nuxt, Astro, SvelteKit, and Fumadocs.
+- [Integrate with Fumadocs](/docs/integrations/integrate-with-fumadocs): Wire leadtype's content layer into a fumadocs app for nav, search, and includes.
+
+## Reference
+
+- [CLI](/docs/reference/cli): leadtype init, generate, sync, lint, mcp, and score — flags, exit codes, and JSON output.
+- [createDocsSource](/docs/reference/source): Framework-neutral docs source primitive — navigation, page loader, search index, and include resolver.
+- [LLM files](/docs/reference/llm): Generate llms.txt for hosted websites and AGENTS.md for npm-bundled offline reading.
+- [Convert](/docs/reference/convert): MDX-to-markdown conversion APIs from leadtype/convert.
+- [Lint rules](/docs/reference/lint): Schema, link, and navigation checks. CLI and library API.
+- [Frontmatter transformers](/docs/reference/frontmatter-transformers): Define typed custom frontmatter and lifecycle hooks for Leadtype pipeline data.
+- [leadtype/mdx](/docs/reference/mdx): Tag type contracts and the build-time source preset for consumers rendering MDX themselves.
+- [Markdown transforms](/docs/reference/markdown): The default transform stack that flattens MDX components into markdown.
+- [OpenAPI](/docs/reference/openapi): Generate native MDX API reference pages from OpenAPI 3.x specs.
+- [Search](/docs/reference/search): API surface for leadtype/search: index generation, runtime query, framework hooks, answer streaming, bash tools, and endpoint guards.
+- [i18n](/docs/reference/i18n): Localization config, locale-aware URL helpers, alternate-locale links, and the per-locale artifact manifest from leadtype/i18n.
+- [Troubleshooting](/docs/reference/troubleshooting): Common Leadtype errors — missing manifests, unknown groups, broken includes, content negotiation, and the base-url audit mismatch — with fixes.
+
+## Changelog
+
+- [Leadtype 0.4](/changelog/0-4): Release notes in progress for the next Leadtype minor release.
+- [Leadtype 0.3](/changelog/0-3): Release notes for Leadtype 0.3, focused on browser-side WebMCP docs tools, RSS and Atom feed generation, and URL-prefixed docs content.
+- [Leadtype 0.2](/changelog/0-2): Release notes for Leadtype 0.2, released June 3, 2026.
+
+## Leadtype REST API
+
+Generated from docs/openapi/leadtype-api.yaml to dogfood native API reference pages.
+
+### Operations
+
+- [Search docs](/docs/rest-api/operations/search-docs): Search a generated Leadtype docs index and return matching docs chunks.
+
+## Other
+
+- [Leadtype](/docs): Build agent-ready docs from MDX: rendered pages, llms.txt, markdown mirrors, search output, and package-bundled AGENTS.md from the same source.
+- [Quickstart](/docs/quickstart): Build an agent-ready docs site from one MDX page: render it, generate llms.txt and markdown mirrors, then verify the output.
+- [How it works](/docs/how-it-works): The mental model: one MDX source, a markdown transform pipeline, two output modes, three audiences.
diff --git a/apps/fumadocs-example/public/sitemap.xml b/apps/fumadocs-example/public/sitemap.xml
new file mode 100644
index 00000000..002471ee
--- /dev/null
+++ b/apps/fumadocs-example/public/sitemap.xml
@@ -0,0 +1,203 @@
+
+
+
+ http://localhost:3000/docs/concepts/methodology
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/concepts/architecture
+ 2026-06-29T18:28:10.000Z
+
+
+ http://localhost:3000/docs/concepts/evals
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/pipeline/configure-sources
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/pipeline/collections
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/pipeline/sync-docs-across-repos
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/pipeline/build-a-docs-site
+ 2026-06-13T02:08:36.000Z
+
+
+ http://localhost:3000/docs/pipeline/use-the-source-primitive
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/pipeline/agent-setup-prompts
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/pipeline/generate-static-artifacts
+ 2026-07-02T10:14:54.000Z
+
+
+ http://localhost:3000/docs/pipeline/generate-rss-atom-feeds
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/pipeline/deploy-generated-artifacts
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/pipeline/validate-in-ci
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/pipeline/localize-docs
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/aeo/overview
+ 2026-06-13T02:08:36.000Z
+
+
+ http://localhost:3000/docs/aeo/optimize-docs-for-agents
+ 2026-06-13T02:08:36.000Z
+
+
+ http://localhost:3000/docs/aeo/generate-artifacts-without-docs
+ 2026-06-13T02:08:36.000Z
+
+
+ http://localhost:3000/docs/aeo/serve-agent-responses
+ 2026-06-13T02:08:36.000Z
+
+
+ http://localhost:3000/docs/reference/skills
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/reference/mcp
+ 2026-06-12T22:00:22.000Z
+
+
+ http://localhost:3000/docs/reference/webmcp
+ 2026-06-07T19:40:27.000Z
+
+
+ http://localhost:3000/docs/reference/nlweb
+ 2026-06-29T16:57:13.000Z
+
+
+ http://localhost:3000/docs/writing/write-for-agents
+ 2026-06-29T15:47:10.000Z
+
+
+ http://localhost:3000/docs/writing/frontmatter
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/writing/components
+ 2026-06-29T18:28:10.000Z
+
+
+ http://localhost:3000/docs/search/add-search
+ 2026-06-04T16:15:13.000Z
+
+
+ http://localhost:3000/docs/search/ai-answers
+ 2026-06-29T16:57:13.000Z
+
+
+ http://localhost:3000/docs/search/agent-tools
+ 2026-06-29T16:57:13.000Z
+
+
+ http://localhost:3000/docs/package-docs/bundle
+ 2026-06-29T16:57:13.000Z
+
+
+ http://localhost:3000/docs/integrations/framework-matrix
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/integrations/integrate-with-fumadocs
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/reference/cli
+ 2026-06-29T18:28:10.000Z
+
+
+ http://localhost:3000/docs/reference/source
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/reference/llm
+ 2026-06-29T15:47:10.000Z
+
+
+ http://localhost:3000/docs/reference/convert
+ 2026-06-29T18:28:10.000Z
+
+
+ http://localhost:3000/docs/reference/lint
+ 2026-06-29T18:28:10.000Z
+
+
+ http://localhost:3000/docs/reference/frontmatter-transformers
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/reference/mdx
+ 2026-06-29T15:47:10.000Z
+
+
+ http://localhost:3000/docs/reference/markdown
+ 2026-06-29T19:15:26.000Z
+
+
+ http://localhost:3000/docs/reference/openapi
+ 2026-07-02T11:48:33.000Z
+
+
+ http://localhost:3000/docs/reference/search
+ 2026-06-04T16:15:13.000Z
+
+
+ http://localhost:3000/docs/reference/i18n
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/reference/troubleshooting
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/changelog/0-4
+ 2026-07-02T10:14:54.000Z
+
+
+ http://localhost:3000/changelog/0-3
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/changelog/0-2
+ 2026-06-07T09:11:55.000Z
+
+
+ http://localhost:3000/docs/rest-api/operations/search-docs
+ 2026-07-02T11:51:12.267Z
+
+
+ http://localhost:3000/docs
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/quickstart
+ 2026-06-11T22:09:01.000Z
+
+
+ http://localhost:3000/docs/how-it-works
+ 2026-06-29T18:28:10.000Z
+
+
diff --git a/packages/leadtype/src/markdown/plugins/openapi.ts b/packages/leadtype/src/markdown/plugins/openapi.ts
index c164b798..191f4a33 100644
--- a/packages/leadtype/src/markdown/plugins/openapi.ts
+++ b/packages/leadtype/src/markdown/plugins/openapi.ts
@@ -19,6 +19,7 @@ import {
createListItem,
createParagraph,
createTable,
+ createText,
createUnorderedList,
getAttributeValue,
type MdxNode,
@@ -200,6 +201,14 @@ function renderResponse(response: OpenApiResponse): RootContent[] {
return nodes;
}
+function detailParagraph(label: string, value: string): RootContent {
+ // Inline code keeps URLs and IDs verbatim (no markdown escaping artifacts).
+ return {
+ children: [createText(`${label}: `), createInlineCode(value)],
+ type: "paragraph",
+ };
+}
+
export function apiEndpointToMarkdown(node: MdxNode): RootContent[] {
const method = (getAttributeValue(node, "method") ?? "").toUpperCase();
const apiPath = getAttributeValue(node, "path") ?? "";
@@ -209,13 +218,14 @@ export function apiEndpointToMarkdown(node: MdxNode): RootContent[] {
const nodes: RootContent[] = [
createCodeBlock(`${method} ${apiPath}`, "http"),
];
- const details = [
- serverUrl ? `Server: ${serverUrl}` : "",
- operationId ? `Operation ID: ${operationId}` : "",
- deprecated ? "Deprecated: true" : "",
- ].filter(Boolean);
- if (details.length > 0) {
- nodes.push(createParagraph(details.join("\n")));
+ if (serverUrl) {
+ nodes.push(detailParagraph("Server", serverUrl));
+ }
+ if (operationId) {
+ nodes.push(detailParagraph("Operation ID", operationId));
+ }
+ if (deprecated) {
+ nodes.push(createParagraph("Deprecated: true"));
}
return nodes;
}
diff --git a/packages/leadtype/src/openapi/index.ts b/packages/leadtype/src/openapi/index.ts
index 0e97fa06..1ded1df2 100644
--- a/packages/leadtype/src/openapi/index.ts
+++ b/packages/leadtype/src/openapi/index.ts
@@ -1362,16 +1362,23 @@ function yamlString(value: string): string {
return JSON.stringify(value);
}
+const MAX_SHORT_DESCRIPTION_LENGTH = 250;
+
/**
* Short, single-line description for frontmatter, nav, and search snippets.
- * Prefers the operation summary, then the first paragraph of the description.
+ * Prefers the first paragraph of the description (the summary usually mirrors
+ * the title), then the summary.
*/
function shortDescription(operation: OpenApiOperation): string {
- const source =
- operation.summary ??
- operation.description.split(BLANK_LINE_PATTERN)[0] ??
- operation.title;
- return source.replace(WHITESPACE_RUN_PATTERN, " ").trim();
+ const firstParagraph = operation.description
+ .split(BLANK_LINE_PATTERN)[0]
+ ?.trim();
+ const source = firstParagraph || (operation.summary ?? operation.title);
+ const collapsed = source.replace(WHITESPACE_RUN_PATTERN, " ").trim();
+ if (collapsed.length <= MAX_SHORT_DESCRIPTION_LENGTH) {
+ return collapsed;
+ }
+ return `${collapsed.slice(0, MAX_SHORT_DESCRIPTION_LENGTH).trimEnd()}…`;
}
function renderFrontmatter(
From 4653c739670455166ea0b264eb7c24de7093b445 Mon Sep 17 00:00:00 2001
From: Kaylee <65376239+KayleeWilliams@users.noreply.github.com>
Date: Thu, 2 Jul 2026 12:52:58 +0100
Subject: [PATCH 4/9] Untrack generated fumadocs example artifacts
The example's docs:generate output (sitemap, robots.txt, mcp.json, feeds,
schema-map) was not covered by .gitignore and slipped into the previous
commit. Remove it from tracking and ignore the artifact paths.
---
.gitignore | 6 +
.../public/feeds/schema.jsonl | 50 -----
apps/fumadocs-example/public/mcp.json | 44 ----
apps/fumadocs-example/public/robots.txt | 211 ------------------
apps/fumadocs-example/public/schema-map.xml | 8 -
apps/fumadocs-example/public/sitemap.md | 98 --------
apps/fumadocs-example/public/sitemap.xml | 203 -----------------
7 files changed, 6 insertions(+), 614 deletions(-)
delete mode 100644 apps/fumadocs-example/public/feeds/schema.jsonl
delete mode 100644 apps/fumadocs-example/public/mcp.json
delete mode 100644 apps/fumadocs-example/public/robots.txt
delete mode 100644 apps/fumadocs-example/public/schema-map.xml
delete mode 100644 apps/fumadocs-example/public/sitemap.md
delete mode 100644 apps/fumadocs-example/public/sitemap.xml
diff --git a/.gitignore b/.gitignore
index c2ab88b6..10e73090 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,6 +41,12 @@ apps/*/public/changelog/
apps/*/public/llms-full.txt
apps/*/public/llms.txt
apps/*/public/.well-known/
+apps/*/public/feeds/
+apps/*/public/mcp.json
+apps/*/public/robots.txt
+apps/*/public/schema-map.xml
+apps/*/public/sitemap.md
+apps/*/public/sitemap.xml
apps/sveltekit-example/static/docs/
apps/sveltekit-example/static/changelog/
apps/sveltekit-example/static/llms-full.txt
diff --git a/apps/fumadocs-example/public/feeds/schema.jsonl b/apps/fumadocs-example/public/feeds/schema.jsonl
deleted file mode 100644
index 9aa9d0ec..00000000
--- a/apps/fumadocs-example/public/feeds/schema.jsonl
+++ /dev/null
@@ -1,50 +0,0 @@
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/concepts/methodology","url":"http://localhost:3000/docs/concepts/methodology","name":"Methodology","description":"Where leadtype fits alongside custom docs apps and frameworks like Fumadocs and Starlight — the portable content and agent-readability layer underneath the host you choose.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/concepts/architecture","url":"http://localhost:3000/docs/concepts/architecture","name":"Architecture","description":"The core / adapter boundary — what ships where, and the rules adapters must follow.","dateModified":"2026-06-29T18:28:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/concepts/evals","url":"http://localhost:3000/docs/concepts/evals","name":"Evals","description":"How Leadtype measures whether bundling agent docs actually helps coding agents — and how the llms.txt defaults were chosen.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/configure-sources","url":"http://localhost:3000/docs/pipeline/configure-sources","name":"Configure docs sources","description":"Choose where Leadtype reads MDX from: one local docs folder, multiple mounted folders, or remote git collections pinned to a branch, tag, or commit.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/collections","url":"http://localhost:3000/docs/pipeline/collections","name":"Collections reference","description":"Detailed defineCollection behavior for multi-source docs: local folders, git repos, filters, schemas, and per-collection navigation.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/sync-docs-across-repos","url":"http://localhost:3000/docs/pipeline/sync-docs-across-repos","name":"Sync docs across repositories","description":"Keep a separate docs UI repository pinned to a reviewed package-docs source revision.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/build-a-docs-site","url":"http://localhost:3000/docs/pipeline/build-a-docs-site","name":"Build an agent-ready docs site","description":"Pick the right Leadtype integration shape for a hosted docs site with rendered pages, markdown mirrors, llms.txt, search, and agent metadata.","dateModified":"2026-06-13T02:08:36.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/use-the-source-primitive","url":"http://localhost:3000/docs/pipeline/use-the-source-primitive","name":"Use the source primitive","description":"Wire createDocsSource into Next, TanStack Start, Nuxt, Astro, SvelteKit, or any MDX-aware bundler. Same primitive, multiple host shapes.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/agent-setup-prompts","url":"http://localhost:3000/docs/pipeline/agent-setup-prompts","name":"Agent setup prompts","description":"Copyable prompts that let a coding agent wire Leadtype into your app — local docs, external/multi-repo docs, or a package bundle — adapting to your real layout.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/generate-static-artifacts","url":"http://localhost:3000/docs/pipeline/generate-static-artifacts","name":"Generate static artifacts","description":"Run leadtype generate from your build pipeline to write llms.txt, markdown mirrors, search index, sitemap, and agent-readability files to disk.","dateModified":"2026-07-02T10:14:54.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/generate-rss-atom-feeds","url":"http://localhost:3000/docs/pipeline/generate-rss-atom-feeds","name":"Generate RSS and Atom feeds","description":"Configure Leadtype to emit RSS and Atom feeds for changelogs, blogs, release notes, or any URL-prefixed generated docs content.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/deploy-generated-artifacts","url":"http://localhost:3000/docs/pipeline/deploy-generated-artifacts","name":"Deploy generated artifacts","description":"Serve Leadtype output on common framework and hosting combinations.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/validate-in-ci","url":"http://localhost:3000/docs/pipeline/validate-in-ci","name":"Validate in CI","description":"Run leadtype lint in CI so frontmatter, navigation, and link issues fail PRs before publish.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/pipeline/localize-docs","url":"http://localhost:3000/docs/pipeline/localize-docs","name":"Localize docs","description":"Author multi-locale MDX, generate per-locale llms.txt and markdown mirrors, and serve locale-prefixed docs with alternate-locale links.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/aeo/overview","url":"http://localhost:3000/docs/aeo/overview","name":"AEO & Agent Readability overview","description":"Every agent-facing artifact leadtype emits, how they map to the agent-readability spec and AEO scoring rubrics, and how to audit a site.","dateModified":"2026-06-13T02:08:36.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/aeo/optimize-docs-for-agents","url":"http://localhost:3000/docs/aeo/optimize-docs-for-agents","name":"Optimize docs for agents","description":"Generate llms.txt, markdown mirrors, JSON-LD inputs, sitemaps, robots.txt, and agent-readability.json from one CLI run.","dateModified":"2026-06-13T02:08:36.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/aeo/generate-artifacts-without-docs","url":"http://localhost:3000/docs/aeo/generate-artifacts-without-docs","name":"Generate artifacts without a docs tree","description":"Emit llms.txt, markdown mirrors, sitemaps, robots.txt, and the agent-readability manifest from an in-memory page list — no .mdx source files required.","dateModified":"2026-06-13T02:08:36.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/aeo/serve-agent-responses","url":"http://localhost:3000/docs/aeo/serve-agent-responses","name":"Serve agent responses","description":"Wire markdown responses, JSON-LD, sitemap, and robots into your framework using the generated agent-readability.json manifest.","dateModified":"2026-06-13T02:08:36.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/skills","url":"http://localhost:3000/docs/reference/skills","name":"Agent skills","description":"Emit a discoverable SKILL.md surface (agentskills.io) from docs.config.ts — an auto docs-skill plus any you declare — to /.well-known/agent-skills and the package bundle.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/mcp","url":"http://localhost:3000/docs/reference/mcp","name":"MCP server","description":"Serve docs to MCP clients over stdio or Streamable HTTP — the gate for when it's worth it, the optional-peer-dep and stateless-HTTP gotchas, and how edge/bundled hosts skip the disk path.","dateModified":"2026-06-12T22:00:22.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/webmcp","url":"http://localhost:3000/docs/reference/webmcp","name":"WebMCP","description":"Register generated docs as browser-side WebMCP tools with document.modelContext / navigator.modelContext — separate from the server MCP endpoint.","dateModified":"2026-06-07T19:40:27.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/nlweb","url":"http://localhost:3000/docs/reference/nlweb","name":"NLWeb","description":"Serve an NLWeb /ask endpoint over your generated docs and publish the schema feeds + robots.txt Schemamap directive that make the site conversational for agents.","dateModified":"2026-06-29T16:57:13.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/writing/write-for-agents","url":"http://localhost:3000/docs/writing/write-for-agents","name":"Write for agents & GEO","description":"Authoring for agents and the answer engines that cite you, in two halves: what to write (the non-obvious, not restatement) and how to structure it (lead with the answer, question-form headings, self-contained sections).","dateModified":"2026-06-29T15:47:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/writing/frontmatter","url":"http://localhost:3000/docs/writing/frontmatter","name":"Frontmatter","description":"Required fields, optional taxonomy metadata, and how authored MDX becomes a navigation tree.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/writing/components","url":"http://localhost:3000/docs/writing/components","name":"Components","description":"MDX components the pipeline knows how to flatten into agent-readable markdown.","dateModified":"2026-06-29T18:28:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/search/add-search","url":"http://localhost:3000/docs/search/add-search","name":"Add search","description":"Generate a static docs search index, query it at runtime, and wire a search UI with the React, Vue, or Svelte hooks.","dateModified":"2026-06-04T16:15:13.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/search/ai-answers","url":"http://localhost:3000/docs/search/ai-answers","name":"Stream AI answers","description":"Source-grounded answer streaming over the static index — Vercel AI SDK, TanStack AI, or Cloudflare Workers AI — behind a hardened endpoint.","dateModified":"2026-06-29T16:57:13.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/search/agent-tools","url":"http://localhost:3000/docs/search/agent-tools","name":"Agent search tools","description":"Expose docs as a read-only virtual filesystem so an agent can explore with ls, cat, find, grep, and rg instead of receiving pre-selected chunks.","dateModified":"2026-06-29T16:57:13.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/package-docs/bundle","url":"http://localhost:3000/docs/package-docs/bundle","name":"Bundle docs into a package","description":"Ship agent-readable docs inside an npm tarball — AGENTS.md at the package root plus per-topic .md files.","dateModified":"2026-06-29T16:57:13.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/integrations/framework-matrix","url":"http://localhost:3000/docs/integrations/framework-matrix","name":"Framework integration matrix","description":"Use Leadtype with native-feeling recipes for Next, TanStack Start, Nuxt, Astro, SvelteKit, and Fumadocs.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/integrations/integrate-with-fumadocs","url":"http://localhost:3000/docs/integrations/integrate-with-fumadocs","name":"Integrate with Fumadocs","description":"Wire leadtype's content layer into a fumadocs app for nav, search, and includes.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/cli","url":"http://localhost:3000/docs/reference/cli","name":"CLI","description":"leadtype init, generate, sync, lint, mcp, and score — flags, exit codes, and JSON output.","dateModified":"2026-06-29T18:28:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/source","url":"http://localhost:3000/docs/reference/source","name":"createDocsSource","description":"Framework-neutral docs source primitive — navigation, page loader, search index, and include resolver.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/llm","url":"http://localhost:3000/docs/reference/llm","name":"LLM files","description":"Generate llms.txt for hosted websites and AGENTS.md for npm-bundled offline reading.","dateModified":"2026-06-29T15:47:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/convert","url":"http://localhost:3000/docs/reference/convert","name":"Convert","description":"MDX-to-markdown conversion APIs from leadtype/convert.","dateModified":"2026-06-29T18:28:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/lint","url":"http://localhost:3000/docs/reference/lint","name":"Lint rules","description":"Schema, link, and navigation checks. CLI and library API.","dateModified":"2026-06-29T18:28:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/frontmatter-transformers","url":"http://localhost:3000/docs/reference/frontmatter-transformers","name":"Frontmatter transformers","description":"Define typed custom frontmatter and lifecycle hooks for Leadtype pipeline data.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/mdx","url":"http://localhost:3000/docs/reference/mdx","name":"leadtype/mdx","description":"Tag type contracts and the build-time source preset for consumers rendering MDX themselves.","dateModified":"2026-06-29T15:47:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/markdown","url":"http://localhost:3000/docs/reference/markdown","name":"Markdown transforms","description":"The default transform stack that flattens MDX components into markdown.","dateModified":"2026-06-29T19:15:26.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/openapi","url":"http://localhost:3000/docs/reference/openapi","name":"OpenAPI","description":"Generate native MDX API reference pages from OpenAPI 3.x specs.","dateModified":"2026-07-02T11:48:33.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/search","url":"http://localhost:3000/docs/reference/search","name":"Search","description":"API surface for leadtype/search: index generation, runtime query, framework hooks, answer streaming, bash tools, and endpoint guards.","dateModified":"2026-06-04T16:15:13.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/i18n","url":"http://localhost:3000/docs/reference/i18n","name":"i18n","description":"Localization config, locale-aware URL helpers, alternate-locale links, and the per-locale artifact manifest from leadtype/i18n.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/reference/troubleshooting","url":"http://localhost:3000/docs/reference/troubleshooting","name":"Troubleshooting","description":"Common Leadtype errors — missing manifests, unknown groups, broken includes, content negotiation, and the base-url audit mismatch — with fixes.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/changelog/0-4","url":"http://localhost:3000/changelog/0-4","name":"Leadtype 0.4","description":"Release notes in progress for the next Leadtype minor release.","dateModified":"2026-07-02T10:14:54.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/changelog/0-3","url":"http://localhost:3000/changelog/0-3","name":"Leadtype 0.3","description":"Release notes for Leadtype 0.3, focused on browser-side WebMCP docs tools, RSS and Atom feed generation, and URL-prefixed docs content.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/changelog/0-2","url":"http://localhost:3000/changelog/0-2","name":"Leadtype 0.2","description":"Release notes for Leadtype 0.2, released June 3, 2026.","dateModified":"2026-06-07T09:11:55.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/rest-api/operations/search-docs","url":"http://localhost:3000/docs/rest-api/operations/search-docs","name":"Search docs","description":"Search a generated Leadtype docs index and return matching docs chunks.","dateModified":"2026-07-02T11:51:12.267Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs","url":"http://localhost:3000/docs","name":"Leadtype","description":"Build agent-ready docs from MDX: rendered pages, llms.txt, markdown mirrors, search output, and package-bundled AGENTS.md from the same source.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/quickstart","url":"http://localhost:3000/docs/quickstart","name":"Quickstart","description":"Build an agent-ready docs site from one MDX page: render it, generate llms.txt and markdown mirrors, then verify the output.","dateModified":"2026-06-11T22:09:01.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
-{"@context":"https://schema.org","@type":"TechArticle","@id":"http://localhost:3000/docs/how-it-works","url":"http://localhost:3000/docs/how-it-works","name":"How it works","description":"The mental model: one MDX source, a markdown transform pipeline, two output modes, three audiences.","dateModified":"2026-06-29T18:28:10.000Z","isPartOf":{"@type":"WebSite","name":"Leadtype","url":"http://localhost:3000"}}
diff --git a/apps/fumadocs-example/public/mcp.json b/apps/fumadocs-example/public/mcp.json
deleted file mode 100644
index b5450870..00000000
--- a/apps/fumadocs-example/public/mcp.json
+++ /dev/null
@@ -1,44 +0,0 @@
-{
- "$schema": "https://static.modelcontextprotocol.io/schemas/mcp-server-card/v1.json",
- "version": "1.0",
- "protocolVersion": "2025-06-18",
- "name": "leadtype-docs",
- "description": "A docs pipeline that turns one MDX source into a website, agent-readable artifacts, and a search index.",
- "serverUrl": "http://localhost:3000/mcp",
- "tools": [
- {
- "name": "search-docs",
- "title": "Search documentation",
- "description": "Search the documentation and return ranked results ({ title, urlPath, snippet }). Use get-page to read a full result.",
- "annotations": {
- "idempotentHint": true,
- "readOnlyHint": true
- }
- },
- {
- "name": "get-page",
- "title": "Get a documentation page",
- "description": "Return the full Markdown of one documentation page by its urlPath (e.g. the urlPath from a search-docs result).",
- "annotations": {
- "idempotentHint": true,
- "readOnlyHint": true
- }
- }
- ],
- "serverInfo": {
- "name": "leadtype-docs",
- "version": "1.0.0",
- "description": "A docs pipeline that turns one MDX source into a website, agent-readable artifacts, and a search index.",
- "instructions": "Search and read the documentation for A docs pipeline that turns one MDX source into a website, agent-readable artifacts, and a search index."
- },
- "transport": {
- "type": "streamable-http",
- "endpoint": "http://localhost:3000/mcp"
- },
- "capabilities": {
- "tools": {}
- },
- "authentication": {
- "required": false
- }
-}
diff --git a/apps/fumadocs-example/public/robots.txt b/apps/fumadocs-example/public/robots.txt
deleted file mode 100644
index 59f5fefe..00000000
--- a/apps/fumadocs-example/public/robots.txt
+++ /dev/null
@@ -1,211 +0,0 @@
-User-agent: *
-Content-Signal: ai-train=no, search=yes, ai-input=yes
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: OAI-SearchBot
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: ChatGPT-User
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: PerplexityBot
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: Perplexity-User
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: ClaudeBot
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: Claude-SearchBot
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: Claude-User
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: Claude-Web
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: Gemini-Deep-Research
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: DeepSeekBot
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: Meta-ExternalFetcher
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: AmazonBot
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: Amazonbot
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: Bingbot
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: MistralBot
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: AppleBot
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: YouBot
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: GPTBot
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: Google-Extended
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: CCBot
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: ByteSpider
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: Bytespider
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: anthropic-ai
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: MetaExternalAgent
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-User-agent: Applebot-Extended
-Allow: /
-Allow: /docs/
-Allow: /llms.txt
-Allow: /docs/llms.txt
-Allow: /sitemap.xml
-Allow: /sitemap.md
-
-Sitemap: http://localhost:3000/sitemap.xml
-Schemamap: http://localhost:3000/schema-map.xml
diff --git a/apps/fumadocs-example/public/schema-map.xml b/apps/fumadocs-example/public/schema-map.xml
deleted file mode 100644
index 74254644..00000000
--- a/apps/fumadocs-example/public/schema-map.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
- http://localhost:3000/feeds/schema.jsonl
- application/jsonl
- https://schema.org/TechArticle
-
-
diff --git a/apps/fumadocs-example/public/sitemap.md b/apps/fumadocs-example/public/sitemap.md
deleted file mode 100644
index 27278727..00000000
--- a/apps/fumadocs-example/public/sitemap.md
+++ /dev/null
@@ -1,98 +0,0 @@
-# Sitemap
-
-Structured documentation sitemap for Leadtype.
-
-## Concepts
-
-- [Methodology](/docs/concepts/methodology): Where leadtype fits alongside custom docs apps and frameworks like Fumadocs and Starlight — the portable content and agent-readability layer underneath the host you choose.
-- [Architecture](/docs/concepts/architecture): The core / adapter boundary — what ships where, and the rules adapters must follow.
-- [Evals](/docs/concepts/evals): How Leadtype measures whether bundling agent docs actually helps coding agents — and how the llms.txt defaults were chosen.
-
-## Docs Pipeline
-
-### Sources
-
-- [Configure docs sources](/docs/pipeline/configure-sources): Choose where Leadtype reads MDX from: one local docs folder, multiple mounted folders, or remote git collections pinned to a branch, tag, or commit.
-- [Collections reference](/docs/pipeline/collections): Detailed defineCollection behavior for multi-source docs: local folders, git repos, filters, schemas, and per-collection navigation.
-- [Sync docs across repositories](/docs/pipeline/sync-docs-across-repos): Keep a separate docs UI repository pinned to a reviewed package-docs source revision.
-
-### Build
-
-- [Build an agent-ready docs site](/docs/pipeline/build-a-docs-site): Pick the right Leadtype integration shape for a hosted docs site with rendered pages, markdown mirrors, llms.txt, search, and agent metadata.
-- [Use the source primitive](/docs/pipeline/use-the-source-primitive): Wire createDocsSource into Next, TanStack Start, Nuxt, Astro, SvelteKit, or any MDX-aware bundler. Same primitive, multiple host shapes.
-- [Agent setup prompts](/docs/pipeline/agent-setup-prompts): Copyable prompts that let a coding agent wire Leadtype into your app — local docs, external/multi-repo docs, or a package bundle — adapting to your real layout.
-
-### Generate & operate
-
-- [Generate static artifacts](/docs/pipeline/generate-static-artifacts): Run leadtype generate from your build pipeline to write llms.txt, markdown mirrors, search index, sitemap, and agent-readability files to disk.
-- [Generate RSS and Atom feeds](/docs/pipeline/generate-rss-atom-feeds): Configure Leadtype to emit RSS and Atom feeds for changelogs, blogs, release notes, or any URL-prefixed generated docs content.
-- [Deploy generated artifacts](/docs/pipeline/deploy-generated-artifacts): Serve Leadtype output on common framework and hosting combinations.
-- [Validate in CI](/docs/pipeline/validate-in-ci): Run leadtype lint in CI so frontmatter, navigation, and link issues fail PRs before publish.
-- [Localize docs](/docs/pipeline/localize-docs): Author multi-locale MDX, generate per-locale llms.txt and markdown mirrors, and serve locale-prefixed docs with alternate-locale links.
-
-## AEO & Agent Readability
-
-- [AEO & Agent Readability overview](/docs/aeo/overview): Every agent-facing artifact leadtype emits, how they map to the agent-readability spec and AEO scoring rubrics, and how to audit a site.
-- [Optimize docs for agents](/docs/aeo/optimize-docs-for-agents): Generate llms.txt, markdown mirrors, JSON-LD inputs, sitemaps, robots.txt, and agent-readability.json from one CLI run.
-- [Generate artifacts without a docs tree](/docs/aeo/generate-artifacts-without-docs): Emit llms.txt, markdown mirrors, sitemaps, robots.txt, and the agent-readability manifest from an in-memory page list — no .mdx source files required.
-- [Serve agent responses](/docs/aeo/serve-agent-responses): Wire markdown responses, JSON-LD, sitemap, and robots into your framework using the generated agent-readability.json manifest.
-- [Agent skills](/docs/reference/skills): Emit a discoverable SKILL.md surface (agentskills.io) from docs.config.ts — an auto docs-skill plus any you declare — to /.well-known/agent-skills and the package bundle.
-- [MCP server](/docs/reference/mcp): Serve docs to MCP clients over stdio or Streamable HTTP — the gate for when it's worth it, the optional-peer-dep and stateless-HTTP gotchas, and how edge/bundled hosts skip the disk path.
-- [WebMCP](/docs/reference/webmcp): Register generated docs as browser-side WebMCP tools with document.modelContext / navigator.modelContext — separate from the server MCP endpoint.
-- [NLWeb](/docs/reference/nlweb): Serve an NLWeb /ask endpoint over your generated docs and publish the schema feeds + robots.txt Schemamap directive that make the site conversational for agents.
-
-## Writing for Agents
-
-- [Write for agents & GEO](/docs/writing/write-for-agents): Authoring for agents and the answer engines that cite you, in two halves: what to write (the non-obvious, not restatement) and how to structure it (lead with the answer, question-form headings, self-contained sections).
-- [Frontmatter](/docs/writing/frontmatter): Required fields, optional taxonomy metadata, and how authored MDX becomes a navigation tree.
-- [Components](/docs/writing/components): MDX components the pipeline knows how to flatten into agent-readable markdown.
-
-## Search & AI Answers
-
-- [Add search](/docs/search/add-search): Generate a static docs search index, query it at runtime, and wire a search UI with the React, Vue, or Svelte hooks.
-- [Stream AI answers](/docs/search/ai-answers): Source-grounded answer streaming over the static index — Vercel AI SDK, TanStack AI, or Cloudflare Workers AI — behind a hardened endpoint.
-- [Agent search tools](/docs/search/agent-tools): Expose docs as a read-only virtual filesystem so an agent can explore with ls, cat, find, grep, and rg instead of receiving pre-selected chunks.
-
-## Package Docs
-
-- [Bundle docs into a package](/docs/package-docs/bundle): Ship agent-readable docs inside an npm tarball — AGENTS.md at the package root plus per-topic .md files.
-
-## Integrations
-
-- [Framework integration matrix](/docs/integrations/framework-matrix): Use Leadtype with native-feeling recipes for Next, TanStack Start, Nuxt, Astro, SvelteKit, and Fumadocs.
-- [Integrate with Fumadocs](/docs/integrations/integrate-with-fumadocs): Wire leadtype's content layer into a fumadocs app for nav, search, and includes.
-
-## Reference
-
-- [CLI](/docs/reference/cli): leadtype init, generate, sync, lint, mcp, and score — flags, exit codes, and JSON output.
-- [createDocsSource](/docs/reference/source): Framework-neutral docs source primitive — navigation, page loader, search index, and include resolver.
-- [LLM files](/docs/reference/llm): Generate llms.txt for hosted websites and AGENTS.md for npm-bundled offline reading.
-- [Convert](/docs/reference/convert): MDX-to-markdown conversion APIs from leadtype/convert.
-- [Lint rules](/docs/reference/lint): Schema, link, and navigation checks. CLI and library API.
-- [Frontmatter transformers](/docs/reference/frontmatter-transformers): Define typed custom frontmatter and lifecycle hooks for Leadtype pipeline data.
-- [leadtype/mdx](/docs/reference/mdx): Tag type contracts and the build-time source preset for consumers rendering MDX themselves.
-- [Markdown transforms](/docs/reference/markdown): The default transform stack that flattens MDX components into markdown.
-- [OpenAPI](/docs/reference/openapi): Generate native MDX API reference pages from OpenAPI 3.x specs.
-- [Search](/docs/reference/search): API surface for leadtype/search: index generation, runtime query, framework hooks, answer streaming, bash tools, and endpoint guards.
-- [i18n](/docs/reference/i18n): Localization config, locale-aware URL helpers, alternate-locale links, and the per-locale artifact manifest from leadtype/i18n.
-- [Troubleshooting](/docs/reference/troubleshooting): Common Leadtype errors — missing manifests, unknown groups, broken includes, content negotiation, and the base-url audit mismatch — with fixes.
-
-## Changelog
-
-- [Leadtype 0.4](/changelog/0-4): Release notes in progress for the next Leadtype minor release.
-- [Leadtype 0.3](/changelog/0-3): Release notes for Leadtype 0.3, focused on browser-side WebMCP docs tools, RSS and Atom feed generation, and URL-prefixed docs content.
-- [Leadtype 0.2](/changelog/0-2): Release notes for Leadtype 0.2, released June 3, 2026.
-
-## Leadtype REST API
-
-Generated from docs/openapi/leadtype-api.yaml to dogfood native API reference pages.
-
-### Operations
-
-- [Search docs](/docs/rest-api/operations/search-docs): Search a generated Leadtype docs index and return matching docs chunks.
-
-## Other
-
-- [Leadtype](/docs): Build agent-ready docs from MDX: rendered pages, llms.txt, markdown mirrors, search output, and package-bundled AGENTS.md from the same source.
-- [Quickstart](/docs/quickstart): Build an agent-ready docs site from one MDX page: render it, generate llms.txt and markdown mirrors, then verify the output.
-- [How it works](/docs/how-it-works): The mental model: one MDX source, a markdown transform pipeline, two output modes, three audiences.
diff --git a/apps/fumadocs-example/public/sitemap.xml b/apps/fumadocs-example/public/sitemap.xml
deleted file mode 100644
index 002471ee..00000000
--- a/apps/fumadocs-example/public/sitemap.xml
+++ /dev/null
@@ -1,203 +0,0 @@
-
-
-
- http://localhost:3000/docs/concepts/methodology
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/concepts/architecture
- 2026-06-29T18:28:10.000Z
-
-
- http://localhost:3000/docs/concepts/evals
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/pipeline/configure-sources
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/pipeline/collections
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/pipeline/sync-docs-across-repos
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/pipeline/build-a-docs-site
- 2026-06-13T02:08:36.000Z
-
-
- http://localhost:3000/docs/pipeline/use-the-source-primitive
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/pipeline/agent-setup-prompts
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/pipeline/generate-static-artifacts
- 2026-07-02T10:14:54.000Z
-
-
- http://localhost:3000/docs/pipeline/generate-rss-atom-feeds
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/pipeline/deploy-generated-artifacts
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/pipeline/validate-in-ci
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/pipeline/localize-docs
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/aeo/overview
- 2026-06-13T02:08:36.000Z
-
-
- http://localhost:3000/docs/aeo/optimize-docs-for-agents
- 2026-06-13T02:08:36.000Z
-
-
- http://localhost:3000/docs/aeo/generate-artifacts-without-docs
- 2026-06-13T02:08:36.000Z
-
-
- http://localhost:3000/docs/aeo/serve-agent-responses
- 2026-06-13T02:08:36.000Z
-
-
- http://localhost:3000/docs/reference/skills
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/reference/mcp
- 2026-06-12T22:00:22.000Z
-
-
- http://localhost:3000/docs/reference/webmcp
- 2026-06-07T19:40:27.000Z
-
-
- http://localhost:3000/docs/reference/nlweb
- 2026-06-29T16:57:13.000Z
-
-
- http://localhost:3000/docs/writing/write-for-agents
- 2026-06-29T15:47:10.000Z
-
-
- http://localhost:3000/docs/writing/frontmatter
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/writing/components
- 2026-06-29T18:28:10.000Z
-
-
- http://localhost:3000/docs/search/add-search
- 2026-06-04T16:15:13.000Z
-
-
- http://localhost:3000/docs/search/ai-answers
- 2026-06-29T16:57:13.000Z
-
-
- http://localhost:3000/docs/search/agent-tools
- 2026-06-29T16:57:13.000Z
-
-
- http://localhost:3000/docs/package-docs/bundle
- 2026-06-29T16:57:13.000Z
-
-
- http://localhost:3000/docs/integrations/framework-matrix
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/integrations/integrate-with-fumadocs
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/reference/cli
- 2026-06-29T18:28:10.000Z
-
-
- http://localhost:3000/docs/reference/source
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/reference/llm
- 2026-06-29T15:47:10.000Z
-
-
- http://localhost:3000/docs/reference/convert
- 2026-06-29T18:28:10.000Z
-
-
- http://localhost:3000/docs/reference/lint
- 2026-06-29T18:28:10.000Z
-
-
- http://localhost:3000/docs/reference/frontmatter-transformers
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/reference/mdx
- 2026-06-29T15:47:10.000Z
-
-
- http://localhost:3000/docs/reference/markdown
- 2026-06-29T19:15:26.000Z
-
-
- http://localhost:3000/docs/reference/openapi
- 2026-07-02T11:48:33.000Z
-
-
- http://localhost:3000/docs/reference/search
- 2026-06-04T16:15:13.000Z
-
-
- http://localhost:3000/docs/reference/i18n
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/reference/troubleshooting
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/changelog/0-4
- 2026-07-02T10:14:54.000Z
-
-
- http://localhost:3000/changelog/0-3
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/changelog/0-2
- 2026-06-07T09:11:55.000Z
-
-
- http://localhost:3000/docs/rest-api/operations/search-docs
- 2026-07-02T11:51:12.267Z
-
-
- http://localhost:3000/docs
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/quickstart
- 2026-06-11T22:09:01.000Z
-
-
- http://localhost:3000/docs/how-it-works
- 2026-06-29T18:28:10.000Z
-
-
From b3d6cc1af8e4240e57f890ca2604336ce50dc15c Mon Sep 17 00:00:00 2001
From: Kaylee <65376239+KayleeWilliams@users.noreply.github.com>
Date: Thu, 2 Jul 2026 15:29:37 +0100
Subject: [PATCH 5/9] Render generated OpenAPI pages in the TanStack example
- Add Api* renderer components (data-attribute styling, tabbed code
samples via the app's Tabs) and register them in the MDX map
- Write generated API pages into src/generated/openapi-docs so Vite's
static import.meta.glob can compile them; merge a second glob in the
docs catch-all route and append manifest entries with matching globKeys
- Flatten the generated pages into public/docs mirrors (pipeline:convert)
so search and agent artifacts include them
- Stage OpenAPI docs in pipeline:llm so llms.txt, docs-nav.json, and the
agent-readability manifest carry the generated section; skills keep
resolving from the real repo root
- Skip the body description on generated pages when it matches the
frontmatter description (docs UIs already print it under the title)
---
apps/tanstack/scripts/docs-source-manifest.ts | 37 +-
apps/tanstack/scripts/llm-generate.ts | 30 +-
apps/tanstack/scripts/mdx-convert.ts | 22 ++
apps/tanstack/src/components/docs-mdx/api.tsx | 330 ++++++++++++++++++
.../src/components/docs-mdx/mdx-components.ts | 16 +
apps/tanstack/src/routes/docs/$.tsx | 15 +-
apps/tanstack/src/styles.css | 87 +++++
packages/leadtype/src/openapi/index.ts | 9 +-
packages/leadtype/src/openapi/openapi.test.ts | 19 +-
9 files changed, 553 insertions(+), 12 deletions(-)
create mode 100644 apps/tanstack/src/components/docs-mdx/api.tsx
diff --git a/apps/tanstack/scripts/docs-source-manifest.ts b/apps/tanstack/scripts/docs-source-manifest.ts
index 40674b51..9fbf7ba3 100644
--- a/apps/tanstack/scripts/docs-source-manifest.ts
+++ b/apps/tanstack/scripts/docs-source-manifest.ts
@@ -13,10 +13,11 @@
* integration shapes from `/docs/pipeline/build-a-docs-site`.
*/
-import { mkdir, writeFile } from "node:fs/promises";
+import { mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, join, sep as platformSep, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { createDocsSource } from "leadtype";
+import { normalizeOpenApiConfig, writeOpenApiPages } from "leadtype/openapi";
import docsConfig from "../../../docs/docs.config";
/** `import.meta.glob` keys are always POSIX even on Windows. */
@@ -30,6 +31,11 @@ const repoRoot = join(appRoot, "..", "..");
const contentDir = join(repoRoot, "docs");
const generatedDir = join(appRoot, "src", "generated");
const manifestPath = join(generatedDir, "docs-pages.json");
+const routesDocsDir = join(appRoot, "src", "routes", "docs");
+// Generated OpenAPI MDX lives inside the app (not the authored /docs tree) so
+// Vite's static `import.meta.glob` can compile it. See `pipeline:convert` for
+// the markdown-mirror counterpart.
+const openapiDocsDir = join(generatedDir, "openapi-docs");
const source = await createDocsSource({
contentDir,
@@ -53,10 +59,37 @@ const manifest = pages.map((page) => ({
// src/routes/docs/$.tsx with POSIX separators so the key matches the
// glob output on every platform (Windows otherwise emits backslashes).
globKey: toPosix(
- `${relative(join(appRoot, "src", "routes", "docs"), join(contentDir, page.relativePath))}${page.extension}`
+ `${relative(routesDocsDir, join(contentDir, page.relativePath))}${page.extension}`
),
}));
+// Regenerate OpenAPI reference pages into the app-local generated dir and
+// append their manifest entries. `writeOpenApiPages` is deterministic, so this
+// stays in sync with the pipeline:convert output for the same spec.
+await rm(openapiDocsDir, { force: true, recursive: true });
+if (docsConfig.openapi !== undefined) {
+ const generated = await writeOpenApiPages({
+ configs: normalizeOpenApiConfig(docsConfig.openapi, contentDir),
+ docsDir: openapiDocsDir,
+ });
+ const MDX_EXTENSION_PATTERN = /\.mdx$/;
+ for (const page of generated.pages) {
+ const relativePath = page.relativePath.replace(MDX_EXTENSION_PATTERN, "");
+ manifest.push({
+ slug: relativePath.split("/"),
+ urlPath: `/docs/${relativePath}`,
+ title: page.title,
+ description: page.description,
+ relativePath,
+ extension: ".mdx",
+ groups: [],
+ globKey: toPosix(
+ relative(routesDocsDir, join(openapiDocsDir, page.relativePath))
+ ),
+ });
+ }
+}
+
await mkdir(generatedDir, { recursive: true });
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
diff --git a/apps/tanstack/scripts/llm-generate.ts b/apps/tanstack/scripts/llm-generate.ts
index 90b1b3e9..4ed0cbe4 100644
--- a/apps/tanstack/scripts/llm-generate.ts
+++ b/apps/tanstack/scripts/llm-generate.ts
@@ -20,14 +20,28 @@ import {
resolveAgentInputs,
resolveDocsNavigation,
} from "leadtype/llm";
+import { stageOpenApiDocs } from "leadtype/openapi";
import docsConfig from "../../../docs/docs.config";
const scriptsRoot = dirname(fileURLToPath(import.meta.url));
const appRoot = join(scriptsRoot, "..");
const repoRoot = join(appRoot, "..", "..");
+// Stage generated OpenAPI pages next to the authored docs (temp copy) so the
+// llms.txt / nav / readability generators below see them like any other page.
+const staged =
+ docsConfig.openapi === undefined
+ ? undefined
+ : await stageOpenApiDocs({
+ contentDir: join(repoRoot, "docs"),
+ openapi: docsConfig.openapi,
+ });
// `generateLlmsTxt` joins `${srcDir}/docs/` and `${outDir}/docs/` internally,
// so we pass the parents (repo root for source, app root/public for output).
-const srcDir = repoRoot;
+const srcDir = staged ? dirname(staged.contentDir) : repoRoot;
+const docsNavigation = [
+ ...(docsConfig.navigation ?? []),
+ ...(staged?.nav ?? []),
+];
const outDir = join(appRoot, "public");
const generatedDir = join(appRoot, "src", "generated");
const LEADING_SLASHES_PATTERN = /^\/+/;
@@ -75,7 +89,7 @@ await generateLlmsTxt({
outDir,
baseUrl,
product: agentInputs.product,
- nav: docsConfig.navigation,
+ nav: docsNavigation,
mounts: docsConfig.mounts,
});
@@ -83,7 +97,7 @@ await generateLLMFullContextFiles({
outDir,
baseUrl,
product: { name: agentInputs.product.name },
- nav: docsConfig.navigation,
+ nav: docsNavigation,
mounts: docsConfig.mounts,
});
@@ -94,7 +108,7 @@ const agentReadability = await generateAgentReadabilityArtifacts({
name: agentInputs.product.name,
summary: agentInputs.product.summary,
},
- nav: docsConfig.navigation,
+ nav: docsNavigation,
mounts: docsConfig.mounts,
// Bake the agent-surface config into the manifest so runtime helpers
// (renderSiteJsonLd, robots) are config-driven from the one docs.config.ts.
@@ -108,7 +122,9 @@ const agentReadability = await generateAgentReadabilityArtifacts({
// points agents at /llms.txt and this app's MCP endpoint (agents.mcp.enabled).
await generateSkillArtifacts({
outDir,
- srcDir,
+ // Skills read authored files outside /docs (e.g. skills/*.md), so resolve
+ // them against the real repo root rather than the staged docs copy.
+ srcDir: repoRoot,
baseUrl,
product: {
name: agentInputs.product.name,
@@ -145,7 +161,7 @@ await generateFeedArtifacts({
const navigation = await resolveDocsNavigation({
srcDir,
baseUrl,
- nav: docsConfig.navigation,
+ nav: docsNavigation,
mounts: docsConfig.mounts,
});
@@ -178,4 +194,6 @@ await Promise.all(
].map((file) => rm(file, { force: true }))
);
+await staged?.cleanup();
+
process.stdout.write("LLM files + agent readability manifests generated\n");
diff --git a/apps/tanstack/scripts/mdx-convert.ts b/apps/tanstack/scripts/mdx-convert.ts
index 1d8606b0..fc007380 100644
--- a/apps/tanstack/scripts/mdx-convert.ts
+++ b/apps/tanstack/scripts/mdx-convert.ts
@@ -15,12 +15,15 @@ import {
includeMarkdown,
nativeMarkdownComponentsToMarkdown,
} from "leadtype/markdown";
+import { normalizeOpenApiConfig, writeOpenApiPages } from "leadtype/openapi";
+import docsConfig from "../../../docs/docs.config";
const scriptsRoot = dirname(fileURLToPath(import.meta.url));
const appRoot = join(scriptsRoot, "..");
const repoRoot = join(appRoot, "..", "..");
const srcDir = join(repoRoot, "docs");
const outDir = join(appRoot, "public", "docs");
+const openapiDocsDir = join(appRoot, "src", "generated", "openapi-docs");
const typeTableMarkdownTransform: NonNullable<
MdxToMarkdownOptions["markdownTransforms"]
>[number] = [
@@ -50,3 +53,22 @@ await convertAllMdx({
markdownTransforms,
enrichFrontmatterFromGit: true,
});
+
+// Generated OpenAPI reference pages: write the MDX into the app-local
+// generated dir (Vite renders it via import.meta.glob), then flatten the same
+// pages into the public markdown mirrors so agents and search see them too.
+// Authored docs keep git-enriched frontmatter above; generated pages have no
+// git history, so enrichment stays off here.
+if (docsConfig.openapi !== undefined) {
+ await rm(openapiDocsDir, { force: true, recursive: true });
+ await writeOpenApiPages({
+ configs: normalizeOpenApiConfig(docsConfig.openapi, srcDir),
+ docsDir: openapiDocsDir,
+ });
+ await convertAllMdx({
+ srcDir: openapiDocsDir,
+ outDir,
+ markdownTransforms,
+ enrichFrontmatterFromGit: false,
+ });
+}
diff --git a/apps/tanstack/src/components/docs-mdx/api.tsx b/apps/tanstack/src/components/docs-mdx/api.tsx
new file mode 100644
index 00000000..37a8fff7
--- /dev/null
+++ b/apps/tanstack/src/components/docs-mdx/api.tsx
@@ -0,0 +1,330 @@
+/**
+ * Native OpenAPI reference components for generated API pages.
+ *
+ * Prop contracts come from `leadtype/mdx` — the same shapes the generator
+ * serializes into `` / `` MDX. Markup
+ * follows this app's data-attribute convention (`data-leadtype-api-*`), with
+ * styling in `src/styles.css`.
+ */
+
+import type {
+ ApiAuthProps,
+ ApiCodeSamplesProps,
+ ApiEndpointProps,
+ ApiMediaType,
+ ApiParametersProps,
+ ApiRequestBodyProps,
+ ApiResponsesProps,
+ ApiSchemaProperty,
+ ApiTryItProps,
+} from "leadtype/mdx";
+import { Callout } from "./callout";
+import { Tab, Tabs } from "./tabs";
+
+const MAX_SCHEMA_DEPTH = 6;
+
+interface FlattenedSchemaRow {
+ description?: string;
+ name: string;
+ required: boolean;
+ type: string;
+}
+
+function formatSchemaType(
+ schema?: Pick
+): string {
+ if (!schema) {
+ return "unknown";
+ }
+ return schema.format ? `${schema.type} (${schema.format})` : schema.type;
+}
+
+// Flatten nested object/array-item properties into dotted rows
+// (`results[].title`) so deep schemas stay fully documented.
+function flattenSchemaRows(
+ properties: ApiSchemaProperty[],
+ prefix = "",
+ depth = 0
+): FlattenedSchemaRow[] {
+ if (depth > MAX_SCHEMA_DEPTH) {
+ return [];
+ }
+ const rows: FlattenedSchemaRow[] = [];
+ for (const property of properties) {
+ const name = `${prefix}${property.name}`;
+ rows.push({
+ description: property.description,
+ name,
+ required: property.required === true,
+ type: formatSchemaType(property),
+ });
+ if (property.properties) {
+ rows.push(
+ ...flattenSchemaRows(property.properties, `${name}.`, depth + 1)
+ );
+ }
+ if (property.items?.properties) {
+ rows.push(
+ ...flattenSchemaRows(property.items.properties, `${name}[].`, depth + 1)
+ );
+ }
+ }
+ return rows;
+}
+
+function SchemaTable({
+ properties = [],
+ nameHeading = "Property",
+}: {
+ properties?: ApiSchemaProperty[];
+ nameHeading?: string;
+}) {
+ const rows = flattenSchemaRows(properties);
+ if (rows.length === 0) {
+ return null;
+ }
+ return (
+
+
+
+
+ | {nameHeading} |
+ Type |
+ Required |
+ Description |
+
+
+
+ {rows.map((row) => (
+
+
+ {row.name}
+ |
+
+ {row.type}
+ |
+ {row.required ? "Required" : "Optional"} |
+ {row.description ?? "—"} |
+
+ ))}
+
+
+
+ );
+}
+
+function JsonExample({ value }: { value: unknown }) {
+ return (
+
+
+ {typeof value === "string" ? value : JSON.stringify(value, null, 2)}
+
+
+ );
+}
+
+function MediaTypeExamples({ media }: { media: ApiMediaType }) {
+ const namedExamples = Object.entries(media.examples ?? {});
+ if (namedExamples.length > 0) {
+ return namedExamples.map(([name, value]) => (
+
+
+ Example: {name}
+
+
+
+ ));
+ }
+ if (media.example === undefined) {
+ return null;
+ }
+ return ;
+}
+
+function MediaType({ media }: { media: ApiMediaType }) {
+ return (
+
+
+ Content type {media.mediaType}
+
+
+
+
+ );
+}
+
+export function ApiEndpoint({
+ method,
+ path,
+ operationId,
+ serverUrl,
+ deprecated,
+}: ApiEndpointProps) {
+ return (
+
+
+
+ {method.toUpperCase()}
+
+ {path}
+ {deprecated ? (
+ Deprecated
+ ) : null}
+
+ {serverUrl || operationId ? (
+
+ {serverUrl ? (
+
+
- Server
+ -
+
{serverUrl}
+
+
+ ) : null}
+ {operationId ? (
+
+
- Operation ID
+ -
+
{operationId}
+
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
+
+export function ApiAuth({ requirements, schemes }: ApiAuthProps) {
+ if (requirements.length === 0 && schemes.length === 0) {
+ return No authentication required.
;
+ }
+ return (
+
+
+ {requirements.map((requirement) => {
+ const names = Object.keys(requirement);
+ const label = names.length > 0 ? names.join(" + ") : "Anonymous";
+ return - {label}
;
+ })}
+
+ {schemes.length > 0 ? (
+
+ {schemes.map((scheme) => (
+ -
+
{scheme.key}: {scheme.type}
+ {scheme.scheme ? ` / ${scheme.scheme}` : ""}
+ {scheme.description ? ` — ${scheme.description}` : ""}
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+export function ApiParameters({ title, parameters }: ApiParametersProps) {
+ if (parameters.length === 0) {
+ return null;
+ }
+ return (
+
+ {title ? {title}
: null}
+
+
+
+
+ | Name |
+ Type |
+ Required |
+ Description |
+
+
+
+ {parameters.map((parameter) => (
+
+
+ {parameter.name}
+ |
+
+ {formatSchemaType(parameter.schema)}
+ |
+ {parameter.required ? "Required" : "Optional"} |
+ {parameter.description ?? "—"} |
+
+ ))}
+
+
+
+
+ );
+}
+
+export function ApiRequestBody({ body }: ApiRequestBodyProps) {
+ return (
+
+ Request Body
+
+ {body.required ? "Required" : "Optional"}
+ {body.description ? ` — ${body.description}` : ""}
+
+ {body.content.map((media) => (
+
+ ))}
+
+ );
+}
+
+export function ApiCodeSamples({ samples }: ApiCodeSamplesProps) {
+ if (samples.length === 0) {
+ return null;
+ }
+ return (
+
+
sample.label)}>
+ {samples.map((sample) => (
+
+
+ {sample.code}
+
+
+ ))}
+
+
+ );
+}
+
+export function ApiResponses({ responses }: ApiResponsesProps) {
+ if (responses.length === 0) {
+ return null;
+ }
+ return (
+
+ {responses.map((response) => (
+
+
+ {response.status}
+
+ {response.description}
+ {response.content.map((media) => (
+
+ ))}
+
+ ))}
+
+ );
+}
+
+export function ApiTryIt({ operation }: ApiTryItProps) {
+ return (
+
+ Wire this component to your API proxy to execute{" "}
+
+ {operation.method.toUpperCase()} {operation.path}
+
+ .
+
+ );
+}
diff --git a/apps/tanstack/src/components/docs-mdx/mdx-components.ts b/apps/tanstack/src/components/docs-mdx/mdx-components.ts
index 1aa449ee..94e195fd 100644
--- a/apps/tanstack/src/components/docs-mdx/mdx-components.ts
+++ b/apps/tanstack/src/components/docs-mdx/mdx-components.ts
@@ -1,4 +1,13 @@
import { Accordion, AccordionItem } from "./accordion";
+import {
+ ApiAuth,
+ ApiCodeSamples,
+ ApiEndpoint,
+ ApiParameters,
+ ApiRequestBody,
+ ApiResponses,
+ ApiTryIt,
+} from "./api";
import { Audience } from "./audience";
import { Callout } from "./callout";
import { Card, Cards } from "./card";
@@ -16,6 +25,13 @@ import { ExtractedTypeTable, TypeTable } from "./type-table";
export const mdxComponents = {
Accordion,
AccordionItem,
+ ApiAuth,
+ ApiCodeSamples,
+ ApiEndpoint,
+ ApiParameters,
+ ApiRequestBody,
+ ApiResponses,
+ ApiTryIt,
Audience,
ExtractedTypeTable,
Callout,
diff --git a/apps/tanstack/src/routes/docs/$.tsx b/apps/tanstack/src/routes/docs/$.tsx
index b8e8edcb..b64d5b49 100644
--- a/apps/tanstack/src/routes/docs/$.tsx
+++ b/apps/tanstack/src/routes/docs/$.tsx
@@ -31,11 +31,24 @@ const TRAILING_SLASH_RE = /\/+$/;
* exactly. (Trade-off: all docs ship in the `/docs` chunk — ideal for a docs
* site you browse page-to-page; revisit lazy loading only for a huge corpus.)
*/
-const mdxModules = import.meta.glob<{ default: ComponentType }>(
+const authoredMdxModules = import.meta.glob<{ default: ComponentType }>(
"../../../../../docs/**/*.mdx",
{ eager: true }
);
+/**
+ * Generated OpenAPI reference pages live in the app-local generated dir
+ * (written by `pipeline:source-manifest`) because Vite globs are static —
+ * they can't reach into the temp staging dir `createDocsSource({ openapi })`
+ * uses. Manifest `globKey`s point into whichever map owns the page.
+ */
+const openapiMdxModules = import.meta.glob<{ default: ComponentType }>(
+ "../../generated/openapi-docs/**/*.mdx",
+ { eager: true }
+);
+
+const mdxModules = { ...authoredMdxModules, ...openapiMdxModules };
+
function resolvePage(
urlPrefix: "/changelog" | "/docs",
splat: string | undefined
diff --git a/apps/tanstack/src/styles.css b/apps/tanstack/src/styles.css
index e927dde1..45548a9b 100644
--- a/apps/tanstack/src/styles.css
+++ b/apps/tanstack/src/styles.css
@@ -667,6 +667,93 @@
@apply w-full;
}
+/* OpenAPI reference components (generated API pages) */
+[data-leadtype-api-endpoint] {
+ @apply my-6 rounded-lg border border-border bg-card p-4;
+}
+
+[data-leadtype-api-endpoint-row] {
+ @apply flex flex-wrap items-center gap-2;
+}
+
+[data-leadtype-api-method] {
+ @apply inline-flex items-center rounded-md border border-border px-2 py-0.5 font-mono text-xs font-semibold uppercase;
+}
+
+[data-leadtype-api-method][data-method="get"] {
+ background: color-mix(
+ in oklab,
+ var(--background) 88%,
+ oklch(0.72 0.1 160) 12%
+ );
+}
+
+[data-leadtype-api-method][data-method="post"] {
+ background: color-mix(
+ in oklab,
+ var(--background) 88%,
+ oklch(0.7 0.1 240) 12%
+ );
+}
+
+[data-leadtype-api-method][data-method="put"],
+[data-leadtype-api-method][data-method="patch"] {
+ background: color-mix(
+ in oklab,
+ var(--background) 88%,
+ oklch(0.75 0.1 75) 12%
+ );
+}
+
+[data-leadtype-api-method][data-method="delete"] {
+ background: color-mix(
+ in oklab,
+ var(--background) 88%,
+ oklch(0.65 0.12 25) 12%
+ );
+}
+
+[data-leadtype-api-path] {
+ @apply text-sm;
+}
+
+[data-leadtype-api-deprecated] {
+ @apply inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground line-through;
+}
+
+[data-leadtype-api-endpoint-details] {
+ @apply mt-3 grid gap-1 text-sm text-muted-foreground;
+}
+
+[data-leadtype-api-endpoint-details] div {
+ @apply flex gap-2;
+}
+
+[data-leadtype-api-endpoint-details] dt {
+ @apply text-muted-foreground;
+}
+
+[data-leadtype-api-auth],
+[data-leadtype-api-response] {
+ @apply my-4 rounded-lg border border-border bg-card p-4;
+}
+
+[data-leadtype-api-response] h3 {
+ @apply mt-0;
+}
+
+[data-leadtype-api-meta] {
+ @apply text-sm text-muted-foreground;
+}
+
+[data-leadtype-api-table] {
+ @apply my-3 overflow-x-auto;
+}
+
+[data-leadtype-api-table] table {
+ @apply w-full;
+}
+
[data-leadtype-extracted-type-table] {
@apply my-6 space-y-4 rounded-lg border border-border bg-card p-4;
}
diff --git a/packages/leadtype/src/openapi/index.ts b/packages/leadtype/src/openapi/index.ts
index 1ded1df2..d09bfcc2 100644
--- a/packages/leadtype/src/openapi/index.ts
+++ b/packages/leadtype/src/openapi/index.ts
@@ -1464,10 +1464,15 @@ function renderOperationMdx(
path: operation.path,
serverUrl: operation.serverUrl,
}),
- "",
- escapeMarkdownForMdx(operation.description),
];
+ // Docs renderers already print the frontmatter description under the title;
+ // only repeat the description in the body when it says more than that.
+ const description = operation.description.trim();
+ if (description && description !== shortDescription(operation)) {
+ blocks.push("", escapeMarkdownForMdx(description));
+ }
+
if (operation.securitySchemes.length > 0 || operation.security.length > 0) {
blocks.push(
"",
diff --git a/packages/leadtype/src/openapi/openapi.test.ts b/packages/leadtype/src/openapi/openapi.test.ts
index e0e2d6fd..9dfab0f7 100644
--- a/packages/leadtype/src/openapi/openapi.test.ts
+++ b/packages/leadtype/src/openapi/openapi.test.ts
@@ -150,12 +150,20 @@ paths:
operationId: readUser
summary: Read a user
description: |
- Returns the user for {id}. Set the header to when calling.
+ Returns the user for {id}.
+
+ Set the header to when calling. Keep \`{literal}\` intact.
responses:
"200":
description: ok
`;
const { result } = await generateFixturePages(spec);
+ const page = await readFile(result.pages[0]?.filePath ?? "", "utf8");
+ // Multi-paragraph description renders in the body, escaped for MDX.
+ expect(page).toContain("\\{id\\}");
+ expect(page).toContain("\\");
+ expect(page).toContain("`{literal}`");
+
const converted = await convertMdxToMarkdown(
result.pages[0]?.filePath ?? "",
defaultMarkdownTransforms
@@ -164,6 +172,15 @@ paths:
expect(converted.markdown).toContain("");
});
+ it("omits the body description when it matches the frontmatter description", async () => {
+ const { result } = await generateFixturePages(FIXTURE_SPEC);
+ const page = await readFile(result.pages[0]?.filePath ?? "", "utf8");
+ // Single-sentence descriptions already render under the page title via
+ // frontmatter — repeating them in the body would show twice in docs UIs.
+ const body = page.split("---").slice(2).join("---");
+ expect(body).not.toContain("Reads an access group");
+ });
+
it("renders nested array item properties as dotted rows", async () => {
const spec = `
openapi: 3.1.0
From 0e08f29ef1417e187b565de7ae0faa7ed7be1cf9 Mon Sep 17 00:00:00 2001
From: Kaylee <65376239+KayleeWilliams@users.noreply.github.com>
Date: Thu, 2 Jul 2026 15:54:34 +0100
Subject: [PATCH 6/9] Match reference-grade detail on generated API pages
Closes the fidelity gap against Vercel-style REST API markdown:
- Ship the dereferenced JSON Schema for request/response bodies (full
enums, formats, constraints) in flattened markdown and as collapsible
blocks in renderers; disable with includeSchemas: false
- Machine-scannable frontmatter on every generated page: type, method,
path, operationId, server, apiVersion, tags, deprecated
- Generate an overview index page per source listing operations grouped
by tag, wired into nav as the section landing page; operation pages
gain a Related section linking the overview (and remote spec URLs);
links use the new urlPrefix option (default /docs)
- Render response headers tables in the fumadocs and tanstack renderers
(the flattener already did)
- Label examples and skip body descriptions that merely repeat the
frontmatter (whitespace-insensitive)
- Enrich the dogfood spec: second endpoint, $ref schemas, enums,
formats, defaults, named examples, response headers, 401/404 errors
---
apps/fumadocs-example/lib/mdx-components.tsx | 44 +++
apps/tanstack/scripts/docs-source-manifest.ts | 20 +-
apps/tanstack/src/components/docs-mdx/api.tsx | 35 +++
apps/tanstack/src/styles.css | 8 +
docs/openapi/leadtype-api.yaml | 156 +++++++++--
docs/reference/openapi.mdx | 15 +-
packages/leadtype/src/cli/generate.ts | 2 +-
packages/leadtype/src/index.ts | 1 +
.../leadtype/src/markdown/plugins/openapi.ts | 7 +
packages/leadtype/src/openapi/index.ts | 254 ++++++++++++++++--
packages/leadtype/src/openapi/openapi.test.ts | 59 ++++
11 files changed, 560 insertions(+), 41 deletions(-)
diff --git a/apps/fumadocs-example/lib/mdx-components.tsx b/apps/fumadocs-example/lib/mdx-components.tsx
index 7b3643bb..db338125 100644
--- a/apps/fumadocs-example/lib/mdx-components.tsx
+++ b/apps/fumadocs-example/lib/mdx-components.tsx
@@ -363,6 +363,12 @@ function MediaType({ media }: { media: ApiMediaType }) {
properties={media.schema?.properties ?? media.schema?.items?.properties}
/>
+ {media.rawSchema === undefined ? null : (
+
+ JSON Schema
+
+
+ )}
);
}
@@ -400,6 +406,43 @@ function ApiCodeSamples({ samples }: ApiCodeSamplesProps) {
);
}
+function ApiResponseHeaders({
+ headers,
+}: {
+ headers: ApiResponsesProps["responses"][number]["headers"];
+}) {
+ if (!headers || headers.length === 0) {
+ return null;
+ }
+ return (
+
+
Headers
+
+
+
+
+ | Name |
+ Type |
+ Description |
+
+
+
+ {headers.map((header) => (
+
+ | {header.name} |
+
+ {formatApiSchemaType(header.schema)}
+ |
+ {header.description ?? "—"} |
+
+ ))}
+
+
+
+
+ );
+}
+
function ApiResponses({ responses }: ApiResponsesProps) {
if (responses.length === 0) {
return null;
@@ -415,6 +458,7 @@ function ApiResponses({ responses }: ApiResponsesProps) {
{response.content.map((media) => (
))}
+
))}
diff --git a/apps/tanstack/scripts/docs-source-manifest.ts b/apps/tanstack/scripts/docs-source-manifest.ts
index 9fbf7ba3..35a6c229 100644
--- a/apps/tanstack/scripts/docs-source-manifest.ts
+++ b/apps/tanstack/scripts/docs-source-manifest.ts
@@ -73,11 +73,25 @@ if (docsConfig.openapi !== undefined) {
docsDir: openapiDocsDir,
});
const MDX_EXTENSION_PATTERN = /\.mdx$/;
- for (const page of generated.pages) {
+ const INDEX_SEGMENT_PATTERN = /(?:^|\/)index$/;
+ const generatedEntries = [
+ ...generated.pages.map((page) => ({
+ description: page.description,
+ relativePath: page.relativePath,
+ title: page.title,
+ })),
+ ...generated.indexPages.map((page) => ({
+ description: page.description,
+ relativePath: page.relativePath,
+ title: page.title,
+ })),
+ ];
+ for (const page of generatedEntries) {
const relativePath = page.relativePath.replace(MDX_EXTENSION_PATTERN, "");
+ const urlPath = relativePath.replace(INDEX_SEGMENT_PATTERN, "");
manifest.push({
- slug: relativePath.split("/"),
- urlPath: `/docs/${relativePath}`,
+ slug: urlPath.split("/").filter(Boolean),
+ urlPath: `/docs/${urlPath}`,
title: page.title,
description: page.description,
relativePath,
diff --git a/apps/tanstack/src/components/docs-mdx/api.tsx b/apps/tanstack/src/components/docs-mdx/api.tsx
index 37a8fff7..fc2e1d37 100644
--- a/apps/tanstack/src/components/docs-mdx/api.tsx
+++ b/apps/tanstack/src/components/docs-mdx/api.tsx
@@ -151,6 +151,12 @@ function MediaType({ media }: { media: ApiMediaType }) {
properties={media.schema?.properties ?? media.schema?.items?.properties}
/>
+ {media.rawSchema === undefined ? null : (
+
+ JSON Schema
+
+
+ )}
);
}
@@ -311,6 +317,35 @@ export function ApiResponses({ responses }: ApiResponsesProps) {
{response.content.map((media) => (
))}
+ {response.headers && response.headers.length > 0 ? (
+ <>
+ Headers
+
+
+
+
+ | Name |
+ Type |
+ Description |
+
+
+
+ {response.headers.map((header) => (
+
+
+ {header.name}
+ |
+
+ {formatSchemaType(header.schema)}
+ |
+ {header.description ?? "—"} |
+
+ ))}
+
+
+
+ >
+ ) : null}
))}
diff --git a/apps/tanstack/src/styles.css b/apps/tanstack/src/styles.css
index 45548a9b..7b6d1b0f 100644
--- a/apps/tanstack/src/styles.css
+++ b/apps/tanstack/src/styles.css
@@ -754,6 +754,14 @@
@apply w-full;
}
+[data-leadtype-api-schema] {
+ @apply my-3 rounded-lg border border-border bg-card px-4 py-2;
+}
+
+[data-leadtype-api-schema] summary {
+ @apply cursor-pointer text-sm font-medium text-muted-foreground;
+}
+
[data-leadtype-extracted-type-table] {
@apply my-6 space-y-4 rounded-lg border border-border bg-card p-4;
}
diff --git a/docs/openapi/leadtype-api.yaml b/docs/openapi/leadtype-api.yaml
index 0e2a1d85..be58d228 100644
--- a/docs/openapi/leadtype-api.yaml
+++ b/docs/openapi/leadtype-api.yaml
@@ -11,14 +11,56 @@ components:
type: http
scheme: bearer
description: Optional token for private docs deployments.
+ schemas:
+ SearchResult:
+ type: object
+ required:
+ - title
+ - urlPath
+ properties:
+ title:
+ type: string
+ description: Page or heading title.
+ urlPath:
+ type: string
+ description: Docs URL path for the result.
+ snippet:
+ type: string
+ description: Highlight-ready excerpt from the matched chunk.
+ score:
+ type: number
+ format: float
+ description: BM25 relevance score, higher is more relevant.
+ ApiError:
+ type: object
+ required:
+ - error
+ properties:
+ error:
+ type: object
+ required:
+ - code
+ - message
+ properties:
+ code:
+ type: string
+ enum: [invalid_request, unauthorized, not_found, rate_limited]
+ description: Stable machine-readable error code.
+ message:
+ type: string
+ description: Human-readable explanation of the failure.
paths:
/api/docs/search:
post:
operationId: searchDocs
summary: Search docs
- description: Search a generated Leadtype docs index and return matching docs chunks.
+ description: |
+ Search a generated Leadtype docs index and return matching docs chunks.
+
+ Results are ranked with BM25 over page titles, headings, and body
+ chunks. Use `limit` to bound the response size for agent contexts.
tags:
- - Operations
+ - Search
security:
- docsToken: []
requestBody:
@@ -38,9 +80,19 @@ paths:
format: int32
description: Maximum number of results to return.
default: 8
+ scope:
+ type: string
+ enum: [all, docs, changelog]
+ default: all
+ description: Restrict results to one generated collection.
responses:
"200":
description: Ranked docs search results.
+ headers:
+ X-RateLimit-Remaining:
+ description: Requests remaining in the current window.
+ schema:
+ type: integer
content:
application/json:
schema:
@@ -52,19 +104,91 @@ paths:
type: array
description: Matching docs chunks ordered by relevance.
items:
- type: object
- required:
- - title
- - urlPath
- properties:
- title:
- type: string
- description: Page or heading title.
- urlPath:
- type: string
- description: Docs URL path for the result.
- snippet:
- type: string
- description: Highlight-ready excerpt from the matched chunk.
+ $ref: "#/components/schemas/SearchResult"
+ examples:
+ match:
+ summary: A query with one hit
+ value:
+ results:
+ - title: "Add search"
+ urlPath: "/docs/search/add-search"
+ snippet: "Generate a static docs search index…"
+ score: 12.4
+ empty:
+ summary: No hits
+ value:
+ results: []
"400":
description: The request body was malformed or the query was empty.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ApiError"
+ "401":
+ description: A docs token is required for private deployments.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ApiError"
+ /api/docs/pages/{urlPath}:
+ get:
+ operationId: getDocsPage
+ summary: Get a docs page
+ description: |
+ Fetch one generated docs page as agent-readable markdown, including
+ frontmatter. The `urlPath` matches entries returned by search.
+ tags:
+ - Pages
+ security:
+ - docsToken: []
+ parameters:
+ - name: urlPath
+ in: path
+ required: true
+ schema:
+ type: string
+ description: URL path of the page, e.g. `docs/quickstart`.
+ example: docs/quickstart
+ - name: format
+ in: query
+ required: false
+ schema:
+ type: string
+ enum: [markdown, html]
+ default: markdown
+ description: Response body format.
+ responses:
+ "200":
+ description: The rendered page.
+ headers:
+ ETag:
+ description: Content hash for cache validation.
+ schema:
+ type: string
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - urlPath
+ - markdown
+ properties:
+ urlPath:
+ type: string
+ description: Canonical docs URL path.
+ title:
+ type: string
+ description: Page title from frontmatter.
+ markdown:
+ type: string
+ description: Flattened agent-readable markdown body.
+ lastModified:
+ type: string
+ format: date-time
+ description: Last content change, from git history.
+ "404":
+ description: No page exists at the given `urlPath`.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ApiError"
diff --git a/docs/reference/openapi.mdx b/docs/reference/openapi.mdx
index c66a9462..b6370836 100644
--- a/docs/reference/openapi.mdx
+++ b/docs/reference/openapi.mdx
@@ -67,6 +67,9 @@ The same generated pages are added to the docs navigation used by
Every operation page carries the full request/response contract:
+- **Machine-scannable frontmatter** — `method`, `path`, `operationId`,
+ `server`, `apiVersion`, `tags`, and `type: api-reference`, so agents can
+ route to the right endpoint without parsing the body.
- **Endpoint** — method, path, server URL, operation ID, deprecation.
- **Authentication** — the operation's security requirements and only the
schemes those requirements reference.
@@ -75,12 +78,20 @@ Every operation page carries the full request/response contract:
- **Request body and responses** — property tables plus a JSON example. When
the spec provides `example`/`examples` they are used verbatim; otherwise a
representative payload is synthesized from the schema (defaults, enums, and
- formats included).
+ formats included). The dereferenced **JSON Schema** ships alongside as the
+ precise contract (disable with `includeSchemas: false`).
- **Code samples** — generated cURL and `fetch` snippets with real example
bodies, required query parameters, and auth headers derived from the
security scheme (`Authorization: Bearer `, API-key headers, and so
on). Specs that define Redocly-style `x-codeSamples` (or `x-code-samples`)
override the generated snippets with their hand-written ones.
+- **Related links** — every page links back to the generated overview page,
+ plus the machine-readable spec when `input` is a URL.
+
+An **overview page** (`