501 lines · 16.1 KB · TypeScript
RawDownload
import { useState, useCallback, useMemo } from "react";import { ChevronRight } from "lucide-react";import * as Option from "effect/Option";import { CardStack, CardStackHeader, CardStackContent } from "./card-stack"; // ---------------------------------------------------------------------------// JSON Schema types (subset we render)// --------------------------------------------------------------------------- type JsonSchema = {  type?: string | string[];  properties?: Record<string, JsonSchema>;  required?: string[];  items?: JsonSchema | JsonSchema[];  additionalProperties?: boolean | JsonSchema;  oneOf?: JsonSchema[];  anyOf?: JsonSchema[];  allOf?: JsonSchema[];  enum?: unknown[];  const?: unknown;  $ref?: string;  $defs?: Record<string, JsonSchema>;  description?: SchemaText;  title?: SchemaText;  default?: unknown;  nullable?: boolean;  format?: string;}; export const safeSchemaValueLabel = (value: unknown): string => {  if (typeof value === "bigint") return `${value.toString()}n`;  try {    const label = JSON.stringify(value);    if (label === undefined) return String(value);    return label.length > 120 ? `${label.slice(0, 117)}...` : label;  } catch {    return "[unavailable]";  }}; type SchemaText = string | Option.Option<string>; const schemaText = (value: SchemaText | undefined): string | undefined => {  if (typeof value === "string") return value;  if (Option.isOption(value) && Option.isSome(value)) {    return value.value;  }  return undefined;}; // ---------------------------------------------------------------------------// Ref resolution — lazy, only on expand// --------------------------------------------------------------------------- const resolveRef = (ref: string, root: JsonSchema): JsonSchema | null => {  const name = ref.match(/^#\/\$defs\/(.+)$/)?.[1];  if (!name || !root.$defs) return null;  return root.$defs[name] ?? null;}; const getRefName = (ref: string): string | undefined => ref.match(/^#\/\$defs\/(.+)$/)?.[1]; /** * Fully resolve a schema, following $ref and unwrapping single-variant * oneOf/anyOf so we can inspect the concrete shape. */const deepResolve = (schema: JsonSchema, root: JsonSchema): JsonSchema => {  let s = schema;  if (s.$ref) {    const resolved = resolveRef(s.$ref, root);    if (resolved) s = resolved;  }  // Unwrap single-variant unions  if (s.oneOf?.length === 1) s = deepResolve(s.oneOf[0]!, root);  if (s.anyOf?.length === 1) s = deepResolve(s.anyOf[0]!, root);  return s;}; // ---------------------------------------------------------------------------// Type label — human readable, shows ref names// --------------------------------------------------------------------------- // oxlint-disable-next-line only-used-in-recursionconst getTypeLabel = (schema: JsonSchema, root: JsonSchema): string => {  if (schema.$ref) {    return getRefName(schema.$ref) ?? "ref";  }   if (schema.const !== undefined) return safeSchemaValueLabel(schema.const);   if (schema.enum) {    if (schema.enum.length <= 3) {      return schema.enum.map((v) => safeSchemaValueLabel(v)).join(" | ");    }    return `enum (${schema.enum.length})`;  }   if (schema.oneOf) {    if (schema.oneOf.length === 1) return getTypeLabel(schema.oneOf[0]!, root);    const labels = schema.oneOf.slice(0, 3).map((s) => getTypeLabel(s, root));    if (schema.oneOf.length > 3) labels.push("…");    return labels.join(" | ");  }   if (schema.anyOf) {    if (schema.anyOf.length === 1) return getTypeLabel(schema.anyOf[0]!, root);    const labels = schema.anyOf.slice(0, 3).map((s) => getTypeLabel(s, root));    if (schema.anyOf.length > 3) labels.push("…");    return labels.join(" | ");  }   if (schema.allOf) {    // If any allOf member is a named ref, show that    for (const s of schema.allOf) {      if (s.$ref) {        const name = getRefName(s.$ref);        if (name) return name;      }    }    return "object";  }   const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];   if (types.includes("array")) {    if (schema.items && !Array.isArray(schema.items)) {      return `${getTypeLabel(schema.items, root)}[]`;    }    return "array";  }   if (types.includes("object")) {    if (schema.additionalProperties && typeof schema.additionalProperties === "object") {      return `Record<string, ${getTypeLabel(schema.additionalProperties, root)}>`;    }    return "object";  }   if (types.length === 1) {    const t = types[0]!;    if (schema.format) return `${t}<${schema.format}>`;    return t;  }  if (types.length > 1) return types.join(" | ");   return "any";}; // ---------------------------------------------------------------------------// Expandability check — conservative, resolves deeply// --------------------------------------------------------------------------- const getChildCount = (schema: JsonSchema, root: JsonSchema): number => {  const s = deepResolve(schema, root);   if (s.properties) return Object.keys(s.properties).length;   if (s.items && !Array.isArray(s.items)) {    const itemResolved = deepResolve(s.items, root);    if (itemResolved.properties) return Object.keys(itemResolved.properties).length;    return 0;  }   if (s.allOf) {    const merged = mergeAllOf(s.allOf, root);    if (merged.properties) return Object.keys(merged.properties).length;    return 0;  }   if (s.oneOf && s.oneOf.length > 1) return s.oneOf.length;  if (s.anyOf && s.anyOf.length > 1) return s.anyOf.length;   if (s.additionalProperties && typeof s.additionalProperties === "object") return 1;   return 0;}; const isExpandable = (schema: JsonSchema, root: JsonSchema): boolean =>  getChildCount(schema, root) > 0; // ---------------------------------------------------------------------------// Merge allOf// --------------------------------------------------------------------------- const mergeAllOf = (schemas: JsonSchema[], root: JsonSchema): JsonSchema => {  const merged: JsonSchema = { type: "object", properties: {}, required: [] };  for (const s of schemas) {    const resolved = deepResolve(s, root);    if (resolved.properties) {      merged.properties = { ...merged.properties, ...resolved.properties };    }    if (resolved.required) {      merged.required = [...(merged.required ?? []), ...resolved.required];    }    if (schemaText(resolved.description) && !schemaText(merged.description)) {      merged.description = schemaText(resolved.description);    }  }  return merged;}; // ---------------------------------------------------------------------------// Type label styling — plain text, no colored pills// --------------------------------------------------------------------------- const typeClasses = "font-mono text-xs leading-5 text-muted-foreground"; // ---------------------------------------------------------------------------// PropertyRow// --------------------------------------------------------------------------- function PropertyRow(props: {  name: string;  schema: JsonSchema;  root: JsonSchema;  required: boolean;  depth: number;  isLast?: boolean;  /** Hide the required/optional badge entirely (e.g. for union variants) */  hideRequiredBadge?: boolean;}) {  const { name, schema, root, required, depth, hideRequiredBadge } = props;  const [open, setOpen] = useState(false);  const [resolved, setResolved] = useState<JsonSchema | null>(null);   const expandable = isExpandable(schema, root);  const typeLabel = getTypeLabel(schema, root);  const description =    schemaText(schema.description) ??    (schema.$ref ? schemaText(resolveRef(schema.$ref, root)?.description) : undefined);   const handleToggle = useCallback(() => {    if (!open && !resolved && schema.$ref) {      setResolved(resolveRef(schema.$ref, root));    }    setOpen((v) => !v);  }, [open, resolved, schema, root]);   const childSchema = schema.$ref ? (resolved ?? resolveRef(schema.$ref, root) ?? schema) : schema;   return (    <div>      <div        role={expandable ? "button" : undefined}        tabIndex={expandable ? 0 : undefined}        onClick={expandable ? handleToggle : undefined}        onKeyDown={          expandable            ? (e) => {                if (e.key === "Enter" || e.key === " ") {                  e.preventDefault();                  handleToggle();                }              }            : undefined        }        className={[          "flex items-start gap-2 py-2.5 px-4",          expandable ? "cursor-pointer hover:bg-accent/40 transition-colors" : "",        ].join(" ")}        style={depth > 0 ? { paddingLeft: `${depth * 16 + 16}px` } : undefined}      >        {/* Chevron or dot */}        <div className="mt-0.5 flex size-4 shrink-0 items-center justify-center">          {expandable ? (            <ChevronRight              className="size-3.5 shrink-0 text-muted-foreground transition-transform duration-150"              style={open ? { transform: "rotate(90deg)" } : undefined}            />          ) : (            <span className="size-1 rounded-full bg-muted-foreground/40" />          )}        </div>         {/* Content */}        <div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5">          <p className="font-medium text-foreground text-sm">{name}</p>          <p className={typeClasses}>{typeLabel}</p>          {!hideRequiredBadge &&            (required ? (              <p className="text-xs leading-5 font-medium text-muted-foreground">required</p>            ) : (              <p className="text-xs leading-5 text-muted-foreground">optional</p>            ))}          {schema.default !== undefined && (            <p className="text-xs leading-5 text-muted-foreground">              = {safeSchemaValueLabel(schema.default)}            </p>          )}        </div>      </div>       {/* Description — below the row */}      {description && (        <p          className="pr-4 pb-2.5 text-sm leading-5 text-muted-foreground"          style={{ paddingLeft: `${depth * 16 + 36}px` }}        >          {description}        </p>      )}       {/* Children — rendered lazily on expand */}      {open && expandable && (        <div className="border-l border-border/60" style={{ marginLeft: `${depth * 16 + 22}px` }}>          <PropertyChildren schema={childSchema} root={root} depth={depth + 1} />        </div>      )}    </div>  );} // ---------------------------------------------------------------------------// PropertyChildren — renders sub-properties// --------------------------------------------------------------------------- function PropertyChildren(props: { schema: JsonSchema; root: JsonSchema; depth: number }) {  const { schema: rawSchema, root, depth } = props;   if (depth > 6) {    return <p className="px-4 py-2 text-sm text-muted-foreground">Nested too deep to display.</p>;  }   const schema = deepResolve(rawSchema, root);  const required = new Set(schema.required ?? []);   // Object properties  if (schema.properties && Object.keys(schema.properties).length > 0) {    const entries = Object.entries(schema.properties);    entries.sort(([a], [b]) => {      const ar = required.has(a);      const br = required.has(b);      if (ar !== br) return ar ? -1 : 1;      return a.localeCompare(b);    });    return (      <div className="divide-y divide-border/50">        {entries.map(([key, value], i) => (          <PropertyRow            key={key}            name={key}            schema={value}            root={root}            required={required.has(key)}            depth={depth}            isLast={i === entries.length - 1}          />        ))}      </div>    );  }   // Array items  if (schema.items && !Array.isArray(schema.items)) {    const itemSchema = deepResolve(schema.items, root);    if (itemSchema.properties && Object.keys(itemSchema.properties).length > 0) {      return <PropertyChildren schema={itemSchema} root={root} depth={depth} />;    }    return <PropertyRow name="items" schema={schema.items} root={root} required depth={depth} />;  }   // allOf  if (schema.allOf) {    const merged = mergeAllOf(schema.allOf, root);    if (merged.properties && Object.keys(merged.properties).length > 0) {      return <PropertyChildren schema={merged} root={root} depth={depth} />;    }  }   // oneOf / anyOf (only if multiple) — these are choices, not required fields  const variants = schema.oneOf ?? schema.anyOf;  if (variants && variants.length > 1) {    const label = schema.oneOf ? "One of" : "Any of";    return (      <div>        <p          className="px-4 py-2 text-xs font-medium uppercase tracking-widest text-muted-foreground"          style={depth > 0 ? { paddingLeft: `${depth * 16 + 16}px` } : undefined}        >          {label}        </p>        <div className="divide-y divide-border/50">          {variants.map((variant, i) => (            <PropertyRow              key={i}              name={schemaText(variant.title) ?? `option ${i + 1}`}              schema={variant}              root={root}              required={false}              depth={depth}              hideRequiredBadge            />          ))}        </div>      </div>    );  }   // Record / additionalProperties  if (schema.additionalProperties && typeof schema.additionalProperties === "object") {    return (      <PropertyRow        name="[key]"        schema={schema.additionalProperties}        root={root}        required        depth={depth}      />    );  }   return null;} // ---------------------------------------------------------------------------// Count top-level fields for the header subtitle// --------------------------------------------------------------------------- const countTopLevelFields = (schema: JsonSchema): number => {  const resolved = deepResolve(schema, schema);  if (resolved.properties) return Object.keys(resolved.properties).length;  if (resolved.allOf) {    const merged = mergeAllOf(resolved.allOf, schema);    return merged.properties ? Object.keys(merged.properties).length : 0;  }  if (resolved.oneOf && resolved.oneOf.length > 1) return resolved.oneOf.length;  if (resolved.anyOf && resolved.anyOf.length > 1) return resolved.anyOf.length;  return 0;}; // ---------------------------------------------------------------------------// SchemaExplorer — main export// --------------------------------------------------------------------------- const asSchemaDefinitions = (value: unknown): Record<string, JsonSchema> | undefined =>  value && typeof value === "object" && !Array.isArray(value)    ? (value as Record<string, JsonSchema>)    : undefined; export function SchemaExplorer(props: {  schema: unknown;  schemaDefinitions?: unknown;  title?: string;}) {  const schema = props.schema as JsonSchema | undefined;  const root = useMemo(() => {    if (!schema) return undefined;    const sharedDefs = asSchemaDefinitions(props.schemaDefinitions);    if (!sharedDefs && !schema.$defs) return schema;    return {      ...schema,      $defs: {        ...sharedDefs,        ...schema.$defs,      },    };  }, [schema, props.schemaDefinitions]);  if (!schema || !root) return null;   const hasContent = isExpandable(schema, root);  const title = props.title;   if (!hasContent) {    const typeLabel = getTypeLabel(schema, root);    return (      <CardStack>        {title && <CardStackHeader>{title}</CardStackHeader>}        <CardStackContent>          <div className="flex items-center gap-2 px-4 py-3">            <span className={typeClasses}>{typeLabel}</span>          </div>        </CardStackContent>      </CardStack>    );  }   const fieldCount = countTopLevelFields(root);  const countLabel =    fieldCount > 0 ? `${fieldCount} ${fieldCount === 1 ? "field" : "fields"}` : null;   return (    <CardStack>      {title && (        <CardStackHeader          rightSlot={            countLabel ? (              <span className="shrink-0 tabular-nums text-xs text-muted-foreground">                {countLabel}              </span>            ) : undefined          }        >          {title}        </CardStackHeader>      )}      <CardStackContent>        <PropertyChildren schema={schema} root={root} depth={0} />      </CardStackContent>    </CardStack>  );

Lines 1–500 of 501.