feat(Markdown): disable indented code blocks (#7288)
* feat(Markdown): disable indented code blocks * chore: update remark/rehype packages
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+44
-41
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
+107
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user