formatRenderers.tsx
1 import React from 'react'; 2 3 /** 4 * Renders content in various formats (markdown, plain text, etc.) 5 */ 6 7 interface FormatRendererProps { 8 content: string; 9 format: string; 10 } 11 12 // Function to process emphasis within a line of text for markdown 13 const processEmphasis = (text: string): JSX.Element[] => { 14 // Split the text by the markers for bold, italic, and code 15 const parts: JSX.Element[] = []; 16 let currentText = ''; 17 let inBold = false; 18 let inItalic = false; 19 let inCode = false; 20 21 for (let i = 0; i < text.length; i++) { 22 // Handle code (backticks) 23 if (text[i] === '`' && (!inBold && !inItalic)) { 24 if (currentText) { 25 parts.push(<span key={`text-${parts.length}`}>{currentText}</span>); 26 currentText = ''; 27 } 28 inCode = !inCode; 29 if (!inCode) { 30 // End of code section 31 const codeText = text.substring(text.indexOf('`', i - currentText.length - 1) + 1, i); 32 parts.push(<code key={`code-${parts.length}`} className="bg-gray-100 text-red-600 px-1 py-0.5 rounded font-mono text-sm">{codeText}</code>); 33 currentText = ''; 34 } 35 continue; 36 } 37 38 if (inCode) { 39 // Collecting code content 40 continue; 41 } 42 43 // Handle bold (**text**) 44 if (text.substring(i, i + 2) === '**' && !inItalic) { 45 if (currentText) { 46 parts.push(<span key={`text-${parts.length}`}>{currentText}</span>); 47 currentText = ''; 48 } 49 inBold = !inBold; 50 i++; // Skip the second asterisk 51 continue; 52 } 53 54 // Handle italic (*text*) 55 if (text[i] === '*' && text[i - 1] !== '*' && text[i + 1] !== '*' && !inBold) { 56 if (currentText) { 57 parts.push(<span key={`text-${parts.length}`}>{currentText}</span>); 58 currentText = ''; 59 } 60 inItalic = !inItalic; 61 continue; 62 } 63 64 // If we're in bold or italic, collect the text 65 if (inBold) { 66 currentText += text[i]; 67 if (i === text.length - 1 || (text.substring(i + 1, i + 3) === '**')) { 68 parts.push(<strong key={`bold-${parts.length}`}>{currentText}</strong>); 69 currentText = ''; 70 inBold = false; 71 i += 2; // Skip the closing asterisks 72 } 73 } else if (inItalic) { 74 currentText += text[i]; 75 if (i === text.length - 1 || text[i + 1] === '*') { 76 parts.push(<em key={`italic-${parts.length}`}>{currentText}</em>); 77 currentText = ''; 78 inItalic = false; 79 i++; // Skip the closing asterisk 80 } 81 } else { 82 currentText += text[i]; 83 } 84 } 85 86 // Add any remaining text 87 if (currentText) { 88 parts.push(<span key={`text-${parts.length}`}>{currentText}</span>); 89 } 90 91 return parts; 92 }; 93 94 // Parse and render markdown content 95 export const renderMarkdown = (text: string): JSX.Element[] => { 96 const lines = text.split('\n'); 97 const result: JSX.Element[] = []; 98 let inList = false; 99 let listItems: JSX.Element[] = []; 100 let inOrderedList = false; 101 let inCodeBlock = false; 102 let codeContent = ''; 103 let key = 0; 104 105 const processListItems = () => { 106 if (listItems.length > 0) { 107 if (inOrderedList) { 108 result.push(<ol key={key++} className="ml-5 list-decimal space-y-1 my-3">{listItems}</ol>); 109 } else { 110 result.push(<ul key={key++} className="ml-5 list-disc space-y-1 my-3">{listItems}</ul>); 111 } 112 listItems = []; 113 } 114 inList = false; 115 inOrderedList = false; 116 }; 117 118 const processCodeBlock = () => { 119 if (codeContent) { 120 result.push( 121 <pre key={key++} className="bg-gray-100 p-3 rounded-md my-3 overflow-auto"> 122 <code>{codeContent}</code> 123 </pre> 124 ); 125 codeContent = ''; 126 } 127 inCodeBlock = false; 128 }; 129 130 lines.forEach((line) => { 131 // Code blocks 132 if (line.startsWith('```')) { 133 if (inCodeBlock) { 134 processCodeBlock(); 135 } else { 136 inCodeBlock = true; 137 } 138 return; 139 } 140 141 if (inCodeBlock) { 142 codeContent += line + '\n'; 143 return; 144 } 145 146 // Horizontal rule 147 if (line.match(/^(\*{3,}|-{3,}|_{3,})$/)) { 148 if (inList) processListItems(); 149 result.push(<hr key={key++} className="my-4 border-t border-gray-200" />); 150 return; 151 } 152 153 // Headers 154 if (line.startsWith('# ')) { 155 if (inList) processListItems(); 156 result.push(<h1 key={key++} className="text-2xl font-bold mt-6 mb-3">{processEmphasis(line.substring(2))}</h1>); 157 return; 158 } 159 if (line.startsWith('## ')) { 160 if (inList) processListItems(); 161 result.push(<h2 key={key++} className="text-xl font-bold mt-5 mb-2">{processEmphasis(line.substring(3))}</h2>); 162 return; 163 } 164 if (line.startsWith('### ')) { 165 if (inList) processListItems(); 166 result.push(<h3 key={key++} className="text-lg font-bold mt-4 mb-2">{processEmphasis(line.substring(4))}</h3>); 167 return; 168 } 169 if (line.startsWith('#### ')) { 170 if (inList) processListItems(); 171 result.push(<h4 key={key++} className="text-base font-bold mt-3 mb-2">{processEmphasis(line.substring(5))}</h4>); 172 return; 173 } 174 if (line.startsWith('##### ')) { 175 if (inList) processListItems(); 176 result.push(<h5 key={key++} className="text-sm font-bold mt-3 mb-1">{processEmphasis(line.substring(6))}</h5>); 177 return; 178 } 179 if (line.startsWith('###### ')) { 180 if (inList) processListItems(); 181 result.push(<h6 key={key++} className="text-xs font-bold mt-2 mb-1">{processEmphasis(line.substring(7))}</h6>); 182 return; 183 } 184 185 // List items 186 const unorderedListMatch = line.match(/^(\s*[-*+]\s+)(.+)$/); 187 if (unorderedListMatch) { 188 if (inOrderedList) { 189 processListItems(); // End the ordered list before starting unordered 190 } 191 inList = true; 192 const content = unorderedListMatch[2]; 193 listItems.push(<li key={`list-${key++}`}>{processEmphasis(content)}</li>); 194 return; 195 } 196 197 const orderedListMatch = line.match(/^(\s*\d+\.\s+)(.+)$/); 198 if (orderedListMatch) { 199 if (!inOrderedList && inList) { 200 processListItems(); // End the unordered list before starting ordered 201 } 202 inList = true; 203 inOrderedList = true; 204 const content = orderedListMatch[2]; 205 listItems.push(<li key={`list-${key++}`}>{processEmphasis(content)}</li>); 206 return; 207 } 208 209 // If we were in a list but this line isn't a list item, end the list 210 if (inList && line.trim() !== '') { 211 processListItems(); 212 } 213 214 // Empty line 215 if (line.trim() === '') { 216 return; 217 } 218 219 // Regular paragraph 220 result.push(<p key={key++} className="my-2">{processEmphasis(line)}</p>); 221 }); 222 223 // Clean up any remaining lists or code blocks 224 if (inList) processListItems(); 225 if (inCodeBlock) processCodeBlock(); 226 227 return result; 228 }; 229 230 // Render plain text content 231 export const renderPlainText = (text: string): JSX.Element[] => { 232 return text.split('\n').map((line, i) => ( 233 line.trim() === '' ? <br key={i} /> : <p key={i} className="my-1">{line}</p> 234 )); 235 }; 236 237 // Main format renderer component 238 export const FormatRenderer: React.FC<FormatRendererProps> = ({ content, format }) => { 239 let renderedContent: JSX.Element | JSX.Element[]; 240 241 switch (format.toLowerCase()) { 242 case 'markdown': 243 case 'md': 244 renderedContent = renderMarkdown(content); 245 break; 246 247 case 'plain': 248 case 'text': 249 case 'txt': 250 default: 251 renderedContent = renderPlainText(content); 252 break; 253 } 254 255 return ( 256 <div className="format-renderer prose prose-sm prose-blue max-w-none"> 257 {renderedContent} 258 </div> 259 ); 260 }; 261 262 export default FormatRenderer;