Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions src/components/shared/MarkdownRenderer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { describe, it, expect } from 'vitest';
import { markdownToHtml } from './MarkdownRenderer';

// Tests cover the pure markdownToHtml function (no DOM/React needed).

describe('markdownToHtml', () => {
it('returns empty string for empty input', () => {
expect(markdownToHtml('')).toBe('');
});

// ── Headings ──────────────────────────────────────────────────────────────

it('renders h1', () => {
expect(markdownToHtml('# Hello')).toContain('<h1>Hello</h1>');
});

it('renders h2', () => {
expect(markdownToHtml('## Section')).toContain('<h2>Section</h2>');
});

it('renders h3', () => {
expect(markdownToHtml('### Sub')).toContain('<h3>Sub</h3>');
});

// ── Emphasis ──────────────────────────────────────────────────────────────

it('renders bold with **', () => {
expect(markdownToHtml('This is **bold** text')).toContain('<strong>bold</strong>');
});

it('renders bold with __', () => {
expect(markdownToHtml('This is __bold__ text')).toContain('<strong>bold</strong>');
});

it('renders italic with *', () => {
expect(markdownToHtml('This is *italic* text')).toContain('<em>italic</em>');
});

it('renders italic with _', () => {
expect(markdownToHtml('This is _italic_ text')).toContain('<em>italic</em>');
});

// ── Inline code ───────────────────────────────────────────────────────────

it('renders inline code', () => {
expect(markdownToHtml('Use `console.log` here')).toContain('<code>console.log</code>');
});

// ── Fenced code blocks ────────────────────────────────────────────────────

it('renders fenced code block', () => {
const md = '```js\nconsole.log("hi");\n```';
const html = markdownToHtml(md);
expect(html).toContain('<pre><code');
expect(html).toContain('console.log');
});

it('sets language class on fenced code block', () => {
const md = '```typescript\nconst x = 1;\n```';
expect(markdownToHtml(md)).toContain('class="language-typescript"');
});

it('escapes HTML inside code blocks', () => {
const md = '```\n<script>alert(1)</script>\n```';
expect(markdownToHtml(md)).not.toContain('<script>');
expect(markdownToHtml(md)).toContain('&lt;script&gt;');
});

// ── Links & images ────────────────────────────────────────────────────────

it('renders links', () => {
const html = markdownToHtml('[TeachLink](https://example.com)');
expect(html).toContain('<a href="https://example.com">TeachLink</a>');
});

it('renders images', () => {
const html = markdownToHtml('![Logo](https://example.com/logo.png)');
expect(html).toContain('<img src="https://example.com/logo.png" alt="Logo"');
});

// ── Lists ─────────────────────────────────────────────────────────────────

it('renders unordered list items with -', () => {
const md = '- Alpha\n- Beta\n- Gamma';
const html = markdownToHtml(md);
expect(html).toContain('<ul>');
expect(html).toContain('<li>Alpha</li>');
expect(html).toContain('<li>Beta</li>');
});

it('renders unordered list items with *', () => {
const md = '* One\n* Two';
const html = markdownToHtml(md);
expect(html).toContain('<ul>');
expect(html).toContain('<li>One</li>');
});

it('renders ordered list items', () => {
const md = '1. First\n2. Second\n3. Third';
const html = markdownToHtml(md);
expect(html).toContain('<ol>');
expect(html).toContain('<li>First</li>');
expect(html).toContain('<li>Second</li>');
});

// ── Blockquotes ───────────────────────────────────────────────────────────

it('renders blockquotes', () => {
const html = markdownToHtml('> A wise quote');
expect(html).toContain('<blockquote>A wise quote</blockquote>');
});

// ── Horizontal rule ───────────────────────────────────────────────────────

it('renders horizontal rules', () => {
expect(markdownToHtml('---')).toContain('<hr />');
});

// ── Paragraphs ────────────────────────────────────────────────────────────

it('wraps plain text in a paragraph', () => {
expect(markdownToHtml('Hello world')).toContain('<p>Hello world</p>');
});

it('creates separate paragraphs for blank-line separated text', () => {
const html = markdownToHtml('First paragraph.\n\nSecond paragraph.');
expect(html).toContain('<p>First paragraph.</p>');
expect(html).toContain('<p>Second paragraph.</p>');
});

// ── XSS safety ────────────────────────────────────────────────────────────

it('does not pass raw script tags through from fenced block', () => {
const md = '```\n<script>evil()</script>\n```';
const html = markdownToHtml(md);
expect(html).not.toContain('<script>');
});

it('does not inject onerror attributes from image syntax', () => {
const md = '![x](onerror=alert(1))';
// The raw function may output it but DOMPurify would strip it;
// the raw output at minimum should not double-execute it as markup.
const html = markdownToHtml(md);
// Verify we at least produce an img tag (format check)
expect(html).toContain('<img');
});
});
152 changes: 152 additions & 0 deletions src/components/shared/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
'use client';

