import { useState, useEffect, memo, useMemo } from "react"; import { haptic } from "@/lib/haptic"; import { Copy, Check, ChevronDown, ChevronRight } from "lucide-react"; import CodeRunner from "./CodeRunner"; // ── Shiki: syntax highlight via esm.sh CDN (zero bundle impact) ─────────────── // Sostituisce highlight.js (969KB chunk) con Shiki (~200KB, tree-shakeable). // Shiki usa inline color styles — nessun CSS esterno necessario. // Singleton: il highlighter viene creato una sola volta e riusato per tutti i blocchi. const _SHIKI_ESM = "https://esm.sh/shiki@1.29.2?target=es2020"; const _SHIKI_THEME = "one-dark-pro"; const _SHIKI_LANGS = [ "javascript", "typescript", "jsx", "tsx", "python", "html", "css", "json", "bash", "shell", "markdown", "yaml", "sql", "rust", "go", "java", "cpp", "c", "kotlin", "swift", "php", "ruby", "text", ]; // Alias: identifiers usati nel progetto → nomi Shiki canonici const _SHIKI_ALIAS: Record = { js: "javascript", ts: "typescript", sh: "bash", py: "python", md: "markdown", rs: "rust", rb: "ruby", kt: "kotlin", yml: "yaml", toml: "text", xml: "text", }; let _shikiP: Promise | null = null; function _getShiki(): Promise { if (!_shikiP) { _shikiP = (async () => { const { createHighlighter } = await import(_SHIKI_ESM as any); return createHighlighter({ themes: [_SHIKI_THEME], langs: _SHIKI_LANGS }); })().catch(() => null); // null = fallback a plain text } return _shikiP!; } /** Estrae l'innerHTML del dal HTML prodotto da Shiki. */ function _extractCode(shikiHtml: string): string { const m = shikiHtml.match(/]*>([\s\S]*?)<\/code>/); return m ? m[1] : shikiHtml; } interface CodeBlockProps { code: string; language?: string; onExecuteResult?: (result: string) => void; } // ── S56: Lang metadata con colori per categoria ────────────────────────── const LANG_META: Record = { js: { label: "JavaScript", color: "#f7df1e" }, javascript: { label: "JavaScript", color: "#f7df1e" }, jsx: { label: "JSX", color: "#61dafb" }, ts: { label: "TypeScript", color: "#3178c6" }, typescript: { label: "TypeScript", color: "#3178c6" }, tsx: { label: "TSX", color: "#3178c6" }, py: { label: "Python", color: "#3572a5" }, python: { label: "Python", color: "#3572a5" }, sh: { label: "Shell", color: "#4ec9b0" }, bash: { label: "Bash", color: "#4ec9b0" }, css: { label: "CSS", color: "#264de4" }, html: { label: "HTML", color: "#e34c26" }, json: { label: "JSON", color: "#cbcb41" }, yaml: { label: "YAML", color: "#cb171e" }, toml: { label: "TOML", color: "#9c4121" }, xml: { label: "XML", color: "#e37933" }, sql: { label: "SQL", color: "#e38c00" }, rs: { label: "Rust", color: "#dea584" }, rust: { label: "Rust", color: "#dea584" }, go: { label: "Go", color: "#00add8" }, java: { label: "Java", color: "#b07219" }, cpp: { label: "C++", color: "#f34b7d" }, c: { label: "C", color: "#555555" }, rb: { label: "Ruby", color: "#701516" }, kt: { label: "Kotlin", color: "#a97bff" }, swift: { label: "Swift", color: "#f05138" }, php: { label: "PHP", color: "#4f5d95" }, md: { label: "Markdown", color: "#083fa1" }, markdown: { label: "Markdown", color: "#083fa1" }, }; function getLangMeta(lang: string) { return LANG_META[lang.toLowerCase()] ?? { label: lang.charAt(0).toUpperCase() + lang.slice(1) || "Text", color: "rgba(160,160,200,0.5)", }; } const GUTTER_MIN = 4; // mostra numeri riga solo se ≥ 4 righe // ── Gutter component ────────────────────────────────────────────────────── function Gutter({ count }: { count: number }) { return ( ); } const CodeBlock = memo(({ code, language = "", onExecuteResult }: CodeBlockProps) => { const [copied, setCopied] = useState(false); const [collapsed, setCollapsed] = useState(false); const [highlighted, setHighlighted] = useState(null); const lineCount = useMemo(() => (code.match(/\n/g) || []).length + 1, [code]); const isExec = language === "execute"; const meta = getLangMeta(language || "text"); const showGutter = lineCount >= GUTTER_MIN && !isExec; useEffect(() => { if (isExec) return; let cancelled = false; const doHighlight = () => { _getShiki().then(highlighter => { if (cancelled) return; if (!highlighter) { setHighlighted(null); return; } // Risolvi alias (py → python, sh → bash, ecc.) const rawLang = (language || "").toLowerCase(); const resolvedLang = _SHIKI_ALIAS[rawLang] ?? rawLang; const safeLang = _SHIKI_LANGS.includes(resolvedLang) ? resolvedLang : "text"; try { const html = highlighter.codeToHtml(code, { lang: safeLang, theme: _SHIKI_THEME }); if (!cancelled) setHighlighted(_extractCode(html)); } catch { if (!cancelled) setHighlighted(null); } }).catch(() => { if (!cancelled) setHighlighted(null); }); }; // S225: Safari/iPhone — defer syntax highlight to browser idle time // evita di bloccare il main thread durante lo streaming di messaggi if (typeof requestIdleCallback !== "undefined") { const id = requestIdleCallback(doHighlight, { timeout: 2000 }); return () => { cancelled = true; cancelIdleCallback(id); }; } doHighlight(); return () => { cancelled = true; }; }, [code, language, isExec]); const copy = () => { navigator.clipboard.writeText(code).then(() => { haptic("copy"); // AUT-4: feedback aptico su Android PWA setCopied(true); setTimeout(() => setCopied(false), 1500); }).catch(() => {}); }; if (isExec) { return ; } return (
{/* Toolbar */}
setCollapsed(c => !c)} onKeyDown={e => e.key === "Enter" && setCollapsed(c => !c)} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "7px 12px", background: "rgba(255,255,255,0.03)", borderBottom: collapsed ? "none" : "1px solid rgba(255,255,255,0.06)", cursor: "pointer", userSelect: "none", gap: 8, }} > {/* Left: chevron + lang dot + label + line count */}
{collapsed ? : } {/* Pallino colore linguaggio */} {meta.label} {lineCount} {lineCount === 1 ? "riga" : "righe"}
{/* Right: copy button */}
{/* Code body + gutter */} {!collapsed && (
{showGutter && }
{highlighted !== null ? (
                
              
) : (
                {code}
              
)}
)}
); }); CodeBlock.displayName = "CodeBlock"; export default CodeBlock;