import React, { useState } from "react"; import { Chip } from "@sparkle/components/Chip"; import { cn } from "@sparkle/lib/utils"; // Constants for consistent styling const VALUE_CLASSES = "s-text-primary-700 dark:s-text-primary-700-night s-pt-1 s-text-sm"; const EMPTY_CLASSES = "s-text-primary-500 dark:s-text-primary-500-night s-pt-1 s-text-sm s-italic"; const INDENT_CLASSES = "s-border-structure-200 dark:s-border-structure-200-night s-max-w-full s-border-l s-pl-4 s-ml-4"; // Performance limits to prevent browser crashes. // These limits are meant to be very conservative. const MAX_OBJECT_DEPTH = 8; const MAX_ARRAY_ITEMS = 128; const MAX_OBJECT_KEYS = 64; const MAX_STRING_LENGTH = 1024; export type JsonValueType = | string | number | boolean | null | undefined | JsonValueType[] | { [key: string]: JsonValueType }; // Helper component for inline expand/collapse buttons with consistent styling. function InlineExpandButton({ label, buttonText, onClick, className, }: { label: string; buttonText: string; onClick: () => void; className?: string; }) { return ( {label}{" "} ); } // Helper component for rendering key-value pairs with consistent styling. function KeyValuePair({ keyName, value, depth, chipColor, isRootLevel = false, expandedPaths, setExpandedPaths, currentPath, }: { keyName: string; value: JsonValueType; depth: number; chipColor: "info" | "highlight"; isRootLevel?: boolean; expandedPaths?: Set; setExpandedPaths?: React.Dispatch>>; currentPath?: string; }) { const isComplexValue = typeof value === "object" && value !== null; if (isComplexValue) { return ( <>
); } return (
); } function JsonValue({ value, depth = 0, expandedPaths = new Set(), setExpandedPaths, currentPath = "", }: { value: JsonValueType; depth?: number; expandedPaths?: Set; setExpandedPaths?: React.Dispatch>>; currentPath?: string; }) { const handleToggleExpanded = (path: string) => { if (!setExpandedPaths) { return; } setExpandedPaths((prev) => { const newSet = new Set(prev); if (newSet.has(path)) { newSet.delete(path); } else { newSet.add(path); } return newSet; }); }; if ( depth >= MAX_OBJECT_DEPTH && typeof value === "object" && value !== null ) { const deepObjectPath = `${currentPath}:deep`; const isExpanded = expandedPaths?.has(deepObjectPath) ?? false; if (isExpanded) { // Render the full object/array when expanded, ignoring depth limit. return ( ); } return (
handleToggleExpanded(deepObjectPath)} />
); } if (value === null || value === undefined) { return empty; } if (typeof value === "boolean") { return {value ? "Yes" : "No"}; } if (typeof value === "number") { return {value}; } if (typeof value === "string") { if (value.length > MAX_STRING_LENGTH) { const longStringPath = `${currentPath}:longstring`; const isExpanded = expandedPaths?.has(longStringPath) ?? false; return ( {isExpanded ? value : value.substring(0, MAX_STRING_LENGTH)} {!isExpanded && "…"}{" "} ); } return ( {value} ); } if (Array.isArray(value)) { if (value.length === 0) { return empty list; } // Check if it's a simple array of primitives. const isSimpleArray = value.every( (item) => typeof item !== "object" || item === null ); if (isSimpleArray && value.length <= 5) { return ( {value.map((item, index) => ( {index < value.length - 1 && ", "} ))} ); } // Truncate arrays that have too many items. const arrayPath = `${currentPath}[]`; const isExpanded = expandedPaths?.has(arrayPath) ?? false; const itemsToShow = isExpanded ? value.length : Math.min(value.length, MAX_ARRAY_ITEMS); const hasMore = value.length > MAX_ARRAY_ITEMS && !isExpanded; return (
{value.slice(0, itemsToShow).map((item, index) => (
))} {hasMore && (
handleToggleExpanded(arrayPath)} />
)}
); } if (typeof value === "object") { const entries = Object.entries(value); if (entries.length === 0) { return empty; } // Truncate objects with too many properties. const objectPath = `${currentPath}{}`; const isExpanded = expandedPaths?.has(objectPath) ?? false; const keysToShow = isExpanded ? entries.length : Math.min(entries.length, MAX_OBJECT_KEYS); const hasMore = entries.length > MAX_OBJECT_KEYS && !isExpanded; const visibleEntries = entries.slice(0, keysToShow); // For nested objects, use a card-like layout with vertical bars. if (depth > 0) { return (
{visibleEntries.map(([key, val]) => (
))} {hasMore && (
handleToggleExpanded(objectPath)} />
)}
); } // Root level objects use a table-like layout. return (
{visibleEntries.map(([key, val]) => (
))} {hasMore && (
handleToggleExpanded(objectPath)} />
)}
); } return ( {String(value)} ); } function formatKey(key: string): string { // Convert snake_case or camelCase to Title Case. return key .replace(/_/g, " ") .replace(/([A-Z])/g, " $1") .trim() .split(" ") .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(" "); } interface JsonViewerProps { data: JsonValueType; className?: string; } export function PrettyJsonViewer({ data, className }: JsonViewerProps) { const [expandedPaths, setExpandedPaths] = useState>(new Set()); return (
); }