AUDIT / src /components /CodeBlock.tsx
Arypulka98's picture
feat(audit): deploy full backend cluster node
aea470f verified
Raw
History Blame Contribute Delete
11.5 kB
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<string, string> = {
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<any> | null = null;
function _getShiki(): Promise<any> {
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 <code> dal HTML prodotto da Shiki. */
function _extractCode(shikiHtml: string): string {
const m = shikiHtml.match(/<code[^>]*>([\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<string, { label: string; color: string }> = {
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 (
<div aria-hidden="true" style={{
display: "flex", flexDirection: "column", alignItems: "flex-end",
padding: "14px 10px 14px 10px", minWidth: 34,
userSelect: "none", flexShrink: 0,
borderRight: "1px solid rgba(255,255,255,0.05)",
background: "rgba(0,0,0,0.12)",
}}>
{Array.from({ length: count }, (_, i) => (
<span key={i} style={{
fontSize: "0.72rem", lineHeight: 1.65,
color: "rgba(150,150,175,0.28)",
fontFamily: "'JetBrains Mono',Menlo,monospace",
fontVariantNumeric: "tabular-nums",
}}>
{i + 1}
</span>
))}
</div>
);
}
const CodeBlock = memo(({ code, language = "", onExecuteResult }: CodeBlockProps) => {
const [copied, setCopied] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const [highlighted, setHighlighted] = useState<string | null>(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 <CodeRunner code={code} onResult={onExecuteResult} />;
}
return (
<div style={{
borderRadius: 10,
border: "1px solid rgba(255,255,255,0.07)",
background: "rgba(0,0,0,0.35)",
overflow: "hidden",
margin: "0.5em 0 0.9em",
fontSize: "0.82rem",
}}>
{/* Toolbar */}
<div
role="button"
tabIndex={0}
onClick={() => 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 */}
<div style={{ display: "flex", alignItems: "center", gap: 7, minWidth: 0 }}>
{collapsed
? <ChevronRight size={13} style={{ color: "rgba(180,180,200,0.45)", flexShrink: 0 }} />
: <ChevronDown size={13} style={{ color: "rgba(180,180,200,0.45)", flexShrink: 0 }} />
}
{/* Pallino colore linguaggio */}
<span style={{
width: 8, height: 8, borderRadius: "50%",
background: meta.color, flexShrink: 0,
boxShadow: `0 0 5px ${meta.color}55`,
}} />
<span style={{
fontSize: "0.72rem", fontWeight: 600,
color: "rgba(220,220,240,0.75)",
fontFamily: "monospace", letterSpacing: "0.02em",
}}>
{meta.label}
</span>
<span style={{
fontSize: "0.63rem",
color: "rgba(140,140,170,0.4)",
fontFamily: "monospace",
}}>
{lineCount} {lineCount === 1 ? "riga" : "righe"}
</span>
</div>
{/* Right: copy button */}
<button
onClick={e => { e.stopPropagation(); copy(); }}
title={copied ? "Copiato!" : "Copia codice"}
style={{
all: "unset",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 5,
padding: "3px 8px",
borderRadius: 6,
fontSize: "0.68rem",
fontWeight: 500,
color: copied ? "#7c7cff" : "rgba(160,160,200,0.55)",
background: copied ? "rgba(124,124,255,0.08)" : "transparent",
border: copied ? "1px solid rgba(124,124,255,0.18)" : "1px solid transparent",
transition: "all 0.2s",
flexShrink: 0,
}}
onMouseEnter={e => {
if (!copied) {
e.currentTarget.style.color = "rgba(200,200,230,0.85)";
e.currentTarget.style.background = "rgba(255,255,255,0.05)";
}
}}
onMouseLeave={e => {
if (!copied) {
e.currentTarget.style.color = "rgba(160,160,200,0.55)";
e.currentTarget.style.background = "transparent";
}
}}
>
{copied
? <><Check size={11} /><span>Copiato</span></>
: <><Copy size={11} /><span>Copia</span></>
}
</button>
</div>
{/* Code body + gutter */}
{!collapsed && (
<div style={{ display: "flex", overflowX: "auto" }}>
{showGutter && <Gutter count={lineCount} />}
<div style={{ flex: 1, padding: "14px 16px" }}>
{highlighted !== null ? (
<pre style={{
margin: 0, padding: 0,
fontFamily: "'JetBrains Mono','Fira Code','Cascadia Code',Menlo,Monaco,Consolas,monospace",
fontSize: "0.82rem", lineHeight: 1.65,
color: "#d4d4d8", whiteSpace: "pre",
}}>
<code
className={`language-${language || "text"}`}
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
</pre>
) : (
<pre style={{
margin: 0, padding: 0,
fontFamily: "'JetBrains Mono','Fira Code',Menlo,Monaco,Consolas,monospace",
fontSize: "0.82rem", lineHeight: 1.65,
color: "#d4d4d8", whiteSpace: "pre",
}}>
<code>{code}</code>
</pre>
)}
</div>
</div>
)}
</div>
);
});
CodeBlock.displayName = "CodeBlock";
export default CodeBlock;