From 1e0dc698400a2fab1cb7f66d57cb6deb58c43a0f Mon Sep 17 00:00:00 2001 From: Zhicheng Han <43314240+hanzckernel@users.noreply.github.com> Date: Sun, 26 Apr 2026 04:38:05 +0200 Subject: [PATCH] =?UTF-8?q?fix(markdown):=20=E5=AE=89=E5=85=A8=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=20Mermaid=20code=20fence=20(#229)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(markdown): render mermaid fences safely * chore: drop local smoke screenshot asset --- package.json | 1 + .../hermes/chat/MarkdownRenderer.vue | 176 +++++++++++++- .../components/hermes/chat/mermaidRenderer.ts | 45 ++++ ...n-rendering-mermaid-import-timeout.test.ts | 57 +++++ tests/client/markdown-rendering.test.ts | 220 +++++++++++++++++- 5 files changed, 494 insertions(+), 5 deletions(-) create mode 100644 packages/client/src/components/hermes/chat/mermaidRenderer.ts create mode 100644 tests/client/markdown-rendering-mermaid-import-timeout.test.ts diff --git a/package.json b/package.json index 5d32458..2661077 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "koa-send": "^5.0.1", "koa-static": "^5.0.0", "markdown-it": "^14.1.1", + "mermaid": "^11.14.0", "monaco-editor": "^0.55.1", "naive-ui": "^2.44.1", "nodemon": "^3.1.14", diff --git a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue index 1131d5b..57e9e5b 100644 --- a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue +++ b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue @@ -1,10 +1,19 @@ diff --git a/packages/client/src/components/hermes/chat/mermaidRenderer.ts b/packages/client/src/components/hermes/chat/mermaidRenderer.ts new file mode 100644 index 0000000..cb3b7b9 --- /dev/null +++ b/packages/client/src/components/hermes/chat/mermaidRenderer.ts @@ -0,0 +1,45 @@ +const MERMAID_LANGUAGE = 'mermaid' + +export const MERMAID_MAX_DIAGRAMS_PER_MESSAGE = 4 +export const MERMAID_MAX_SOURCE_LENGTH = 20_000 +export const MERMAID_RENDER_TIMEOUT_MS = 5_000 + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +export function getFenceLanguage(info: string | undefined): string { + return info?.trim().split(/\s+/)[0]?.toLowerCase() || '' +} + +export function isMermaidFence(info: string | undefined): boolean { + return getFenceLanguage(info) === MERMAID_LANGUAGE +} + +export function encodeMermaidSource(source: string): string { + return encodeURIComponent(source) +} + +export function decodeMermaidSource(encoded: string | null | undefined): string { + if (!encoded) return '' + + try { + return decodeURIComponent(encoded) + } catch { + return '' + } +} + +export function renderMermaidPlaceholder(source: string): string { + return [ + '
`, + '
Rendering Mermaid diagram…
', + '
', + ].join('') +} diff --git a/tests/client/markdown-rendering-mermaid-import-timeout.test.ts b/tests/client/markdown-rendering-mermaid-import-timeout.test.ts new file mode 100644 index 0000000..026f531 --- /dev/null +++ b/tests/client/markdown-rendering-mermaid-import-timeout.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' + +vi.mock('mermaid', () => new Promise(() => {})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('naive-ui', () => ({ + useMessage: () => ({ + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }), +})) + +import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue' + +async function flushMermaidRender(): Promise { + for (let i = 0; i < 16; i += 1) { + await nextTick() + await Promise.resolve() + } +} + +describe('MarkdownRenderer Mermaid import timeout', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('falls back to copyable code when the mermaid dynamic import never settles', async () => { + vi.useFakeTimers() + + const wrapper = mount(MarkdownRenderer, { + props: { + content: '```mermaid\nflowchart TD\nA --> B\n```', + }, + }) + + await nextTick() + await Promise.resolve() + await vi.advanceTimersByTimeAsync(5_001) + await flushMermaidRender() + + expect(wrapper.find('.mermaid-loading').exists()).toBe(false) + expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(false) + expect(wrapper.find('.hljs-code-block').exists()).toBe(true) + expect(wrapper.find('.code-lang').text()).toBe('mermaid') + expect(wrapper.find('code.hljs').text()).toContain('flowchart TD') + }) +}) diff --git a/tests/client/markdown-rendering.test.ts b/tests/client/markdown-rendering.test.ts index 34350c8..1f4962b 100644 --- a/tests/client/markdown-rendering.test.ts +++ b/tests/client/markdown-rendering.test.ts @@ -1,6 +1,25 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' + +const mermaidMock = vi.hoisted(() => ({ + initialize: vi.fn(), + render: vi.fn(async (id: string, source: string) => ({ + svg: `${source}`, + })), +})) + +vi.mock('mermaid', () => ({ + default: mermaidMock, +})) + +async function flushMermaidRender(): Promise { + for (let i = 0; i < 16; i += 1) { + await nextTick() + await Promise.resolve() + } +} vi.mock('vue-i18n', () => ({ useI18n: () => ({ @@ -20,7 +39,17 @@ vi.mock('naive-ui', () => ({ import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue' describe('MarkdownRenderer', () => { + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { + mermaidMock.initialize.mockClear() + mermaidMock.render.mockClear() + mermaidMock.render.mockImplementation(async (id: string, source: string) => ({ + svg: `${source}`, + })) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { @@ -235,6 +264,195 @@ describe('MarkdownRenderer', () => { expect(wrapper.find('.markdown-body').text()).toContain('Done outside.') }) + it('renders mermaid fences as diagrams instead of raw highlighted code', async () => { + const wrapper = mount(MarkdownRenderer, { + props: { + content: [ + '```mermaid', + 'flowchart TD', + 'A[User] --> B[Web UI
command]', + '```', + '', + '具体 behavior:', + '- Markdown below still renders.', + ].join('\n'), + }, + }) + + await flushMermaidRender() + + expect(mermaidMock.initialize).toHaveBeenCalledWith(expect.objectContaining({ + startOnLoad: false, + securityLevel: 'strict', + })) + expect(mermaidMock.render).toHaveBeenCalledWith( + expect.stringMatching(/^hermes-mermaid-/), + expect.stringContaining('flowchart TD'), + ) + expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(true) + expect(wrapper.findAll('.hljs-code-block')).toHaveLength(0) + expect(wrapper.find('.markdown-body').find('ul').exists()).toBe(true) + }) + + it('renders mermaid inside repaired outer markdown draft fences', async () => { + const wrapper = mount(MarkdownRenderer, { + props: { + content: [ + '```md', + '## Command flow', + '', + '```Mermaid title', + 'flowchart LR', + 'A --> B', + '```', + '', + 'Done outside.', + '```', + ].join('\n'), + }, + }) + + await flushMermaidRender() + + expect(wrapper.find('.markdown-body').find('h2').text()).toBe('Command flow') + expect(mermaidMock.render).toHaveBeenCalledWith( + expect.stringMatching(/^hermes-mermaid-/), + expect.stringContaining('flowchart LR'), + ) + expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(true) + expect(wrapper.find('.markdown-body').text()).toContain('Done outside.') + }) + + it('falls back to a copyable code block when mermaid rendering fails', async () => { + mermaidMock.render.mockImplementationOnce((id: string) => { + const errorContainer = document.createElement('div') + errorContainer.id = `d${id}` + errorContainer.textContent = 'Syntax error in text\nmermaid version 11.14.0' + document.body.appendChild(errorContainer) + return Promise.reject(new Error('bad diagram')) + }) + const wrapper = mount(MarkdownRenderer, { + props: { + content: '```mermaid\nnot valid mermaid\n```', + }, + }) + + await flushMermaidRender() + + expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(false) + expect(wrapper.find('.hljs-code-block').exists()).toBe(true) + expect(wrapper.find('.code-lang').text()).toBe('mermaid') + expect(wrapper.find('code.hljs').text()).toContain('not valid mermaid') + expect(wrapper.find('[data-copy-code="true"]').exists()).toBe(true) + expect(document.body.textContent).not.toContain('Syntax error in text') + }) + + it('falls back to copyable code blocks when mermaid initialization fails', async () => { + mermaidMock.initialize.mockImplementationOnce(() => { + throw new Error('init failed') + }) + + const wrapper = mount(MarkdownRenderer, { + props: { + content: '```mermaid\nflowchart TD\nA --> B\n```', + }, + }) + + await flushMermaidRender() + + expect(mermaidMock.render).not.toHaveBeenCalled() + expect(wrapper.find('.hljs-code-block').exists()).toBe(true) + expect(wrapper.find('.code-lang').text()).toBe('mermaid') + expect(wrapper.find('code.hljs').text()).toContain('flowchart TD') + }) + + it('falls back without initializing mermaid when every pending diagram is oversized', async () => { + const wrapper = mount(MarkdownRenderer, { + props: { + content: `\`\`\`mermaid\n${'A'.repeat(20_001)}\n\`\`\``, + }, + }) + + await flushMermaidRender() + + expect(mermaidMock.initialize).not.toHaveBeenCalled() + expect(mermaidMock.render).not.toHaveBeenCalled() + expect(wrapper.find('.hljs-code-block').exists()).toBe(true) + expect(wrapper.find('.code-lang').text()).toBe('mermaid') + }) + + it('falls back without initializing mermaid when every pending diagram is empty', async () => { + const wrapper = mount(MarkdownRenderer, { + props: { + content: '```mermaid\n```', + }, + }) + + await flushMermaidRender() + + expect(mermaidMock.initialize).not.toHaveBeenCalled() + expect(mermaidMock.render).not.toHaveBeenCalled() + expect(wrapper.find('.hljs-code-block').exists()).toBe(true) + expect(wrapper.find('.code-lang').text()).toBe('mermaid') + }) + + it('falls back to copyable code when mermaid rendering never settles', async () => { + vi.useFakeTimers() + mermaidMock.render.mockImplementationOnce(() => new Promise(() => {})) + + const wrapper = mount(MarkdownRenderer, { + props: { + content: '```mermaid\nflowchart TD\nA --> B\n```', + }, + }) + + await nextTick() + await Promise.resolve() + await vi.advanceTimersByTimeAsync(5_001) + await flushMermaidRender() + + expect(wrapper.find('.mermaid-loading').exists()).toBe(false) + expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(false) + expect(wrapper.find('.hljs-code-block').exists()).toBe(true) + expect(wrapper.find('.code-lang').text()).toBe('mermaid') + expect(wrapper.find('code.hljs').text()).toContain('flowchart TD') + }) + + it('does not load or render mermaid when the message has no mermaid block', async () => { + const wrapper = mount(MarkdownRenderer, { + props: { + content: '```ts\nconst answer = 42\n```', + }, + }) + + await flushMermaidRender() + + expect(mermaidMock.initialize).not.toHaveBeenCalled() + expect(mermaidMock.render).not.toHaveBeenCalled() + expect(wrapper.find('.code-lang').text()).toBe('ts') + }) + + it('does not let stale async mermaid renders mutate newer message content', async () => { + let resolveRender: ((value: { svg: string }) => void) | undefined + mermaidMock.render.mockImplementationOnce((id: string) => new Promise(resolve => { + resolveRender = resolve + })) + + const wrapper = mount(MarkdownRenderer, { + props: { + content: '```mermaid\nflowchart TD\nA --> B\n```', + }, + }) + + await nextTick() + await wrapper.setProps({ content: 'No diagram now.' }) + resolveRender?.({ svg: '' }) + await flushMermaidRender() + + expect(wrapper.find('[data-testid="stale-mermaid-svg"]').exists()).toBe(false) + expect(wrapper.find('.markdown-body').text()).toContain('No diagram now.') + }) + it('copies code through the delegated click handler', async () => { const writeText = vi.mocked(navigator.clipboard.writeText) const wrapper = mount(MarkdownRenderer, {