feat(Markdown): disable indented code blocks (#7288)

* feat(Markdown): disable indented code blocks

* chore: update remark/rehype packages
This commit is contained in:
one 2025-06-19 19:39:33 +08:00 committed by GitHub
parent c9f94a3b15
commit ed0bb7fd16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 425 additions and 96 deletions

View File

@ -190,7 +190,7 @@
"react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2",
"react-infinite-scroll-component": "^6.1.0",
"react-markdown": "^9.0.1",
"react-markdown": "^10.1.0",
"react-redux": "^9.1.2",
"react-router": "6",
"react-router-dom": "6",
@ -199,10 +199,10 @@
"redux": "^5.0.1",
"redux-persist": "^6.0.0",
"rehype-katex": "^7.0.1",
"rehype-mathjax": "^7.0.0",
"rehype-mathjax": "^7.1.0",
"rehype-raw": "^7.0.0",
"remark-cjk-friendly": "^1.1.0",
"remark-gfm": "^4.0.0",
"remark-cjk-friendly": "^1.2.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"remove-markdown": "^0.6.2",
"rollup-plugin-visualizer": "^5.12.0",

View File

@ -41,11 +41,10 @@ const MarkdownEditor: FC<MarkdownEditorProps> = ({
return (
<EditorContainer style={{ height }}>
<InputArea value={inputValue} onChange={handleChange} placeholder={placeholder} autoFocus={autoFocus} />
<PreviewArea>
<PreviewArea className="markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkCjkFriendly, remarkMath]}
rehypePlugins={[rehypeRaw, rehypeKatex]}
className="markdown">
rehypePlugins={[rehypeRaw, rehypeKatex]}>
{inputValue || t('settings.provider.notes.markdown_editor_default_value')}
</ReactMarkdown>
</PreviewArea>

View File

@ -75,8 +75,8 @@ const AgentsPage: FC = () => {
{agent.description && <AgentDescription>{agent.description}</AgentDescription>}
{agent.prompt && (
<AgentPrompt>
<ReactMarkdown className="markdown">{agent.prompt}</ReactMarkdown>{' '}
<AgentPrompt className="markdown">
<ReactMarkdown>{agent.prompt}</ReactMarkdown>
</AgentPrompt>
)}
</Flex>

View File

@ -24,6 +24,7 @@ import remarkMath from 'remark-math'
import CodeBlock from './CodeBlock'
import Link from './Link'
import remarkDisableConstructs from './plugins/remarkDisableConstructs'
import Table from './Table'
const ALLOWED_ELEMENTS =
@ -40,7 +41,7 @@ const Markdown: FC<Props> = ({ block }) => {
const { mathEngine } = useSettings()
const remarkPlugins = useMemo(() => {
const plugins = [remarkGfm, remarkCjkFriendly]
const plugins = [remarkGfm, remarkCjkFriendly, remarkDisableConstructs(['codeIndented'])]
if (mathEngine !== 'none') {
plugins.push(remarkMath)
}
@ -105,20 +106,21 @@ const Markdown: FC<Props> = ({ block }) => {
}, [])
return (
<ReactMarkdown
rehypePlugins={rehypePlugins}
remarkPlugins={remarkPlugins}
className="markdown"
components={components}
disallowedElements={DISALLOWED_ELEMENTS}
urlTransform={urlTransform}
remarkRehypeOptions={{
footnoteLabel: t('common.footnotes'),
footnoteLabelTagName: 'h4',
footnoteBackContent: ' '
}}>
{messageContent}
</ReactMarkdown>
<div className="markdown">
<ReactMarkdown
rehypePlugins={rehypePlugins}
remarkPlugins={remarkPlugins}
components={components}
disallowedElements={DISALLOWED_ELEMENTS}
urlTransform={urlTransform}
remarkRehypeOptions={{
footnoteLabel: t('common.footnotes'),
footnoteLabelTagName: 'h4',
footnoteBackContent: ' '
}}>
{messageContent}
</ReactMarkdown>
</div>
)
}

View File

@ -103,6 +103,12 @@ vi.mock('rehype-katex', () => ({ __esModule: true, default: vi.fn() }))
vi.mock('rehype-mathjax', () => ({ __esModule: true, default: vi.fn() }))
vi.mock('rehype-raw', () => ({ __esModule: true, default: vi.fn() }))
// Mock custom plugins
vi.mock('../plugins/remarkDisableConstructs', () => ({
__esModule: true,
default: vi.fn()
}))
// Mock ReactMarkdown with realistic rendering
vi.mock('react-markdown', () => ({
__esModule: true,
@ -162,12 +168,16 @@ describe('Markdown', () => {
describe('rendering', () => {
it('should render markdown content with correct structure', () => {
const block = createMainTextBlock({ content: 'Test content' })
render(<Markdown block={block} />)
const { container } = render(<Markdown block={block} />)
const markdown = screen.getByTestId('markdown-content')
expect(markdown).toBeInTheDocument()
expect(markdown).toHaveClass('markdown')
expect(markdown).toHaveTextContent('Test content')
// Check that the outer container has the markdown class
const markdownContainer = container.querySelector('.markdown')
expect(markdownContainer).toBeInTheDocument()
// Check that the markdown content is rendered inside
const markdownContent = screen.getByTestId('markdown-content')
expect(markdownContent).toBeInTheDocument()
expect(markdownContent).toHaveTextContent('Test content')
})
it('should handle empty content gracefully', () => {

View File

@ -3,55 +3,58 @@
exports[`Markdown > rendering > should match snapshot 1`] = `
<div
class="markdown"
data-testid="markdown-content"
>
# Test Markdown
<div
data-testid="markdown-content"
>
# Test Markdown
This is **bold** text.
<span
data-testid="has-link-component"
>
link
</span>
<div
data-testid="has-code-component"
>
<div
data-id="code-block-1"
data-testid="code-block"
<span
data-testid="has-link-component"
>
<code>
test code
</code>
<button
type="button"
>
Save
</button>
</div>
</div>
<div
data-testid="has-table-component"
>
link
</span>
<div
data-block-id="test-block-1"
data-testid="table-component"
data-testid="has-code-component"
>
<table>
test table
</table>
<button
data-testid="copy-table-button"
type="button"
<div
data-id="code-block-1"
data-testid="code-block"
>
Copy Table
</button>
<code>
test code
</code>
<button
type="button"
>
Save
</button>
</div>
</div>
<div
data-testid="has-table-component"
>
<div
data-block-id="test-block-1"
data-testid="table-component"
>
<table>
test table
</table>
<button
data-testid="copy-table-button"
type="button"
>
Copy Table
</button>
</div>
</div>
<span
data-testid="has-img-component"
>
img
</span>
</div>
<span
data-testid="has-img-component"
>
img
</span>
</div>
`;

View File

@ -0,0 +1,155 @@
import { render } from '@testing-library/react'
import ReactMarkdown from 'react-markdown'
import { describe, expect, it } from 'vitest'
import remarkDisableConstructs from '../remarkDisableConstructs'
describe('disableIndentedCode', () => {
const renderMarkdown = (markdown: string, constructs: string[] = ['codeIndented']) => {
return render(<ReactMarkdown remarkPlugins={[remarkDisableConstructs(constructs)]}>{markdown}</ReactMarkdown>)
}
describe('normal path', () => {
it('should disable indented code blocks while preserving other code types', () => {
const markdown = `
# Test Document
Regular paragraph.
This should be treated as a regular paragraph, not code
\`inline code\` should work
\`\`\`javascript
// This fenced code should work
console.log('hello')
\`\`\`
Another paragraph.
`
const { container } = renderMarkdown(markdown)
// Verify only fenced code (pre element)
expect(container.querySelectorAll('pre')).toHaveLength(1)
// Verify inline code
const inlineCode = container.querySelector('code:not(pre code)')
expect(inlineCode?.textContent).toBe('inline code')
// Verify fenced code
const fencedCode = container.querySelector('pre code')
expect(fencedCode?.textContent).toContain('console.log')
// Verify indented content becomes paragraph
const paragraphs = container.querySelectorAll('p')
const indentedParagraph = Array.from(paragraphs).find((p) =>
p.textContent?.includes('This should be treated as a regular paragraph')
)
expect(indentedParagraph).toBeTruthy()
})
it('should handle indented code in nested structures', () => {
const markdown = `
> Blockquote with \`inline code\`
>
> This indented code in blockquote should become text
1. List item
This indented code in list should become text
* Bullet list
* Nested item
More indented code to convert
`
const { container } = renderMarkdown(markdown)
// Verify no indented code blocks
expect(container.querySelectorAll('pre')).toHaveLength(0)
// Verify blockquote exists and contains converted text
const blockquote = container.querySelector('blockquote')
expect(blockquote?.textContent).toContain('This indented code in blockquote should become text')
// Verify lists exist
const lists = container.querySelectorAll('ul, ol')
expect(lists.length).toBeGreaterThan(0)
})
it('should preserve other markdown elements when disabling constructs', () => {
const markdown = `
# Heading
Paragraph text.
Indented code to disable
[Link text](https://example.com)
\`\`\`
Fenced code to keep
\`\`\`
`
const { container } = renderMarkdown(markdown)
// Verify heading
expect(container.querySelector('h1')?.textContent).toBe('Heading')
// Verify link
const link = container.querySelector('a')
expect(link?.textContent).toBe('Link text')
expect(link?.getAttribute('href')).toBe('https://example.com')
// Verify only fenced code
expect(container.querySelectorAll('pre')).toHaveLength(1)
})
})
describe('edge cases', () => {
it('should not affect markdown when no constructs are disabled', () => {
const markdown = `
This is indented code
\`inline code\`
\`\`\`javascript
console.log('fenced')
\`\`\`
`
const { container } = renderMarkdown(markdown, [])
// Should have indented code and fenced code
expect(container.querySelectorAll('pre')).toHaveLength(2)
})
it('should handle markdown with only inline and fenced code', () => {
const markdown = `
Regular paragraph with \`inline code\`.
\`\`\`typescript
function test(): string {
return "hello";
}
\`\`\`
`
const { container } = renderMarkdown(markdown)
// Should have only fenced code
expect(container.querySelectorAll('pre')).toHaveLength(1)
// Verify fenced code content
const fencedCode = container.querySelector('pre code')
expect(fencedCode?.textContent).toContain('function test()')
// Verify inline code
const inlineCode = container.querySelector('code:not(pre code)')
expect(inlineCode?.textContent).toBe('inline code')
})
})
})

View File

@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import remarkDisableConstructs from '../remarkDisableConstructs'
describe('remarkDisableConstructs', () => {
let mockData: any
let mockThis: any
beforeEach(() => {
mockData = {}
mockThis = {
data: vi.fn().mockReturnValue(mockData)
}
})
describe('plugin creation', () => {
it('should return a function when called', () => {
const plugin = remarkDisableConstructs(['codeIndented'])
expect(typeof plugin).toBe('function')
})
})
describe('normal path', () => {
it('should add micromarkExtensions for single construct', () => {
const plugin = remarkDisableConstructs(['codeIndented'])
plugin.call(mockThis as any)
expect(mockData).toHaveProperty('micromarkExtensions')
expect(Array.isArray(mockData.micromarkExtensions)).toBe(true)
expect(mockData.micromarkExtensions).toHaveLength(1)
expect(mockData.micromarkExtensions[0]).toEqual({
disable: {
null: ['codeIndented']
}
})
})
it('should handle multiple constructs', () => {
const constructs = ['codeIndented', 'autolink', 'htmlFlow']
const plugin = remarkDisableConstructs(constructs)
plugin.call(mockThis as any)
expect(mockData.micromarkExtensions[0]).toEqual({
disable: {
null: constructs
}
})
})
})
describe('edge cases', () => {
it('should not add extensions when empty array is provided', () => {
const plugin = remarkDisableConstructs([])
plugin.call(mockThis as any)
expect(mockData).not.toHaveProperty('micromarkExtensions')
})
it('should not add extensions when undefined is passed', () => {
const plugin = remarkDisableConstructs()
plugin.call(mockThis as any)
expect(mockData).not.toHaveProperty('micromarkExtensions')
})
it('should handle empty construct names', () => {
const plugin = remarkDisableConstructs(['', ' '])
plugin.call(mockThis as any)
expect(mockData.micromarkExtensions[0]).toEqual({
disable: {
null: ['', ' ']
}
})
})
it('should handle mixed valid and empty construct names', () => {
const plugin = remarkDisableConstructs(['codeIndented', '', 'autolink'])
plugin.call(mockThis as any)
expect(mockData.micromarkExtensions[0]).toEqual({
disable: {
null: ['codeIndented', '', 'autolink']
}
})
})
})
describe('interaction with existing data', () => {
it('should append to existing micromarkExtensions', () => {
const existingExtension = { some: 'extension' }
mockData.micromarkExtensions = [existingExtension]
const plugin = remarkDisableConstructs(['codeIndented'])
plugin.call(mockThis as any)
expect(mockData.micromarkExtensions).toHaveLength(2)
expect(mockData.micromarkExtensions[0]).toBe(existingExtension)
expect(mockData.micromarkExtensions[1]).toEqual({
disable: {
null: ['codeIndented']
}
})
})
})
})

View File

@ -0,0 +1,53 @@
import type { Plugin } from 'unified'
/**
* Custom remark plugin to disable specific markdown constructs
*
* This plugin allows you to disable specific markdown constructs by passing
* them as micromark extensions to the underlying parser.
*
* @see https://github.com/micromark/micromark
*
* @example
* ```typescript
* // Disable indented code blocks
* remarkDisableConstructs(['codeIndented'])
*
* // Disable multiple constructs
* remarkDisableConstructs(['codeIndented', 'autolink', 'htmlFlow'])
* ```
*/
/**
* Helper function to add values to plugin data
* @param data - The plugin data object
* @param field - The field name to add to
* @param value - The value to add
*/
function add(data: any, field: string, value: unknown): void {
const list = data[field] ? data[field] : (data[field] = [])
list.push(value)
}
/**
* Remark plugin to disable specific markdown constructs
* @param constructs - Array of construct names to disable (e.g., ['codeIndented', 'autolink'])
* @returns A remark plugin function
*/
function remarkDisableConstructs(constructs: string[] = []): Plugin<[], any, any> {
return function () {
const data = this.data()
if (constructs.length > 0) {
const disableExtension = {
disable: {
null: constructs
}
}
add(data, 'micromarkExtensions', disableExtension)
}
}
}
export default remarkDisableConstructs

View File

@ -103,8 +103,8 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
</HStack>
<TextAreaContainer>
{showMarkdown ? (
<MarkdownContainer onClick={() => setShowMarkdown(false)}>
<ReactMarkdown className="markdown">{prompt}</ReactMarkdown>
<MarkdownContainer className="markdown" onClick={() => setShowMarkdown(false)}>
<ReactMarkdown>{prompt}</ReactMarkdown>
<div style={{ height: '30px' }} />
</MarkdownContainer>
) : (

View File

@ -5712,7 +5712,7 @@ __metadata:
react-hotkeys-hook: "npm:^4.6.1"
react-i18next: "npm:^14.1.2"
react-infinite-scroll-component: "npm:^6.1.0"
react-markdown: "npm:^9.0.1"
react-markdown: "npm:^10.1.0"
react-redux: "npm:^9.1.2"
react-router: "npm:6"
react-router-dom: "npm:6"
@ -5721,10 +5721,10 @@ __metadata:
redux: "npm:^5.0.1"
redux-persist: "npm:^6.0.0"
rehype-katex: "npm:^7.0.1"
rehype-mathjax: "npm:^7.0.0"
rehype-mathjax: "npm:^7.1.0"
rehype-raw: "npm:^7.0.0"
remark-cjk-friendly: "npm:^1.1.0"
remark-gfm: "npm:^4.0.0"
remark-cjk-friendly: "npm:^1.2.0"
remark-gfm: "npm:^4.0.1"
remark-math: "npm:^6.0.0"
remove-markdown: "npm:^0.6.2"
rollup-plugin-visualizer: "npm:^5.12.0"
@ -12737,9 +12737,9 @@ __metadata:
languageName: node
linkType: hard
"micromark-extension-cjk-friendly-util@npm:^1.1.0":
version: 1.1.0
resolution: "micromark-extension-cjk-friendly-util@npm:1.1.0"
"micromark-extension-cjk-friendly-util@npm:^2.0.0":
version: 2.0.0
resolution: "micromark-extension-cjk-friendly-util@npm:2.0.0"
dependencies:
get-east-asian-width: "npm:^1.3.0"
micromark-util-character: "npm:^2.0.0"
@ -12747,16 +12747,16 @@ __metadata:
peerDependenciesMeta:
micromark-util-types:
optional: true
checksum: 10c0/3ae1d4fd92f03a6c8e34e314c14a42b35cdd1bcbe043fceb1d2d45cd1a7b364e77643a3ca181910666cb11cc3606a1595fae9a15e87b0a4988fc57d5e4f65f67
checksum: 10c0/194c799d88982ebf785e65a1c29cbded17d5dd3510a1769123ec30ddb7e256502b97753f63e8994d91ebafa1e9b96aa2dc2a90aa4e2f2072269b05652a412886
languageName: node
linkType: hard
"micromark-extension-cjk-friendly@npm:^1.1.0":
version: 1.1.0
resolution: "micromark-extension-cjk-friendly@npm:1.1.0"
"micromark-extension-cjk-friendly@npm:^1.2.0":
version: 1.2.0
resolution: "micromark-extension-cjk-friendly@npm:1.2.0"
dependencies:
devlop: "npm:^1.0.0"
micromark-extension-cjk-friendly-util: "npm:^1.1.0"
micromark-extension-cjk-friendly-util: "npm:^2.0.0"
micromark-util-chunked: "npm:^2.0.0"
micromark-util-resolve-all: "npm:^2.0.0"
micromark-util-symbol: "npm:^2.0.0"
@ -12766,7 +12766,7 @@ __metadata:
peerDependenciesMeta:
micromark-util-types:
optional: true
checksum: 10c0/95be6d8b4164b9b3b5281d77ed4f9337d95b2041ad4f7a775baa0d7f8ec495818101881eea2c7cc0ee4ee11738716899f20b3fbfbc2e6b80106544065d2ec04d
checksum: 10c0/5be1841629310e21c803b64feb00453fb8ac939be80c2ff473d8b4486d8eca973347520912a6e4abeda5bea4ed8ef39d3db48c4bad8285dd380d9ed34417dd0d
languageName: node
linkType: hard
@ -15611,9 +15611,9 @@ __metadata:
languageName: node
linkType: hard
"react-markdown@npm:^9.0.1":
version: 9.1.0
resolution: "react-markdown@npm:9.1.0"
"react-markdown@npm:^10.1.0":
version: 10.1.0
resolution: "react-markdown@npm:10.1.0"
dependencies:
"@types/hast": "npm:^3.0.0"
"@types/mdast": "npm:^4.0.0"
@ -15629,7 +15629,7 @@ __metadata:
peerDependencies:
"@types/react": ">=18"
react: ">=18"
checksum: 10c0/5bd645d39379f776d64588105f4060c390c3c8e4ff048552c9fa0ad31b756bb3ff7c11081542dc58d840ccf183a6dd4fd4d4edab44d8c24dee8b66551a5fd30d
checksum: 10c0/4a5dc7d15ca6d05e9ee95318c1904f83b111a76f7588c44f50f1d54d4c97193b84e4f64c4b592057c989228238a2590306cedd0c4d398e75da49262b2b5ae1bf
languageName: node
linkType: hard
@ -15915,7 +15915,7 @@ __metadata:
languageName: node
linkType: hard
"rehype-mathjax@npm:^7.0.0":
"rehype-mathjax@npm:^7.1.0":
version: 7.1.0
resolution: "rehype-mathjax@npm:7.1.0"
dependencies:
@ -15942,18 +15942,18 @@ __metadata:
languageName: node
linkType: hard
"remark-cjk-friendly@npm:^1.1.0":
version: 1.1.0
resolution: "remark-cjk-friendly@npm:1.1.0"
"remark-cjk-friendly@npm:^1.2.0":
version: 1.2.0
resolution: "remark-cjk-friendly@npm:1.2.0"
dependencies:
micromark-extension-cjk-friendly: "npm:^1.1.0"
micromark-extension-cjk-friendly: "npm:^1.2.0"
peerDependencies:
"@types/mdast": ^4.0.0
unified: ^11.0.0
peerDependenciesMeta:
"@types/mdast":
optional: true
checksum: 10c0/ef43a4c404baaaa3e3d888ea68db8ffa101746faadb96d19d6b7ee8d00f0a025613c2e508527236961b226e41d8fb34f6cc6ac217ae8770fcbf47b9f496ab32a
checksum: 10c0/ca7dc4fd50491693c4a84164650b30c3ae027cc7aa11b7a2e3811ab07ad0bf0c73484e37f9aed710bb68f95ca03cc540effe64cbe94bbc055b40e1aa951e2013
languageName: node
linkType: hard
@ -15967,7 +15967,7 @@ __metadata:
languageName: node
linkType: hard
"remark-gfm@npm:^4.0.0":
"remark-gfm@npm:^4.0.1":
version: 4.0.1
resolution: "remark-gfm@npm:4.0.1"
dependencies: