import { useCallback, useMemo, useState, type ReactNode } from "react"; import { jsx, jsxs, Fragment } from "react/jsx-runtime"; import { toJsxRuntime } from "hast-util-to-jsx-runtime"; import { dualThemeOptions, getHighlighter, ensureLang, resolveLang, type ShikiThemeProp, } from "../lib/shiki"; import { toast } from "sonner"; import { cn } from "../lib/utils"; import { copyToClipboard } from "../lib/clipboard"; import { Button } from "./button"; // --------------------------------------------------------------------------- // Language detection // --------------------------------------------------------------------------- function detectLanguage(code: string, hint?: string): string { if (hint) return resolveLang(hint) ?? "json"; const trimmed = code.trimStart(); if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json"; if (trimmed.startsWith("<")) return "xml"; if (trimmed.startsWith("---")) return "yaml"; return "json"; } // --------------------------------------------------------------------------- // Icons // --------------------------------------------------------------------------- const CopyIcon = () => ( ); const CheckIcon = () => ( ); // --------------------------------------------------------------------------- // Highlight hook // --------------------------------------------------------------------------- function useHighlighted(code: string, lang: string, theme?: ShikiThemeProp): ReactNode | null { const [, setTick] = useState(0); const resolvedLang = (resolveLang(lang) ?? "json") as Parameters[0]; const isReady = ensureLang(resolvedLang, () => setTick((t) => t + 1)); if (!isReady) return null; const highlighter = getHighlighter(); // Dual-theme light-dark() colors: the markup is correct in BOTH color // schemes, so SSR/hydration can't paint the wrong palette first. const hast = highlighter.codeToHast(code, { lang: resolvedLang, ...dualThemeOptions(theme) }); return toJsxRuntime(hast, { jsx, jsxs, Fragment }); } // --------------------------------------------------------------------------- // CodeBlock // --------------------------------------------------------------------------- export function CodeBlock(props: { code: string; lang?: string; title?: string; maxHeight?: string; className?: string; theme?: ShikiThemeProp; /** Fires after a successful copy. Receives nothing — the code may be sensitive. */ onCopy?: () => void; }) { const { code, lang: langHint, title, className, theme, onCopy } = props; const [expanded, setExpanded] = useState(false); const [copied, setCopied] = useState(false); const language = useMemo(() => detectLanguage(code, langHint), [code, langHint]); const highlighted = useHighlighted(code, language, theme); const lines = code.split("\n"); const isLong = lines.length > 24; const maxH = !expanded && isLong ? (props.maxHeight ?? "24rem") : undefined; const handleCopy = useCallback(() => { void copyToClipboard(code).then((ok) => { if (!ok) { toast.error("Failed to copy to clipboard"); return; } setCopied(true); onCopy?.(); setTimeout(() => setCopied(false), 1500); }); }, [code, onCopy]); return (
{title && (
{title}
)}
{!title && ( )}
{highlighted ?? (
{code}
)}
{isLong && !expanded && (
)}
); }