mono/packages/ui/src/components/MarkdownRenderer.tsx
babayaga 8ec419b87e ui
2026-01-29 17:57:27 +01:00

145 lines
4.6 KiB
TypeScript

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<HTMLDivElement>(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 (
<div className={`prose prose-sm max-w-none dark:prose-invert ${className}`}>
<HashtagText>{content}</HashtagText>
</div>
);
}
// Memoize the expensive HTML processing
const htmlContent = useMemo(() => {
// Decode HTML entities first if present
const decodedContent = content
.replace(/&#x20;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/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 `<h${depth} id="${slug}">${text}</h${depth}>`;
};
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 href="([^"]*)"([^>]*)>([^<]*)<\/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 `<a href="${href}"${attrs} target="_blank" rel="noopener noreferrer" class="text-primary hover:text-primary/80 underline hover:no-underline transition-colors">${displayText}</a>`;
}
);
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 (
<div
ref={containerRef}
className={`prose prose-sm max-w-none dark:prose-invert ${className}`}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
);
});
MarkdownRenderer.displayName = 'MarkdownRenderer';
export default MarkdownRenderer;