Skip to content

Commit 83ebfce

Browse files
LadyBluenotesautofix-ci[bot]tannerlinsley
authored
breakup plugins (#618)
* Feat: Add CodeBlock component for syntax highlighting and code copying * remove unused file * refactor: give plugins their own files * feat: integrate heading collection into rehype processor * ci: apply automated fixes * fix: CodeBlock imports * fix formatting * fix: update themes in CodeBlock component and add migration configuration files * fix --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Tanner Linsley <[email protected]>
1 parent c087ca6 commit 83ebfce

18 files changed

+549
-532
lines changed

src/components/CodeBlock.tsx

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

src/components/CodeExplorer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react'
2-
import { CodeBlock } from '~/components/Markdown'
2+
import { CodeBlock } from '~/components/CodeBlock'
33
import { FileExplorer } from './FileExplorer'
44
import { InteractiveSandbox } from './InteractiveSandbox'
55
import { CodeExplorerTopBar } from './CodeExplorerTopBar'

0 commit comments

Comments
 (0)