JSON Schema Viewer

PreviousNext

A read-only component for displaying JSON Schema structures with support for union types, nested objects, arrays, and enums.

Docs
simple-aiui

Preview

Loading preview…
./src/registry/ui/json-schema-viewer.tsx
"use client";

import type { JSONSchema7 } from "ai";
import type { ComponentProps } from "react";
import { useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";

interface SchemaProperty {
	name: string;
	type: string;
	required: boolean;
	description?: string;
	isArray: boolean;
	enumValues?: string[];
	nested?: SchemaProperty[];
}

interface UnionBranch {
	properties: SchemaProperty[];
	description?: string;
	branchIndex: number;
}

export interface JsonSchemaViewerProps extends ComponentProps<"div"> {
	schema: JSONSchema7;
	title?: string;
}

function parseProperty(
	prop: Record<string, unknown>,
	name: string,
	required: boolean,
): SchemaProperty {
	// Determine property type
	let type: string;
	if (prop.enum) {
		type = "enum";
	} else if (prop.type === "object") {
		type = "object";
	} else if (Array.isArray(prop.type)) {
		type = prop.type[0] || "any";
	} else {
		type = (prop.type as string) || "any";
	}

	const result: SchemaProperty = {
		name,
		type,
		required,
		isArray: false,
	};

	// Handle arrays
	if (prop.type === "array" && prop.items) {
		result.isArray = true;
		const items = prop.items;

		if (typeof items === "object" && !Array.isArray(items)) {
			const itemProp = parseProperty(
				items as Record<string, unknown>,
				"",
				false,
			);
			result.type = itemProp.type;
			result.enumValues = itemProp.enumValues;
			result.nested = itemProp.nested;
		}
	}

	// Handle enums
	if (prop.enum && Array.isArray(prop.enum)) {
		result.type = "enum";
		result.enumValues = prop.enum.map(String);
	}

	// Handle objects
	if (prop.type === "object" && prop.properties) {
		result.nested = [];
		const requiredFields = Array.isArray(prop.required)
			? prop.required
			: [];

		for (const [nestedName, nestedProp] of Object.entries(
			prop.properties,
		)) {
			const isNestedRequired = requiredFields.includes(nestedName);
			result.nested.push(
				parseProperty(nestedProp, nestedName, isNestedRequired),
			);
		}
	}

	// Extract description
	if (prop.description && typeof prop.description === "string") {
		result.description = prop.description;
	}

	return result;
}

function parseSchema(schema: JSONSchema7): {
	isUnion: boolean;
	properties?: SchemaProperty[];
	unions?: UnionBranch[];
} {
	if (!schema || typeof schema !== "object") {
		return { isUnion: false, properties: [] };
	}

	// Handle union schemas (anyOf)
	if (schema.anyOf && Array.isArray(schema.anyOf)) {
		const unions: UnionBranch[] = [];

		schema.anyOf.forEach((branch: unknown, index: number) => {
			if (
				typeof branch === "object" &&
				branch !== null &&
				!Array.isArray(branch)
			) {
				const branchSchema = branch as JSONSchema7;
				const properties: SchemaProperty[] = [];

				if (branchSchema.properties) {
					const requiredFields = Array.isArray(branchSchema.required)
						? branchSchema.required
						: [];

					for (const [name, prop] of Object.entries(
						branchSchema.properties,
					)) {
						const isRequired = requiredFields.includes(name);
						properties.push(
							parseProperty(
								prop as Record<string, unknown>,
								name,
								isRequired,
							),
						);
					}
				}

				unions.push({
					properties,
					description: branchSchema.description,
					branchIndex: index,
				});
			}
		});

		return { isUnion: true, unions };
	}

	// Handle regular schemas
	const properties: SchemaProperty[] = [];
	const requiredFields = Array.isArray(schema.required)
		? schema.required
		: [];

	if (schema.properties) {
		for (const [name, prop] of Object.entries(schema.properties)) {
			const isRequired = requiredFields.includes(name);
			properties.push(
				parseProperty(
					prop as Record<string, unknown>,
					name,
					isRequired,
				),
			);
		}
	}

	return { isUnion: false, properties };
}

function PropertyRow({
	property,
	depth = 0,
}: {
	property: SchemaProperty;
	depth?: number;
}) {
	return (
		<div className="relative">
			<div
				className={cn(
					"flex items-start gap-3 py-2 px-3 text-sm rounded-sm hover:bg-muted/50 transition-colors",
					depth > 0 &&
						"before:absolute before:top-0 before:bottom-0 before:w-px before:bg-border/40 before:left-(--indent-line-left)",
				)}
				style={{
					paddingLeft: `${12 + depth * 16}px`,
					// @ts-expect-error - CSS variables are not typed
					"--indent-line-left":
						depth > 0 ? `${12 + (depth - 1) * 16}px` : undefined,
				}}
			>
				<div className="flex-1 min-w-0">
					<div className="flex items-center gap-2 mb-1">
						<span className="font-mono font-medium text-foreground">
							{property.name}
						</span>
						{property.required && (
							<span className="text-destructive text-xs font-bold">
								*
							</span>
						)}
						<Badge variant="secondary">
							{property.isArray
								? `${property.type}[]`
								: property.type}
						</Badge>
					</div>
					{property.type === "enum" && property.enumValues && (
						<div className="flex flex-wrap gap-1 mt-2">
							{property.enumValues.map((value) => (
								<Badge
									key={value}
									variant="outline"
									className="text-xs"
								>
									{value}
								</Badge>
							))}
						</div>
					)}
					{property.description && (
						<div className="text-muted-foreground text-xs leading-relaxed">
							{property.description}
						</div>
					)}
				</div>
			</div>
			{property.nested && property.nested.length > 0 && (
				<div>
					{property.nested.map((nestedProp) => (
						<PropertyRow
							key={nestedProp.name}
							property={nestedProp}
							depth={depth + 1}
						/>
					))}
				</div>
			)}
		</div>
	);
}

function UnionBranchView({
	branch,
	isLast,
}: {
	branch: UnionBranch;
	isLast: boolean;
}) {
	return (
		<div className="mb-4">
			<div className="flex items-center gap-2 px-3 py-2 bg-muted/30 rounded-sm">
				<Badge variant="outline">Option {branch.branchIndex + 1}</Badge>
				{branch.description && (
					<span className="text-sm text-muted-foreground">
						{branch.description}
					</span>
				)}
			</div>

			<div className="mt-1">
				{branch.properties.length === 0 ? (
					<div className="p-4 text-sm text-muted-foreground text-center">
						No properties defined
					</div>
				) : (
					branch.properties.map((property) => (
						<PropertyRow key={property.name} property={property} />
					))
				)}
			</div>

			{!isLast && <Separator className="my-3" />}
		</div>
	);
}

export interface JsonSchemaViewerProps extends ComponentProps<"div"> {
	schema: JSONSchema7;
	title?: string;
	hideHeader?: boolean;
}

export function JsonSchemaViewer({
	schema,
	title = "Schema Structure",
	className,
	hideHeader = false,
	...props
}: JsonSchemaViewerProps) {
	const { isUnion, properties, unions } = useMemo(
		() => parseSchema(schema),
		[schema],
	);

	return (
		<div
			className={cn("border rounded-md overflow-hidden", className)}
			{...props}
		>
			{!hideHeader && (
				<div className="p-3 border-b bg-muted/50">
					<h4 className="text-sm font-semibold">{title}</h4>
					{schema.description && (
						<p className="text-xs text-muted-foreground mt-1">
							{schema.description}
						</p>
					)}
				</div>
			)}
			<div className="max-h-[400px] overflow-y-auto p-2">
				{isUnion ? (
					unions && unions.length > 0 ? (
						unions.map((branch: UnionBranch, index: number) => (
							<UnionBranchView
								key={branch.branchIndex}
								branch={branch}
								isLast={index === unions.length - 1}
							/>
						))
					) : (
						<div className="p-4 text-sm text-muted-foreground text-center">
							No union branches defined
						</div>
					)
				) : properties && properties.length > 0 ? (
					properties.map((property) => (
						<PropertyRow key={property.name} property={property} />
					))
				) : (
					<div className="p-4 text-sm text-muted-foreground text-center">
						No properties defined
					</div>
				)}
			</div>
		</div>
	);
}

Installation

npx shadcn@latest add @simple-ai/json-schema-viewer

Usage

import { JsonSchemaViewer } from "@/components/ui/json-schema-viewer"
<JsonSchemaViewer />