- 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>
122 lines
3.2 KiB
TypeScript
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>
|
|
);
|
|
}
|