import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Check, Copy, Moon, Sun, WrapText } from 'lucide-react'; import { codeToHtml } from 'shiki'; // NOTE: spec calls for syntax-highlighted code blocks. Added Shiki in v1.1. // Shiki output is compiler-generated and does not contain user input; setting // it via a ref is safe here. interface Props { code: string; lang?: string; } const LANG_MAP: Record = { ts: 'typescript', tsx: 'tsx', typescript: 'typescript', js: 'javascript', jsx: 'jsx', javascript: 'javascript', py: 'python', python: 'python', go: 'go', rs: 'rust', rust: 'rust', rb: 'ruby', ruby: 'ruby', java: 'java', c: 'c', cpp: 'cpp', cs: 'csharp', csharp: 'csharp', php: 'php', sh: 'bash', bash: 'bash', shell: 'bash', yaml: 'yaml', yml: 'yaml', json: 'json', toml: 'toml', md: 'markdown', markdown: 'markdown', sql: 'sql', dockerfile: 'dockerfile', html: 'html', css: 'css', }; // ── LRU highlight cache (module-scoped) ────────────────────────── // Key = `${code}|${theme}|${mappedLang}`, max 50 entries. // Avoids redundant codeToHtml calls when the same code/theme/lang // combination is rendered multiple times (e.g. across messages). const HIGHLIGHT_CACHE = new Map(); const MAX_CACHE_ENTRIES = 50; function cacheGet(key: string): string | undefined { if (!HIGHLIGHT_CACHE.has(key)) return undefined; const val = HIGHLIGHT_CACHE.get(key)!; // LRU touch — delete & re-set to move to end (most recently used) HIGHLIGHT_CACHE.delete(key); HIGHLIGHT_CACHE.set(key, val); return val; } function cacheSet(key: string, html: string): void { if (HIGHLIGHT_CACHE.size >= MAX_CACHE_ENTRIES) { const oldest = HIGHLIGHT_CACHE.keys().next().value; if (oldest !== undefined) HIGHLIGHT_CACHE.delete(oldest); } HIGHLIGHT_CACHE.set(key, html); } export function CodeBlock({ code, lang }: Props) { const [copied, setCopied] = useState(false); const [html, setHtml] = useState(null); const [theme, setTheme] = useState<'github-dark' | 'github-light'>(() => { try { if (localStorage.getItem('codeblock-theme') === 'github-light') return 'github-light'; } catch { /* localStorage unavailable */ } return 'github-dark'; }); const [wordWrap, setWordWrap] = useState(false); const [expanded, setExpanded] = useState(false); const highlightRef = useRef(null); // ── Derived state ────────────────────────────────────────────── // Diff mode: detect `diff-` prefix (e.g. diff-ts, diff-py). // The actual lang for highlighting is the part after `diff-`. const isDiff = !!lang && lang.startsWith('diff-'); const actualLang = isDiff && lang ? lang.slice('diff-'.length) : lang ?? ''; const mappedLang = actualLang ? (LANG_MAP[actualLang.toLowerCase()] ?? null) : null; // Strip leading `+`/`-` from code lines when in diff mode. // The markers are rendered in the gutter instead. const cleanCode = useMemo( () => (isDiff ? code.replace(/^[+-]/gm, '') : code), [code, isDiff], ); const codeLines = useMemo(() => code.split('\n'), [code]); const totalLines = codeLines.length; // Gutter is hidden entirely when code has >= 1000 lines. const showGutter = totalLines < 1000; // Collapsible: auto-collapse to 15 lines when >= 30 lines total. const isLong = totalLines >= 30; const collapsed = isLong && !expanded; const visibleLines = collapsed ? codeLines.slice(0, 15) : codeLines; // Diff marker array: '+' / '-' / '' per line for the gutter. const diffMarkers = useMemo( () => (isDiff ? codeLines.map((l) => (l[0] === '+' ? '+' : l[0] === '-' ? '-' : '')) : null), [isDiff, codeLines], ); // ── Shiki highlighting ───────────────────────────────────────── useEffect(() => { let cancelled = false; if (!mappedLang) { setHtml(null); return; } const cacheKey = `${cleanCode}|${theme}|${mappedLang}`; const cached = cacheGet(cacheKey); if (cached !== undefined) { setHtml(cached); return; } (async () => { try { const result = await codeToHtml(cleanCode, { lang: mappedLang, theme }); cacheSet(cacheKey, result); if (!cancelled) setHtml(result); } catch (err) { console.warn('shiki highlight failed:', err); if (!cancelled) setHtml(null); } })(); return () => { cancelled = true; }; }, [cleanCode, mappedLang, theme]); // Inject Shiki HTML via ref (output is compiler-generated, not user input) useEffect(() => { if (highlightRef.current) { // Shiki generates sanitized HTML spans — not user-supplied content. // eslint-disable-next-line no-unsanitized/property highlightRef.current.innerHTML = html ?? ''; } }, [html]); // Sync word-wrap state to the injected
 element inside shiki's output
  useEffect(() => {
    const pre = highlightRef.current?.querySelector('pre');
    if (pre) {
      pre.style.whiteSpace = wordWrap ? 'pre-wrap' : 'nowrap';
    }
  }, [html, wordWrap]);

  // ── Handlers ───────────────────────────────────────────────────

  const handleToggleTheme = useCallback(() => {
    setTheme((prev) => {
      const next = prev === 'github-dark' ? 'github-light' : 'github-dark';
      try {
        localStorage.setItem('codeblock-theme', next);
      } catch {
        /* noop */
      }
      return next;
    });
  }, []);

  const handleCopy = useCallback(async () => {
    try {
      await navigator.clipboard.writeText(cleanCode);
      setCopied(true);
      setTimeout(() => setCopied(false), 1200);
    } catch {
      /* ignore */
    }
  }, [cleanCode]);

  // ── Shared class segments ──────────────────────────────────────

  const preBaseClass = 'overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed';
  const shikiWrapperClass = `${preBaseClass} [&>pre]:!bg-transparent [&>pre]:!m-0 [&>pre]:!p-0`;
  const collapsedClass = collapsed ? 'max-h-[calc(15*1.625em)] overflow-hidden' : '';

  // ── Render ─────────────────────────────────────────────────────

  return (
    
{/* ── Toolbar ──────────────────────────────────────────── */}
{actualLang || lang || 'code'}
{/* Theme toggle — persists to localStorage key 'codeblock-theme' */} {/* Word-wrap toggle */} {/* Copy button — existing behavior (Check icon, 1200ms revert) */}
{/* ── Code body (flex row: gutter + code) ──────────────── */}
{/* Gutter — line numbers or diff markers */} {showGutter && ( )} {/* Code area */}
{html !== null ? (
) : (
              {collapsed ? codeLines.slice(0, 15).join('\n') : code}
            
)} {/* Gradient fade overlay for collapsed state */} {collapsed && (
)}
{/* "Show N more" button for collapsed state */} {collapsed && ( )}
); }