import { IconArrowNarrowRight, IconChevronRight, IconLock, IconPlus, IconTrash, } from "@tabler/icons-react"; import { useState } from "react"; import { cn } from "../../utils.js"; import { ltrCodeBlockProps } from "../code-block-direction.js"; import type { BlockEditProps, BlockReadProps } from "../types.js"; import type { ApiEndpointChange, ApiEndpointData, ApiEndpointMethod, ApiEndpointParam, ApiEndpointResponse, ApiParamLocation, } from "./api-endpoint.config.js"; import { API_ENDPOINT_CHANGES, API_ENDPOINT_METHODS, API_PARAM_LOCATIONS, } from "./api-endpoint.config.js"; import { useBlockCopy } from "./block-copy.js"; import { DevBadge, DevInput, DevSwitch, DevTextarea, DevSelect, } from "./dev-doc-ui.js"; import { JsonExplorerSurface } from "./JsonExplorerBlock.js"; /** * Read + Edit renderers for an `api-endpoint` block — a Swagger / Stripe-style * API reference. Lives in core so any app can register the dev-doc block (no * shadcn import). */ /* ── Theme-aware color tokens ──────────────────────────────────────────────── */ /** * Method-pill palette. Tinted background + saturated text in BOTH modes (the * reference HTML hardcoded a dark-only palette — we deliberately avoid that). * Each entry keeps legible contrast against the plan surface under `.dark` and * light via Tailwind `dark:` variants. */ 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", }; /** Location-badge palette for the params table `in` column. */ 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", 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"; // 3xx and everything else → neutral slate. return "bg-slate-200 text-slate-700 dark:bg-slate-500/20 dark:text-slate-300"; } /* ── Theme-aware change tokens (shared vocabulary with file-tree/data-model) ── */ /** * Change-chip palette — IDENTICAL to `FileTreeBlock`'s `CHANGE_BADGE` so a route / * param / response chip reads the same as a file or field change chip elsewhere * in the recap. Tinted background + saturated text in BOTH the `.dark` plan theme * and light mode via Tailwind `dark:` variants (never a dark-only palette). */ const CHANGE_BADGE: Record = { added: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300", modified: "bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300", removed: "bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-300", renamed: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300", }; /** Single-letter glyph shown in the compact chip (VS Code gutter convention). */ const CHANGE_GLYPH: Record = { added: "A", modified: "M", removed: "D", renamed: "R", }; /** Human label for the chip text + its `title` / `aria-label`. */ const CHANGE_LABEL: Record = { added: "Added", modified: "Modified", removed: "Removed", renamed: "Renamed", }; /** Accent ink echoing a change color, for the name/path it applies to. */ const CHANGE_INK: Record = { added: "text-emerald-700 dark:text-emerald-300", modified: "text-blue-700 dark:text-blue-300", removed: "text-red-600 line-through dark:text-red-300", renamed: "text-violet-700 dark:text-violet-300", }; /** * A change chip: compact single-glyph badge (A/M/D/R) by default, or a labeled * pill (`variant="label"`) for the endpoint header where there is room. Matches * the file-tree change badge so the recap reads consistently. */ function ChangeChip({ change, label = CHANGE_LABEL[change], variant = "glyph", className, }: { change: ApiEndpointChange; label?: string; variant?: "glyph" | "label"; className?: string; }) { if (variant === "label") { return ( {label} ); } return ( {CHANGE_GLYPH[change]} ); } /** * Before → after for a modified param: the prior `was` value struck through, a * narrow arrow, then the current value (e.g. `optional → required`, or the old * type → the new type). When `was` is absent we just show the current value. */ function WasArrowCurrent({ was, current, }: { was?: string; current: React.ReactNode; }) { if (!was) return <>{current}; return ( {was} {current} ); } /** * A param carries a single `was` (prior value) for a `modified` change, but that * value may describe either the required flag or the type. Decide which column * the before→after belongs to: a `was` of `required`/`optional` is a required * flag flip; anything else is treated as the prior type. */ function wasIsRequiredFlag(was: string): boolean { const v = was.trim().toLowerCase(); return v === "required" || v === "optional"; } /** Guess a fence language from a content type so examples highlight nicely. */ function fenceLangForContentType(contentType?: string): string { const ct = (contentType ?? "").toLowerCase(); if (ct.includes("xml") || ct.includes("html")) return "html"; if (ct.includes("yaml") || ct.includes("yml")) return "yaml"; return "json"; } /** * Strip JSONC niceties so an otherwise-valid-but-commented example still parses * as JSON and earns the collapsible JsonExplorer (instead of falling back to a * plain code block). Removes `//` line comments and `/* … *​/` block comments, * then trailing commas before `}`/`]`. String contents are preserved: `//` * inside a quoted string (e.g. a URL) is NOT treated as a comment. */ function stripJsonComments(source: string): string { let out = ""; let inString = false; let stringQuote = ""; let inLineComment = false; let inBlockComment = false; for (let i = 0; i < source.length; i += 1) { const char = source[i]; const next = source[i + 1]; if (inLineComment) { if (char === "\n") { inLineComment = false; out += char; } continue; } if (inBlockComment) { if (char === "*" && next === "/") { inBlockComment = false; i += 1; } continue; } if (inString) { out += char; if (char === "\\") { // Copy the escaped char verbatim so an escaped quote can't end the // string early. if (next !== undefined) { out += next; i += 1; } } else if (char === stringQuote) { inString = false; } continue; } if (char === '"' || char === "'") { inString = true; stringQuote = char; out += char; continue; } if (char === "/" && next === "/") { inLineComment = true; i += 1; continue; } if (char === "/" && next === "*") { inBlockComment = true; i += 1; continue; } // Drop a trailing comma before a closing bracket so the result is strict // JSON. Done here (not via a post-pass regex) so it stays string-aware: we // only reach this branch outside strings/comments, and the structural comma // is the last non-whitespace char already emitted. A comma inside a string // value like `"hello,}"` is followed by its closing quote, not the bracket, // so it is never stripped. if (char === "}" || char === "]") { out = out.replace(/,\s*$/, ""); } out += char; } return out; } /** * Decide whether an example should render with the collapsible JsonExplorer. * Returns the strict-JSON text to feed the explorer (comment-stripped when the * raw example was JSONC), or `null` when the example is not parseable as JSON * (free-form / XML / YAML text) and should fall back to the styled code surface. * * Parseability — NOT the declared `contentType` — is the gate, so the REQUEST * body example earns the same interactive explorer as the RESPONSE examples * whenever it is valid JSON. A request often carries a `contentType` that is not * literally `application/json` (e.g. a WebSocket-upgrade body) yet still holds a * JSON payload; keying off the content type would wrongly drop those into the * static code block. `contentType` now only labels the non-JSON code fallback * (via `fenceLangForContentType`), it never suppresses the explorer. */ function jsonExplorerSource(example: string): string | null { try { JSON.parse(example); return example; } catch { // Tolerate JSONC: a commented-but-otherwise-valid body still gets the nice // explorer. Feed the explorer the stripped (strict-JSON) text so it parses. const stripped = stripJsonComments(example); try { JSON.parse(stripped); return stripped; } catch { return null; } } } /** * Plain (non-JSON) example fallback. Renders inside the SAME surface chrome as * {@link JsonExplorerSurface} — one `rounded-xl border bg-plan-code` box with a * label bar and an `overflow-auto` scroll body — so a JSONC / free-form example * reads consistently with the JSON-explorer examples instead of as a separate, * differently-styled code box (no extra background tint, no clipped overflow). * Font-size is inherited from `--plan-code-size` (the same token the global code * rule manages); we never hardcode it here. */ function ApiCodeExample({ code, label = "JSON", className, }: { code: string; label?: string; className?: string; }) { return (
{label}
        {code}
      
); } function ApiExample({ example, contentType, className, }: { example: string; contentType?: string; className?: string; }) { const jsonSource = jsonExplorerSource(example); if (jsonSource !== null) { return ( ); } return ( ); } /* ── Read (collapsed-by-default swagger row) ───────────────────────────────── */ /** * Read-only renderer for an `api-endpoint` block. Collapsed by default: a single * row with a colored method pill, monospace path, muted summary, and a chevron. * Clicking the row expands the full reference (description, params table, * request body, responses) — the Swagger / Stripe house style. Every colored * element is theme-aware (`dark:` variants), so it reads correctly in both the * `.dark` plan theme and light mode. */ export function ApiEndpointRead({ data, blockId, title, summary, ctx, }: BlockReadProps) { const [open, setOpen] = useState(false); const copy = useBlockCopy(); const changeLabel: Record = { added: copy.added, modified: copy.modified, removed: copy.removed, renamed: copy.renamed, }; const params = data.params ?? []; const responses = data.responses ?? []; const hasRequest = Boolean( data.request?.example || data.request?.contentType, ); const hasBody = Boolean(data.description?.trim()) || params.length > 0 || hasRequest || responses.length > 0 || Boolean(data.auth); return ( // `data-block-type` lets the document flow detect a RUN of consecutive // api-endpoint blocks and collapse the divider + gap between them (see // `.plan-document-flow` rules in the plan template's global.css), so a list // of endpoints reads as one tight scannable group instead of separate // full-width cards. `an-api-endpoint-card` is the flush-able card surface // those rules round/merge at the run's edges.
{title &&
{title}
}
{/* Collapsed summary row — the whole row toggles. */} {/* Expanded body. No top divider: the title/summary row flows into the content separated by padding alone (the user finds mid-card dividers distracting; the outer card border + run-flush behavior stay). */} {open && hasBody && (
{data.auth && (
{copy.auth}: {" "} {data.auth}
)} {data.description?.trim() && (
{ctx.renderMarkdown?.(data.description)}
)} {params.length > 0 && (
{copy.parameters}
{params.map((param, index) => { const change = param.change; // A `modified` `was` describes either the required flag // or the prior type; route it to the right column. const wasForRequired = change === "modified" && param.was && wasIsRequiredFlag(param.was) ? param.was : undefined; const wasForType = change === "modified" && param.was && !wasIsRequiredFlag(param.was) ? param.was : undefined; return ( ); })}
{copy.name} {copy.in} {copy.type} {copy.requiredColumn} {copy.description}
{param.name} {change && ( )} {param.in} {param.type || "—"} } /> {copy.required} ) : ( {copy.optional} ) } /> {param.description || "—"}
)} {hasRequest && (
{copy.requestBody} {data.request?.contentType && ( {data.request.contentType} )}
{data.request?.example && ( )}
)} {responses.length > 0 && (
{copy.responses}
{responses.map((response, index) => (
{response.status} {response.description && ( {response.description} )} {response.change && ( )}
{response.example && (
)}
))}
)}
)}
); } /* ── Edit (panel form) ─────────────────────────────────────────────────────── */ const fieldLabelClass = "text-xs font-medium text-muted-foreground"; /** * Options for a change `DevSelect` — a leading "No change" entry (decodes to * `undefined`) plus the four diff states, mirroring the file-tree editor. */ const CHANGE_SELECT_OPTIONS = [ { value: "none", label: "No change" }, ...API_ENDPOINT_CHANGES.map((change) => ({ value: change, label: CHANGE_LABEL[change], })), ]; /** * Panel editor for an `api-endpoint` block. A property form: method (Select), * path/summary/auth (Input), description (Textarea), deprecated (Switch), plus * repeatable rows for params and responses (add/remove) and a request-body * textarea. Renders BARE content (no `
`); the registry's panel surface * supplies the popover chrome. */ export function ApiEndpointEdit({ data, onChange, editable, }: BlockEditProps) { const params = data.params ?? []; const responses = data.responses ?? []; const patch = (next: Partial) => onChange({ ...data, ...next }); const updateParam = (index: number, next: Partial) => patch({ params: params.map((param, i) => i === index ? { ...param, ...next } : param, ), }); const removeParam = (index: number) => patch({ params: params.filter((_, i) => i !== index) }); const addParam = () => patch({ params: [...params, { name: "param", in: "query" as ApiParamLocation }], }); const updateResponse = (index: number, next: Partial) => patch({ responses: responses.map((response, i) => i === index ? { ...response, ...next } : response, ), }); const removeResponse = (index: number) => patch({ responses: responses.filter((_, i) => i !== index) }); const addResponse = () => patch({ responses: [...responses, { status: "200" }] }); const updateRequest = (next: Partial) => { const merged = { ...(data.request ?? {}), ...next }; const empty = !merged.contentType && !merged.example; patch({ request: empty ? undefined : merged }); }; return (
{/* Params */}
Parameters {editable && ( )}
{params.map((param, index) => (
updateParam(index, { name: event.target.value }) } /> updateParam(index, { in: value as ApiParamLocation }) } options={API_PARAM_LOCATIONS.map((location) => ({ value: location, label: location, }))} /> {editable && ( )}
updateParam(index, { type: event.target.value || undefined }) } />
updateParam(index, { description: event.target.value || undefined, }) } /> {/* Diff state: change kind + the prior value (`was`) shown struck-through before the current one when "modified". */}
updateParam(index, { change: value === "none" ? undefined : (value as ApiEndpointChange), }) } options={CHANGE_SELECT_OPTIONS} /> updateParam(index, { was: event.target.value || undefined }) } />
))}
{/* Request body */}
Request body updateRequest({ contentType: event.target.value || undefined }) } /> updateRequest({ example: event.target.value || undefined }) } />
{/* Responses */}
Responses {editable && ( )}
{responses.map((response, index) => (
updateResponse(index, { status: event.target.value }) } /> updateResponse(index, { description: event.target.value || undefined, }) } /> {editable && ( )}
updateResponse(index, { example: event.target.value || undefined, }) } />
))}
); }