From 0c10c6593c9119a1ef669cfdd0c568654529ccb2 Mon Sep 17 00:00:00 2001 From: SecPan Date: Wed, 8 Jul 2026 13:32:54 -0700 Subject: [PATCH] Port upstream mermaid export resilience fix --- .../__tests__/mermaidExportResilience.spec.ts | 81 +++++++++++++++++++ third_party/muya/src/state/markdownToHtml.ts | 19 ++++- 2 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 third_party/muya/src/state/__tests__/mermaidExportResilience.spec.ts diff --git a/third_party/muya/src/state/__tests__/mermaidExportResilience.spec.ts b/third_party/muya/src/state/__tests__/mermaidExportResilience.spec.ts new file mode 100644 index 0000000..59609ba --- /dev/null +++ b/third_party/muya/src/state/__tests__/mermaidExportResilience.spec.ts @@ -0,0 +1,81 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mermaidRun = vi.fn(); + +vi.mock('../../utils/diagram', () => ({ + default: vi.fn(async (name: string) => { + if (name === 'mermaid') { + return { + initialize: vi.fn(), + run: mermaidRun, + }; + } + throw new Error(`unexpected renderer ${name}`); + }), +})); + +// Import after the mock is registered so MarkdownToHtml uses the fake renderer. +const { MarkdownToHtml } = await import('../markdownToHtml'); + +beforeEach(() => { + mermaidRun.mockReset(); +}); + +const INVALID_MERMAID = [ + '# Title', + '', + 'Intro paragraph.', + '', + '```mermaid', + 'graph LR', + 'H[a|b|c]', + '```', + '', + 'Trailing paragraph.', + '', +].join('\n'); + +describe('#4812: mermaid syntax error must not abort export', () => { + it('renderHtml resolves even when a mermaid diagram fails to parse', async () => { + mermaidRun.mockRejectedValue(new Error('Parse error')); + + const html = await new MarkdownToHtml(INVALID_MERMAID).renderHtml(); + + expect(html).toContain('Title'); + expect(html).toContain('Intro paragraph.'); + expect(html).toContain('Trailing paragraph.'); + expect(html).toContain('< Invalid Diagram >'); + }); + + it('one broken diagram does not stop a later valid diagram from rendering', async () => { + mermaidRun + .mockRejectedValueOnce(new Error('Parse error')) + .mockResolvedValueOnce(undefined); + + const markdown = [ + '```mermaid', + 'graph LR', + 'H[a|b|c]', + '```', + '', + '```mermaid', + 'graph TD; A-->B', + '```', + '', + ].join('\n'); + + const html = await new MarkdownToHtml(markdown).renderHtml(); + + expect(mermaidRun).toHaveBeenCalledTimes(2); + expect(html).toContain('< Invalid Diagram >'); + }); + + it('does not load mermaid when the document contains no mermaid diagrams', async () => { + const html = await new MarkdownToHtml('plain paragraph').renderHtml(); + + expect(html).toContain('plain paragraph'); + expect(mermaidRun).not.toHaveBeenCalled(); + }); +}); diff --git a/third_party/muya/src/state/markdownToHtml.ts b/third_party/muya/src/state/markdownToHtml.ts index 58de94b..615f480 100644 --- a/third_party/muya/src/state/markdownToHtml.ts +++ b/third_party/muya/src/state/markdownToHtml.ts @@ -49,6 +49,10 @@ export class MarkdownToHtml { mermaidContainer.classList.add('mermaid'); preEle.replaceWith(mermaidContainer); } + const nodes = [...this._exportContainer!.querySelectorAll('div.mermaid')]; + if (nodes.length === 0) + return; + const mermaid = await loadRenderer('mermaid'); // We only export light theme, so set mermaid theme to `default`, in the future, we can choose which theme to export. mermaid.initialize({ @@ -56,9 +60,18 @@ export class MarkdownToHtml { securityLevel: 'strict', theme: 'default', }); - await mermaid.run({ - nodes: [...this._exportContainer!.querySelectorAll('div.mermaid')], - }); + // Render each diagram in isolation: `mermaid.run` rejects the whole + // batch on the first parse error, so one invalid diagram used to abort + // the entire export (#4812). Contain the failure to that diagram and + // fall back to the same placeholder the other diagram renderers use. + for (const node of nodes) { + try { + await mermaid.run({ nodes: [node] }); + } + catch { + node.innerHTML = '< Invalid Diagram >'; + } + } if (this._muya) { mermaid.initialize({ securityLevel: 'strict',