perf(playground): improve markdown code blocks

- render fenced markdown code with syntax highlighting, line numbers, and fallback plain text
- add copy, download, and collapse controls for playground AI responses
- tighten code block layout and theme token styles for responsive markdown rendering
This commit is contained in:
QuentinHsu
2026-05-30 19:07:53 +08:00
parent fa334c1eb0
commit ffb1e8e97a
3 changed files with 314 additions and 27 deletions
+242 -23
View File
@@ -23,34 +23,63 @@ import {
type ComponentProps,
createContext,
type HTMLAttributes,
type ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import type { Element } from 'hast'
import { CheckIcon, CopyIcon } from 'lucide-react'
import {
type BundledLanguage,
codeToHtml,
type ShikiTransformer,
} from 'shiki/bundle/web'
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
CopyIcon,
DownloadIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { BundledLanguage, ShikiTransformer } from 'shiki'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string
language: BundledLanguage
defaultCollapsed?: boolean
enableCollapse?: boolean
filename?: string
language: BundledLanguage | string
maxCollapsedLines?: number
showLineNumbers?: boolean
showToolbar?: boolean
title?: ReactNode
}
type CodeBlockContextType = {
code: string
language: string
}
const CodeBlockContext = createContext<CodeBlockContextType>({
code: '',
language: 'plaintext',
})
const highlightCache = new Map<string, string>()
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
csharp: 'c#',
golang: 'go',
js: 'javascript',
shell: 'bash',
shellscript: 'bash',
ts: 'typescript',
}
const lineNumberTransformer: ShikiTransformer = {
name: 'line-numbers',
line(node: Element, line: number) {
@@ -72,64 +101,242 @@ const lineNumberTransformer: ShikiTransformer = {
},
}
function getRequestedCodeLanguage(language?: string) {
const normalized = language?.trim().toLowerCase() || 'plaintext'
return LANGUAGE_ALIASES[normalized] ?? normalized
}
async function normalizeCodeLanguage(language?: string) {
const aliased = getRequestedCodeLanguage(language)
const { bundledLanguages } = await import('shiki')
if (aliased in bundledLanguages) {
return aliased as BundledLanguage
}
return 'plaintext'
}
function escapeCodeHtml(code: string) {
return code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function renderPlainCodeHtml(code: string, showLineNumbers: boolean) {
const lines = code.split('\n')
const renderedCode = lines
.map((line, index) => {
const escapedLine = escapeCodeHtml(line) || ' '
if (!showLineNumbers) {
return escapedLine
}
return `<span class="inline-block min-w-10 mr-4 text-right select-none text-muted-foreground">${index + 1}</span>${escapedLine}`
})
.join('\n')
return `<pre class="shiki"><code>${renderedCode}</code></pre>`
}
export async function highlightCode(
code: string,
language: BundledLanguage,
language: BundledLanguage | string,
showLineNumbers = false
) {
const resolvedLanguage = await normalizeCodeLanguage(language)
const cacheKey = `${resolvedLanguage}:${showLineNumbers ? 'line' : 'plain'}:${code}`
const cachedHtml = highlightCache.get(cacheKey)
if (cachedHtml) {
return cachedHtml
}
const transformers: ShikiTransformer[] = showLineNumbers
? [lineNumberTransformer]
: []
return codeToHtml(code, {
lang: language,
if (resolvedLanguage === 'plaintext') {
const html = renderPlainCodeHtml(code, showLineNumbers)
highlightCache.set(cacheKey, html)
return html
}
const { codeToHtml } = await import('shiki')
const html = await codeToHtml(code, {
lang: resolvedLanguage,
themes: {
light: 'one-light',
dark: 'one-dark-pro',
},
defaultColor: false,
transformers,
})
highlightCache.set(cacheKey, html)
return html
}
function getCodeLineCount(code: string) {
if (!code) {
return 1
}
return code.split('\n').length
}
function getDownloadFilename(language: string, filename?: string) {
if (filename) {
return filename
}
const extension = language === 'plaintext' ? 'txt' : language
return `code.${extension}`
}
export const CodeBlock = ({
code,
defaultCollapsed,
enableCollapse = true,
filename,
language,
maxCollapsedLines = 24,
showLineNumbers = false,
showToolbar = false,
title,
className,
children,
...props
}: CodeBlockProps) => {
const { t } = useTranslation()
const [html, setHtml] = useState<string>('')
const [isCollapsed, setIsCollapsed] = useState(Boolean(defaultCollapsed))
const displayLanguage = getRequestedCodeLanguage(language)
const lineCount = useMemo(() => getCodeLineCount(code), [code])
const canCollapse = enableCollapse && lineCount > maxCollapsedLines
const isCodeCollapsed = canCollapse && isCollapsed
const displayTitle = title ?? displayLanguage
const bodyMaxHeight = isCodeCollapsed
? `${Math.max(10, maxCollapsedLines) * 1.5 + 2}rem`
: undefined
useEffect(() => {
let cancelled = false
highlightCode(code, language, showLineNumbers).then((next) => {
if (!cancelled) {
setHtml(next)
}
})
highlightCode(code, language, showLineNumbers)
.then((next) => {
if (!cancelled) {
setHtml(next)
}
})
.catch(() => {
if (!cancelled) {
setHtml(renderPlainCodeHtml(code, showLineNumbers))
}
})
return () => {
cancelled = true
}
}, [code, language, showLineNumbers])
const downloadCode = () => {
if (typeof window === 'undefined') {
return
}
const blob = new Blob([code], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = getDownloadFilename(displayLanguage, filename)
anchor.click()
URL.revokeObjectURL(url)
}
return (
<CodeBlockContext.Provider value={{ code }}>
<CodeBlockContext.Provider value={{ code, language: displayLanguage }}>
<div
className={cn(
'group bg-background text-foreground relative w-full overflow-hidden rounded-md border',
'group/code-block bg-muted/20 text-foreground my-3 w-full max-w-full overflow-hidden rounded-lg border shadow-xs',
className
)}
{...props}
>
<div className='relative'>
{showToolbar && (
<div className='bg-muted/35 border-border/70 flex min-h-10 items-center gap-2 border-b px-2 py-1.5'>
<div className='min-w-0 flex-1'>
<div className='text-muted-foreground truncate font-mono text-[11px] font-medium tracking-wide uppercase'>
{displayTitle}
</div>
</div>
<div className='flex shrink-0 items-center gap-1'>
{canCollapse && (
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label={
isCodeCollapsed ? t('Expand') : t('Collapse')
}
className='size-8'
onClick={() => setIsCollapsed((value) => !value)}
size='icon-sm'
type='button'
variant='ghost'
>
{isCodeCollapsed ? (
<ChevronRightIcon className='size-4' />
) : (
<ChevronDownIcon className='size-4' />
)}
</Button>
}
/>
<TooltipContent>
<p>{isCodeCollapsed ? t('Expand') : t('Collapse')}</p>
</TooltipContent>
</Tooltip>
)}
{children}
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label={t('Download')}
className='size-8'
onClick={downloadCode}
size='icon-sm'
type='button'
variant='ghost'
>
<DownloadIcon className='size-4' />
</Button>
}
/>
<TooltipContent>
<p>{t('Download')}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
)}
<div className='relative min-w-0'>
<div
className='[&>pre]:bg-background! [&>pre]:text-foreground! overflow-hidden [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:p-4 [&>pre]:text-sm'
className={cn(
'code-block-scroll max-w-full overflow-auto transition-[max-height] duration-200 ease-out',
'[&_.shiki]:bg-transparent! [&_.shiki]:text-foreground! [&_code]:font-mono [&_code]:text-[13px] [&_code]:leading-6',
'[&>pre]:m-0 [&>pre]:min-w-max [&>pre]:p-4 [&>pre]:text-[13px] [&>pre]:leading-6'
)}
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: html }}
style={{ maxHeight: bodyMaxHeight }}
/>
{children && (
<div className='absolute top-2 right-2 flex items-center gap-2'>
{isCodeCollapsed && (
<div className='from-muted/20 pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-linear-to-b to-background' />
)}
{!showToolbar && children && (
<div className='absolute top-2 right-2 flex items-center gap-1'>
{children}
</div>
)}
@@ -153,6 +360,7 @@ export const CodeBlockCopyButton = ({
className,
...props
}: CodeBlockCopyButtonProps) => {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState(false)
const { code } = useContext(CodeBlockContext)
@@ -174,15 +382,26 @@ export const CodeBlockCopyButton = ({
const Icon = isCopied ? CheckIcon : CopyIcon
return (
const button = (
<Button
className={cn('shrink-0', className)}
aria-label={isCopied ? t('Copied!') : t('Copy code')}
className={cn('size-8 shrink-0', className)}
onClick={copyToClipboard}
size='icon'
size='icon-sm'
type='button'
variant='ghost'
{...props}
>
{children ?? <Icon size={14} />}
</Button>
)
return (
<Tooltip>
<TooltipTrigger render={button} />
<TooltipContent>
<p>{isCopied ? t('Copied!') : t('Copy code')}</p>
</TooltipContent>
</Tooltip>
)
}
+64 -4
View File
@@ -18,14 +18,73 @@ For commercial licensing, please contact support@quantumnous.com
*/
'use client'
import { type ComponentProps, memo } from 'react'
import { Streamdown } from 'streamdown'
import { type ComponentProps, memo, type ReactNode } from 'react'
import { Streamdown, type Components } from 'streamdown'
import { cn } from '@/lib/utils'
import {
CodeBlock,
CodeBlockCopyButton,
} from '@/components/ai-elements/code-block'
type ResponseProps = ComponentProps<typeof Streamdown>
type CodeComponentProps = ComponentProps<'code'> & {
node?: unknown
'data-block'?: boolean
}
function getCodeText(children: ReactNode) {
if (typeof children === 'string') {
return children.replace(/\n$/, '')
}
if (Array.isArray(children)) {
return children.join('').replace(/\n$/, '')
}
return String(children ?? '')
}
function getCodeLanguage(className?: string) {
return className?.match(/language-([\w#+.-]+)/)?.[1] ?? 'plaintext'
}
const responseComponents: Components = {
code({ children, className, ...props }: CodeComponentProps) {
if (!props['data-block']) {
return (
<code
className={cn(
'bg-muted/70 text-foreground rounded px-1 py-0.5 font-mono text-[0.9em]',
className
)}
{...props}
>
{children}
</code>
)
}
const code = getCodeText(children)
const language = getCodeLanguage(className)
return (
<CodeBlock
code={code}
defaultCollapsed={code.split('\n').length > 80}
language={language}
showLineNumbers={true}
showToolbar={true}
title={language}
>
<CodeBlockCopyButton />
</CodeBlock>
)
},
}
export const Response = memo(
({ className, children, ...props }: ResponseProps) => {
({ className, children, components, ...props }: ResponseProps) => {
const stripCustomTags = (input: unknown): unknown => {
if (typeof input !== 'string') return input
return (
@@ -45,9 +104,10 @@ export const Response = memo(
return (
<Streamdown
className={cn(
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
'size-full min-w-0 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
className
)}
components={{ ...responseComponents, ...components }}
{...props}
>
{safeChildren}
+8
View File
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
@import 'tailwindcss';
@source "../../node_modules/streamdown/dist/*.js";
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@import '@fontsource-variable/public-sans';
@@ -32,6 +33,13 @@ For commercial licensing, please contact support@quantumnous.com
/* Shiki dual themes: token colors follow dark theme (pre background stays `bg-background` on the block) */
@layer components {
.shiki span {
color: var(--shiki-light) !important;
font-style: var(--shiki-light-font-style) !important;
font-weight: var(--shiki-light-font-weight) !important;
text-decoration: var(--shiki-light-text-decoration) !important;
}
.dark .shiki span {
color: var(--shiki-dark) !important;
font-style: var(--shiki-dark-font-style) !important;