Skip to content

Commit 0ac8d0c

Browse files
committed
Feat: Add CodeBlock component for syntax highlighting and code copying
1 parent fc5278a commit 0ac8d0c

File tree

2 files changed

+233
-230
lines changed

2 files changed

+233
-230
lines changed

src/components/CodeBlock.tsx

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import * as React from 'react'
2+
import { twMerge } from 'tailwind-merge'
3+
import { useToast } from '~/components/ToastProvider'
4+
import { Copy } from 'lucide-react'
5+
import type { Mermaid } from 'mermaid'
6+
import { transformerNotationDiff } from '@shikijs/transformers'
7+
import { createHighlighter, type HighlighterGeneric } from 'shiki'
8+
9+
// Language aliases mapping
10+
const LANG_ALIASES: Record<string, string> = {
11+
ts: 'typescript',
12+
js: 'javascript',
13+
sh: 'bash',
14+
shell: 'bash',
15+
console: 'bash',
16+
zsh: 'bash',
17+
md: 'markdown',
18+
txt: 'plaintext',
19+
text: 'plaintext',
20+
}
21+
22+
23+
// Lazy highlighter singleton
24+
let highlighterPromise: Promise<HighlighterGeneric<any, any>> | null = null
25+
let mermaidInstance: Mermaid | null = null
26+
const genSvgMap = new Map<string, string>()
27+
28+
async function getHighlighter(language: string) {
29+
if (!highlighterPromise) {
30+
highlighterPromise = createHighlighter({
31+
themes: ['github-light', 'tokyo-night'],
32+
langs: [
33+
'typescript',
34+
'javascript',
35+
'tsx',
36+
'jsx',
37+
'bash',
38+
'json',
39+
'html',
40+
'css',
41+
'markdown',
42+
'plaintext',
43+
],
44+
})
45+
}
46+
47+
const highlighter = await highlighterPromise
48+
const normalizedLang = LANG_ALIASES[language] || language
49+
const langToLoad = normalizedLang === 'mermaid' ? 'plaintext' : normalizedLang
50+
51+
// Load language if not already loaded
52+
if (!highlighter.getLoadedLanguages().includes(langToLoad as any)) {
53+
try {
54+
await highlighter.loadLanguage(langToLoad as any)
55+
} catch {
56+
console.warn(`Shiki: Language "${langToLoad}" not found, using plaintext`)
57+
}
58+
}
59+
60+
return highlighter
61+
}
62+
63+
// Lazy load mermaid only when needed
64+
async function getMermaid(): Promise<Mermaid> {
65+
if (!mermaidInstance) {
66+
const { default: mermaid } = await import('mermaid')
67+
mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' })
68+
mermaidInstance = mermaid
69+
}
70+
return mermaidInstance
71+
}
72+
73+
function extractPreAttributes(html: string): {
74+
class: string | null
75+
style: string | null
76+
} {
77+
const match = html.match(/<pre\b([^>]*)>/i)
78+
if (!match) {
79+
return { class: null, style: null }
80+
}
81+
82+
const attributes = match[1]
83+
84+
const classMatch = attributes.match(/\bclass\s*=\s*["']([^"']*)["']/i)
85+
const styleMatch = attributes.match(/\bstyle\s*=\s*["']([^"']*)["']/i)
86+
87+
return {
88+
class: classMatch ? classMatch[1] : null,
89+
style: styleMatch ? styleMatch[1] : null,
90+
}
91+
}
92+
93+
export function CodeBlock({
94+
isEmbedded,
95+
showTypeCopyButton = true,
96+
...props
97+
}: React.HTMLProps<HTMLPreElement> & {
98+
isEmbedded?: boolean
99+
showTypeCopyButton?: boolean
100+
}) {
101+
let lang = props?.children?.props?.className?.replace('language-', '')
102+
103+
if (lang === 'diff') {
104+
lang = 'plaintext'
105+
}
106+
107+
const children = props.children as
108+
| undefined
109+
| {
110+
props: {
111+
children: string
112+
}
113+
}
114+
115+
const [copied, setCopied] = React.useState(false)
116+
const ref = React.useRef<any>(null)
117+
const { notify } = useToast()
118+
119+
const code = children?.props.children
120+
121+
const [codeElement, setCodeElement] = React.useState(
122+
<>
123+
<pre ref={ref} className={`shiki github-light h-full`}>
124+
<code>{lang === 'mermaid' ? <svg /> : code}</code>
125+
</pre>
126+
<pre className={`shiki tokyo-night`}>
127+
<code>{lang === 'mermaid' ? <svg /> : code}</code>
128+
</pre>
129+
</>,
130+
)
131+
132+
React[
133+
typeof document !== 'undefined' ? 'useLayoutEffect' : 'useEffect'
134+
](() => {
135+
;(async () => {
136+
const themes = ['github-light', 'tokyo-night']
137+
const normalizedLang = LANG_ALIASES[lang] || lang
138+
const effectiveLang =
139+
normalizedLang === 'mermaid' ? 'plaintext' : normalizedLang
140+
141+
const highlighter = await getHighlighter(lang)
142+
143+
const htmls = await Promise.all(
144+
themes.map(async (theme) => {
145+
const output = highlighter.codeToHtml(code, {
146+
lang: effectiveLang,
147+
theme,
148+
transformers: [transformerNotationDiff()],
149+
})
150+
151+
if (lang === 'mermaid') {
152+
const preAttributes = extractPreAttributes(output)
153+
let svgHtml = genSvgMap.get(code || '')
154+
if (!svgHtml) {
155+
const mermaid = await getMermaid()
156+
const { svg } = await mermaid.render('foo', code || '')
157+
genSvgMap.set(code || '', svg)
158+
svgHtml = svg
159+
}
160+
return `<div class='${preAttributes.class} py-4 bg-neutral-50'>${svgHtml}</div>`
161+
}
162+
163+
return output
164+
}),
165+
)
166+
167+
setCodeElement(
168+
<div
169+
// className={`m-0 text-sm rounded-md w-full border border-gray-500/20 dark:border-gray-500/30`}
170+
className={twMerge(
171+
isEmbedded ? 'h-full [&>pre]:h-full [&>pre]:rounded-none' : '',
172+
)}
173+
dangerouslySetInnerHTML={{ __html: htmls.join('') }}
174+
ref={ref}
175+
/>,
176+
)
177+
})()
178+
}, [code, lang])
179+
180+
return (
181+
<div
182+
className={twMerge(
183+
'codeblock w-full max-w-full relative not-prose border border-gray-500/20 rounded-md [&_pre]:rounded-md [*[data-tab]_&]:only:border-0',
184+
props.className,
185+
)}
186+
style={props.style}
187+
>
188+
{showTypeCopyButton ? (
189+
<div
190+
className={twMerge(
191+
`absolute flex items-stretch bg-white text-sm z-10 rounded-md`,
192+
`dark:bg-gray-800 overflow-hidden divide-x divide-gray-500/20`,
193+
'shadow-md',
194+
isEmbedded ? 'top-2 right-4' : '-top-3 right-2',
195+
)}
196+
>
197+
{lang ? <div className="px-2">{lang}</div> : null}
198+
<button
199+
className="px-2 py-1 flex items-center text-gray-500 hover:bg-gray-500 hover:text-gray-100 dark:hover:text-gray-200 transition duration-200"
200+
onClick={() => {
201+
let copyContent =
202+
typeof ref.current?.innerText === 'string'
203+
? ref.current.innerText
204+
: ''
205+
206+
if (copyContent.endsWith('\n')) {
207+
copyContent = copyContent.slice(0, -1)
208+
}
209+
210+
navigator.clipboard.writeText(copyContent)
211+
setCopied(true)
212+
setTimeout(() => setCopied(false), 2000)
213+
notify(
214+
<div>
215+
<div className="font-medium">Copied code</div>
216+
<div className="text-gray-500 dark:text-gray-400 text-xs">
217+
Code block copied to clipboard
218+
</div>
219+
</div>,
220+
)
221+
}}
222+
aria-label="Copy code to clipboard"
223+
>
224+
{copied ? <span className="text-xs">Copied!</span> : <Copy />}
225+
</button>
226+
</div>
227+
) : null}
228+
{codeElement}
229+
</div>
230+
)
231+
}

0 commit comments

Comments
 (0)