diff --git a/package.json b/package.json index 2f7446c706..57f14e728d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/renderer/src/components/MarkdownEditor/index.tsx b/src/renderer/src/components/MarkdownEditor/index.tsx index 5a355a07c2..427ff1ccc8 100644 --- a/src/renderer/src/components/MarkdownEditor/index.tsx +++ b/src/renderer/src/components/MarkdownEditor/index.tsx @@ -41,11 +41,10 @@ const MarkdownEditor: FC = ({ return ( - + + rehypePlugins={[rehypeRaw, rehypeKatex]}> {inputValue || t('settings.provider.notes.markdown_editor_default_value')} diff --git a/src/renderer/src/pages/agents/AgentsPage.tsx b/src/renderer/src/pages/agents/AgentsPage.tsx index b9873c310b..1a8e3ce990 100644 --- a/src/renderer/src/pages/agents/AgentsPage.tsx +++ b/src/renderer/src/pages/agents/AgentsPage.tsx @@ -75,8 +75,8 @@ const AgentsPage: FC = () => { {agent.description && {agent.description}} {agent.prompt && ( - - {agent.prompt}{' '} + + {agent.prompt} )} diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index 8ee90a859a..2a6446fec7 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -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 = ({ 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 = ({ block }) => { }, []) return ( - - {messageContent} - +
+ + {messageContent} + +
) } diff --git a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx index c72f30de98..abd7067ab0 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx +++ b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx @@ -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() + const { container } = render() - 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', () => { diff --git a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Markdown.test.tsx.snap b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Markdown.test.tsx.snap index 29aae68dc0..975aa2e09a 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Markdown.test.tsx.snap +++ b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Markdown.test.tsx.snap @@ -3,55 +3,58 @@ exports[`Markdown > rendering > should match snapshot 1`] = `
- # Test Markdown +
+ # Test Markdown This is **bold** text. - - link - -
-
- - test code - - -
-
-
+ link +
- - test table -
- + + test code + + +
+
+
+ + test table +
+ +
+
+ + img +
- - img -
`; diff --git a/src/renderer/src/pages/home/Markdown/plugins/__tests__/disableIndentedCode.test.tsx b/src/renderer/src/pages/home/Markdown/plugins/__tests__/disableIndentedCode.test.tsx new file mode 100644 index 0000000000..0a20e79b81 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/plugins/__tests__/disableIndentedCode.test.tsx @@ -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({markdown}) + } + + 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') + }) + }) +}) diff --git a/src/renderer/src/pages/home/Markdown/plugins/__tests__/remarkDisableConstructs.test.ts b/src/renderer/src/pages/home/Markdown/plugins/__tests__/remarkDisableConstructs.test.ts new file mode 100644 index 0000000000..dcf0b32b28 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/plugins/__tests__/remarkDisableConstructs.test.ts @@ -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'] + } + }) + }) + }) +}) diff --git a/src/renderer/src/pages/home/Markdown/plugins/remarkDisableConstructs.ts b/src/renderer/src/pages/home/Markdown/plugins/remarkDisableConstructs.ts new file mode 100644 index 0000000000..967e67ef5f --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/plugins/remarkDisableConstructs.ts @@ -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 diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx index 02b8185046..d5b2882986 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx @@ -103,8 +103,8 @@ const AssistantPromptSettings: React.FC = ({ assistant, updateAssistant } {showMarkdown ? ( - setShowMarkdown(false)}> - {prompt} + setShowMarkdown(false)}> + {prompt}
) : ( diff --git a/yarn.lock b/yarn.lock index a0daa3b3ce..b066f44a11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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: