import React from 'react'; import { CodeBlock } from '../code-block'; /** * Props for the MarkdownMessage component. */ interface MarkdownMessageProps { /** Raw markdown content to render */ content: string; /** Custom CSS classes for the container */ className?: string; } /** * Fenced code blocks (```lang\ncode\n```) are extracted before any other * regex runs and rendered as real `` elements instead of HTML * strings — two reasons: (1) `CodeBlock`'s syntax highlighting needs a * genuine React element, not a `dangerouslySetInnerHTML` string, and (2) if * fenced blocks were left in the string, the bold/italic/inline-code regexes * below would run over their contents first and corrupt any code containing * `*`, `_`, or backticks (e.g. `x**2`, docstrings, nested template strings). */ interface CodeSegment { type: 'code'; language?: string; code: string; } interface HtmlSegment { type: 'html'; html: string; } type Segment = CodeSegment | HtmlSegment; const FENCE_RE = /```([a-zA-Z0-9_+-]*)[ \t]*\r?\n?([\s\S]*?)```/g; function splitCodeBlocks(markdown: string): Segment[] { const segments: Segment[] = []; let lastIndex = 0; FENCE_RE.lastIndex = 0; let match: RegExpExecArray | null; while ((match = FENCE_RE.exec(markdown))) { if (match.index > lastIndex) { segments.push({ type: 'html', html: convertMarkdownToHtml(markdown.slice(lastIndex, match.index)) }); } segments.push({ type: 'code', language: match[1] || undefined, code: match[2].replace(/\n$/, ''), }); lastIndex = FENCE_RE.lastIndex; } if (lastIndex < markdown.length || segments.length === 0) { segments.push({ type: 'html', html: convertMarkdownToHtml(markdown.slice(lastIndex)) }); } return segments; } /** * Lightweight Markdown → HTML conversion for a single non-code-fence segment. * * @ai-rules * 1. Sanitization: Uses regex for conversion. Be cautious with complex markdown structures. * 2. Styling: Injected HTML uses specific Tailwind classes for consistent typography. * 3. Security: Result is used with `dangerouslySetInnerHTML`. HTML is escaped * first, so this is only as safe as that escape step — never skip it. * 4. Ordering matters: blockquote/heading detection must run on the escaped * `>`/`<` form (the escape step runs first); lists must run before * bold/italic since GFM allows `*` as a bullet marker (the same character * used for italic) — resolving list structure first removes the ambiguity; * and bold must run before italic so `**x**` isn't first read as two * `*x*` matches. */ function convertMarkdownToHtml(markdown: string): string { let html = markdown; // Escape existing HTML html = html.replace(//g, '>'); // Headers (most specific to least, though the trailing required space // after the last `#` already prevents `##`/`###` from matching `# `) html = html.replace( /^#### (.*$)/gim, '

$1

' ); html = html.replace( /^### (.*$)/gim, '

$1

' ); html = html.replace( /^## (.*$)/gim, '

$1

' ); html = html.replace( /^# (.*$)/gim, '

$1

' ); // Blockquotes — `>` was already escaped to `>` above, so match that. // Consecutive `>`-prefixed lines are grouped into a single
. html = html.replace(/(?:^> ?.*$(?:\n|$))+/gim, match => { const lines = match .trim() .split(/\r?\n/) .map(line => line.replace(/^> ?/, '')); return `
${lines.join('
')}
`; }); // Lists run BEFORE bold/italic, not after — GFM allows `*` as a bullet // marker (alongside `-`/`•`), which is also the italic delimiter. If bold/ // italic ran first, a bullet line's leading `* ` with no closing `*` on // the same line would sit there waiting, and the *next* bullet line's `*` // (or a genuine `*italic*` later in the same item) could pair with it, // corrupting both. Converting bullet/number markers to `
  • ` first removes // the ambiguity — plain text search on `*`/`_` after that only ever finds // real emphasis markers. html = html.replace(/((?:^[•\-*] .*$(?:\n|$))+)/gim, match => { const items = match .trim() .split('\n') .map( item => `
  • ${item.replace(/^[•\-*]\s+/, '')}
  • ` ) .join(''); return ``; }); // Ordered lists - properly wrap in ol html = html.replace(/((?:^\d+\. .*$(?:\n|$))+)/gim, match => { const items = match .trim() .split('\n') .map( item => `
  • ${item.replace(/^\d+\.\s+/, '')}
  • ` ) .join(''); return `
      ${items}
    `; }); // Bold (must run before italic, or "**x**" is read as two "*...*" matches) html = html.replace(/\*\*(.*?)\*\*/g, '$1'); // Italic html = html.replace(/\*(.*?)\*/g, '$1'); // Strikethrough (GFM) html = html.replace(/~~(.*?)~~/g, '$1'); // Links html = html.replace( /\[([^\]]+)\]\(([^)]+)\)/g, '$1' ); // Code inline html = html.replace( /`([^`]+)`/g, '$1' ); // GFM Tables — must run before line-break transforms html = html.replace(/((?:^\|.+\|[ \t]*(?:\r?\n|$))+)/gm, match => { const rows = match .trim() .split(/\r?\n/) .filter(line => line.trim()); if (rows.length < 2) return match; const isSeparatorRow = /^\|[\s\-:|]+\|$/.test(rows[1].trim()); if (!isSeparatorRow) return match; const parseCells = (row: string) => row .split('|') .slice(1, -1) .map(cell => cell.trim()); const headerCells = parseCells(rows[0]) .map( cell => `${cell}` ) .join(''); const bodyRows = rows .slice(2) .map( row => `${parseCells(row) .map(cell => `${cell}`) .join('')}` ) .join(''); return `
    ${headerCells}${bodyRows}
    `; }); // Line breaks (preserve double line breaks as paragraphs) html = html.replace(/\n\n/g, '

    '); html = html.replace(/\n/g, '
    '); // Wrap in initial paragraph html = '

    ' + html + '

    '; // Clean up empty paragraphs html = html.replace(/]*>\s*<\/p>/g, ''); return html; } /** * Lightweight Markdown Parser and Renderer. * * @description * A simplified markdown renderer built for chat messages, where a full-blown * parser (react-markdown + remark/rehype plugins) would be overkill. Supports * headers (h1-h4), bold, italic, strikethrough, links, inline code, fenced * code blocks (rendered via `CodeBlock`, with syntax highlighting), blockquotes, * ordered/unordered lists, and GFM tables. * * @ai-rules * 1. Sanitization: Uses regex for conversion. Be cautious with complex markdown structures. * 2. Styling: Injected HTML uses specific Tailwind classes for consistent typography. * 3. Security: Non-code content uses `dangerouslySetInnerHTML` after escaping `<`/`>`. * Fenced code content is rendered as a React text child (via `CodeBlock`), which * React escapes automatically — never re-escape it manually. */ export function MarkdownMessage({ content, className = '' }: MarkdownMessageProps) { const segments = splitCodeBlocks(content); return (
    {segments.map((segment, index) => segment.type === 'code' ? (
    ) : ( ) )}
    ); }