mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
fix(web): skip shiki WASM engine load for plain-text fences (#42957)
Co-authored-by: Harsh Kashyap <harsh@harshkashyap.dev> Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
This commit is contained in:
2 files changed
+143
No files matched your search
@@ -1,7 +1,44 @@
|
||||
import type { createSingletonShorthands } from 'shiki/core'
|
||||
import type * as shikiCore from 'shiki/core'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { highlightCode } from '../shiki-highlight'
|
||||
|
||||
// `shiki/core` only exposes `getSingletonHighlighter` as a property of the
|
||||
// shorthand bundle returned by `createSingletonShorthands`, not as a top-level
|
||||
// symbol. Derive the accessor signature from the return type so wrapped calls
|
||||
// remain correctly typed without importing a non-exported name.
|
||||
type SingletonAccessor = ReturnType<typeof createSingletonShorthands>['getSingletonHighlighter']
|
||||
|
||||
// Mock the singleton accessor so tests can assert that the WASM engine is not
|
||||
// loaded for plain-text fences while still letting supported-language tests
|
||||
// observe a real `getSingletonHighlighter` invocation.
|
||||
const { mockGetSingletonHighlighter } = vi.hoisted(() => ({
|
||||
mockGetSingletonHighlighter: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('shiki/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof shikiCore>()
|
||||
const realCreateSingletonShorthands = actual.createSingletonShorthands
|
||||
return {
|
||||
...actual,
|
||||
createSingletonShorthands: (factory: Parameters<typeof realCreateSingletonShorthands>[0]) => {
|
||||
const real = realCreateSingletonShorthands(factory)
|
||||
// Wrap real `getSingletonHighlighter` so tests can observe invocations.
|
||||
const realGet = real.getSingletonHighlighter
|
||||
const wrapped: SingletonAccessor = (...args: Parameters<typeof realGet>) => {
|
||||
mockGetSingletonHighlighter(...args)
|
||||
return realGet(...args)
|
||||
}
|
||||
return { getSingletonHighlighter: wrapped }
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('README code highlighting', () => {
|
||||
beforeEach(() => {
|
||||
mockGetSingletonHighlighter.mockReset()
|
||||
})
|
||||
|
||||
it.each(['github-light', 'github-dark'] as const)('highlights dotenv with %s', async (theme) => {
|
||||
const code = 'OPENAI_API_KEY=your-api-key\n# OPENAI_ORGANIZATION=org-id'
|
||||
const result = renderToStaticMarkup(await highlightCode({ code, language: 'dotenv', theme }))
|
||||
@@ -10,6 +47,7 @@ describe('README code highlighting', () => {
|
||||
expect(result).toContain('your-api-key')
|
||||
expect(result).toContain('OPENAI_ORGANIZATION=org-id')
|
||||
expect(result).toContain('<span style="color:')
|
||||
expect(mockGetSingletonHighlighter).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders unsupported languages as readable, escaped plain text', async () => {
|
||||
@@ -23,6 +61,67 @@ describe('README code highlighting', () => {
|
||||
)
|
||||
|
||||
expect(result).toContain('<custom>example</custom>')
|
||||
expect(mockGetSingletonHighlighter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders an empty language fence as escaped plain text without loading the WASM engine', async () => {
|
||||
const code = 'plain line one\nplain line two'
|
||||
const result = renderToStaticMarkup(
|
||||
await highlightCode({ code, language: '', theme: 'github-light' }),
|
||||
)
|
||||
|
||||
expect(result).toContain('plain line one')
|
||||
expect(result).toContain('plain line two')
|
||||
expect(mockGetSingletonHighlighter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders a whitespace-only language fence as escaped plain text without loading the WASM engine', async () => {
|
||||
const code = 'no language here'
|
||||
const result = renderToStaticMarkup(
|
||||
await highlightCode({ code, language: ' ', theme: 'github-light' }),
|
||||
)
|
||||
|
||||
expect(result).toContain('no language here')
|
||||
expect(mockGetSingletonHighlighter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['\n', '\r\n'])(
|
||||
'preserves indentation and blank lines with %j line endings',
|
||||
async (lineEnding) => {
|
||||
const lines = ['', ' <custom>example</custom>', '', '\tindented', '']
|
||||
const result = renderToStaticMarkup(
|
||||
await highlightCode({
|
||||
code: lines.join(lineEnding),
|
||||
language: 'text',
|
||||
theme: 'github-light',
|
||||
}),
|
||||
)
|
||||
const document = new DOMParser().parseFromString(result, 'text/html')
|
||||
|
||||
expect(document.querySelector('code')?.textContent).toBe(lines.join('\n'))
|
||||
expect(document.querySelector('custom')).toBeNull()
|
||||
expect(mockGetSingletonHighlighter).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('renders an unsupported language as escaped plain text without loading the WASM engine', async () => {
|
||||
const code = 'function hello() { return 42 }'
|
||||
const result = renderToStaticMarkup(
|
||||
await highlightCode({ code, language: 'made-up-language', theme: 'github-light' }),
|
||||
)
|
||||
|
||||
expect(result).toContain('function hello() { return 42 }')
|
||||
expect(mockGetSingletonHighlighter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('escapes HTML-sensitive characters in plain-text fences', async () => {
|
||||
const code = 'a < b && b > c & "x"'
|
||||
const result = renderToStaticMarkup(
|
||||
await highlightCode({ code, language: 'text', theme: 'github-light' }),
|
||||
)
|
||||
|
||||
expect(result).toContain('a < b && b > c & "x"')
|
||||
expect(mockGetSingletonHighlighter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves highlighting for bundled language aliases', async () => {
|
||||
@@ -36,5 +135,6 @@ describe('README code highlighting', () => {
|
||||
|
||||
expect(result).toContain('const')
|
||||
expect(result).toContain('<span style="color:')
|
||||
expect(mockGetSingletonHighlighter).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -35,6 +35,49 @@ export const highlightCode = async ({
|
||||
normalizedLanguage === 'dotenv' || Object.hasOwn(codeLanguages, normalizedLanguage)
|
||||
? normalizedLanguage
|
||||
: 'text'
|
||||
|
||||
// Plain text and unsupported languages do not need syntax highlighting, so
|
||||
// skip `getSingletonHighlighter` to avoid loading the WASM engine for them
|
||||
// (see #42943). The shiki engine is still loaded on the first real highlight
|
||||
// call, so subsequent supported-language code blocks render normally.
|
||||
if (lang === 'text') {
|
||||
// Hand-built minimal HAST tree; typed via `toJsxRuntime`'s own parameter
|
||||
// so we don't need a top-level `hast` import (the shipped `@types/hast`
|
||||
// transitively pulls in `hast` module declarations that we cannot resolve).
|
||||
const plainHast: Parameters<typeof toJsxRuntime>[0] = {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'pre',
|
||||
properties: {},
|
||||
children: [
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'code',
|
||||
properties: {},
|
||||
// The code-block gutter uses .line::before for its line numbers.
|
||||
children: code.split(/\r?\n/).flatMap((line, index) => [
|
||||
{ type: 'text' as const, value: index === 0 ? '' : '\n' },
|
||||
{
|
||||
type: 'element' as const,
|
||||
tagName: 'span',
|
||||
properties: { className: ['line'] },
|
||||
children: [{ type: 'text' as const, value: line }],
|
||||
},
|
||||
]),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
return toJsxRuntime(plainHast, {
|
||||
Fragment,
|
||||
jsx,
|
||||
jsxs,
|
||||
}) as JSX.Element
|
||||
}
|
||||
|
||||
// README fences may name languages outside the web bundle. Load dotenv on
|
||||
// demand and keep unknown languages readable without throwing an error.
|
||||
const highlighter = await getSingletonHighlighter({
|
||||
|
||||
Reference in new issue
Block a user