From f34775ce53875d54b53a961458b1e9cd754c7e4b Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Fri, 5 Jun 2026 12:56:31 +0530 Subject: [PATCH] feat(renderer): markdown lib (marked + DOMPurify + mermaid placeholders) --- src/renderer/lib/markdown.ts | 29 +++++++++++++++++++++++++++++ tests/unit/lib/markdown.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 src/renderer/lib/markdown.ts create mode 100644 tests/unit/lib/markdown.test.ts diff --git a/src/renderer/lib/markdown.ts b/src/renderer/lib/markdown.ts new file mode 100644 index 0000000..960cc1e --- /dev/null +++ b/src/renderer/lib/markdown.ts @@ -0,0 +1,29 @@ +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; + +marked.setOptions({ + gfm: true, + breaks: false, +}); + +const MERMAID_RE = /```mermaid\n([\s\S]*?)```/g; + +export function renderMarkdown(source: string): string { + // Mark mermaid blocks with a placeholder we can replace client-side. + const placeholders: string[] = []; + const withPlaceholders = source.replace(MERMAID_RE, (_m, code) => { + const idx = placeholders.length; + placeholders.push(code.trim()); + return `
`; + }); + + const rawHtml = marked.parse(withPlaceholders, { async: false }) as string; + const clean = DOMPurify.sanitize(rawHtml, { + ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'data-mermaid-source', 'data-language', 'id'], + }); + + // The sanitized HTML still has placeholders; we leave the actual mermaid + // rendering to the React layer (MermaidLazy component) so the heavy + // mermaid library only loads when needed. + return clean; +} \ No newline at end of file diff --git a/tests/unit/lib/markdown.test.ts b/tests/unit/lib/markdown.test.ts new file mode 100644 index 0000000..d093f52 --- /dev/null +++ b/tests/unit/lib/markdown.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { renderMarkdown } from '@/lib/markdown'; + +describe('renderMarkdown', () => { + it('renders headings', () => { + const html = renderMarkdown('# Hello'); + expect(html).toContain('

Hello

'); + }); + + it('renders bold and italic', () => { + const html = renderMarkdown('**bold** and *italic*'); + expect(html).toContain('bold'); + expect(html).toContain('italic'); + }); + + it('renders code blocks with language class', () => { + const html = renderMarkdown('```js\nconst x = 1;\n```'); + expect(html).toContain('language-js'); + }); + + it('renders mermaid blocks with a placeholder', () => { + const html = renderMarkdown('```mermaid\ngraph TD\nA-->B\n```'); + expect(html).toContain('data-mermaid-source='); + }); + + it('sanitizes dangerous HTML', () => { + const html = renderMarkdown(''); + expect(html).not.toContain('