import React, { useMemo, useEffect, useRef } from 'react'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; import HashtagText from './HashtagText'; import Prism from 'prismjs'; import 'prismjs/components/prism-typescript'; import 'prismjs/components/prism-javascript'; import 'prismjs/components/prism-json'; import 'prismjs/components/prism-bash'; import 'prismjs/components/prism-css'; import 'prismjs/components/prism-markup'; import '../styles/prism-custom-theme.css'; interface MarkdownRendererProps { content: string; className?: string; } const MarkdownRenderer = React.memo(({ content, className = "" }: MarkdownRendererProps) => { const containerRef = React.useRef(null); // Memoize content analysis const contentAnalysis = useMemo(() => { const hasHashtags = /#[a-zA-Z0-9_]+/.test(content); const hasMarkdownLinks = /\[.*?\]\(.*?\)/.test(content); const hasMarkdownSyntax = /(\*\*|__|##?|###?|####?|#####?|######?|\*|\n\*|\n-|\n\d+\.)/.test(content); return { hasHashtags, hasMarkdownLinks, hasMarkdownSyntax }; }, [content]); // Only use HashtagText if content has hashtags but NO markdown syntax at all // This preserves hashtag linking for simple text while allowing markdown to render properly if (contentAnalysis.hasHashtags && !contentAnalysis.hasMarkdownLinks && !contentAnalysis.hasMarkdownSyntax) { return (
{content}
); } // Memoize the expensive HTML processing const htmlContent = useMemo(() => { // Decode HTML entities first if present const decodedContent = content .replace(/ /g, ' ') .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, "'"); // Configure marked options marked.setOptions({ breaks: true, gfm: true, }); // Custom renderer to add IDs to headings const renderer = new marked.Renderer(); renderer.heading = ({ text, depth }: { text: string; depth: number }) => { const slug = text .toLowerCase() .trim() .replace(/[^\w\s-]/g, '') .replace(/[\s_-]+/g, '-') .replace(/^-+|-+$/g, ''); return `${text}`; }; marked.use({ renderer }); // Convert markdown to HTML const rawHtml = marked.parse(decodedContent) as string; // Helper function to format URL display text const formatUrlDisplay = (url: string): string => { try { // Remove protocol let displayUrl = url.replace(/^https?:\/\//, ''); // Remove www. if present displayUrl = displayUrl.replace(/^www\./, ''); // Truncate if too long (keep domain + some path) if (displayUrl.length > 40) { const parts = displayUrl.split('/'); const domain = parts[0]; const path = parts.slice(1).join('/'); if (path.length > 20) { displayUrl = `${domain}/${path.substring(0, 15)}...`; } else { displayUrl = `${domain}/${path}`; } } return displayUrl; } catch { return url; } }; // Post-process to add target="_blank", styling, and format link text const processedHtml = rawHtml.replace( /]*)>([^<]*)<\/a>/g, (match, href, attrs, text) => { // If the link text is the same as the URL (auto-generated), format it nicely const isAutoLink = text === href || text.replace(/^https?:\/\//, '') === href.replace(/^https?:\/\//, ''); const displayText = isAutoLink ? formatUrlDisplay(href) : text; return `${displayText}`; } ); return DOMPurify.sanitize(processedHtml, { ADD_ATTR: ['target', 'rel', 'class'] // Allow target, rel, and class attributes for links }); }, [content]); // Apply syntax highlighting after render React.useEffect(() => { if (containerRef.current) { Prism.highlightAllUnder(containerRef.current); } }, [htmlContent]); return (
); }); MarkdownRenderer.displayName = 'MarkdownRenderer'; export default MarkdownRenderer;