File size: 4,364 Bytes
9853396 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 | 'use client';
import { type ComponentProps, createContext, type HTMLAttributes, useContext, useEffect, useRef, useState } from 'react';
import type { Element } from 'hast';
import { CheckIcon, CopyIcon } from 'lucide-react';
import { type BundledLanguage, codeToHtml, type ShikiTransformer } from 'shiki';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
};
type CodeBlockContextType = {
code: string;
};
const CodeBlockContext = createContext<CodeBlockContextType>({
code: '',
});
const lineNumberTransformer: ShikiTransformer = {
name: 'line-numbers',
line(node: Element, line: number) {
node.children.unshift({
type: 'element',
tagName: 'span',
properties: {
className: ['inline-block', 'min-w-10', 'mr-4', 'text-right', 'select-none', 'text-muted-foreground'],
},
children: [{ type: 'text', value: String(line) }],
});
},
};
export async function highlightCode(code: string, language: BundledLanguage, showLineNumbers = false) {
const transformers: ShikiTransformer[] = showLineNumbers ? [lineNumberTransformer] : [];
return await Promise.all([
codeToHtml(code, {
lang: language,
theme: 'one-light',
transformers,
}),
codeToHtml(code, {
lang: language,
theme: 'one-dark-pro',
transformers,
}),
]);
}
export const CodeBlock = ({ code, language, showLineNumbers = false, className, children, ...props }: CodeBlockProps) => {
const [html, setHtml] = useState<string>('');
const [darkHtml, setDarkHtml] = useState<string>('');
const mounted = useRef(false);
useEffect(() => {
highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
if (!mounted.current) {
setHtml(light);
setDarkHtml(dark);
mounted.current = true;
}
});
return () => {
mounted.current = false;
};
}, [code, language, showLineNumbers]);
return (
<CodeBlockContext.Provider value={{ code }}>
<div className={cn('group bg-background text-foreground relative w-full overflow-hidden rounded-md border', className)} {...props}>
<div className='relative'>
<div
className='[&>pre]:bg-background! [&>pre]:text-foreground! overflow-hidden dark:hidden [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:p-4 [&>pre]:text-sm'
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: html }}
/>
<div
className='[&>pre]:bg-background! [&>pre]:text-foreground! hidden overflow-hidden dark:block [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:p-4 [&>pre]:text-sm'
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: darkHtml }}
/>
{children && <div className='absolute top-2 right-2 flex items-center gap-2'>{children}</div>}
</div>
</div>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({ onCopy, onError, timeout = 2000, children, className, ...props }: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => {
if (typeof window === 'undefined' || !navigator?.clipboard?.writeText) {
onError?.(new Error('Clipboard API not available'));
return;
}
try {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
}
};
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button className={cn('shrink-0', className)} onClick={copyToClipboard} size='icon' variant='ghost' {...props}>
{children ?? <Icon size={14} />}
</Button>
);
};
|