import { SecondaryPageLink, useSecondaryPage } from '@/PageManager' import ImageWithLightbox from '@/components/ImageWithLightbox' import ImageCarousel from '@/components/ImageCarousel/ImageCarousel' import MediaPlayer from '@/components/MediaPlayer' import Wikilink from '@/components/UniversalContent/Wikilink' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList, toProfile } from '@/lib/link' import { extractAllImagesFromEvent } from '@/lib/image-extraction' import { getImetaInfosFromEvent } from '@/lib/event' import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react' import { Event, kinds } from 'nostr-tools' import React, { useMemo, useEffect, useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' import 'katex/dist/katex.min.css' import NostrNode from './NostrNode' import { remarkNostr } from './remarkNostr' import { Components } from './types' import { Button } from '@/components/ui/button' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' export default function MarkdownArticle({ event, className, showImageGallery = false }: { event: Event className?: string showImageGallery?: boolean }) { const { push } = useSecondaryPage() const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const [isImagesOpen, setIsImagesOpen] = useState(false) // Extract all images from the event const allImages = useMemo(() => extractAllImagesFromEvent(event), [event]) const contentRef = useRef(null) // Extract, normalize, and deduplicate all media URLs (images, audio, video) // from content, imeta tags, and image tags const mediaUrls = useMemo(() => { if (showImageGallery) return [] // Don't render inline for article content const seenUrls = new Set() const mediaUrls: string[] = [] // Helper to normalize and add URL const addUrl = (url: string) => { if (!url) return // Normalize URL by removing tracking parameters and cleaning it let normalizedUrl = url .replace(/[?&](utm_[^&]*)/g, '') .replace(/[?&](fbclid|gclid|msclkid)=[^&]*/g, '') .replace(/[?&]w=\d+/g, '') .replace(/[?&]h=\d+/g, '') .replace(/[?&]q=\d+/g, '') .replace(/[?&]f=\w+/g, '') .replace(/[?&]auto=\w+/g, '') .replace(/[?&]format=\w+/g, '') .replace(/[?&]fit=\w+/g, '') .replace(/[?&]crop=\w+/g, '') .replace(/[?&]&+/g, '&') .replace(/[?&]$/, '') .replace(/\?$/, '') try { // Validate URL const parsedUrl = new URL(normalizedUrl) const extension = parsedUrl.pathname.split('.').pop()?.toLowerCase() // Check if it's a media file const isMedia = // Audio extensions (extension && ['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma'].includes(extension)) || // Video extensions (extension && ['mp4', 'webm', 'ogg', 'avi', 'mov', 'mkv', 'm4v', '3gp'].includes(extension)) || // Image extensions (extension && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff'].includes(extension)) if (isMedia && !seenUrls.has(normalizedUrl)) { mediaUrls.push(normalizedUrl) seenUrls.add(normalizedUrl) } } catch { // Invalid URL, skip } } // 1. Extract from content - all URLs (need to match exactly what markdown will find) const content = event.content || '' // Match URLs that could be in markdown links or plain text const urlMatches = content.match(/https?:\/\/[^\s<>"']+/g) || [] urlMatches.forEach(url => { // Normalize the URL before adding const normalized = url.replace(/[?&](utm_[^&]*)/g, '') .replace(/[?&](fbclid|gclid|msclkid)=[^&]*/g, '') .replace(/[?&]w=\d+/g, '') .replace(/[?&]h=\d+/g, '') .replace(/[?&]q=\d+/g, '') .replace(/[?&]f=\w+/g, '') .replace(/[?&]auto=\w+/g, '') .replace(/[?&]format=\w+/g, '') .replace(/[?&]fit=\w+/g, '') .replace(/[?&]crop=\w+/g, '') .replace(/[?&]&+/g, '&') .replace(/[?&]$/, '') .replace(/\?$/, '') addUrl(normalized) }) // 2. Extract from imeta tags const imetaInfos = getImetaInfosFromEvent(event) imetaInfos.forEach(info => addUrl(info.url)) // 3. Extract from image tag const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1]) if (imageTag?.[1]) { addUrl(imageTag[1]) } return mediaUrls }, [event.content, event.tags, event.pubkey, showImageGallery]) // Initialize highlight.js for syntax highlighting useEffect(() => { const initHighlight = async () => { if (typeof window !== 'undefined') { const hljs = await import('highlight.js') if (contentRef.current) { contentRef.current.querySelectorAll('pre code').forEach((block) => { // Ensure text color is visible before highlighting const element = block as HTMLElement element.style.color = 'inherit' element.classList.add('text-gray-900', 'dark:text-gray-100') hljs.default.highlightElement(element) // Ensure text color remains visible after highlighting element.style.color = 'inherit' }) } } } // Run highlight after a short delay to ensure content is rendered const timeoutId = setTimeout(initHighlight, 100) return () => clearTimeout(timeoutId) }, [event.content]) const components = useMemo( () => ({ nostr: ({ rawText, bech32Id }) => , a: ({ href, children, ...props }) => { if (!href) { return } // Handle hashtag links (format: /notes?t=tag) if (href.startsWith('/notes?t=') || href.startsWith('notes?t=')) { // Normalize href to include leading slash if missing const normalizedHref = href.startsWith('/') ? href : `/${href}` return ( {children} ) } // Handle wikilinks - only handle if href looks like a wikilink format // (we'll handle wikilinks in the text component below) if (href.startsWith('note1') || href.startsWith('nevent1') || href.startsWith('naddr1')) { return ( {children} ) } if (href.startsWith('npub1') || href.startsWith('nprofile1')) { return ( {children} ) } // Check if this is a media URL that should be rendered inline (for non-article content) // If so, don't render it as a link - it will be rendered as inline media below if (!showImageGallery) { // Normalize the href to match the normalized mediaUrls const normalizedHref = href.replace(/[?&](utm_[^&]*)/g, '') .replace(/[?&](fbclid|gclid|msclkid)=[^&]*/g, '') .replace(/[?&]w=\d+/g, '') .replace(/[?&]h=\d+/g, '') .replace(/[?&]q=\d+/g, '') .replace(/[?&]f=\w+/g, '') .replace(/[?&]auto=\w+/g, '') .replace(/[?&]format=\w+/g, '') .replace(/[?&]fit=\w+/g, '') .replace(/[?&]crop=\w+/g, '') .replace(/[?&]&+/g, '&') .replace(/[?&]$/, '') .replace(/\?$/, '') if (mediaUrls.includes(normalizedHref)) { return null } } return ( {children} ) }, p: (props) => { // Check if the paragraph contains only an image const children = props.children if (React.Children.count(children) === 1 && React.isValidElement(children)) { const child = children as React.ReactElement if (child.type === ImageWithLightbox) { // Render image outside paragraph context return
} } return

}, div: (props) =>

