#!/usr/bin/env bun import { mkdir, readFile, stat, writeFile } from "fs/promises"; import { extname, join, resolve } from "path"; const VERSION = "0.1.0"; type Theme = "light" | "slate"; interface CliOptions { spec?: string; output: string; theme: Theme; baseUrl?: string; title?: string; json: boolean; } interface JsonObject { [key: string]: unknown; } interface ParamModel { name: string; in: string; required: boolean; description: string; type: string; enum?: string[]; example?: string; } interface BodyModel { contentType: string; required: boolean; description: string; schema: SchemaField[]; raw: unknown; } interface ResponseModel { status: string; description: string; contentType: string | null; schema: SchemaField[]; } interface SchemaField { name: string; type: string; required: boolean; description: string; children?: SchemaField[]; } interface EndpointModel { id: string; method: string; path: string; operationId: string | null; summary: string; description: string; tags: string[]; group: string; deprecated: boolean; parameters: ParamModel[]; requestBody: BodyModel | null; responses: ResponseModel[]; security: string[]; } interface PortalModel { title: string; version: string; description: string; baseUrl: string; servers: string[]; theme: Theme; generatedAt: string; groups: Array<{ name: string; description: string; endpoints: EndpointModel[] }>; endpointCount: number; securitySchemes: Array<{ name: string; type: string; detail: string }>; } const METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"]; /* ------------------------------------------------------------------ * * dependency loading * ------------------------------------------------------------------ */ async function loadYaml(): Promise<{ parse: (text: string) => unknown }> { try { return await import("yaml"); } catch { throw new Error("Missing dependency 'yaml'. Run bun install in this skill directory."); } } /* ------------------------------------------------------------------ * * CLI * ------------------------------------------------------------------ */ function printHelp(): void { console.log(`api-docs-portal v${VERSION} USAGE: api-docs-portal --spec [options] OPTIONS: -s, --spec OpenAPI 3.x or Swagger 2.0 document (JSON or YAML) -o, --output Output directory (default: ./api-portal) --theme Portal theme: light | slate (default: light) --base-url Base URL shown in the portal and cURL examples --title Override the portal title (default: spec info.title) --json Print a JSON summary on stdout --help Show this help message --version Show the current version OUTPUT FILES: index.html Self-contained portal (inline CSS, no external requests) endpoints.json Normalized endpoint model reference.md Markdown API reference EXAMPLES: api-docs-portal --spec ./openapi.yaml api-docs-portal --spec ./openapi.json --output ./docs/api --theme slate api-docs-portal --spec ./openapi.yaml --base-url https://api.example.com --json `); } function parseArgs(argv: string[]): CliOptions { const options: CliOptions = { output: "./api-portal", theme: "light", json: false, }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; switch (arg) { case "--help": case "-h": printHelp(); process.exit(0); case "--version": case "-v": console.log(VERSION); process.exit(0); case "--spec": case "-s": options.spec = argv[++i]; break; case "--output": case "-o": options.output = argv[++i] ?? options.output; break; case "--theme": { const value = (argv[++i] ?? "").toLowerCase(); if (value !== "light" && value !== "slate") { throw new Error(`Invalid --theme value: ${value || "(empty)"} (expected light or slate)`); } options.theme = value; break; } case "--base-url": options.baseUrl = argv[++i]; break; case "--title": options.title = argv[++i]; break; case "--json": options.json = true; break; default: if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`); if (!options.spec) { options.spec = arg; break; } throw new Error(`Unexpected argument: ${arg}`); } } if (!options.spec) throw new Error("Missing required --spec argument"); return options; } /* ------------------------------------------------------------------ * * spec loading + $ref resolution * ------------------------------------------------------------------ */ function isObject(value: unknown): value is JsonObject { return typeof value === "object" && value !== null && !Array.isArray(value); } function asString(value: unknown, fallback = ""): string { if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); return fallback; } async function loadSpec(path: string): Promise { let fileStat; try { fileStat = await stat(path); } catch { throw new Error(`Cannot read spec file: ${path}`); } if (!fileStat.isFile()) throw new Error(`Spec path is not a file: ${path}`); const text = await readFile(path, "utf8"); if (text.trim() === "") throw new Error(`Spec file is empty: ${path}`); const extension = extname(path).toLowerCase(); const looksJson = extension === ".json" || text.trimStart().startsWith("{"); let parsed: unknown; if (looksJson) { try { parsed = JSON.parse(text); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Spec file is not valid JSON (${path}): ${message}`); } } else { const yaml = await loadYaml(); try { parsed = yaml.parse(text); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Spec file is not valid YAML (${path}): ${message}`); } } if (!isObject(parsed)) throw new Error(`Spec file did not contain an object: ${path}`); if (!isObject(parsed.paths)) { throw new Error(`Spec file has no "paths" object; this does not look like an OpenAPI document: ${path}`); } return parsed; } /** Resolve a local `#/...` JSON pointer against the root document. */ function resolvePointer(root: JsonObject, pointer: string): unknown { if (!pointer.startsWith("#/")) return undefined; const segments = pointer .slice(2) .split("/") .map((segment) => segment.replace(/~1/gu, "/").replace(/~0/gu, "~")); let current: unknown = root; for (const segment of segments) { if (Array.isArray(current)) { const index = Number.parseInt(segment, 10); if (!Number.isFinite(index)) return undefined; current = current[index]; continue; } if (!isObject(current)) return undefined; current = current[segment]; } return current; } function deref(root: JsonObject, node: unknown, seen: Set = new Set()): unknown { if (Array.isArray(node)) return node.map((item) => deref(root, item, seen)); if (!isObject(node)) return node; if (typeof node.$ref === "string") { const pointer = node.$ref; if (!pointer.startsWith("#/")) { return { ...node, "x-unresolved-ref": pointer }; } if (seen.has(pointer)) return { type: "object", description: `Circular reference to ${pointer}` }; const target = resolvePointer(root, pointer); if (target === undefined) return { ...node, "x-unresolved-ref": pointer }; const nextSeen = new Set(seen); nextSeen.add(pointer); const resolved = deref(root, target, nextSeen); const rest = { ...node }; delete rest.$ref; return isObject(resolved) ? { ...resolved, ...rest } : resolved; } const out: JsonObject = {}; for (const [key, value] of Object.entries(node)) out[key] = deref(root, value, seen); return out; } /* ------------------------------------------------------------------ * * schema flattening * ------------------------------------------------------------------ */ function schemaTypeLabel(schema: unknown): string { if (!isObject(schema)) return "any"; if (typeof schema["x-unresolved-ref"] === "string") return `ref(${schema["x-unresolved-ref"]})`; const compose = ["oneOf", "anyOf", "allOf"] as const; for (const key of compose) { const branches = schema[key]; if (Array.isArray(branches)) { const parts = branches.map((branch) => schemaTypeLabel(branch)); return key === "allOf" ? parts.join(" & ") : parts.join(" | "); } } const type = asString(schema.type); if (type === "array") return `${schemaTypeLabel(schema.items)}[]`; if (Array.isArray(schema.enum)) { const values = schema.enum.map((value) => asString(value)).join(" | "); return type ? `${type} (${values})` : values; } if (schema.format) return `${type || "string"}<${asString(schema.format)}>`; if (type) return type; if (isObject(schema.properties)) return "object"; return "any"; } function flattenSchema(schema: unknown, depth = 0): SchemaField[] { if (!isObject(schema) || depth > 3) return []; let target = schema; if (asString(schema.type) === "array" && isObject(schema.items)) target = schema.items; if (Array.isArray(target.allOf)) { const merged: SchemaField[] = []; for (const branch of target.allOf) merged.push(...flattenSchema(branch, depth)); return merged; } if (!isObject(target.properties)) return []; const required = new Set(Array.isArray(target.required) ? target.required.map((item) => asString(item)) : []); return Object.entries(target.properties).map(([name, value]) => { const field: SchemaField = { name, type: schemaTypeLabel(value), required: required.has(name), description: isObject(value) ? asString(value.description) : "", }; const children = flattenSchema(value, depth + 1); if (children.length > 0) field.children = children; return field; }); } /* ------------------------------------------------------------------ * * endpoint model * ------------------------------------------------------------------ */ function firstContent(content: unknown): { contentType: string | null; media: JsonObject | null } { if (!isObject(content)) return { contentType: null, media: null }; const preferred = Object.keys(content).find((key) => key.includes("json")) ?? Object.keys(content)[0]; if (!preferred) return { contentType: null, media: null }; const media = content[preferred]; return { contentType: preferred, media: isObject(media) ? media : null }; } function buildParameters(raw: unknown): ParamModel[] { if (!Array.isArray(raw)) return []; return raw.filter(isObject).map((parameter) => { const schema = isObject(parameter.schema) ? parameter.schema : parameter; return { name: asString(parameter.name, "(unnamed)"), in: asString(parameter.in, "query"), required: parameter.required === true || asString(parameter.in) === "path", description: asString(parameter.description), type: schemaTypeLabel(schema), enum: Array.isArray(schema.enum) ? schema.enum.map((value) => asString(value)) : undefined, example: parameter.example !== undefined ? asString(parameter.example) : undefined, }; }); } function buildRequestBody(operation: JsonObject): BodyModel | null { const body = operation.requestBody; if (!isObject(body)) return null; const { contentType, media } = firstContent(body.content); const schema = media?.schema; return { contentType: contentType ?? "application/json", required: body.required === true, description: asString(body.description), schema: flattenSchema(schema), raw: schema ?? null, }; } function buildResponses(operation: JsonObject): ResponseModel[] { if (!isObject(operation.responses)) return []; return Object.entries(operation.responses).map(([status, value]) => { const response = isObject(value) ? value : {}; const { contentType, media } = firstContent(response.content); return { status, description: asString(response.description), contentType, schema: flattenSchema(media?.schema), }; }); } function groupName(tags: string[], path: string): string { if (tags.length > 0) return tags[0]; const segment = path.split("/").filter((part) => part && !part.startsWith("{"))[0]; return segment ? segment.replace(/[-_]/gu, " ") : "default"; } function buildModel(spec: JsonObject, options: CliOptions): PortalModel { const info = isObject(spec.info) ? spec.info : {}; const servers = Array.isArray(spec.servers) ? spec.servers.filter(isObject).map((server) => asString(server.url)).filter(Boolean) : []; if (typeof spec.host === "string") { const scheme = Array.isArray(spec.schemes) && spec.schemes.length > 0 ? asString(spec.schemes[0]) : "https"; servers.push(`${scheme}://${spec.host}${asString(spec.basePath)}`); } const baseUrl = options.baseUrl ?? servers[0] ?? ""; const paths = isObject(spec.paths) ? spec.paths : {}; const groups = new Map(); let counter = 0; for (const [path, rawItem] of Object.entries(paths)) { if (!isObject(rawItem)) continue; const item = deref(spec, rawItem) as JsonObject; const sharedParameters = buildParameters(item.parameters); for (const method of METHODS) { const rawOperation = item[method]; if (!isObject(rawOperation)) continue; const operation = rawOperation; const tags = Array.isArray(operation.tags) ? operation.tags.map((tag) => asString(tag)).filter(Boolean) : []; const group = groupName(tags, path); const parameters = [...sharedParameters, ...buildParameters(operation.parameters)]; const security = Array.isArray(operation.security) ? operation.security.flatMap((entry) => (isObject(entry) ? Object.keys(entry) : [])) : Array.isArray(spec.security) ? spec.security.flatMap((entry) => (isObject(entry) ? Object.keys(entry) : [])) : []; counter += 1; const endpoint: EndpointModel = { id: `${method}-${path}`.replace(/[^a-zA-Z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").toLowerCase() || `endpoint-${counter}`, method: method.toUpperCase(), path, operationId: operation.operationId ? asString(operation.operationId) : null, summary: asString(operation.summary) || asString(operation.operationId) || `${method.toUpperCase()} ${path}`, description: asString(operation.description), tags, group, deprecated: operation.deprecated === true, parameters, requestBody: buildRequestBody(operation), responses: buildResponses(operation), security: [...new Set(security)], }; const bucket = groups.get(group); if (bucket) bucket.push(endpoint); else groups.set(group, [endpoint]); } } if (counter === 0) throw new Error("Spec contains no operations under \"paths\""); const tagDescriptions = new Map(); if (Array.isArray(spec.tags)) { for (const tag of spec.tags) { if (isObject(tag)) tagDescriptions.set(asString(tag.name), asString(tag.description)); } } const components = isObject(spec.components) ? spec.components : {}; const schemes = isObject(components.securitySchemes) ? components.securitySchemes : isObject(spec.securityDefinitions) ? spec.securityDefinitions : {}; const securitySchemes = Object.entries(schemes) .filter((entry): entry is [string, JsonObject] => isObject(entry[1])) .map(([name, scheme]) => ({ name, type: asString(scheme.type, "unknown"), detail: [ scheme.scheme ? `scheme: ${asString(scheme.scheme)}` : "", scheme.bearerFormat ? `format: ${asString(scheme.bearerFormat)}` : "", scheme.in ? `in: ${asString(scheme.in)}` : "", scheme.name ? `name: ${asString(scheme.name)}` : "", asString(scheme.description), ] .filter(Boolean) .join(", "), })); return { title: options.title ?? asString(info.title, "API Documentation"), version: asString(info.version, "unversioned"), description: asString(info.description), baseUrl, servers, theme: options.theme, generatedAt: new Date().toISOString(), groups: [...groups.entries()] .sort((a, b) => a[0].localeCompare(b[0])) .map(([name, endpoints]) => ({ name, description: tagDescriptions.get(name) ?? "", endpoints: endpoints.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method)), })), endpointCount: counter, securitySchemes, }; } /* ------------------------------------------------------------------ * * rendering * ------------------------------------------------------------------ */ function escapeHtml(value: string): string { return value .replace(/&/gu, "&") .replace(//gu, ">") .replace(/"/gu, """) .replace(/'/gu, "'"); } const THEMES: Record> = { light: { bg: "#f7f8fa", surface: "#ffffff", text: "#1b1f24", muted: "#5b6472", border: "#e2e6ec", accent: "#2c5fd8", code: "#f2f4f7", sidebar: "#ffffff", }, slate: { bg: "#11151c", surface: "#182029", text: "#e6ebf2", muted: "#93a1b3", border: "#26313d", accent: "#63a4ff", code: "#0d1219", sidebar: "#151c24", }, }; const METHOD_COLORS: Record = { GET: "#1f8a4c", POST: "#2c5fd8", PUT: "#a86a12", PATCH: "#8a5cd6", DELETE: "#c2392e", HEAD: "#5b6472", OPTIONS: "#5b6472", TRACE: "#5b6472", }; function renderSchemaRows(fields: SchemaField[], prefix = "", depth = 0): string { return fields .map((field) => { const name = prefix ? `${prefix}.${field.name}` : field.name; const row = ` 0 ? ' class="nested"' : ""}>${escapeHtml(name)}${escapeHtml( field.type, )}${ field.required ? 'required' : 'optional' }${escapeHtml(field.description)}`; return field.children ? row + renderSchemaRows(field.children, name, depth + 1) : row; }) .join(""); } function renderSchemaTable(fields: SchemaField[]): string { if (fields.length === 0) return ""; return `
${renderSchemaRows( fields, )}
FieldTypeDescription
`; } function curlExample(endpoint: EndpointModel, baseUrl: string): string { const url = `${baseUrl.replace(/\/+$/u, "")}${endpoint.path}`; const parts = [`curl -X ${endpoint.method} "${url}"`]; if (endpoint.security.length > 0) parts.push(` -H "Authorization: Bearer $TOKEN"`); if (endpoint.requestBody) { parts.push(` -H "Content-Type: ${endpoint.requestBody.contentType}"`); const sample = endpoint.requestBody.schema .slice(0, 3) .map((field) => `"${field.name}": "..."`) .join(", "); parts.push(` -d '{${sample}}'`); } return parts.join(" \\\n"); } function renderEndpoint(endpoint: EndpointModel, baseUrl: string): string { const paramGroups = new Map(); for (const parameter of endpoint.parameters) { const bucket = paramGroups.get(parameter.in); if (bucket) bucket.push(parameter); else paramGroups.set(parameter.in, [parameter]); } const params = [...paramGroups.entries()] .map( ([location, list]) => `