import DOMPurify from 'dompurify';
import { useMemo } from 'react';

/**
* Converts a subset of Markdown to sanitized HTML.
*
* Supported syntax:
* - Headings: `# H1`, `## H2`, `### H3`
* - Bold: `**text**` or `__text__`
* - Italic: `*text*` or `_text_`
* - Inline code: `` `code` ``
* - Fenced code blocks: ` ```lang\n...\n``` `
* - Blockquotes: `> text`
* - Unordered lists: `- item` or `* item`
* - Ordered lists: `1. item`
* - Links: `[label](url)`
* - Images: `![alt](url)`
* - Horizontal rules: `---`
* - Paragraphs: blank-line separated runs of text
*
* Output is sanitized with DOMPurify before rendering.
*/
export function markdownToHtml(markdown: string): string {
if (!markdown) return '';

let html = markdown;

// Fenced code blocks (must run before inline code to avoid nested matches)
html = html.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_match, lang, code) => {
const langAttr = lang.trim() ? ` class="language-${lang.trim()}"` : '';
return `<pre><code${langAttr}>${escapeHtml(code.trimEnd())}</code></pre>`;
});

// Headings
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');

// Horizontal rules
html = html.replace(/^---$/gm, '<hr />');

// Blockquotes
html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');

// Unordered lists — group consecutive `- ` or `* ` lines
html = html.replace(/((?:^[*-] .+\n?)+)/gm, (block) => {
const items = block
.trim()
.split('\n')
.map((line) => `<li>${line.replace(/^[*-] /, '')}</li>`)
.join('');
return `<ul>${items}</ul>`;
});

// Ordered lists — group consecutive `N. ` lines
html = html.replace(/((?:^\d+\. .+\n?)+)/gm, (block) => {
const items = block
.trim()
.split('\n')
.map((line) => `<li>${line.replace(/^\d+\. /, '')}</li>`)
.join('');
return `<ol>${items}</ol>`;
});

// Bold (** or __)
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');

// Italic (* or _) — exclude already-processed ** / __
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
html = html.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '<em>$1</em>');

// Inline code
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');

// Images (before links so they're not confused)
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />');

// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');

// Paragraphs: wrap lines not already inside a block element
html = html
.split(/\n{2,}/)
.map((block) => {
const trimmed = block.trim();
if (!trimmed) return '';
const isBlock = /^<(h[1-6]|ul|ol|li|blockquote|pre|hr)/.test(trimmed);
return isBlock ? trimmed : `<p>${trimmed.replace(/\n/g, '<br />')}</p>`;
})
.filter(Boolean)
.join('\n');

return html;
}

function escapeHtml(raw: string): string {
return raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

export interface MarkdownRendererProps {
/** Markdown source text to render. */
content: string;
/** Additional CSS class names applied to the wrapper `<div>`. */
className?: string;
}

/**
* Renders a Markdown string as sanitized HTML inside a styled `<div>`.
*
* @example
* ```tsx
* <MarkdownRenderer content="# Hello\n\nThis is **bold**." />
* ```
*
* The component is safe to use with user-supplied content: DOMPurify removes
* any JavaScript event handlers and non-standard attributes before the HTML
* reaches the DOM.
*
* Note: `dangerouslySetInnerHTML` is intentional here — the content is
* sanitized by DOMPurify before being set.
*/
export function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
const sanitizedHtml = useMemo(() => {
const raw = markdownToHtml(content);
if (typeof window === 'undefined') return raw;
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS: [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'strong', 'em', 'code', 'pre',
'ul', 'ol', 'li', 'blockquote', 'hr',
'a', 'img',
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'class', 'target', 'rel'],
});
}, [content]);

return (
<div
className={`prose prose-sm max-w-none dark:prose-invert ${className}`.trim()}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
);
}
Loading