, code: ({ className, children, ...props }: any) => { const match = /language-(\w+)/.exec(className || '') const isInline = !match return !isInline && match ? (
              
                {children}
              
            
) : ( {children} ) }, text: ({ children }) => { if (typeof children !== 'string') { return <>{children} } // Handle hashtags and wikilinks const hashtagRegex = /#(\w+)/g const wikilinkRegex = /\[\[([^\]]+)\]\]/g const allMatches: Array<{index: number, end: number, type: 'hashtag' | 'wikilink', data: any}> = [] let match while ((match = hashtagRegex.exec(children)) !== null) { allMatches.push({ index: match.index, end: match.index + match[0].length, type: 'hashtag', data: match[1] }) } while ((match = wikilinkRegex.exec(children)) !== null) { const content = match[1] let target = content.includes('|') ? content.split('|')[0].trim() : content.trim() let displayText = content.includes('|') ? content.split('|')[1].trim() : content.trim() if (content.startsWith('book:')) { target = content.replace('book:', '').trim() } const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') allMatches.push({ index: match.index, end: match.index + match[0].length, type: 'wikilink', data: { dtag, displayText } }) } if (allMatches.length === 0) return <>{children} allMatches.sort((a, b) => a.index - b.index) const parts: (string | JSX.Element)[] = [] let lastIndex = 0 for (const match of allMatches) { if (match.index > lastIndex) { parts.push(children.slice(lastIndex, match.index)) } if (match.type === 'hashtag') { parts.push( #{match.data} ) } else { parts.push() } lastIndex = match.end } if (lastIndex < children.length) { parts.push(children.slice(lastIndex)) } return <>{parts} }, img: ({ src }) => { if (!src) return null // If showing image gallery, don't render inline images - they'll be shown in the carousel if (showImageGallery) { return null } // For all other content, render images inline return ( ) } }) as Components, [showImageGallery, event.pubkey, mediaUrls, event.kind] ) return ( <>
{metadata.title &&

{metadata.title}

} {metadata.summary && (

{metadata.summary}

)} {metadata.image && ( )}
{event.content.split(/(#\w+|\[\[[^\]]+\]\])/).map((part, index, array) => { // Check if this part is a hashtag if (part.match(/^#\w+$/)) { const hashtag = part.slice(1) // Add spaces before and after unless at start/end of line const isStartOfLine = index === 0 || array[index - 1].match(/^[\s]*$/) !== null const isEndOfLine = index === array.length - 1 || array[index + 1].match(/^[\s]*$/) !== null const beforeSpace = isStartOfLine ? '' : ' ' const afterSpace = isEndOfLine ? '' : ' ' return ( {beforeSpace && beforeSpace} { e.preventDefault() e.stopPropagation() const url = `/notes?t=${hashtag.toLowerCase()}` console.log('[MarkdownArticle] Clicking hashtag, navigating to:', url) push(url) }} > {part} {afterSpace && afterSpace} ) } // Check if this part is a wikilink if (part.match(/^\[\[([^\]]+)\]\]$/)) { const content = part.slice(2, -2) let target = content.includes('|') ? content.split('|')[0].trim() : content.trim() let displayText = content.includes('|') ? content.split('|')[1].trim() : content.trim() if (content.startsWith('book:')) { target = content.replace('book:', '').trim() } const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') return } // Regular text return {part} })}
{/* Inline Media - Show for non-article content (kinds 1, 11, 1111) */} {!showImageGallery && mediaUrls.length > 0 && (
{mediaUrls.map((url) => { const extension = url.split('.').pop()?.toLowerCase() // Images are already handled by the img component if (extension && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(extension)) { return null } // Render audio and video return ( ) })}
)} {/* Image Carousel - Only show for article content (30023, 30041, 30818) */} {showImageGallery && allImages.length > 0 && ( )} {metadata.tags.length > 0 && (
{metadata.tags.map((tag) => (
{ e.stopPropagation() push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) }} > #{tag}
))}
)}
) }