Files
boocode/apps/web/src/components/CodeBlock.tsx
indifferentketchup b29555ee28 batch3 T6: usePanes hook + Shiki integration in CodeBlock
- hooks/usePanes: per-session panes CRUD; debounced (300ms) state PATCH;
  immediate position-change PATCH with refresh
- CodeBlock: shiki async highlighting via codeToHtml + github-dark theme;
  LANG_MAP for ts/tsx/js/jsx/py/go/rs/rb/java/c/cpp/cs/php/sh/yaml/json/
  toml/md/sql/dockerfile/html/css; falls back to plain pre on unknown lang
  or async failure
- package.json: + shiki

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:32:04 +00:00

122 lines
3.2 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { Check, Copy } 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<string, string> = {
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',
};
const SHIKI_THEME = 'github-dark';
export function CodeBlock({ code, lang }: Props) {
const [copied, setCopied] = useState(false);
const [html, setHtml] = useState<string | null>(null);
const highlightRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let cancelled = false;
const mappedLang = (lang && LANG_MAP[lang.toLowerCase()]) ?? null;
if (!mappedLang) {
setHtml(null);
return;
}
(async () => {
try {
const result = await codeToHtml(code, { lang: mappedLang, theme: SHIKI_THEME });
if (!cancelled) setHtml(result);
} catch (err) {
console.warn('shiki failed', err);
if (!cancelled) setHtml(null);
}
})();
return () => {
cancelled = true;
};
}, [code, lang]);
// 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]);
async function copy() {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
/* ignore */
}
}
return (
<div className="rounded-md border bg-muted/40 overflow-hidden text-sm my-1">
<div className="flex items-center justify-between px-2 py-1 border-b text-xs text-muted-foreground">
<span className="font-mono">{lang || 'code'}</span>
<button
type="button"
onClick={() => void copy()}
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
aria-label="Copy code"
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
{html !== null ? (
<div
ref={highlightRef}
className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed [&>pre]:!bg-transparent [&>pre]:!m-0 [&>pre]:!p-0"
/>
) : (
<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">
{code}
</pre>
)}
</div>
);
}