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 ( / < p r e \b ( [ ^ > ] * ) > / i)
78+ if ( ! match ) {
79+ return { class : null , style : null }
80+ }
81+
82+ const attributes = match [ 1 ]
83+
84+ const classMatch = attributes . match ( / \b c l a s s \s * = \s * [ " ' ] ( [ ^ " ' ] * ) [ " ' ] / i)
85+ const styleMatch = attributes . match ( / \b s t y l e \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