mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 10:25:48 +08:00
refactor(web): replace react-syntax-highlighter with shiki (#33473)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -5,6 +5,10 @@ import { Theme } from '@/types/app'
|
||||
|
||||
import CodeBlock from '../code-block'
|
||||
|
||||
const { mockHighlightCode } = vi.hoisted(() => ({
|
||||
mockHighlightCode: vi.fn(),
|
||||
}))
|
||||
|
||||
type UseThemeReturn = {
|
||||
theme: Theme
|
||||
}
|
||||
@@ -70,6 +74,10 @@ vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => mockUseTheme(),
|
||||
}))
|
||||
|
||||
vi.mock('../shiki-highlight', () => ({
|
||||
highlightCode: mockHighlightCode,
|
||||
}))
|
||||
|
||||
vi.mock('echarts', () => ({
|
||||
getInstanceByDom: mockEcharts.getInstanceByDom,
|
||||
}))
|
||||
@@ -130,6 +138,11 @@ describe('CodeBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light })
|
||||
mockHighlightCode.mockImplementation(async ({ code, language }) => (
|
||||
<pre className="shiki">
|
||||
<code className={`language-${language}`}>{code}</code>
|
||||
</pre>
|
||||
))
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
clientWidthSpy = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(900)
|
||||
@@ -198,11 +211,13 @@ describe('CodeBlock', () => {
|
||||
expect(container.querySelector('code')?.textContent).toBe('plain text')
|
||||
})
|
||||
|
||||
it('should render syntax-highlighted output when language is standard', () => {
|
||||
it('should render syntax-highlighted output when language is standard', async () => {
|
||||
render(<CodeBlock className="language-javascript">const x = 1;</CodeBlock>)
|
||||
|
||||
expect(screen.getByText('JavaScript')).toBeInTheDocument()
|
||||
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const x = 1;')
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const x = 1;')
|
||||
})
|
||||
})
|
||||
|
||||
it('should format unknown language labels with capitalized fallback when language is not in map', () => {
|
||||
@@ -242,13 +257,26 @@ describe('CodeBlock', () => {
|
||||
expect(screen.queryByText(/Error rendering SVG/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render syntax-highlighted output when language is standard and app theme is dark', () => {
|
||||
it('should render syntax-highlighted output when language is standard and app theme is dark', async () => {
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.dark })
|
||||
|
||||
render(<CodeBlock className="language-javascript">const y = 2;</CodeBlock>)
|
||||
|
||||
expect(screen.getByText('JavaScript')).toBeInTheDocument()
|
||||
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const y = 2;')
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const y = 2;')
|
||||
})
|
||||
})
|
||||
|
||||
it('should fall back to plain code block when shiki highlighting fails', async () => {
|
||||
mockHighlightCode.mockRejectedValueOnce(new Error('highlight failed'))
|
||||
|
||||
render(<CodeBlock className="language-javascript">const z = 3;</CodeBlock>)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('const z = 3;')).toBeInTheDocument()
|
||||
})
|
||||
expect(document.querySelector('code.language-javascript')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { JSX } from 'react'
|
||||
import type { BundledLanguage, BundledTheme } from 'shiki/bundle/web'
|
||||
import ReactEcharts from 'echarts-for-react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter'
|
||||
import {
|
||||
atelierHeathDark,
|
||||
atelierHeathLight,
|
||||
} from 'react-syntax-highlighter/dist/esm/styles/hljs'
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import CopyIcon from '@/app/components/base/copy-icon'
|
||||
import MarkdownMusic from '@/app/components/base/markdown-blocks/music'
|
||||
@@ -14,6 +11,7 @@ import useTheme from '@/hooks/use-theme'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { Theme } from '@/types/app'
|
||||
import SVGRenderer from '../svg-gallery' // Assumes svg-gallery.tsx is in /base directory
|
||||
import { highlightCode } from './shiki-highlight'
|
||||
|
||||
const Flowchart = dynamic(() => import('@/app/components/base/mermaid'), { ssr: false })
|
||||
|
||||
@@ -64,6 +62,61 @@ const getCorrectCapitalizationLanguageName = (language: string) => {
|
||||
// visit https://reactjs.org/docs/error-decoder.html?invariant=185 for the full message
|
||||
// or use the non-minified dev environment for full errors and additional helpful warnings.
|
||||
|
||||
const ShikiCodeBlock = memo(({ code, language, theme, initial }: { code: string, language: string, theme: BundledTheme, initial?: JSX.Element }) => {
|
||||
const [nodes, setNodes] = useState(initial)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void highlightCode({
|
||||
code,
|
||||
language: language as BundledLanguage,
|
||||
theme,
|
||||
}).then((result) => {
|
||||
if (!cancelled)
|
||||
setNodes(result)
|
||||
}).catch((error) => {
|
||||
console.error('Shiki highlighting failed:', error)
|
||||
if (!cancelled)
|
||||
setNodes(undefined)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [code, language, theme])
|
||||
|
||||
if (!nodes) {
|
||||
return (
|
||||
<pre style={{
|
||||
paddingLeft: 12,
|
||||
borderBottomLeftRadius: '10px',
|
||||
borderBottomRightRadius: '10px',
|
||||
backgroundColor: 'var(--color-components-input-bg-normal)',
|
||||
margin: 0,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderBottomLeftRadius: '10px',
|
||||
borderBottomRightRadius: '10px',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
className="shiki-line-numbers [&_pre]:m-0! [&_pre]:rounded-t-none! [&_pre]:rounded-b-[10px]! [&_pre]:bg-components-input-bg-normal! [&_pre]:py-2!"
|
||||
>
|
||||
{nodes}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
ShikiCodeBlock.displayName = 'ShikiCodeBlock'
|
||||
|
||||
// Define ECharts event parameter types
|
||||
type EChartsEventParams = {
|
||||
type: string
|
||||
@@ -416,20 +469,11 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
{...props}
|
||||
style={theme === Theme.light ? atelierHeathLight : atelierHeathDark}
|
||||
customStyle={{
|
||||
paddingLeft: 12,
|
||||
borderBottomLeftRadius: '10px',
|
||||
borderBottomRightRadius: '10px',
|
||||
backgroundColor: 'var(--color-components-input-bg-normal)',
|
||||
}}
|
||||
language={match?.[1]}
|
||||
showLineNumbers
|
||||
>
|
||||
{content}
|
||||
</SyntaxHighlighter>
|
||||
<ShikiCodeBlock
|
||||
code={content}
|
||||
language={match?.[1] || 'text'}
|
||||
theme={isDarkMode ? 'github-dark' : 'github-light'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}, [children, language, isSVG, finalChartOption, props, theme, match, chartState, isDarkMode, echartsStyle, echartsOpts, handleChartReady, echartsEvents])
|
||||
@@ -440,7 +484,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex h-8 items-center justify-between rounded-t-[10px] border-b border-divider-subtle bg-components-input-bg-normal p-1 pl-3">
|
||||
<div className="text-text-secondary system-xs-semibold-uppercase">{languageShowName}</div>
|
||||
<div className="system-xs-semibold-uppercase text-text-secondary">{languageShowName}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{language === 'svg' && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />}
|
||||
<ActionButton>
|
||||
|
||||
29
web/app/components/base/markdown-blocks/shiki-highlight.tsx
Normal file
29
web/app/components/base/markdown-blocks/shiki-highlight.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { JSX } from 'react'
|
||||
import type { BundledLanguage, BundledTheme } from 'shiki/bundle/web'
|
||||
import { toJsxRuntime } from 'hast-util-to-jsx-runtime'
|
||||
import { Fragment } from 'react'
|
||||
import { jsx, jsxs } from 'react/jsx-runtime'
|
||||
import { codeToHast } from 'shiki/bundle/web'
|
||||
|
||||
type HighlightCodeOptions = {
|
||||
code: string
|
||||
language: BundledLanguage
|
||||
theme: BundledTheme
|
||||
}
|
||||
|
||||
export const highlightCode = async ({
|
||||
code,
|
||||
language,
|
||||
theme,
|
||||
}: HighlightCodeOptions): Promise<JSX.Element> => {
|
||||
const hast = await codeToHast(code, {
|
||||
lang: language,
|
||||
theme,
|
||||
})
|
||||
|
||||
return toJsxRuntime(hast, {
|
||||
Fragment,
|
||||
jsx,
|
||||
jsxs,
|
||||
}) as JSX.Element
|
||||
}
|
||||
@@ -61,7 +61,7 @@ vi.mock('@/app/components/datasets/common/image-list', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Markdown uses next/dynamic and react-syntax-highlighter (ESM)
|
||||
// Markdown uses next/dynamic and shiki (ESM)
|
||||
vi.mock('@/app/components/base/markdown', () => ({
|
||||
Markdown: ({ content, className }: { content: string, className?: string }) => (
|
||||
<div data-testid="markdown" className={`markdown-body ${className || ''}`}>{content}</div>
|
||||
|
||||
@@ -909,4 +909,19 @@
|
||||
[data-theme='light'] [data-hide-on-theme='light'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Shiki code block line numbers */
|
||||
.shiki-line-numbers code {
|
||||
counter-reset: line;
|
||||
}
|
||||
.shiki-line-numbers .line::before {
|
||||
counter-increment: line;
|
||||
content: counter(line);
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
margin-right: 0.75rem;
|
||||
text-align: right;
|
||||
color: var(--color-text-quaternary);
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3142,9 +3142,6 @@
|
||||
"react/set-state-in-effect": {
|
||||
"count": 7
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 9
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"es-toolkit": "catalog:",
|
||||
"fast-deep-equal": "catalog:",
|
||||
"foxact": "catalog:",
|
||||
"hast-util-to-jsx-runtime": "catalog:",
|
||||
"html-entities": "catalog:",
|
||||
"html-to-image": "catalog:",
|
||||
"i18next": "catalog:",
|
||||
@@ -134,7 +135,6 @@
|
||||
"react-papaparse": "catalog:",
|
||||
"react-pdf-highlighter": "catalog:",
|
||||
"react-sortablejs": "catalog:",
|
||||
"react-syntax-highlighter": "catalog:",
|
||||
"react-textarea-autosize": "catalog:",
|
||||
"react-window": "catalog:",
|
||||
"reactflow": "catalog:",
|
||||
@@ -142,6 +142,7 @@
|
||||
"remark-directive": "catalog:",
|
||||
"scheduler": "catalog:",
|
||||
"sharp": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"sortablejs": "catalog:",
|
||||
"std-semver": "catalog:",
|
||||
"streamdown": "catalog:",
|
||||
@@ -196,7 +197,6 @@
|
||||
"@types/qs": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@types/react-syntax-highlighter": "catalog:",
|
||||
"@types/react-window": "catalog:",
|
||||
"@types/sortablejs": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
|
||||
Reference in New Issue
Block a user