mono/packages/ui/src/components/MarkdownRenderer.tsx
2026-03-31 10:56:00 +02:00

448 lines
18 KiB
TypeScript

import React, { useMemo, useEffect, useRef, useState, Suspense, useCallback } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import HashtagText from './HashtagText';
import Prism from 'prismjs';
import ResponsiveImage from './ResponsiveImage';
import { useAuth } from '@/hooks/useAuth';
// Import type from Post module
import { PostMediaItem } from '@/modules/posts/views/types';
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';
// Lazy load SmartLightbox to avoid circular deps or heavy bundle on initial load
const SmartLightbox = React.lazy(() => import('@/modules/posts/views/components/SmartLightbox'));
const GalleryWidget = React.lazy(() => import('./widgets/GalleryWidget'));
const MermaidWidget = React.lazy(() => import('./widgets/MermaidWidget'));
interface MarkdownRendererProps {
content: string;
className?: string;
variables?: Record<string, any>;
baseUrl?: string;
onLinkClick?: (href: string, e: React.MouseEvent<HTMLAnchorElement>) => void;
}
// Helper function to format URL display text (ported from previous implementation)
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;
}
};
// Helper for slugifying headings
const slugify = (text: string) => {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
};
/** Recursively extract plain text from React children (handles nested <a>, <strong>, etc.) */
const getPlainText = (children: React.ReactNode): string => {
if (typeof children === 'string' || typeof children === 'number') return String(children);
if (Array.isArray(children)) return children.map(getPlainText).join('');
if (React.isValidElement(children)) return getPlainText((children.props as any).children);
return '';
};
import { substitute } from '@/lib/variables';
// Helper to strip YAML frontmatter
const stripFrontmatter = (text: string) => {
return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trimStart();
};
const MarkdownRenderer = React.memo(({ content, className = "", variables, baseUrl, onLinkClick }: MarkdownRendererProps) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const { user } = useAuth();
// Helper to resolve relative URLs
const resolveUrl = useCallback((url: string | undefined) => {
if (!url) return '';
if (!baseUrl) return url;
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url;
// Resolve relative path against baseUrl
try {
// If baseUrl is relative, make it absolute using the API origin so the server can fetch it
let absoluteBase = baseUrl;
if (baseUrl.startsWith('/')) {
const apiOrigin = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
// if API url is absolute (http://...), use it as the base.
// fallback to window.location.origin for relative API configs.
const originToUse = apiOrigin.startsWith('http') ? apiOrigin : window.location.origin;
// Avoid double-prefixing if baseUrl already contains the origin root (e.g. from SSR)
if (!baseUrl.startsWith(originToUse)) {
absoluteBase = `${originToUse}${baseUrl}`;
}
}
// Ensure the base URL resolves to the directory, not the file
// URL constructor resolves './file' relative to the path. If path doesn't end in '/',
// it strips the last segment. So we DO NOT want to arbitrarily append '/' to a file path.
// If absoluteBase is a file path (e.g. '.../document.md'), the URL constructor natively handles:
// new URL('./image.jpg', '.../document.md') => '.../image.jpg'
return new URL(url, absoluteBase).href;
} catch {
return url; // Fallback if parsing fails
}
}, [baseUrl]);
// Substitute variables in content if provided
const finalContent = useMemo(() => {
const withoutFrontmatter = stripFrontmatter(content);
if (!variables || Object.keys(variables).length === 0) return withoutFrontmatter;
return substitute(false, withoutFrontmatter, variables);
}, [content, variables]);
// Lightbox state
const [lightboxOpen, setLightboxOpen] = useState(false);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
// Extract all images from content for navigation
const allImages = useMemo(() => {
const images: { src: string, alt: string }[] = [];
const regex = /!\[([^\]]*)\]\(([^)]+)\)/g;
let match;
// We clone the regex to avoid stateful issues if reuse happens, though local var is fine
const localRegex = new RegExp(regex);
while ((match = localRegex.exec(finalContent)) !== null) {
images.push({
alt: match[1],
src: match[2]
});
}
return images;
}, [finalContent]);
// Memoize content analysis (keep existing logic for simple hashtag views)
const contentAnalysis = useMemo(() => {
const hasHashtags = /#[a-zA-Z0-9_]+/.test(finalContent);
const hasMarkdownLinks = /\[.*?\]\(.*?\)/.test(finalContent);
const hasMarkdownSyntax = /(\*\*|__|##?|###?|####?|#####?|######?|\*|\n\*|\n-|\n\d+\.)/.test(finalContent);
return {
hasHashtags,
hasMarkdownLinks,
hasMarkdownSyntax
};
}, [finalContent]);
// Removed Prism.highlightAllUnder to prevent React NotFoundError during streaming
// Highlighting is now handled safely within the `code` component renderer.
const handleImageClick = (src: string) => {
const index = allImages.findIndex(img => img.src === src);
if (index !== -1) {
setCurrentImageIndex(index);
setLightboxOpen(true);
}
};
const handleNavigate = (direction: 'prev' | 'next') => {
if (direction === 'prev') {
setCurrentImageIndex(prev => (prev > 0 ? prev - 1 : prev));
} else {
setCurrentImageIndex(prev => (prev < allImages.length - 1 ? prev + 1 : prev));
}
};
// Mock MediaItem for SmartLightbox
const mockMediaItem = useMemo((): PostMediaItem | null => {
const selectedImage = allImages[currentImageIndex];
if (!selectedImage) return null;
const resolvedUrl = resolveUrl(selectedImage.src);
return {
id: 'md-' + btoa(encodeURIComponent(selectedImage.src)).substring(0, 10), // stable ID based on SRC
title: selectedImage.alt || 'Image',
description: '',
image_url: resolvedUrl,
thumbnail_url: resolvedUrl,
user_id: user?.id || 'unknown',
type: 'image',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
position: 0,
likes_count: 0,
post_id: null
} as any;
}, [currentImageIndex, allImages, user, resolveUrl]);
// Only use HashtagText if content has hashtags but NO markdown syntax at all
if (contentAnalysis.hasHashtags && !contentAnalysis.hasMarkdownLinks && !contentAnalysis.hasMarkdownSyntax) {
return (
<div className={`prose prose-sm max-w-none dark:prose-invert ${className}`}>
<HashtagText>{finalContent}</HashtagText>
</div>
);
}
return (
<>
<div
ref={containerRef}
className={`prose prose-sm max-w-none dark:prose-invert ${className}`}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
img: ({ node, src, alt, title, ...props }) => {
// Basic implementation of ResponsiveImage
const resolvedSrc = resolveUrl(src);
return (
<span className="block my-4">
<ResponsiveImage
src={resolvedSrc}
alt={alt || ''}
title={title} // Pass title down if ResponsiveImage supports it or wrap it
className={`cursor-pointer ${props.className || ''}`}
imgClassName="cursor-pointer hover:opacity-95 transition-opacity"
// Default generous sizes for blog post content
sizes="(max-width: 768px) 100vw, 800px"
loading="lazy"
onClick={() => resolvedSrc && handleImageClick(src || '')}
/>
{title && <span className="block text-center text-sm text-muted-foreground mt-2 italic">{title}</span>}
</span>
);
},
a: ({ node, href, children, ...props }) => {
if (!href) return <span {...props}>{children}</span>;
// Logic to format display text if it matches the URL
let childText = '';
if (typeof children === 'string') {
childText = children;
} else if (Array.isArray(children) && children.length > 0 && typeof children[0] === 'string') {
// Simple approximation for React children
childText = children[0];
}
const isAutoLink = childText === href || childText.replace(/^https?:\/\//, '') === href.replace(/^https?:\/\//, '');
const displayContent = isAutoLink ? formatUrlDisplay(href) : children;
const isRelative = !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('mailto:') && !href.startsWith('tel:') && !href.startsWith('data:') && !href.startsWith('#');
return (
<a
href={href}
target={isRelative ? undefined : "_blank"}
rel="noopener noreferrer"
className="text-primary hover:text-primary/80 underline hover:no-underline transition-colors"
onClick={(e) => {
if (onLinkClick) {
onLinkClick(href, e);
}
}}
{...props}
>
{displayContent}
</a>
);
},
h1: ({ node, children, ...props }) => {
const text = getPlainText(children);
const id = slugify(text);
return <h1 id={id} {...props}>{children}</h1>;
},
h2: ({ node, children, ...props }) => {
const text = getPlainText(children);
const id = slugify(text);
return <h2 id={id} {...props}>{children}</h2>;
},
h3: ({ node, children, ...props }) => {
const text = getPlainText(children);
const id = slugify(text);
return <h3 id={id} {...props}>{children}</h3>;
},
h4: ({ node, children, ...props }) => {
const text = getPlainText(children);
const id = slugify(text);
return <h4 id={id} {...props}>{children}</h4>;
},
p: ({ node, children, ...props }) => {
// Deep check if any child is an image to avoid <p> nesting issues
const hasImage = (n: any): boolean => {
if (n.type === 'element' && n.tagName === 'img') return true;
if (n.children) return n.children.some(hasImage);
return false;
};
if (hasImage(node)) {
return <div {...props}>{children}</div>;
}
return <p {...props}>{children}</p>;
},
table: ({ node, ...props }) => (
<div className="overflow-x-auto my-4">
<table className="min-w-full border-collapse border border-border" {...props} />
</div>
),
thead: ({ node, ...props }) => (
<thead className="bg-muted/50" {...props} />
),
th: ({ node, ...props }) => (
<th className="border border-border px-3 py-2 text-left text-sm font-semibold" {...props} />
),
td: ({ node, ...props }) => (
<td className="border border-border px-3 py-2 text-sm" {...props} />
),
// Custom component: ```custom-gallery\nid1,id2,id3\n```
code: ({ node, className, children, ...props }) => {
if (className === 'language-mermaid') {
const chart = String(children).trim();
return (
<Suspense fallback={<div className="animate-pulse h-32 bg-muted/20 border border-border/50 rounded-lg flex items-center justify-center my-6 text-sm text-muted-foreground">Loading Mermaid diagram...</div>}>
<MermaidWidget chart={chart} />
</Suspense>
);
}
if (className === 'language-custom-gallery') {
const ids = String(children).trim().split(/[,\s\n]+/).filter(Boolean);
if (ids.length > 0) {
return (
<Suspense fallback={<div className="animate-pulse h-48 bg-muted rounded" />}>
<GalleryWidget
pictureIds={ids}
thumbnailLayout="grid"
imageFit="cover"
thumbnailsPosition="bottom"
thumbnailsOrientation="horizontal"
zoomEnabled={false}
showVersions={false}
autoPlayVideos={false}
showTitle={false}
showDescription={false}
thumbnailsClassName=""
variables={{}}
/>
</Suspense>
);
}
}
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
if (!match) {
// Inline code or unclassified code
return <code className={`${className || ''} whitespace-pre-wrap font-mono text-sm bg-muted/30 px-1 py-0.5 rounded`} {...props}>{children}</code>;
}
const text = String(children).replace(/\n$/, '');
// Handle common language aliases
let prismLang = language;
if (language === 'ts') prismLang = 'typescript';
if (language === 'js') prismLang = 'javascript';
if (language === 'sh') prismLang = 'bash';
if (language === 'html' || language === 'xml') prismLang = 'markup';
if (Prism.languages[prismLang]) {
try {
const html = Prism.highlight(text, Prism.languages[prismLang], prismLang);
return (
<code
className={`${className} whitespace-pre-wrap font-mono text-sm`}
dangerouslySetInnerHTML={{ __html: html }}
{...props}
// Avoid passing children when using dangerouslySetInnerHTML
children={undefined}
/>
);
} catch (e) {
console.error('Prism highlight error', e);
}
}
// Fallback to unhighlighted
return <code className={`${className || ''} whitespace-pre-wrap font-mono text-sm`} {...props}>{children}</code>;
},
// Unwrap <pre> for custom components (gallery etc.)
pre: ({ node, children, ...props }) => {
// Check the actual AST node type to see if it's our custom gallery
const firstChild = node?.children?.[0];
if (firstChild?.type === 'element' && firstChild?.tagName === 'code') {
const isGallery = Array.isArray(firstChild.properties?.className)
&& firstChild.properties?.className.includes('language-custom-gallery');
const isMermaid = Array.isArray(firstChild.properties?.className)
&& firstChild.properties?.className.includes('language-mermaid');
if (isGallery || isMermaid) {
return <>{children}</>;
}
// Normal code block
return <pre className={`${props.className || ''} whitespace-pre-wrap break-words overflow-x-auto p-4 rounded-lg bg-muted/50 border border-border/50 mt-4 mb-4`} {...props}>{children}</pre>;
}
// Fallback
return <pre {...props}>{children}</pre>;
},
}}
>
{finalContent}
</ReactMarkdown>
</div>
{lightboxOpen && mockMediaItem && (
<Suspense fallback={null}>
<SmartLightbox
isOpen={lightboxOpen}
onClose={() => setLightboxOpen(false)}
mediaItem={mockMediaItem}
imageUrl={mockMediaItem.image_url}
imageTitle={mockMediaItem.title}
user={user}
isVideo={false}
// Dummy handlers for actions that aren't supported in this context
onPublish={async () => { }}
onNavigate={handleNavigate}
onOpenInWizard={() => { }}
currentIndex={currentImageIndex}
totalCount={allImages.length}
/>
</Suspense>
)}
</>
);
});
MarkdownRenderer.displayName = 'MarkdownRenderer';
export default MarkdownRenderer;