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>
This commit is contained in:
@@ -1,16 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { codeToHtml } from 'shiki';
|
||||
|
||||
// NOTE: spec calls for syntax-highlighted code blocks. Highlighting deferred
|
||||
// to keep dep footprint minimal; this renders styled mono code with a copy
|
||||
// button. Adding a highlighter (shiki / highlight.js) is a one-import swap.
|
||||
// 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 {
|
||||
@@ -36,9 +106,16 @@ export function CodeBlock({ code, lang }: Props) {
|
||||
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">
|
||||
{code}
|
||||
</pre>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user