import { IconChevronRight, IconLock } from "@tabler/icons-react"; import { useId, useMemo, useState } from "react"; import { cn } from "../../utils.js"; import { ltrCodeBlockProps } from "../code-block-direction.js"; import type { BlockEditProps, BlockReadProps } from "../types.js"; import { DevInput, DevLabel, DevTextarea } from "./dev-doc-ui.js"; import { CodeSurface } from "./HighlightedCode.js"; import type { OpenApiSpecData } from "./openapi-spec.config.js"; /** * Read + Edit renderers for an `openapi-spec` block — a Redoc / Swagger-UI-style * API reference rendered from a whole OpenAPI 3 / Swagger 2 document. The raw * `spec` TEXT (`data.spec`) is the source of truth; the Read renderer parses it * defensively and, on any parse error, falls back to the raw text plus the error * message (it never throws). Lives in core so any app can register the dev-doc * block (no shadcn import). * * Operations are grouped by tag; each operation is a collapsed-by-default row * (colored method pill + monospace path + summary) that expands to its * description, params table, request body, and per-status responses — the SAME * per-operation house style as the single-endpoint `api-endpoint` block. `$ref` * model references are resolved against `components.schemas` (OpenAPI 3) or * top-level `definitions` (Swagger 2), with a cycle guard. * * v1 parses JSON specs only (no `yaml` dependency is declared). `parseSpec` is * the single seam to extend when a YAML parser is added. * * DARK/LIGHT: the plan editor toggles a `.dark` class on . Every color * token (method/status/location pills, chrome) uses Tailwind `dark:` variants or * the theme-aware plan CSS-var utilities, so the reference reads correctly in * BOTH modes (no hardcoded dark-only palette). SSR-safe: rendering derives only * from props (no window/document access at module or render time). */ /* ── Theme-aware color tokens (mirrors ApiEndpointBlock) ────────────────────── */ const METHOD_PILL: Record = { GET: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300", POST: "bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300", PUT: "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300", PATCH: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300", DELETE: "bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-300", HEAD: "bg-slate-200 text-slate-700 dark:bg-slate-500/20 dark:text-slate-300", OPTIONS: "bg-slate-200 text-slate-700 dark:bg-slate-500/20 dark:text-slate-300", TRACE: "bg-slate-200 text-slate-700 dark:bg-slate-500/20 dark:text-slate-300", }; const PARAM_IN_BADGE: Record = { path: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300", query: "bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300", header: "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300", cookie: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300", body: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300", }; /** Status-pill palette keyed by the leading status digit (2xx/3xx/4xx/5xx). */ function statusPillClass(status: string): string { const lead = status.trim().charAt(0); if (lead === "2") return "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"; if (lead === "4") return "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300"; if (lead === "5") return "bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-300"; return "bg-slate-200 text-slate-700 dark:bg-slate-500/20 dark:text-slate-300"; } /* ── Defensive spec parsing + normalization ────────────────────────────────── */ type Json = unknown; type JsonObject = Record; const HTTP_METHODS = [ "get", "post", "put", "patch", "delete", "head", "options", "trace", ] as const; interface NormalizedParam { name: string; in: string; type?: string; required?: boolean; description?: string; } interface NormalizedResponse { status: string; description?: string; /** Inline JSON example/schema preview, when derivable. */ example?: string; } interface NormalizedOperation { id: string; method: string; path: string; summary?: string; description?: string; deprecated?: boolean; secured?: boolean; tags: string[]; params: NormalizedParam[]; requestContentType?: string; requestExample?: string; responses: NormalizedResponse[]; } interface NormalizedTagGroup { tag: string; description?: string; operations: NormalizedOperation[]; } interface NormalizedSpec { title?: string; version?: string; description?: string; format: "OpenAPI 3" | "Swagger 2" | "Unknown"; groups: NormalizedTagGroup[]; operationCount: number; } interface ParseResult { ok: boolean; spec?: NormalizedSpec; error?: string; } function isObject(value: Json): value is JsonObject { return value !== null && typeof value === "object" && !Array.isArray(value); } function asString(value: Json): string | undefined { return typeof value === "string" ? value : undefined; } /** * Parse the raw spec text into a JSON object. v1 supports JSON only — a YAML * parser is not a declared dependency. This is the single seam to extend with * YAML once `yaml` is a real dependency. */ function parseSpec(raw: string): ParseResult { const trimmed = raw.trim(); if (!trimmed) { return { ok: false, error: "Empty spec — paste an OpenAPI 3 / Swagger 2 document.", }; } let doc: Json; try { doc = JSON.parse(trimmed); } catch (error) { const hint = /^[A-Za-z][\w-]*\s*:/.test(trimmed) || trimmed.startsWith("---") ? " (YAML is not supported yet — paste JSON, or convert the spec to JSON.)" : ""; return { ok: false, error: `${error instanceof Error ? error.message : "Invalid JSON"}${hint}`, }; } if (!isObject(doc)) { return { ok: false, error: "Spec must be a JSON object." }; } try { return { ok: true, spec: normalizeSpec(doc) }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : "Could not interpret the spec document.", }; } } /** Resolve a local `$ref` (e.g. `#/components/schemas/User`) against the root. */ function resolveRef( root: JsonObject, ref: string, seen: Set, ): Json | undefined { if (!ref.startsWith("#/")) return undefined; if (seen.has(ref)) return undefined; // cycle guard seen.add(ref); const segments = ref .slice(2) .split("/") .map((seg) => seg.replace(/~1/g, "/").replace(/~0/g, "~")); let current: Json = root; for (const segment of segments) { if (!isObject(current)) return undefined; current = current[segment]; } return current; } /** Follow a single `$ref` hop on a schema/param object if present. */ function deref(root: JsonObject, value: Json, seen: Set): Json { let current = value; let guard = 0; while (isObject(current) && typeof current.$ref === "string" && guard < 20) { const resolved = resolveRef(root, current.$ref, seen); if (resolved === undefined) return current; current = resolved; guard += 1; } return current; } /** Short human type label for a (deref'd) schema object. */ function schemaTypeLabel( root: JsonObject, schema: Json, seen: Set, ): string | undefined { const resolved = deref(root, schema, new Set(seen)); if (!isObject(resolved)) return undefined; if (typeof resolved.type === "string") { if (resolved.type === "array" && resolved.items) { const inner = schemaTypeLabel(root, resolved.items, seen); return inner ? `${inner}[]` : "array"; } return resolved.type; } if (resolved.$ref && typeof resolved.$ref === "string") { return resolved.$ref.split("/").pop(); } if (resolved.enum) return "enum"; if (resolved.properties) return "object"; if (resolved.oneOf || resolved.anyOf || resolved.allOf) return "object"; return undefined; } /** * Build a compact JSON skeleton from a (resolved) schema so the request/response * panels can show a Swagger-UI-style model preview. Bounded depth + a cycle * guard keep it safe on recursive models. */ function schemaExample( root: JsonObject, schema: Json, seen: Set, depth: number, ): Json { if (depth > 6) return "…"; const resolved = deref(root, schema, new Set(seen)); if (!isObject(resolved)) return resolved ?? null; if (resolved.example !== undefined) return resolved.example; if (resolved.default !== undefined) return resolved.default; if (Array.isArray(resolved.enum) && resolved.enum.length > 0) { return resolved.enum[0]; } const allOf = Array.isArray(resolved.allOf) ? resolved.allOf : null; if (allOf) { const merged: JsonObject = {}; for (const part of allOf) { const value = schemaExample(root, part, seen, depth + 1); if (isObject(value)) Object.assign(merged, value); } if (Object.keys(merged).length > 0) return merged; } const oneOf = (Array.isArray(resolved.oneOf) && resolved.oneOf) || (Array.isArray(resolved.anyOf) && resolved.anyOf) || null; if (oneOf && oneOf.length > 0) { return schemaExample(root, oneOf[0], seen, depth + 1); } const type = resolved.type; if (type === "object" || resolved.properties) { const props = isObject(resolved.properties) ? resolved.properties : {}; const out: JsonObject = {}; for (const [key, propSchema] of Object.entries(props).slice(0, 30)) { out[key] = schemaExample(root, propSchema, seen, depth + 1); } return out; } if (type === "array") { return [schemaExample(root, resolved.items ?? {}, seen, depth + 1)]; } if (type === "integer" || type === "number") return 0; if (type === "boolean") return true; if (type === "string") { if (resolved.format === "date-time") return "2020-01-01T00:00:00Z"; if (resolved.format === "uuid") return "00000000-0000-0000-0000-000000000000"; return "string"; } return null; } /** Stringify a derived example, guarding against oversized payloads. */ function stringifyExample(value: Json): string | undefined { try { const text = JSON.stringify(value, null, 2); if (!text || text === "null" || text === "{}" || text === "[]") return undefined; return text.length > 8_000 ? `${text.slice(0, 8_000)}\n…` : text; } catch { return undefined; } } function normalizeParam( root: JsonObject, raw: Json, seen: Set, ): NormalizedParam | undefined { const param = deref(root, raw, new Set(seen)); if (!isObject(param)) return undefined; const name = asString(param.name); const location = asString(param.in); if (!name || !location) return undefined; // OpenAPI 3 nests type under `schema`; Swagger 2 puts it on the param. const type = schemaTypeLabel(root, param.schema, seen) ?? asString(param.type); return { name, in: location, type, required: param.required === true, description: asString(param.description), }; } function normalizeOperation( root: JsonObject, path: string, method: string, rawOp: Json, pathLevelParams: NormalizedParam[], seen: Set, ): NormalizedOperation | undefined { if (!isObject(rawOp)) return undefined; const params: NormalizedParam[] = [...pathLevelParams]; if (Array.isArray(rawOp.parameters)) { for (const rawParam of rawOp.parameters) { const normalized = normalizeParam(root, rawParam, seen); if (normalized) params.push(normalized); } } // Request body: OpenAPI 3 `requestBody.content[*].schema`; Swagger 2 `body` param. let requestContentType: string | undefined; let requestExample: string | undefined; const requestBody = deref(root, rawOp.requestBody, new Set(seen)); if (isObject(requestBody) && isObject(requestBody.content)) { const [contentType, media] = Object.entries(requestBody.content)[0] ?? []; requestContentType = contentType; if (isObject(media)) { requestExample = stringifyExample(media.example) ?? stringifyExample(schemaExample(root, media.schema, seen, 0)); } } else { const bodyParam = params.find((p) => p.in === "body"); if (bodyParam) { // Swagger 2 body param carries its schema on the raw parameter. const rawBody = Array.isArray(rawOp.parameters) ? rawOp.parameters.find( (p) => isObject(deref(root, p, new Set(seen))) && asString((deref(root, p, new Set(seen)) as JsonObject).in) === "body", ) : undefined; const resolvedBody = isObject(deref(root, rawBody, new Set(seen))) ? (deref(root, rawBody, new Set(seen)) as JsonObject) : undefined; requestContentType = "application/json"; requestExample = stringifyExample( schemaExample(root, resolvedBody?.schema, seen, 0), ); } } // Swagger 2 `body` params are represented via requestBody above; drop them // from the visible param table so they don't double-render. const visibleParams = params.filter((p) => p.in !== "body"); const responses: NormalizedResponse[] = []; if (isObject(rawOp.responses)) { for (const [status, rawResponse] of Object.entries(rawOp.responses)) { const response = deref(root, rawResponse, new Set(seen)); let example: string | undefined; if (isObject(response)) { // OpenAPI 3: response.content[*].schema; Swagger 2: response.schema. if (isObject(response.content)) { const media = Object.values(response.content)[0]; if (isObject(media)) { example = stringifyExample(media.example) ?? stringifyExample(schemaExample(root, media.schema, seen, 0)); } } else if (response.schema) { example = stringifyExample( schemaExample(root, response.schema, seen, 0), ); } } responses.push({ status, description: isObject(response) ? asString(response.description) : undefined, example, }); } } const tags = Array.isArray(rawOp.tags) && rawOp.tags.length > 0 ? rawOp.tags.filter((t): t is string => typeof t === "string") : []; const secured = Array.isArray(rawOp.security) && rawOp.security.length > 0 ? rawOp.security.some( (req) => isObject(req) && Object.keys(req).length > 0, ) : undefined; return { id: `${method}-${path}`, method: method.toUpperCase(), path, summary: asString(rawOp.summary), description: asString(rawOp.description), deprecated: rawOp.deprecated === true, secured, tags: tags.length > 0 ? tags : ["default"], params: visibleParams, requestContentType, requestExample, responses: responses.sort((a, b) => a.status.localeCompare(b.status)), }; } function normalizeSpec(doc: JsonObject): NormalizedSpec { const format: NormalizedSpec["format"] = typeof doc.openapi === "string" ? "OpenAPI 3" : typeof doc.swagger === "string" ? "Swagger 2" : "Unknown"; const info = isObject(doc.info) ? doc.info : undefined; // Global security requirement → every operation without its own override is // secured. Operation-level `security: []` opts out; we treat presence here as // the default-secured signal. const globalSecured = Array.isArray(doc.security) && doc.security.some((req) => isObject(req) && Object.keys(req).length > 0); // Tag order + descriptions from the document's top-level `tags`. const tagOrder: string[] = []; const tagDescriptions = new Map(); if (Array.isArray(doc.tags)) { for (const tag of doc.tags) { if (isObject(tag) && typeof tag.name === "string") { tagOrder.push(tag.name); if (typeof tag.description === "string") { tagDescriptions.set(tag.name, tag.description); } } } } const groups = new Map(); let operationCount = 0; const paths = isObject(doc.paths) ? doc.paths : {}; for (const [path, rawPathItem] of Object.entries(paths)) { const seen = new Set(); const pathItem = deref(doc, rawPathItem, seen); if (!isObject(pathItem)) continue; const pathLevelParams: NormalizedParam[] = []; if (Array.isArray(pathItem.parameters)) { for (const rawParam of pathItem.parameters) { const normalized = normalizeParam(doc, rawParam, new Set()); if (normalized) pathLevelParams.push(normalized); } } for (const method of HTTP_METHODS) { const rawOp = pathItem[method]; if (!isObject(rawOp)) continue; const operation = normalizeOperation( doc, path, method, rawOp, pathLevelParams, new Set(), ); if (!operation) continue; if (operation.secured === undefined && globalSecured) { operation.secured = true; } operationCount += 1; for (const tag of operation.tags) { const list = groups.get(tag) ?? []; list.push(operation); groups.set(tag, list); } } } // Order: documented tags first (in their declared order), then any remaining // tags alphabetically. const orderedTagNames = [ ...tagOrder.filter((tag) => groups.has(tag)), ...[...groups.keys()] .filter((tag) => !tagOrder.includes(tag)) .sort((a, b) => a.localeCompare(b)), ]; const groupList: NormalizedTagGroup[] = orderedTagNames.map((tag) => ({ tag, description: tagDescriptions.get(tag), operations: groups.get(tag) ?? [], })); return { title: info ? asString(info.title) : undefined, version: info ? asString(info.version) : undefined, description: info ? asString(info.description) : undefined, format, groups: groupList, operationCount, }; } /* ── Operation row (collapsed-by-default, mirrors api-endpoint) ─────────────── */ function OperationRow({ operation, renderMarkdown, }: { operation: NormalizedOperation; renderMarkdown?: (markdown: string) => React.ReactNode; }) { const [open, setOpen] = useState(false); const hasBody = Boolean(operation.description?.trim()) || operation.params.length > 0 || Boolean(operation.requestExample || operation.requestContentType) || operation.responses.length > 0; return (
{open && hasBody && (
{operation.description?.trim() && (
{renderMarkdown ? ( renderMarkdown(operation.description) ) : (

{operation.description}

)}
)} {operation.params.length > 0 && (
Parameters
{operation.params.map((param, index) => ( ))}
Name In Type Required Description
{param.name} {param.in} {param.type || "—"} {param.required ? ( required ) : ( optional )} {param.description || "—"}
)} {(operation.requestExample || operation.requestContentType) && (
Request body {operation.requestContentType && ( {operation.requestContentType} )}
{operation.requestExample && ( )}
)} {operation.responses.length > 0 && (
Responses
{operation.responses.map((response, index) => (
{response.status} {response.description && ( {response.description} )}
{response.example && (
)}
))}
)}
)}
); } /* ── Tag group (collapsed-by-default) ──────────────────────────────────────── */ function TagGroup({ group, defaultOpen, renderMarkdown, }: { group: NormalizedTagGroup; defaultOpen: boolean; renderMarkdown?: (markdown: string) => React.ReactNode; }) { const [open, setOpen] = useState(defaultOpen); return (
{open && (
{group.operations.map((operation) => ( ))}
)}
); } /* ── Read (Redoc / Swagger-UI-style reference) ─────────────────────────────── */ /** * Read-only renderer for an `openapi-spec` block. Parses `data.spec` defensively * and renders a Redoc / Swagger-UI-style reference: a header (title + version + * format badge), then operations grouped by tag, each a collapsed-by-default row * that expands to the full per-operation reference. On a parse error it shows the * error plus the raw payload (never throws). */ export function OpenApiSpecRead({ data, blockId, title, summary, ctx, }: BlockReadProps) { const parsed = useMemo(() => parseSpec(data.spec), [data.spec]); const heading = data.title ?? title; const renderMarkdown = ctx.renderMarkdown; return (
{heading &&
{heading}
} {parsed.ok && parsed.spec ? (
{parsed.spec.title || "API reference"} {parsed.spec.version && ( v{parsed.spec.version} )} {parsed.spec.format} {parsed.spec.operationCount}{" "} {parsed.spec.operationCount === 1 ? "operation" : "operations"}
{parsed.spec.description?.trim() && (
{renderMarkdown ? ( renderMarkdown(parsed.spec.description) ) : (

{parsed.spec.description}

)}
)}
{parsed.spec.groups.length === 0 ? (
No operations found in this spec.
) : ( parsed.spec.groups.map((group, index) => ( )) )}
) : (
OpenAPI

Could not parse spec: {parsed.error}

)} {summary &&

{summary}

}
); } /* ── Edit (panel form) ─────────────────────────────────────────────────────── */ /** * Panel editor for an `openapi-spec` block: a `title` input plus a monospace * textarea bound to the raw `spec`, with a "Format" button that pretty-prints via * `JSON.parse` → `JSON.stringify(_, null, 2)` (guarded — shows an INLINE error, * never `window.alert`). Renders BARE content (no `
`); the registry's * panel surface supplies the popover chrome. */ export function OpenApiSpecEdit({ data, onChange, editable, }: BlockEditProps) { const titleId = useId(); const specId = useId(); const [formatError, setFormatError] = useState(null); const handleFormat = () => { try { const formatted = JSON.stringify(JSON.parse(data.spec), null, 2); setFormatError(null); onChange({ ...data, spec: formatted }); } catch (error) { setFormatError( error instanceof Error ? error.message : "Invalid JSON — cannot format", ); } }; return (
Title onChange({ ...data, title: event.target.value || undefined }) } placeholder="Optional heading" />
OpenAPI / Swagger spec {editable && ( )}
{ setFormatError(null); onChange({ ...data, spec: event.target.value }); }} className="min-h-72 font-mono text-xs" placeholder={ '{\n "openapi": "3.0.0",\n "info": { "title": "My API", "version": "1.0.0" },\n "paths": {}\n}' } /> {formatError && (

{formatError}

)}

Paste a complete OpenAPI 3 or Swagger 2 document. v1 supports JSON specs only — convert YAML to JSON first.

); }