145 lines
4.6 KiB
TypeScript
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(/ /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 `<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; |