${escapeHtml(location)} parameters

${list .map( (parameter) => ``, ) .join("")}
NameTypeDescription
${escapeHtml(parameter.name)}${escapeHtml(parameter.type)}${ parameter.required ? 'required' : 'optional' }${escapeHtml(parameter.description)}${ parameter.enum ? ` (${escapeHtml(parameter.enum.join(", "))})` : "" }
`, ) .join(""); const body = endpoint.requestBody ? `

Request body (${escapeHtml(endpoint.requestBody.contentType)}${ endpoint.requestBody.required ? ", required" : "" })

${ endpoint.requestBody.description ? `

${escapeHtml(endpoint.requestBody.description)}

` : "" }${renderSchemaTable(endpoint.requestBody.schema) || '

No object schema declared.

'}` : ""; const responses = endpoint.responses.length ? `

Responses

${endpoint.responses .map( (response) => `
${escapeHtml( response.status, )} ${escapeHtml(response.description)}${ response.contentType ? ` — ${escapeHtml(response.contentType)}` : "" }${renderSchemaTable(response.schema)}
`, ) .join("")}` : ""; return `
${escapeHtml(endpoint.method)} ${escapeHtml(endpoint.path)} ${endpoint.deprecated ? 'deprecated' : ""}

${escapeHtml(endpoint.summary)}

${endpoint.description ? `

${escapeHtml(endpoint.description)}

` : ""} ${endpoint.security.length ? `

Security: ${escapeHtml(endpoint.security.join(", "))}

` : ""} ${params} ${body} ${responses}

Example

${escapeHtml(curlExample(endpoint, baseUrl))}
`; } function renderHtml(model: PortalModel): string { const palette = THEMES[model.theme]; const methodCss = Object.entries(METHOD_COLORS) .map(([method, color]) => `.method-${method.toLowerCase()}{background:${color};}`) .join(""); const nav = model.groups .map( (group) => ``, ) .join(""); const sections = model.groups .map( (group) => `

${escapeHtml(group.name)}

${group.description ? `

${escapeHtml(group.description)}

` : ""} ${group.endpoints.map((endpoint) => renderEndpoint(endpoint, model.baseUrl)).join("\n")}
`, ) .join("\n"); const security = model.securitySchemes.length ? `

Authentication

${model.securitySchemes .map( (scheme) => `

${escapeHtml(scheme.name)}${escapeHtml(scheme.type)}${ scheme.detail ? ` (${escapeHtml(scheme.detail)})` : "" }

`, ) .join("")}
` : ""; return ` ${escapeHtml(model.title)} — API Reference

${escapeHtml(model.title)}

${model.description ? `

${escapeHtml(model.description)}

` : ""} ${model.baseUrl ? `

${escapeHtml(model.baseUrl)}

` : ""}
${security} ${sections}
Generated by api-docs-portal v${VERSION} on ${escapeHtml(model.generatedAt)}. Static, self-contained, no external requests.
`; } function renderMarkdown(model: PortalModel): string { const lines: string[] = []; lines.push(`# ${model.title}`, ""); lines.push(`Version: ${model.version}`); if (model.baseUrl) lines.push(`Base URL: \`${model.baseUrl}\``); lines.push(`Endpoints: ${model.endpointCount}`, ""); if (model.description) lines.push(model.description, ""); if (model.securitySchemes.length > 0) { lines.push("## Authentication", ""); for (const scheme of model.securitySchemes) { lines.push(`- \`${scheme.name}\` — ${scheme.type}${scheme.detail ? ` (${scheme.detail})` : ""}`); } lines.push(""); } for (const group of model.groups) { lines.push(`## ${group.name}`, ""); if (group.description) lines.push(group.description, ""); for (const endpoint of group.endpoints) { lines.push(`### ${endpoint.method} ${endpoint.path}`, ""); if (endpoint.summary) lines.push(`${endpoint.summary}${endpoint.deprecated ? " **(deprecated)**" : ""}`, ""); if (endpoint.description) lines.push(endpoint.description, ""); if (endpoint.security.length > 0) lines.push(`Security: ${endpoint.security.join(", ")}`, ""); if (endpoint.parameters.length > 0) { lines.push("| Parameter | In | Type | Required | Description |"); lines.push("|-----------|----|------|----------|-------------|"); for (const parameter of endpoint.parameters) { lines.push( `| \`${parameter.name}\` | ${parameter.in} | ${parameter.type} | ${parameter.required ? "yes" : "no"} | ${parameter.description.replace(/\|/gu, "\\|")} |`, ); } lines.push(""); } if (endpoint.requestBody) { lines.push(`Request body (\`${endpoint.requestBody.contentType}\`${endpoint.requestBody.required ? ", required" : ""}):`, ""); if (endpoint.requestBody.schema.length > 0) { lines.push("| Field | Type | Required | Description |"); lines.push("|-------|------|----------|-------------|"); for (const field of endpoint.requestBody.schema) { lines.push(`| \`${field.name}\` | ${field.type} | ${field.required ? "yes" : "no"} | ${field.description.replace(/\|/gu, "\\|")} |`); } lines.push(""); } else { lines.push("_No object schema declared._", ""); } } if (endpoint.responses.length > 0) { lines.push("Responses:", ""); for (const response of endpoint.responses) { const fields = response.schema.map((field) => `\`${field.name}\` (${field.type})`).join(", "); lines.push( `- **${response.status}** ${response.description}${response.contentType ? ` — ${response.contentType}` : ""}${fields ? ` — ${fields}` : ""}`, ); } lines.push(""); } lines.push("```bash", curlExample(endpoint, model.baseUrl), "```", ""); } } lines.push(`_Generated by api-docs-portal v${VERSION} on ${model.generatedAt}._`); return `${lines.join("\n")}\n`; } /* ------------------------------------------------------------------ * * main * ------------------------------------------------------------------ */ async function main(): Promise { const options = parseArgs(process.argv.slice(2)); const specPath = resolve(options.spec!); const outputDir = resolve(options.output); const spec = await loadSpec(specPath); const model = buildModel(spec, options); await mkdir(outputDir, { recursive: true }); const files: string[] = []; const write = async (name: string, content: string) => { await writeFile(join(outputDir, name), content, "utf8"); files.push(name); }; await write("index.html", renderHtml(model)); await write( "endpoints.json", `${JSON.stringify( { title: model.title, version: model.version, baseUrl: model.baseUrl, servers: model.servers, generatedAt: model.generatedAt, securitySchemes: model.securitySchemes, endpoints: model.groups.flatMap((group) => group.endpoints), }, null, 2, )}\n`, ); await write("reference.md", renderMarkdown(model)); if (options.json) { process.stdout.write( `${JSON.stringify( { spec: specPath, outputDir, title: model.title, version: model.version, theme: model.theme, baseUrl: model.baseUrl, endpointCount: model.endpointCount, groups: model.groups.map((group) => ({ name: group.name, endpoints: group.endpoints.length })), files, }, null, 2, )}\n`, ); return; } console.log(`api-docs-portal: ${specPath}`); console.log(` title ${model.title} v${model.version}`); console.log(` base url ${model.baseUrl || "(none declared)"}`); console.log(` theme ${model.theme}`); console.log(` endpoints ${model.endpointCount} in ${model.groups.length} groups`); for (const group of model.groups) console.log(` ${group.name}: ${group.endpoints.length}`); console.log(` output ${outputDir}`); console.log(` files ${files.join(", ")}`); } main().catch((error) => { const message = error instanceof Error ? error.message : String(error); process.stderr.write(`api-docs-portal: ${message}\n`); process.exit(1); });