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 ( +
+ + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
PropertyTypeRequiredDescription
{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

+ + + ) : null} + {schemes.length > 0 ? ( + <> +

Schemes

+ + + ) : null} +
+ ); +} + +function ApiParameters({ title, parameters }: ApiParametersProps) { + if (parameters.length === 0) { + return null; + } + return ( +
+ {title ?

{title}

: null} +
+ + + + + + + + + + + {parameters.map((parameter) => ( + + + + + + + ))} + +
NameTypeRequiredDescription
{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

+
+ + + + + + + + + + {headers.map((header) => ( + + + + + + ))} + +
NameTypeDescription
{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 ( +
+ + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
{nameHeading}TypeRequiredDescription
+ {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} +
+ + + + + + + + + + + {parameters.map((parameter) => ( + + + + + + + ))} + +
NameTypeRequiredDescription
+ {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

+
+ + + + + + + + + + {response.headers.map((header) => ( + + + + + + ))} + +
NameTypeDescription
+ {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** (`/index.mdx`) is generated per source: the API +title, description, and version, plus every operation grouped by tag with +method, path, and summary. It joins the generated navigation as the section +landing page. Links use the `urlPrefix` option (default `/docs`). + +## 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 fuller copyable implementations, see the leadtype repo's example +component maps: `apps/fumadocs-example/lib/mdx-components.tsx` (Tailwind +utility classes) and `apps/tanstack/src/components/docs-mdx/api.tsx` +(data-attribute styling with tabbed code samples). + +### 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. + +### Static-glob bundlers (TanStack Start, Vite) + +Apps that compile MDX with a static `import.meta.glob` can't reach into the +temp staging directory — Vite resolves glob patterns at build time. Generate +the pages into an app-local directory instead and add a second glob: + +```ts title="scripts/docs-source-manifest.ts" +import { normalizeOpenApiConfig, writeOpenApiPages } from "leadtype/openapi"; +import docsConfig from "../../docs/docs.config"; + +const generated = await writeOpenApiPages({ + configs: normalizeOpenApiConfig(docsConfig.openapi, contentDir, { + baseUrl, + }), + docsDir: openapiDocsDir, // e.g. src/generated/openapi-docs (gitignored) +}); +// generated.pages / generated.indexPages → append to your route manifest +// generated.nav → append to your navigation +``` + +```ts title="src/routes/docs/$.tsx" +const authored = import.meta.glob("../../../docs/**/*.mdx", { eager: true }); +const openapi = import.meta.glob("../../generated/openapi-docs/**/*.mdx", { + eager: true, +}); +const mdxModules = { ...authored, ...openapi }; +``` + +The TanStack example in the leadtype repo (`apps/tanstack`) wires this +end-to-end: generated MDX for the router, flattened mirrors for agents and +search, and generated navigation for the sidebar and `llms.txt`. + +## 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`. | +| `includeSchemas` | Ship the dereferenced JSON Schema for request/response bodies. Defaults to `true`. | +| `urlPrefix` | Site prefix for overview and Related links. Defaults to `/docs`. | +| `baseUrl` | Absolute site base URL — adds `canonicalUrl` frontmatter to every generated page. The CLI passes `--base-url` automatically; `createDocsSource` forwards its own `baseUrl`. | + +## 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, + 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. When multiple specs share an +`output` directory, colliding slugs get numeric suffixes instead of +overwriting each other. + +## Dogfooding + +Leadtype's own package docs generate an 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 passes the same `openapi` config to `fumadocsSource()`, +and the TanStack example renders the same pages through the static-glob +recipe above — both browser-rendered apps exercise the component contract. diff --git a/docs/writing/components.mdx b/docs/writing/components.mdx index 2c4673b7..a76c4aff 100644 --- a/docs/writing/components.mdx +++ b/docs/writing/components.mdx @@ -20,7 +20,11 @@ This means the same content reaches three audiences (humans, agents, search) wit The markdown transform pipeline recognizes these names. If your components use the same names, flattening Just Works: -`Accordion`, `AccordionItem`, `Audience`, `AutoTypeTable`, `Callout`, `Card`, `Cards`, `CommandTabs`, `Details`, `Example`, `ExtractedTypeTable`, `File`, `FileTree`, `Folder`, `Mermaid`, `Prompt`, `Section`, `Step`, `Steps`, `Tab`, `Tabs`, `TopicSwitcher`, `TypeTable`. +`Accordion`, `AccordionItem`, `ApiAuth`, `ApiCodeSamples`, `ApiEndpoint`, `ApiParameters`, `ApiRequestBody`, `ApiResponses`, `ApiTryIt`, `Audience`, `AutoTypeTable`, `Callout`, `Card`, `Cards`, `CommandTabs`, `Details`, `Example`, `ExtractedTypeTable`, `File`, `FileTree`, `Folder`, `Mermaid`, `Prompt`, `Section`, `Step`, `Steps`, `Tab`, `Tabs`, `TopicSwitcher`, `TypeTable`. + +The `Api*` names are emitted by [OpenAPI page generation](/docs/reference/openapi) +rather than authored by hand — map them in your renderer when you generate an +API reference. If your app uses different names — or a component that has no equivalent above — you have two options: rename to match the contract, or define how it flattens with [`defineComponentFlattener`](/docs/reference/markdown#custom-component-flatteners). You declare the component name and a `toMarkdown` function; leadtype handles tree-walking, prop parsing, child flattening, and transform order for you. @@ -317,6 +321,14 @@ Data-driven preview and source examples. The host component receives code as dat ``` +### API reference components + +`ApiEndpoint`, `ApiAuth`, `ApiParameters`, `ApiRequestBody`, `ApiCodeSamples`, `ApiResponses`, and `ApiTryIt` render generated OpenAPI operation pages — endpoint badges, parameter tables, request/response contracts with JSON Schema, and code samples. You don't author these by hand; [OpenAPI page generation](/docs/reference/openapi) emits them with serialized props, and the flattener turns them into tables, fenced code, and schema blocks for agents. Prop contracts ship from [`leadtype/mdx`](/docs/reference/mdx) (`ApiEndpointProps`, `ApiResponsesProps`, …). + +```tsx + +``` + ### Mermaid Diagrams authored as plain text. Renders client-side as interactive SVG. Flattening preserves the source as a fenced Mermaid code block so other tools can render the diagram from the markdown copy. 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..9a8c5930 100644 --- a/packages/leadtype/scripts/generate-docs.ts +++ b/packages/leadtype/scripts/generate-docs.ts @@ -12,6 +12,7 @@ import { resolveDocsNavigation, } from "../src/llm/index"; import { defaultMarkdownTransforms } from "../src/markdown/index"; +import { stageOpenApiDocs } from "../src/openapi/index"; import { generateDocsSearchFiles } from "../src/search/node-index"; const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); @@ -28,88 +29,108 @@ 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, -}); +// 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 ?? []), +]; -// 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, -}); +try { + await convertAllMdx({ + srcDir: docsDir, + outDir: OUT_DOCS_DIR, + markdownTransforms: defaultMarkdownTransforms, + }); -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 }, - }, - }); + // 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: srcRoot, + 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: srcRoot, + 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 staged?.cleanup(); +} diff --git a/packages/leadtype/src/cli/generate.ts b/packages/leadtype/src/cli/generate.ts index 2fbef3cd..626e7ff9 100644 --- a/packages/leadtype/src/cli/generate.ts +++ b/packages/leadtype/src/cli/generate.ts @@ -71,6 +71,12 @@ import { generateNlwebArtifacts, NLWEB_SCHEMA_MAP_PATH, } from "../nlweb/artifacts"; +import { + type DocsOpenApiConfig, + normalizeOpenApiConfig, + validateDocsOpenApiConfig, + writeOpenApiPages, +} from "../openapi"; import type { GenerateDocsSearchFilesResult } from "../search/node"; import { generateDocsSearchFiles } from "../search/node"; import { @@ -300,6 +306,7 @@ type ResolvedGenerateMetadata = { mounts?: DocsPathMount[]; feeds?: DocsFeedConfig[]; git?: DocsConfig["git"]; + openapi?: DocsOpenApiConfig; transformers?: DocsTransformer[]; typeTableBasePath?: string; typeTableStrict?: boolean; @@ -1295,6 +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 = validateDocsOpenApiConfig( + value.openapi, + `docs config at "${configPath}"` + ); if (value.flatteners !== undefined && !Array.isArray(value.flatteners)) { throw new Error( @@ -1312,6 +1323,7 @@ function validateDocsConfig(value: unknown, configPath: string): DocsConfig { ...(mounts ? { mounts } : {}), ...(feeds ? { feeds } : {}), ...(git ? { git } : {}), + ...(openapi ? { openapi } : {}), ...(value.frontmatterSchema === undefined ? {} : { @@ -1846,6 +1858,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 +2335,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 +2350,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 +2627,23 @@ 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 + ? { indexPages: [], nav: [], pages: [] } + : await writeOpenApiPages({ + configs: normalizeOpenApiConfig( + metadata.openapi, + metadata.configPath ? path.dirname(metadata.configPath) : docsDir, + args.baseUrl ? { baseUrl: args.baseUrl } : {} + ), + 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 +2664,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..5886bc8a 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,30 @@ export { type SourceConfigInheritance, type SourceConfigInheritField, } from "./llm"; +export type { + DocsOpenApiConfig, + GeneratedOpenApiIndexPage, + GeneratedOpenApiPage, + GenerateOpenApiPagesResult, + NormalizeOpenApiConfigDefaults, + OpenApiCodeSample, + OpenApiHttpMethod, + OpenApiMediaType, + OpenApiOperation, + OpenApiParameter, + OpenApiRequestBody, + OpenApiResponse, + OpenApiSchemaProperty, + OpenApiSchemaSummary, + OpenApiSecurityRequirement, + OpenApiSecurityScheme, + OpenApiSlugStrategy, + OpenApiSourceConfig, + OpenApiSourceInput, + ResolvedOpenApiSourceConfig, + StagedOpenApiDocs, + StageOpenApiDocsConfig, +} 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/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/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..7d81266c --- /dev/null +++ b/packages/leadtype/src/markdown/plugins/openapi.ts @@ -0,0 +1,345 @@ +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, + OpenApiSchemaSummary, + OpenApiSecurityRequirement, + OpenApiSecurityScheme, +} from "../../openapi"; +import { + createHeading, + createInlineCode, + createJsxComponentProcessor, + createListItem, + createParagraph, + createTable, + createText, + createUnorderedList, + getAttributeValue, + type MdxNode, +} from "../libs"; + +const MAX_SCHEMA_DEPTH = 6; + +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 ?? ""); +} + +type SchemaRow = (string | ReturnType[])[]; + +function appendSchemaRows( + rows: SchemaRow[], + properties: OpenApiSchemaProperty[], + prefix: string, + depth: number +): void { + if (depth > MAX_SCHEMA_DEPTH) { + return; + } + 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[] { + 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 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); + 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.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(createParagraph("Example:")); + nodes.push(renderJsonExample(media.example)); + } + if (media.rawSchema !== undefined) { + // The dereferenced contract, Vercel-docs style: full constraints (enums, + // formats, required arrays) for agents and codegen. + nodes.push(createParagraph("JSON Schema:")); + nodes.push(renderJsonExample(media.rawSchema)); + } + 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; +} + +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") ?? ""; + const operationId = getAttributeValue(node, "operationId"); + const serverUrl = getAttributeValue(node, "serverUrl"); + const deprecated = getAttributeValue(node, "deprecated") === "true"; + const nodes: RootContent[] = [ + createCodeBlock(`${method} ${apiPath}`, "http"), + ]; + if (serverUrl) { + nodes.push(detailParagraph("Server", serverUrl)); + } + if (operationId) { + nodes.push(detailParagraph("Operation ID", operationId)); + } + if (deprecated) { + nodes.push(createParagraph("Deprecated: true")); + } + 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; +} + +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; +} + +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; +} + +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); +} + +export function apiCodeSamplesToMarkdown(node: MdxNode): RootContent[] { + const samples = parseAttr( + getAttributeValue(node, "samples"), + [] + ); + return renderCodeSamples(samples); +} + +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) => { + for (const process of processors) { + process(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..d47da372 --- /dev/null +++ b/packages/leadtype/src/openapi/index.ts @@ -0,0 +1,2004 @@ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { + cp, + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +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_URL_PREFIX = "/docs"; +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; +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]; + +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; + /** + * Include the dereferenced JSON Schema for request/response bodies on each + * page — the full machine-readable contract. Defaults to `true`. + */ + includeSchemas?: boolean; + /** + * Site URL prefix the docs are served under, used for links on the + * generated overview page and Related sections. Defaults to `/docs` + * (leadtype's default docs mount). + */ + urlPrefix?: string; + /** + * Absolute site base URL. When set, generated pages carry a + * `canonicalUrl` frontmatter field (`baseUrl` + `urlPrefix` + page path). + */ + baseUrl?: string; +}; + +export type ResolvedOpenApiSourceConfig = Required< + Pick< + OpenApiSourceConfig, + | "groupByTags" + | "includeSchemas" + | "includeTryIt" + | "output" + | "slugStrategy" + | "title" + | "urlPrefix" + > +> & + Omit< + OpenApiSourceConfig, + | "groupByTags" + | "includeSchemas" + | "includeTryIt" + | "output" + | "slugStrategy" + | "title" + | "urlPrefix" + > & { + cwd: string; + }; + +export type OpenApiSchemaObject = Record; + +export type OpenApiSchemaSummary = { + type: string; + description?: string; + required?: boolean; + deprecated?: boolean; + 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 & { + 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; + /** + * The dereferenced JSON Schema exactly as authored — the full contract + * (enums, formats, constraints) for agents and strict clients. Omitted when + * `includeSchemas: false`. + */ + rawSchema?: unknown; + 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; + /** API version from the document's `info.version`. */ + apiVersion?: string; + /** Last change to the source spec (git commit date, else file mtime). */ + lastModified?: string; + 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 GeneratedOpenApiIndexPage = { + filePath: string; + relativePath: string; + title: string; + description: string; + /** Rendered MDX for the overview page. */ + content: string; +}; + +export type GenerateOpenApiPagesResult = { + pages: GeneratedOpenApiPage[]; + nav: DocsNavNode[]; + /** Overview page listing every generated operation, one per source. */ + indexPages: GeneratedOpenApiIndexPage[]; +}; + +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 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, "") + .split("/") + .filter(Boolean) + .map(slugify) + .join("-"); + return slugify(`${method}-${pathSlug || "root"}`); +} + +function unescapePointerSegment(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) => unescapePointerSegment(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.")) { + 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` + ); + } + // 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` + ); + } +} + +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 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 +): OpenApiSchemaSummary | undefined { + if (!isRecord(value)) { + return; + } + + // 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; + } + 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), + ...(description ? { description } : {}), + ...(required ? { required } : {}), + ...(value.deprecated === true ? { deprecated: true } : {}), + ...(value.default === undefined ? {} : { default: value.default }), + ...(Array.isArray(value.enum) ? { enum: value.enum } : {}), + ...(format ? { format } : {}), + ...(value.example === undefined ? {} : { example: value.example }), + ...(properties.length > 0 ? { properties } : {}), + ...(items ? { items } : {}), + }; +} + +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; + } + const description = asString(value.description); + const schema = summarizeSchema(value.schema); + return { + name, + in: location, + required: value.required === true || location === "path", + ...(description ? { description } : {}), + ...(value.deprecated === true ? { deprecated: true } : {}), + ...(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, + includeSchemas = false +): OpenApiMediaType[] { + if (!isRecord(content)) { + return []; + } + const mediaTypes: OpenApiMediaType[] = []; + for (const [mediaType, value] of Object.entries(content)) { + 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, + ...(schema ? { schema } : {}), + ...(includeSchemas && isRecord(value.schema) + ? { rawSchema: value.schema } + : {}), + ...(example === undefined ? {} : { example }), + ...(hasNamedExamples ? { examples } : {}), + }); + } + return mediaTypes; +} + +function normalizeRequestBody( + value: unknown, + includeSchemas = false +): OpenApiRequestBody | undefined { + if (!isRecord(value)) { + return; + } + const description = asString(value.description); + return { + required: value.required === true, + ...(description ? { description } : {}), + content: normalizeMediaTypes(value.content, includeSchemas), + }; +} + +function normalizeResponses( + value: unknown, + includeSchemas = false +): 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, includeSchemas), + }); + } + 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; + } + 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, + ...(name ? { name } : {}), + ...(location ? { in: location } : {}), + ...(scheme ? { scheme } : {}), + ...(bearerFormat ? { bearerFormat } : {}), + ...(description ? { description } : {}), + ...(value.flows === undefined ? {} : { flows: value.flows }), + ...(openIdConnectUrl ? { 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 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(TRAILING_SLASHES_PATTERN, "") ?? ""; + return `${server}${operation.path}${requiredQueryString(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; + } + if (!headers.has(parameter.name)) { + headers.set(parameter.name, sampleParameterValue(parameter)); + } + } + const mediaType = operation.requestBody?.content[0]?.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}"`); + } + 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 = Object.fromEntries(sampleHeaders(operation)); + 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 ")},` + ); + } + 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, + 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)) + ); + // 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 apiVersion = asString(asRecord(document.info).version); + const requestBody = normalizeRequestBody( + operation.requestBody, + config.includeSchemas + ); + + const normalized: OpenApiOperation = { + method, + path: apiPath, + title, + description, + tags: asStringArray(operation.tags), + deprecated: operation.deprecated === true, + parameters, + responses: normalizeResponses(operation.responses, config.includeSchemas), + security, + securitySchemes, + codeSamples: [], + ...(operationId ? { operationId } : {}), + ...(summary ? { summary } : {}), + ...(apiVersion ? { apiVersion } : {}), + ...(serverUrl ? { serverUrl } : {}), + ...(requestBody ? { requestBody } : {}), + }; + normalized.codeSamples = vendorCodeSamples(operation) ?? [ + { code: buildCurlSample(normalized), label: "cURL", language: "bash" }, + { code: buildFetchSample(normalized), label: "JavaScript", language: "ts" }, + ]; + return normalized; +} + +export type NormalizeOpenApiConfigDefaults = { + /** Fallback `baseUrl` applied to sources that don't set their own. */ + baseUrl?: string; +}; + +export function normalizeOpenApiConfig( + config: DocsOpenApiConfig, + cwd: string, + defaults: NormalizeOpenApiConfigDefaults = {} +): ResolvedOpenApiSourceConfig[] { + const inputs = Array.isArray(config) ? config : [config]; + 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` + ); + } + const { baseUrl: sourceBaseUrl, ...rest } = source; + const baseUrl = (sourceBaseUrl ?? defaults.baseUrl) + ?.trim() + .replace(TRAILING_SLASHES_PATTERN, ""); + return { + ...rest, + cwd, + ...(baseUrl ? { baseUrl } : {}), + groupByTags: source.groupByTags ?? true, + includeSchemas: source.includeSchemas ?? true, + includeTryIt: source.includeTryIt ?? false, + output: normalizeOutputPath(source.output), + slugStrategy: source.slugStrategy ?? "operation-id", + title: source.title ?? DEFAULT_NAV_TITLE, + urlPrefix: normalizeUrlPrefixOption(source.urlPrefix), + }; + }); +} + +function normalizeUrlPrefixOption(input: string | undefined): string { + const raw = input?.trim() || DEFAULT_URL_PREFIX; + const normalized = normalizeDocsPath(raw).replace( + TRAILING_SLASHES_PATTERN, + "" + ); + return normalized.startsWith("/") ? normalized : `/${normalized}`; +} + +const execFileAsync = promisify(execFile); + +/** + * Last change date for a local spec file: the file's latest git commit date, + * falling back to filesystem mtime outside a repository. Remote specs return + * undefined — a fetch date would say nothing about the spec's freshness. + */ +async function resolveSpecLastModified( + input: string, + cwd: string +): Promise { + if (/^https?:\/\//i.test(input)) { + return; + } + const filePath = path.isAbsolute(input) ? input : path.resolve(cwd, input); + try { + const { stdout } = await execFileAsync( + "git", + ["log", "-1", "--format=%cI", "--", filePath], + { cwd: path.dirname(filePath) } + ); + const iso = stdout.trim(); + if (iso) { + return iso; + } + } catch { + // Not a git checkout (or git unavailable) — fall through to mtime. + } + try { + const stats = await stat(filePath); + return stats.mtime.toISOString(); + } catch { + return; + } +} + +/** + * 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", + "urlPrefix", + "baseUrl", + ] 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", + "includeSchemas", + "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 +): 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); +} + +const MAX_SHORT_DESCRIPTION_LENGTH = 250; + +/** + * Short, single-line description for frontmatter, nav, and search snippets. + * Prefers the first paragraph of the description (the summary usually mirrors + * the title), then the summary. + */ +function shortDescription(operation: OpenApiOperation): string { + 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 canonicalUrlFor( + config: ResolvedOpenApiSourceConfig, + relativePath: string +): string | undefined { + if (!config.baseUrl) { + return; + } + return `${config.baseUrl}${config.urlPrefix}/${stripDocsExtension(relativePath)}`; +} + +function renderFrontmatter( + operation: OpenApiOperation, + config: ResolvedOpenApiSourceConfig, + index: number, + relativePath: string +): string { + const lines = [ + "---", + `title: ${yamlString(operation.title)}`, + `description: ${yamlString(shortDescription(operation))}`, + ]; + if (config.group) { + lines.push(`group: ${JSON.stringify(config.group)}`); + } + if (config.order !== undefined) { + lines.push(`order: ${config.order + index}`); + } + // Machine-scannable operation metadata: agents can route to the right + // endpoint from frontmatter alone, without parsing the page body. `source` + // points at the OpenAPI document this page was generated from. + lines.push("type: api-reference"); + lines.push(`source: ${yamlString(config.input)}`); + lines.push(`method: ${yamlString(operation.method)}`); + lines.push(`path: ${yamlString(operation.path)}`); + if (operation.operationId) { + lines.push(`operationId: ${yamlString(operation.operationId)}`); + } + if (operation.serverUrl) { + lines.push(`server: ${yamlString(operation.serverUrl)}`); + } + if (operation.apiVersion) { + lines.push(`apiVersion: ${yamlString(operation.apiVersion)}`); + } + if (operation.tags.length > 0) { + lines.push(`tags: ${JSON.stringify(operation.tags)}`); + } + if (operation.deprecated) { + lines.push("deprecated: true"); + } + const canonicalUrl = canonicalUrlFor(config, relativePath); + if (canonicalUrl) { + lines.push(`canonicalUrl: ${yamlString(canonicalUrl)}`); + } + if (operation.lastModified) { + lines.push(`lastModified: ${yamlString(operation.lastModified)}`); + } + lines.push("---"); + 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, + relativePath: string +): 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, relativePath), + "", + renderMdxComponent("ApiEndpoint", { + deprecated: operation.deprecated || undefined, + method: operation.method, + operationId: operation.operationId, + path: operation.path, + serverUrl: operation.serverUrl, + }), + ]; + + // Docs renderers already print the frontmatter description under the title; + // only repeat the description in the body when it says more than that + // (compare whitespace-collapsed so soft-wrapped YAML text doesn't differ). + const description = operation.description.trim(); + const collapsedDescription = description + .replace(WHITESPACE_RUN_PATTERN, " ") + .trim(); + if (description && collapsedDescription !== shortDescription(operation)) { + blocks.push("", escapeMarkdownForMdx(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 }) + ); + } + + const related = relatedLinks(config); + if (related.length > 0) { + blocks.push("", "## Related", ""); + for (const link of related) { + blocks.push(`- ${link}`); + } + } + + return `${blocks.join("\n")}\n`; +} + +function overviewUrlPath(config: ResolvedOpenApiSourceConfig): string { + return `${config.urlPrefix}/${stripDocsExtension(config.output)}`; +} + +function relatedLinks(config: ResolvedOpenApiSourceConfig): string[] { + const links = [ + `[${config.title} overview](${overviewUrlPath(config)}): Every operation in this API.`, + ]; + if (/^https?:\/\//i.test(config.input)) { + links.push( + `[OpenAPI spec](${config.input}): Machine-readable source for this reference.` + ); + } + return links; +} + +function renderIndexMdx( + pages: GeneratedOpenApiPage[], + config: ResolvedOpenApiSourceConfig, + document: Record, + lastModified: string | undefined +): string { + const info = asRecord(document.info); + const infoDescription = asString(info.description); + const description = + config.description ?? + infoDescription ?? + `API reference for ${config.title}.`; + const version = asString(info.version); + const canonicalUrl = config.baseUrl + ? `${config.baseUrl}${overviewUrlPath(config)}` + : undefined; + const lines = [ + "---", + `title: ${yamlString(config.title)}`, + `description: ${yamlString(description)}`, + ...(config.group ? [`group: ${JSON.stringify(config.group)}`] : []), + "type: api-reference", + `source: ${yamlString(config.input)}`, + ...(version ? [`apiVersion: ${yamlString(version)}`] : []), + ...(canonicalUrl ? [`canonicalUrl: ${yamlString(canonicalUrl)}`] : []), + ...(lastModified ? [`lastModified: ${yamlString(lastModified)}`] : []), + "---", + ]; + // Docs renderers print the frontmatter description under the title; only + // repeat the spec's own description when it adds something beyond that. + if (infoDescription && infoDescription !== description) { + lines.push("", escapeMarkdownForMdx(infoDescription)); + } + if (version) { + lines.push("", `API version: \`${version}\``); + } + + 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); + } + for (const [tag, tagPages] of byTag) { + lines.push("", `## ${escapeMarkdownForMdx(tag)}`, ""); + for (const page of tagPages) { + const urlPath = `${config.urlPrefix}/${stripDocsExtension(page.relativePath)}`; + const label = `\`${page.operation.method.toUpperCase()} ${page.operation.path}\``; + lines.push( + `- [${escapeMarkdownForMdx(page.title)}](${urlPath}) — ${label}. ${escapeMarkdownForMdx(page.description)}` + ); + } + } + return `${lines.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: [ + "index", + ...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, + pages: ["index"], + 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, + usedPaths: Set = new Set() +): Promise { + const document = await loadOpenApiDocument(config.input, config.cwd); + const lastModified = await resolveSpecLastModified(config.input, config.cwd); + const candidates = collectOperationCandidates(document).filter((candidate) => + shouldIncludeOperation(candidate.operation, config) + ); + const pages: GeneratedOpenApiPage[] = []; + for (const candidate of candidates) { + const normalized = normalizeOperation(candidate, document, config); + const operation = lastModified + ? { ...normalized, lastModified } + : normalized; + const relativePath = operationRelativePath(operation, config, usedPaths); + pages.push({ + description: shortDescription(operation), + filePath: relativePath, + operation, + relativePath, + title: operation.title, + }); + } + const nav = buildGeneratedNav(pages, config); + const indexPages: GeneratedOpenApiIndexPage[] = []; + if (pages.length > 0) { + const relativePath = normalizeDocsPath( + path.posix.join(config.output, "index.mdx") + ); + if (!usedPaths.has(relativePath.toLowerCase())) { + usedPaths.add(relativePath.toLowerCase()); + const content = renderIndexMdx(pages, config, document, lastModified); + indexPages.push({ + content, + description: + config.description ?? + asString(asRecord(document.info).description) ?? + `API reference for ${config.title}.`, + filePath: relativePath, + relativePath, + title: config.title, + }); + } + } + return { + indexPages, + pages, + nav: nav ? [nav] : [], + }; +} + +export type WriteOpenApiPagesConfig = { + configs: ResolvedOpenApiSourceConfig[]; + docsDir: string; +}; + +export async function writeOpenApiPages({ + configs, + docsDir, +}: WriteOpenApiPagesConfig): Promise { + const allPages: GeneratedOpenApiPage[] = []; + const allIndexPages: GeneratedOpenApiIndexPage[] = []; + 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, usedPaths); + 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, page.relativePath) + ); + allPages.push({ ...page, filePath: outputPath }); + } + for (const indexPage of generated.indexPages) { + const outputPath = path.join(docsDir, indexPage.relativePath); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, indexPage.content); + allIndexPages.push({ ...indexPage, filePath: outputPath }); + } + nav.push(...generated.nav); + } + return { indexPages: allIndexPages, 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; + /** Site base URL for `canonicalUrl` frontmatter on generated pages. */ + baseUrl?: 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[]; + indexPages: GeneratedOpenApiIndexPage[]; + /** 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, { + ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), + }), + docsDir: stagedContentDir, + }); + return { + cleanup: async () => { + await rm(stagedRoot, { force: true, recursive: true }); + }, + contentDir: stagedContentDir, + indexPages: result.indexPages, + nav: result.nav, + pages: result.pages, + }; +} diff --git a/packages/leadtype/src/openapi/openapi.test.ts b/packages/leadtype/src/openapi/openapi.test.ts new file mode 100644 index 00000000..7c7b20b0 --- /dev/null +++ b/packages/leadtype/src/openapi/openapi.test.ts @@ -0,0 +1,657 @@ +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 { convertMdxToMarkdown } from "../convert/convert"; +import { defaultMarkdownTransforms } from "../markdown/index"; +import { + buildSchemaExample, + escapeMarkdownForMdx, + normalizeOpenApiConfig, + stageOpenApiDocs, + validateDocsOpenApiConfig, + 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 }); + }) + ); +}); + +const FIXTURE_SPEC = ` +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. +`; + +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({ + 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(" { + const { docsDir, result } = await generateFixturePages(FIXTURE_SPEC); + expect(result.indexPages).toHaveLength(1); + expect(result.nav[0]?.pages).toContain("index"); + + const index = await readFile( + path.join(docsDir, "rest-api", "index.mdx"), + "utf8" + ); + expect(index).toContain('title: "API Reference"'); + expect(index).toContain("## Access Groups"); + expect(index).toContain( + "[Reads an access group](/docs/rest-api/access-groups/read-access-group)" + ); + expect(index).toContain("`GET /access-groups/{id}`"); + }); + + it("stamps canonicalUrl and lastModified when baseUrl is set", async () => { + const dir = await createTempDir(); + const docsDir = path.join(dir, "docs"); + await mkdir(docsDir, { recursive: true }); + await writeFixture(dir, "openapi.yaml", FIXTURE_SPEC); + const configs = normalizeOpenApiConfig( + { input: "openapi.yaml", output: "rest-api" }, + dir, + { baseUrl: "https://example.com/" } + ); + const result = await writeOpenApiPages({ configs, docsDir }); + + const page = await readFile(result.pages[0]?.filePath ?? "", "utf8"); + expect(page).toContain( + 'canonicalUrl: "https://example.com/docs/rest-api/access-groups/read-access-group"' + ); + // Temp fixture is not a git checkout — falls back to the file mtime. + expect(page).toMatch(/lastModified: "2\d{3}-/); + + const index = await readFile(result.indexPages[0]?.filePath ?? "", "utf8"); + expect(index).toContain( + 'canonicalUrl: "https://example.com/docs/rest-api"' + ); + expect(index).toMatch(/lastModified: "2\d{3}-/); + }); + + it("omits canonicalUrl without a baseUrl", async () => { + const { result } = await generateFixturePages(FIXTURE_SPEC); + const page = await readFile(result.pages[0]?.filePath ?? "", "utf8"); + expect(page).not.toContain("canonicalUrl:"); + }); + + it("honors a custom urlPrefix in overview and related links", async () => { + const dir = await createTempDir(); + const docsDir = path.join(dir, "docs"); + await mkdir(docsDir, { recursive: true }); + await writeFixture(dir, "openapi.yaml", FIXTURE_SPEC); + const configs = normalizeOpenApiConfig( + { input: "openapi.yaml", output: "rest-api", urlPrefix: "/reference" }, + dir + ); + const result = await writeOpenApiPages({ configs, docsDir }); + const index = await readFile(result.indexPages[0]?.filePath ?? "", "utf8"); + expect(index).toContain("(/reference/rest-api/access-groups/"); + const page = await readFile(result.pages[0]?.filePath ?? "", "utf8"); + expect(page).toContain("(/reference/rest-api)"); + }); + + it("flattens generated pages into agent-readable markdown", async () => { + 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"'); + // The dereferenced JSON Schema ships as the full contract. + expect(converted.markdown).toContain("JSON Schema:"); + expect(converted.markdown).toContain('"required": ['); + }); + + it("omits raw schemas when includeSchemas is false", async () => { + const dir = await createTempDir(); + const docsDir = path.join(dir, "docs"); + await mkdir(docsDir, { recursive: true }); + await writeFixture(dir, "openapi.yaml", FIXTURE_SPEC); + const configs = normalizeOpenApiConfig( + { includeSchemas: false, input: "openapi.yaml", output: "rest-api" }, + dir + ); + const result = await writeOpenApiPages({ configs, docsDir }); + const page = await readFile(result.pages[0]?.filePath ?? "", "utf8"); + expect(page).not.toContain("rawSchema"); + }); + + 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. 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 + ); + expect(converted.markdown).toContain("{id}"); + 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 +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..7ce4f05b 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,36 @@ 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, + ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), + }); + 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 +519,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,