diff --git a/.changeset/openapi-generated-docs.md b/.changeset/openapi-generated-docs.md
new file mode 100644
index 00000000..a0b3e6c7
--- /dev/null
+++ b/.changeset/openapi-generated-docs.md
@@ -0,0 +1,9 @@
+---
+"leadtype": minor
+---
+
+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/.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/lib/mdx-components.tsx b/apps/fumadocs-example/lib/mdx-components.tsx
index 9ea808ca..db338125 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,361 @@ 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[] }) {
+ const rows = flattenSchemaRows(properties);
+ if (rows.length === 0) {
+ return null;
+ }
+ return (
+
+
+
+
+ | Property |
+ Type |
+ Required |
+ Description |
+
+
+
+ {rows.map((row) => (
+
+ | {row.name} |
+ {row.type} |
+
+ {row.required ? "Required" : "Optional"}
+ |
+ {row.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 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.rawSchema === undefined ? null : (
+
+ JSON Schema
+
+
+ )}
+
+ );
+}
+
+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 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;
+ }
+ 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 +715,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..6146b10c 100644
--- a/apps/fumadocs-example/lib/source.ts
+++ b/apps/fumadocs-example/lib/source.ts
@@ -11,12 +11,16 @@ const contentDir = resolve(repoRoot, "docs");
* 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,
includeMetaJson: false,
nav: docsConfig.navigation,
mounts: docsConfig.mounts,
+ openapi: docsConfig.openapi,
typeTableBasePath: repoRoot,
});
diff --git a/apps/tanstack/scripts/docs-source-manifest.ts b/apps/tanstack/scripts/docs-source-manifest.ts
index 40674b51..97ad0d20 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,10 +31,16 @@ 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");
+const baseUrl = process.env.BASE_URL?.trim() || "https://leadtype.dev";
+// 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,
- baseUrl: process.env.BASE_URL?.trim() || "https://leadtype.dev",
+ baseUrl,
nav: docsConfig.navigation,
mounts: docsConfig.mounts,
});
@@ -53,10 +60,53 @@ 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, {
+ baseUrl,
+ }),
+ docsDir: openapiDocsDir,
+ });
+ const MDX_EXTENSION_PATTERN = /\.mdx$/;
+ 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: urlPath.split("/").filter(Boolean),
+ urlPath: `/docs/${urlPath}`,
+ 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..b1efa437 100644
--- a/apps/tanstack/scripts/llm-generate.ts
+++ b/apps/tanstack/scripts/llm-generate.ts
@@ -20,17 +20,12 @@ 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, "..", "..");
-// `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 outDir = join(appRoot, "public");
-const generatedDir = join(appRoot, "src", "generated");
-const LEADING_SLASHES_PATTERN = /^\/+/;
// Base URL precedence: package-specific override, generic deployment URL,
// portless local URL, then a stable docs example fallback.
const baseUrl =
@@ -38,6 +33,26 @@ const baseUrl =
process.env.BASE_URL?.trim() ||
process.env.PORTLESS_URL?.trim() ||
"https://leadtype.dev";
+// 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({
+ baseUrl,
+ 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 = 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 = /^\/+/;
function outputPathForUrlPrefix(urlPrefix: string): string {
return urlPrefix.replace(LEADING_SLASHES_PATTERN, "");
@@ -75,7 +90,7 @@ await generateLlmsTxt({
outDir,
baseUrl,
product: agentInputs.product,
- nav: docsConfig.navigation,
+ nav: docsNavigation,
mounts: docsConfig.mounts,
});
@@ -83,7 +98,7 @@ await generateLLMFullContextFiles({
outDir,
baseUrl,
product: { name: agentInputs.product.name },
- nav: docsConfig.navigation,
+ nav: docsNavigation,
mounts: docsConfig.mounts,
});
@@ -94,7 +109,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 +123,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 +162,7 @@ await generateFeedArtifacts({
const navigation = await resolveDocsNavigation({
srcDir,
baseUrl,
- nav: docsConfig.navigation,
+ nav: docsNavigation,
mounts: docsConfig.mounts,
});
@@ -178,4 +195,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..7ad118a9 100644
--- a/apps/tanstack/scripts/mdx-convert.ts
+++ b/apps/tanstack/scripts/mdx-convert.ts
@@ -15,12 +15,16 @@ 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 baseUrl = process.env.BASE_URL?.trim() || "https://leadtype.dev";
const typeTableMarkdownTransform: NonNullable<
MdxToMarkdownOptions["markdownTransforms"]
>[number] = [
@@ -50,3 +54,24 @@ 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, {
+ baseUrl,
+ }),
+ 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..fc2e1d37
--- /dev/null
+++ b/apps/tanstack/src/components/docs-mdx/api.tsx
@@ -0,0 +1,365 @@
+/**
+ * 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}
+
+
+
+ {media.rawSchema === undefined ? null : (
+
+ JSON Schema
+
+
+ )}
+
+ );
+}
+
+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) => (
+
+ ))}
+ {response.headers && response.headers.length > 0 ? (
+ <>
+ Headers
+
+
+
+
+ | Name |
+ Type |
+ Description |
+
+
+
+ {response.headers.map((header) => (
+
+
+ {header.name}
+ |
+
+ {formatSchemaType(header.schema)}
+ |
+ {header.description ?? "—"} |
+
+ ))}
+
+
+
+ >
+ ) : null}
+
+ ))}
+
+ );
+}
+
+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..7b6d1b0f 100644
--- a/apps/tanstack/src/styles.css
+++ b/apps/tanstack/src/styles.css
@@ -667,6 +667,101 @@
@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-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/changelog/0-4.mdx b/docs/changelog/0-4.mdx
index 09a1ccf5..cb4fec15 100644
--- a/docs/changelog/0-4.mdx
+++ b/docs/changelog/0-4.mdx
@@ -9,6 +9,14 @@ In progress.
Leadtype 0.4 is the next minor release after the 0.3 line. These notes are being written as changes land.
+## OpenAPI API reference generation
+
+- Added native OpenAPI 3.x page generation: an `openapi` block in `docs.config.ts` turns a JSON/YAML spec into MDX operation pages that render through your docs UI and flatten into agent-readable markdown — the same pages flow into `llms.txt`, search, markdown mirrors, `AGENTS.md`, and Agent Readability artifacts. See the [OpenAPI reference](/docs/reference/openapi).
+- Each operation page carries the full contract: machine-scannable frontmatter (`method`, `path`, `operationId`, `apiVersion`, `canonicalUrl`, `lastModified`, and a `source` pointing at the spec), parameter and property tables with nested dotted rows (`results[].title`), the dereferenced JSON Schema per media type, named or synthesized JSON examples, response headers, and generated cURL/`fetch` samples with real bodies and auth headers derived from the security scheme. Redocly-style `x-codeSamples` override the generated snippets.
+- A generated overview page per spec lists every operation grouped by tag and joins the navigation as the section landing page; operation pages link back to it from a Related section.
+- Three integration shapes: `createDocsSource({ openapi })` / `fumadocsSource({ openapi })` stage pages without touching authored docs, `leadtype generate` picks the config up automatically, and static-glob bundlers (TanStack Start / Vite) use `writeOpenApiPages()` into an app-local directory. `stageOpenApiDocs()` exposes the staging primitive for custom pipelines.
+- Rendering follows the existing component naming contract: seven new `Api*` tags with prop types from `leadtype/mdx`, built-in markdown flatteners, and copyable reference implementations in the Fumadocs and TanStack examples. Operation summaries and descriptions are treated as CommonMark and escaped, so arbitrary specs can't break the MDX build.
+
## Agent-readability artifacts
- Sorted `manifest.pages` from `generateAgentReadabilityArtifacts` in navigation order — groups depth-first, then pages within each group — instead of alphabetical `urlPath` order. Navigation order is the authored reading order, which is what agent and LLM consumers of the manifest want. Pages not present in the navigation are appended sorted by `urlPath`, so the manifest stays fully deterministic without consumer-side re-sorting. `sitemap.xml` is rendered from the same page list and shares the order. ([#115](https://github.com/inthhq/leadtype/issues/115), [#120](https://github.com/inthhq/leadtype/pull/120))
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..be58d228
--- /dev/null
+++ b/docs/openapi/leadtype-api.yaml
@@ -0,0 +1,194 @@
+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.
+ 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.
+
+ Results are ranked with BM25 over page titles, headings, and body
+ chunks. Use `limit` to bound the response size for agent contexts.
+ tags:
+ - Search
+ 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
+ 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:
+ type: object
+ required:
+ - results
+ properties:
+ results:
+ type: array
+ description: Matching docs chunks ordered by relevance.
+ items:
+ $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
new file mode 100644
index 00000000..6942eab8
--- /dev/null
+++ b/docs/reference/openapi.mdx
@@ -0,0 +1,366 @@
+---
+title: OpenAPI
+description: Generate native MDX API reference pages from OpenAPI 3.x specs.
+---
+
+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:
+
+
+
+
+
+
+
+
+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 an overview page plus one page per operation:
+
+```text
+public/docs/rest-api/index.md
+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.
+
+Every generated page opens with machine-scannable frontmatter, so an agent
+can pick the right endpoint — and fetch the source spec — without parsing
+the body:
+
+```yaml
+title: Read a user
+description: Fetch one user by ID.
+type: api-reference
+source: ./openapi/api.yaml
+method: get
+path: /users/{id}
+operationId: readUser
+server: https://api.example.com
+apiVersion: 1.2.0
+tags: ["Users"]
+canonicalUrl: https://example.com/docs/rest-api/users/read-user
+lastModified: "2026-07-02T15:54:34+01:00"
+```
+
+## What each page includes
+
+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. Local specs also get
+ `lastModified` (git commit date, falling back to file mtime), and setting
+ `baseUrl` adds a `canonicalUrl` per page.
+- **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). 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** (`