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
11 changed files with 425 additions and 96 deletions
@@ -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>
+2 -2
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>
@@ -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>
)
}
@@ -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', () => {
@@ -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>
`;
@@ -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')
})
})
})
@@ -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']
}
})
})
})
})
@@ -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
@@ -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>
) : (