import { useSecondaryPageOptional, useSmartHashtagNavigationOptional, useSmartRelayNavigationOptional } from '@/PageManager' import Image from '@/components/Image' import MediaPlayer from '@/components/MediaPlayer' import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNoteList } from '@/lib/link' import { useMediaExtraction } from '@/hooks' import { cleanUrl, isImage, isMedia, isVideo, isAudio, isWebsocketUrl, preferBlossomPrimalDisplayUrl } from '@/lib/url' import { getImetaInfosFromEvent } from '@/lib/event' import { Event, kinds } from 'nostr-tools' import { useMemo, useState, useCallback, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { createRoot, Root } from 'react-dom/client' import Lightbox from 'yet-another-react-lightbox' import Zoom from 'yet-another-react-lightbox/plugins/zoom' import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded' import EmbeddedCitation from '@/components/EmbeddedCitation' import { parsePaytoUri } from '@/lib/payto' import PaytoLink from '@/components/PaytoLink' import { DeletedEventProvider } from '@/providers/DeletedEventProvider' import { ReplyProvider } from '@/providers/ReplyProvider' import Wikilink from '@/components/UniversalContent/Wikilink' import { BookstrContent } from '@/components/Bookstr' import { preprocessAsciidocMediaLinks } from '../MarkdownArticle/preprocessMarkup' import { NOSTR_ASCIIDOC_EARLY_LINK_REGEX, NOSTR_ASCIIDOC_TEXT_NODE_REGEX, NOSTR_HTML_BECH32_RELAXED } from '@/lib/content-patterns' import logger from '@/lib/logger' import { extractBookMetadata } from '@/lib/bookstr-parser' import { ExtendedKind } from '@/constants' import katex from 'katex' import '@/styles/katex-bundle.css' import { WS_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants' /** * Truncate link display text to 200 characters, adding ellipsis if truncated */ function truncateLinkText(text: string, maxLength: number = 200): string { if (text.length <= maxLength) { return text } return text.substring(0, maxLength) + '...' } /** * Check if a URL is a YouTube URL */ function isYouTubeUrl(url: string): boolean { // Create a new regex instance to avoid state issues with global regex // Keep the 'i' flag for case-insensitivity but remove 'g' to avoid state issues const flags = YOUTUBE_URL_REGEX.flags.replace('g', '') const regex = new RegExp(YOUTUBE_URL_REGEX.source, flags) return regex.test(url) } /** * Convert markdown syntax to AsciiDoc syntax * This converts all markdown elements to their AsciiDoc equivalents before processing */ function convertMarkdownToAsciidoc(content: string): string { let asciidoc = content // Note: We don't remove front matter here because the user's content uses --- as horizontal rules // If there's actual YAML front matter, it should be handled separately // For now, we'll convert --- to horizontal rules (except table separators) // Convert nostr addresses directly to AsciiDoc link format // Do this early so they're protected from other markdown conversions // naddr addresses can be 200+ characters, so we use + instead of specific length // Also handle optional [] suffix (empty link text in AsciiDoc) // Note: Citations are already protected in passthrough (+++...+++), so nostr: links inside them won't be processed asciidoc = asciidoc.replace(NOSTR_ASCIIDOC_EARLY_LINK_REGEX, (_match, bech32Id, emptyBrackets) => { // Convert directly to AsciiDoc link format // This will be processed later in HTML post-processing to render as React components // If [] suffix is present, use empty link text, otherwise use the bech32Id const linkText = emptyBrackets ? '' : bech32Id return `link:nostr:${bech32Id}[${linkText}]` }) // Protect code blocks - we'll process them separately const codeBlockPlaceholders: string[] = [] asciidoc = asciidoc.replace(/```(\w+)?\n([\s\S]*?)```/g, (_match, lang, code) => { const placeholder = `__CODE_BLOCK_${codeBlockPlaceholders.length}__` codeBlockPlaceholders.push(`[source${lang ? ',' + lang : ''}]\n----\n${code.trim()}\n----`) return placeholder }) // Protect inline code - but handle LaTeX math separately const inlineCodePlaceholders: string[] = [] // Handle LaTeX math in inline code blocks like `$...$` // The content may have escaped backslashes: `$\\frac{\\infty}{21,000,000} = \\infty$` // We need to detect LaTeX math and convert it to AsciiDoc stem: syntax asciidoc = asciidoc.replace(/`([^`\n]+)`/g, (_match, content) => { // Check if this is LaTeX math - pattern: $...$ where ... contains LaTeX syntax // Match the full pattern: $ followed by LaTeX expression and ending with $ const latexMatch = content.match(/^\$([^$]+)\$$/) if (latexMatch) { // This is pure LaTeX math - convert to AsciiDoc stem syntax const latexExpr = latexMatch[1] // The latexExpr contains the LaTeX code (backslashes are already in the string) // AsciiDoc stem:[...] will process this with the stem processor return `stem:[${latexExpr}]` } // Check if content contains LaTeX math mixed with other text if (content.includes('$') && content.match(/\$[^$]+\$/)) { // Replace $...$ parts with stem:[...] const processed = content.replace(/\$([^$]+)\$/g, 'stem:[$1]') // If it's now just stem, return it directly, otherwise it needs to be in code if (processed.startsWith('stem:[') && processed.endsWith(']') && !processed.includes('`')) { return processed } // Mixed content - keep as code but with stem inside (won't work well, but preserve it) const placeholder = `__INLINE_CODE_${inlineCodePlaceholders.length}__` inlineCodePlaceholders.push(`\`${processed}\``) return placeholder } // Regular inline code - preserve it const placeholder = `__INLINE_CODE_${inlineCodePlaceholders.length}__` inlineCodePlaceholders.push(`\`${content}\``) return placeholder }) // Convert headers (must be at start of line) asciidoc = asciidoc.replace(/^#{6}\s+(.+)$/gm, '====== $1 ======') asciidoc = asciidoc.replace(/^#{5}\s+(.+)$/gm, '===== $1 =====') asciidoc = asciidoc.replace(/^#{4}\s+(.+)$/gm, '==== $1 ====') asciidoc = asciidoc.replace(/^#{3}\s+(.+)$/gm, '=== $1 ===') asciidoc = asciidoc.replace(/^#{2}\s+(.+)$/gm, '== $1 ==') asciidoc = asciidoc.replace(/^#{1}\s+(.+)$/gm, '= $1 =') // Convert tables BEFORE horizontal rules (to avoid converting table separators) // Markdown tables: | col1 | col2 |\n|------|------|\n| data1 | data2 | // Use a simpler approach: match lines with pipes, separator row, and data rows asciidoc = asciidoc.replace(/(\|[^\n]+\|\s*\n\|[\s\-\|:]+\|\s*\n(?:\|[^\n]+\|\s*\n?)+)/gm, (match) => { const lines = match.trim().split('\n').map(line => line.trim()).filter(line => line) if (lines.length < 2) return match // First line is header, second is separator, rest are data const headerRow = lines[0] const separatorRow = lines[1] // Verify it's a table separator (has dashes) if (!separatorRow.match(/[\-:]/)) return match // Parse header cells - markdown format: | col1 | col2 | col3 | // When split by |, we get: ['', ' col1 ', ' col2 ', ' col3 ', ''] // We need to extract all non-empty cells const headerParts = headerRow.split('|') const headerCells: string[] = [] for (let i = 0; i < headerParts.length; i++) { const cell = headerParts[i].trim() // Skip empty cells only at the very start and end if (cell === '' && (i === 0 || i === headerParts.length - 1)) continue headerCells.push(cell) } if (headerCells.length < 2) return match const colCount = headerCells.length const dataRows = lines.slice(2) // Build AsciiDoc table - use equal width columns let tableAsciidoc = `[cols="${Array(colCount).fill('*').join(',')}"]\n|===\n` // Header row - prefix each cell with . to make it a header cell in AsciiDoc // Ensure cells are properly formatted (no leading/trailing spaces, escape special chars) const headerRowCells = headerCells.map(cell => { // Clean up the cell content let cleanCell = cell.trim() // Escape pipe characters if any cleanCell = cleanCell.replace(/\|/g, '\\|') // Return with . prefix for header return `.${cleanCell}` }) tableAsciidoc += headerRowCells.join('|') + '\n\n' // Data rows dataRows.forEach(row => { if (!row.includes('|')) return const rowParts = row.split('|') const rowCells: string[] = [] // Parse data row cells the same way as header for (let i = 0; i < rowParts.length; i++) { const cell = rowParts[i].trim() // Skip empty cells only at the very start and end if (cell === '' && (i === 0 || i === rowParts.length - 1)) continue rowCells.push(cell) } // Ensure we have the right number of cells while (rowCells.length < colCount) { rowCells.push('') } // Take only the number of columns we need const finalCells = rowCells.slice(0, colCount) tableAsciidoc += finalCells.map(cell => cell.replace(/\|/g, '\\|')).join('|') + '\n' }) tableAsciidoc += '|===' return tableAsciidoc }) // Convert horizontal rules (but not table separators, which are already processed) // Convert standalone --- lines to AsciiDoc horizontal rule // We do this after table processing to avoid interfering with table separators asciidoc = asciidoc.replace(/^---\s*$/gm, (match, offset, string) => { // Check if this is part of a table separator (would have been processed already) const lines = string.split('\n') const lineIndex = string.substring(0, offset).split('\n').length - 1 const prevLine = lines[lineIndex - 1]?.trim() || '' const nextLine = lines[lineIndex + 1]?.trim() || '' // If it looks like a table separator (has pipes nearby), don't convert if (prevLine.includes('|') || nextLine.includes('|')) { return match } // Convert to AsciiDoc horizontal rule (three single quotes) return '\'\'\'' }) // Convert blockquotes - handle multi-line blockquotes // Match consecutive lines starting with > asciidoc = asciidoc.replace(/(^>\s+.+(?:\n>\s+.+)*)/gm, (match) => { const lines = match.split('\n').map((line: string) => line.replace(/^>\s*/, '')) const content = lines.join('\n').trim() return `____\n${content}\n____` }) // Convert lists (must be at start of line) // Unordered lists: *, -, + asciidoc = asciidoc.replace(/^(\s*)[\*\-\+]\s+(.+)$/gm, '$1* $2') // Ordered lists: 1., 2., etc. asciidoc = asciidoc.replace(/^(\s*)\d+\.\s+(.+)$/gm, '$1. $2') // Protect existing AsciiDoc links (both url[text] and link:url[text] formats) // Do this FIRST before any other processing to avoid double-processing const asciidocLinkPlaceholders: string[] = [] // Match AsciiDoc link format: url[text] or link:url[text] // Pattern matches: http(s)://url[text] or link:url[text] // URL can contain dots, slashes, hyphens, etc., but stops at whitespace or [ // Then we match [text] where text can contain anything except ] // Use a more permissive pattern - match URL until [ then match [text] // The URL part can contain most characters except whitespace and [ asciidoc = asciidoc.replace(/(https?:\/\/[^\s\[\]]+\[[^\]]+\])/g, (_match, link) => { // This is an AsciiDoc link format (url[text]), protect it const placeholder = `__ASCIIDOC_LINK_${asciidocLinkPlaceholders.length}__` asciidocLinkPlaceholders.push(link) return placeholder }) // Also protect link:url[text] format asciidoc = asciidoc.replace(/(link:[^\s\[\]]+\[[^\]]+\])/g, (_match, link) => { const placeholder = `__ASCIIDOC_LINK_${asciidocLinkPlaceholders.length}__` asciidocLinkPlaceholders.push(link) return placeholder }) // Convert images: ![alt](url) -> image:url[alt] (single colon for inline, but AsciiDoc will render as block) // For block images in AsciiDoc, we can use image:: or just ensure it's on its own line asciidoc = asciidoc.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => { // Escape brackets in alt text and URL if needed const escapedAlt = alt.replace(/\[/g, '\\[').replace(/\]/g, '\\]').replace(/"/g, '"') // Use image:: for block-level images (double colon) // Add width attribute to make it responsive return `image::${url}[${escapedAlt},width=100%]` }) // Convert links: [text](url) -> link:url[text] asciidoc = asciidoc.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { // Skip if it was an image (shouldn't happen after image conversion, but safety check) if (match.startsWith('![')) return match // Escape brackets in link text const escapedText = text.replace(/\[/g, '\\[').replace(/\]/g, '\\]') return `link:${url}[${escapedText}]` }) // Restore AsciiDoc links asciidocLinkPlaceholders.forEach((link, index) => { asciidoc = asciidoc.replace(`__ASCIIDOC_LINK_${index}__`, link) }) // Nostr addresses are already converted to link: format above, no need to restore // Convert strikethrough: ~~text~~ -> [line-through]#text# // Also handle single tilde strikethrough: ~text~ -> [line-through]#text# asciidoc = asciidoc.replace(/~~([^~\n]+?)~~/g, '[line-through]#$1#') // Single tilde strikethrough (common in some markdown flavors) asciidoc = asciidoc.replace(/(? { // Skip if it looks like a list item if (/^\s*\*\s/.test(match)) return match // Skip if already processed as bold (shouldn't happen, but safety) if (match.includes('*$1*')) return match return `_${text}_` }) // Process single underscore for italic asciidoc = asciidoc.replace(/(? { // Skip if already processed as bold if (match.includes('*$1*')) return match return `_${text}_` }) // Restore inline code inlineCodePlaceholders.forEach((code, index) => { asciidoc = asciidoc.replace(`__INLINE_CODE_${index}__`, code) }) // Restore code blocks codeBlockPlaceholders.forEach((block, index) => { asciidoc = asciidoc.replace(`__CODE_BLOCK_${index}__`, block) }) return asciidoc } export default function AsciidocArticle({ event, className, hideImagesAndInfo = false, parentImageUrl, footnotesContainerId }: { event: Event className?: string hideImagesAndInfo?: boolean parentImageUrl?: string footnotesContainerId?: string }) { const secondaryPage = useSecondaryPageOptional() const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) const { navigateToHashtag } = useSmartHashtagNavigationOptional() const { navigateToRelay } = useSmartRelayNavigationOptional() const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const bookMetadata = useMemo(() => extractBookMetadata(event), [event]) const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book const contentRef = useRef(null) // Preprocess content: convert all markdown to AsciiDoc syntax const processedContent = useMemo(() => { let content = event.content // Normalize excessive newlines (reduce 3+ to 2) content = content.replace(/\n\s*\n\s*\n+/g, '\n\n') // PROTECT WIKILINKS FIRST before any other processing // This prevents AsciiDoc or other processors from converting them to regular links // First, protect bookstr wikilinks by converting them to passthrough format // Don't use [[...]] inside passthrough as AsciiDoc processes it - use a plain marker instead content = content.replace(/\[\[book::([^\]]+)\]\]/g, (_match, bookContent) => { const cleanContent = bookContent.trim() // Use AsciiDoc passthrough without brackets - AsciiDoc processes [[...]] even in passthrough // Use a unique marker format that won't conflict with other content return `+++BOOKSTR_MARKER:${cleanContent}:BOOKSTR_END+++` }) // Protect citations by converting them to passthrough format // Don't use [[...]] inside passthrough as AsciiDoc processes it - use a plain marker instead content = content.replace(/\[\[citation::(end|foot|foot-end|inline|quote|prompt-end|prompt-inline)::([^\]]+)\]\]/g, (_match, citationType, citationId) => { // Strip all nostr: prefixes if present (handle cases like nostr:nostr:nevent1...) let cleanId = citationId.trim() while (cleanId.startsWith('nostr:')) { cleanId = cleanId.substring(6) // Remove 'nostr:' prefix } // Use a unique marker format that won't conflict with other content return `+++CITATION_MARKER:${citationType}::${cleanId}:CITATION_END+++` }) // Then protect regular wikilinks by converting them to passthrough format // This prevents AsciiDoc from processing them and prevents URLs inside from being processed content = content.replace(/\[\[([^\]]+)\]\]/g, (_match, linkContent) => { // Skip if this was already processed as a bookstr wikilink (shouldn't happen, but safety check) if (linkContent.startsWith('book::')) { return _match } // Skip citations - they're already processed above if (linkContent.startsWith('citation::')) { return _match } // Convert to AsciiDoc passthrough format so it's preserved return `+++WIKILINK:${linkContent}+++` }) // Convert all markdown syntax to AsciiDoc syntax content = convertMarkdownToAsciidoc(content) // Now process raw URLs that aren't already in AsciiDoc syntax content = preprocessAsciidocMediaLinks(content) // Convert "Read naddr... instead." patterns to AsciiDoc links const redirectRegex = /Read (naddr1[a-z0-9]+) instead\./gi content = content.replace(redirectRegex, (_match, naddr) => { return `Read link:/notes/${naddr}[${naddr}] instead.` }) return content }, [event.content]) // Extract all media from event const extractedMedia = useMediaExtraction(event, event.content) // Extract media from tags only (for display at top) const tagMedia = useMemo(() => { const seenUrls = new Set() const media: Array<{ url: string; type: 'image' | 'video' | 'audio'; poster?: string }> = [] // Extract from imeta tags const imetaInfos = getImetaInfosFromEvent(event) imetaInfos.forEach((info) => { const cleaned = cleanUrl(info.url) if (!cleaned || seenUrls.has(cleaned)) return if (!isImage(cleaned) && !isMedia(cleaned)) return seenUrls.add(cleaned) if (info.m?.startsWith('image/') || isImage(cleaned)) { media.push({ url: info.url, type: 'image' }) } else if (info.m?.startsWith('video/') || isVideo(cleaned)) { media.push({ url: info.url, type: 'video', poster: info.image }) } else if (info.m?.startsWith('audio/') || isAudio(cleaned)) { media.push({ url: info.url, type: 'audio' }) } }) // Extract from r tags event.tags.filter(tag => tag[0] === 'r' && tag[1]).forEach(tag => { const url = tag[1] const cleaned = cleanUrl(url) if (!cleaned || seenUrls.has(cleaned)) return if (!isImage(cleaned) && !isMedia(cleaned)) return seenUrls.add(cleaned) if (isImage(cleaned)) { media.push({ url, type: 'image' }) } else if (isVideo(cleaned)) { media.push({ url, type: 'video' }) } else if (isAudio(cleaned)) { media.push({ url, type: 'audio' }) } }) // Extract from image tag const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1]) if (imageTag?.[1]) { const cleaned = cleanUrl(imageTag[1]) if (cleaned && !seenUrls.has(cleaned) && isImage(cleaned)) { seenUrls.add(cleaned) media.push({ url: imageTag[1], type: 'image' }) } } return media }, [event.id, JSON.stringify(event.tags)]) // Extract YouTube URLs from tags (for display at top) const tagYouTubeUrls = useMemo(() => { const youtubeUrls: string[] = [] const seenUrls = new Set() event.tags .filter(tag => tag[0] === 'r' && tag[1]) .forEach(tag => { const url = tag[1] if (!url.startsWith('http://') && !url.startsWith('https://')) return if (!isYouTubeUrl(url)) return const cleaned = cleanUrl(url) if (cleaned && !seenUrls.has(cleaned)) { youtubeUrls.push(cleaned) seenUrls.add(cleaned) } }) return youtubeUrls }, [event.id, JSON.stringify(event.tags)]) // Note: tagLinks removed - WebPreview is disabled for AsciiDoc articles // Get all images for gallery (deduplicated) const allImages = useMemo(() => { const seenUrls = new Set() const images: Array<{ url: string; alt?: string }> = [] // Add images from extractedMedia extractedMedia.images.forEach(img => { const cleaned = cleanUrl(img.url) if (cleaned && !seenUrls.has(cleaned)) { seenUrls.add(cleaned) images.push({ url: img.url, alt: img.alt }) } }) // Add metadata image if it exists if (metadata.image) { const cleaned = cleanUrl(metadata.image) if (cleaned && !seenUrls.has(cleaned) && isImage(cleaned)) { seenUrls.add(cleaned) images.push({ url: metadata.image }) } } return images }, [extractedMedia.images, metadata.image]) // Create image index map for lightbox const imageIndexMap = useMemo(() => { const map = new Map() allImages.forEach((img, index) => { const cleaned = cleanUrl(img.url) if (cleaned) map.set(cleaned, index) }) return map }, [allImages]) // Parse content to find media URLs that are already rendered const mediaUrlsInContent = useMemo(() => { const urls = new Set() const urlRegex = /https?:\/\/[^\s<>"']+/g let match while ((match = urlRegex.exec(event.content)) !== null) { const url = match[0] const cleaned = cleanUrl(url) if (cleaned && (isImage(cleaned) || isVideo(cleaned) || isAudio(cleaned))) { urls.add(cleaned) } } return urls }, [event.content]) // Extract YouTube URLs from content const youtubeUrlsInContent = useMemo(() => { const urls = new Set() const urlRegex = /https?:\/\/[^\s<>"']+/g let match while ((match = urlRegex.exec(event.content)) !== null) { const url = match[0] const cleaned = cleanUrl(url) if (cleaned && isYouTubeUrl(cleaned)) { urls.add(cleaned) } } return urls }, [event.content]) // Note: contentLinks removed - WebPreview is disabled for AsciiDoc articles // Image gallery state const [lightboxIndex, setLightboxIndex] = useState(-1) const openLightbox = useCallback((index: number) => { setLightboxIndex(index) }, []) // Filter tag media to only show what's not in content const leftoverTagMedia = useMemo(() => { const metadataImageUrl = metadata.image ? cleanUrl(metadata.image) : null const parentImageUrlCleaned = parentImageUrl ? cleanUrl(parentImageUrl) : null return tagMedia.filter(media => { const cleaned = cleanUrl(media.url) if (!cleaned) return false // Skip if already in content if (mediaUrlsInContent.has(cleaned)) return false // Skip if this is the metadata image (shown separately) if (metadataImageUrl && cleaned === metadataImageUrl && !hideImagesAndInfo) return false // Skip if this matches the parent publication's image (to avoid duplicate cover images) if (parentImageUrlCleaned && cleaned === parentImageUrlCleaned) return false return true }) }, [tagMedia, mediaUrlsInContent, metadata.image, hideImagesAndInfo, parentImageUrl]) // Filter tag YouTube URLs to only show what's not in content const leftoverTagYouTubeUrls = useMemo(() => { return tagYouTubeUrls.filter(url => { const cleaned = cleanUrl(url) return cleaned && !youtubeUrlsInContent.has(cleaned) }) }, [tagYouTubeUrls, youtubeUrlsInContent]) // Note: leftoverTagLinks removed - WebPreview is disabled for AsciiDoc articles // Extract hashtags from content (for deduplication with metadata tags) const hashtagsInContent = useMemo(() => { const tags = new Set() const hashtagRegex = /#([a-zA-Z0-9_]+)/g let match while ((match = hashtagRegex.exec(event.content)) !== null) { tags.add(match[1].toLowerCase()) } return tags }, [event.content]) // Filter metadata tags to only show what's not already in content const leftoverMetadataTags = useMemo(() => { return metadata.tags.filter(tag => !hashtagsInContent.has(tag.toLowerCase())) }, [metadata.tags, hashtagsInContent]) // Parse AsciiDoc content and post-process for nostr: links and hashtags const [parsedHtml, setParsedHtml] = useState('') const [isLoading, setIsLoading] = useState(true) useEffect(() => { let cancelled = false const parseAsciidoc = async () => { setIsLoading(true) try { const Asciidoctor = await import('@asciidoctor/core') const asciidoctor = Asciidoctor.default() if (cancelled) return const html = asciidoctor.convert(processedContent, { safe: 'safe', backend: 'html5', doctype: 'article', attributes: { 'showtitle': true, 'sectanchors': true, 'sectlinks': true, 'toc': 'left', 'toclevels': 6, 'toc-title': 'Table of Contents', 'source-highlighter': 'highlight.js', 'stem': 'latexmath', 'data-uri': true, 'imagesdir': '', 'linkcss': false, 'stylesheet': '', 'stylesdir': '', 'prewrap': true, 'sectnums': false, 'sectnumlevels': 6, 'experimental': true, 'compat-mode': false, 'attribute-missing': 'warn', 'attribute-undefined': 'warn', 'skip-front-matter': true } }) if (cancelled) return let htmlString = typeof html === 'string' ? html : html.toString() // Debug: log HTML to check if passthrough markers are preserved if (process.env.NODE_ENV === 'development') { const hasBookstrMarker = htmlString.includes('BOOKSTR_START') || htmlString.includes('BOOKSTR') const hasWikilinkMarker = htmlString.includes('WIKILINK') logger.debug('AsciidocArticle: HTML contains markers', { hasBookstrMarker, hasWikilinkMarker, htmlPreview: htmlString.substring(0, 2000) }) } // Note: Markdown is now converted to AsciiDoc in preprocessing, // so post-processing markdown should not be necessary // IMPORTANT: Process citations FIRST before any nostr: link processing // This prevents nostr: links inside citations from being processed incorrectly // Handle citation markers - convert passthrough markers to placeholders // AsciiDoc passthrough +++CITATION_MARKER:type::id:CITATION_END+++ outputs CITATION_MARKER:type::id:CITATION_END in HTML htmlString = htmlString.replace(/CITATION_MARKER:\s*(end|foot|foot-end|inline|quote|prompt-end|prompt-inline)::\s*(.+?)\s*:CITATION_END/g, (_match, citationType, citationId) => { // Strip all nostr: prefixes if present (handle cases like nostr:nostr:nevent1...) let cleanId = citationId.trim() while (cleanId.startsWith('nostr:')) { cleanId = cleanId.substring(6) // Remove 'nostr:' prefix } const escapedId = cleanId.replace(/"/g, '"').replace(/'/g, ''') // Use inline element for inline citations and footnotes/endnotes (they need to be inline) // Only block-level citations (quote) should use div const isInline = citationType === 'inline' || citationType === 'prompt-inline' || citationType === 'foot' || citationType === 'foot-end' || citationType === 'end' || citationType === 'prompt-end' const tag = isInline ? 'span' : 'div' return `<${tag} data-citation="${escapedId}" data-citation-type="${citationType}" class="citation-placeholder${isInline ? ' inline' : ''}">` }) // Post-process HTML to handle nostr: links // Mentions (npub/nprofile) should be inline, events (note/nevent/naddr) should be block-level // First, handle nostr: links in tags (from AsciiDoc link: syntax) // Match the full bech32 address format - addresses can vary in length // npub: 58 chars, nprofile: variable, note: 58 chars, nevent: variable, naddr: 200+ chars // Use a more flexible pattern that matches any valid bech32 address htmlString = htmlString.replace( new RegExp( `]*href=["']nostr:(${NOSTR_HTML_BECH32_RELAXED})["'][^>]*>([^<]*)`, 'gi' ), (_match, bech32Id, _linkText) => { // Validate bech32 ID and create appropriate placeholder if (!bech32Id) return _match // Escape the bech32 ID for HTML attributes const escapedId = bech32Id.replace(/"/g, '"').replace(/'/g, ''') if (bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')) { return `` } else if (bech32Id.startsWith('note') || bech32Id.startsWith('nevent') || bech32Id.startsWith('naddr')) { return `
` } return _match }) // Also handle nostr: addresses in plain text nodes (not already in tags) // Process text nodes by replacing content between > and < // Use more flexible regex that matches any valid bech32 address (naddr can be 200+ chars) // Match addresses with optional [] suffix htmlString = htmlString.replace( new RegExp( `>([^<]*nostr:(${NOSTR_HTML_BECH32_RELAXED})(\\[\\])?[^<]*)<`, 'g' ), (_match, textContent) => { // Extract nostr addresses from the text content - use flexible pattern that handles long addresses // npub and note are typically 58 chars, but naddr can be 200+ chars const nostrRegex = new RegExp( NOSTR_ASCIIDOC_TEXT_NODE_REGEX.source, NOSTR_ASCIIDOC_TEXT_NODE_REGEX.flags ) let processedText = textContent const replacements: Array<{ start: number; end: number; replacement: string }> = [] nostrRegex.lastIndex = 0 let m while ((m = nostrRegex.exec(textContent)) !== null) { const bech32Id = m[1] const start = m.index const end = m.index + m[0].length // Escape bech32Id for HTML attributes const escapedId = bech32Id.replace(/"/g, '"').replace(/'/g, ''') if (bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')) { replacements.push({ start, end, replacement: `` }) } else if (bech32Id.startsWith('note') || bech32Id.startsWith('nevent') || bech32Id.startsWith('naddr')) { replacements.push({ start, end, replacement: `
` }) } } // Apply replacements in reverse order to preserve indices for (let i = replacements.length - 1; i >= 0; i--) { const r = replacements[i] processedText = processedText.substring(0, r.start) + r.replacement + processedText.substring(r.end) } return `>${processedText}<` }) // Fallback: ensure any remaining nostr: addresses are shown as plain text // This catches any that weren't converted to placeholders htmlString = htmlString.replace( new RegExp(`([^>])nostr:(${NOSTR_HTML_BECH32_RELAXED})(\\[\\])?`, 'g'), (_match, prefix, bech32Id, emptyBrackets) => { // Show as plain text if not already in a tag or placeholder return `${prefix}nostr:${bech32Id}${emptyBrackets || ''}` }) // payto: URIs (RFC-8905 / NIP-A3) – replace
and plain payto:// with placeholder htmlString = htmlString.replace(/]*href=["'](payto:\/\/[^"']+)["'][^>]*>([^<]*)<\/a>/gi, (_match, paytoUri, _linkText) => { const parsed = parsePaytoUri(paytoUri) if (!parsed) return _match const escaped = paytoUri.replace(/"/g, '"').replace(/&/g, '&').replace(/'/g, ''') return `` }) htmlString = htmlString.replace(/(^|[\s>])(payto:\/\/[a-z0-9-]+\/[^\s<\]\)\"']+)/gi, (_match, prefix, paytoUri) => { const parsed = parsePaytoUri(paytoUri.trim()) if (!parsed) return _match const escaped = parsed.raw.replace(/"/g, '"').replace(/&/g, '&').replace(/'/g, ''') return `${prefix}` }) // Handle LaTeX math expressions from AsciiDoc stem processor // AsciiDoc with stem: latexmath outputs \(...\) for inline and \[...\] for block math // In HTML, these appear as literal \( and \) characters (backslash + parenthesis) // We need to match the literal backslash-paren sequence // In regex: \\ matches a literal backslash, \( matches a literal ( htmlString = htmlString.replace(/\\\(([^)]+?)\\\)/g, (_match, latex) => { // Inline math - escape for HTML attribute // Unescape any HTML entities that might have been created const unescaped = latex.replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&') const escaped = unescaped.replace(/"/g, '"').replace(/'/g, ''') return `` }) htmlString = htmlString.replace(/\\\[([^\]]+?)\\\]/g, (_match, latex) => { // Block math - escape for HTML attribute const unescaped = latex.replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&') const escaped = unescaped.replace(/"/g, '"').replace(/'/g, ''') return `
` }) // Handle bookstr markers - convert passthrough markers to placeholders // AsciiDoc passthrough +++BOOKSTR_MARKER:...:BOOKSTR_END+++ outputs BOOKSTR_MARKER:...:BOOKSTR_END in HTML // Match the delimited format to extract the exact content // IMPORTANT: Process this BEFORE any other pattern matching htmlString = htmlString.replace(/BOOKSTR_MARKER:\s*(.+?)\s*:BOOKSTR_END/g, (_match, bookContent) => { // Trim whitespace and escape special characters for HTML attributes const cleanContent = bookContent.trim() const escaped = cleanContent.replace(/"/g, '"').replace(/'/g, ''') logger.debug('BookstrContent: Found bookstr marker in HTML', { cleanContent, escaped }) return `` }) // Also handle if AsciiDoc converted it to WIKILINK: format (fallback) htmlString = htmlString.replace(/WIKILINK:bookstr::([^<>\s]+)/g, (_match, bookContent) => { const cleanContent = bookContent.trim() const escaped = cleanContent.replace(/"/g, '"').replace(/'/g, ''') logger.debug('BookstrContent: Found bookstr in WIKILINK format', { cleanContent, escaped }) return `` }) // Handle wikilinks - convert passthrough markers to placeholders // AsciiDoc passthrough +++WIKILINK:link|display+++ outputs just WIKILINK:link|display in HTML // Match WIKILINK: followed by any characters (including |) until end of text or HTML tag // IMPORTANT: Skip any [[bookstr::...]] patterns that might have been missed htmlString = htmlString.replace(/WIKILINK:([^<>\s]+)/g, (_match, linkContent) => { // Skip if this is a bookstr wikilink if (linkContent.includes('bookstr::')) { return _match } // Escape special characters for HTML attributes const escaped = linkContent.replace(/"/g, '"').replace(/'/g, ''') return `` }) // Handle YouTube URLs and relay URLs in links // Also check for bookstr content that might have been converted to links // Only replace links that need special handling - leave AsciiDoc-generated links alone const linkMatches: Array<{ match: string; href: string; linkText: string; index: number }> = [] const bookstrLinkMatches: Array<{ match: string; bookContent: string; index: number }> = [] const linkRegex = /]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/g let linkMatch while ((linkMatch = linkRegex.exec(htmlString)) !== null) { const match = linkMatch[0] const href = linkMatch[1] const linkText = linkMatch[2] const index = linkMatch.index // Check if this link contains bookstr content (might have been converted by AsciiDoc) if (linkText.includes('bookstr::') || href.includes('bookstr::')) { // Extract bookstr content from link text or href const bookstrMatch = linkText.match(/bookstr::([^\]]+)/) || href.match(/bookstr::([^\]]+)/) if (bookstrMatch) { const bookContent = bookstrMatch[1].trim() bookstrLinkMatches.push({ match, bookContent, index }) continue } } // Only process links that need special handling (YouTube, relay URLs) // Leave regular HTTP/HTTPS links as-is since AsciiDoc already formatted them correctly if (isYouTubeUrl(href) || isWebsocketUrl(href)) { linkMatches.push({ match, href, linkText, index }) } } // Replace bookstr links in reverse order to preserve indices for (let i = bookstrLinkMatches.length - 1; i >= 0; i--) { const { match, bookContent, index } = bookstrLinkMatches[i] const escaped = bookContent.replace(/"/g, '"').replace(/'/g, ''') logger.debug('BookstrContent: Found bookstr in converted link', { bookContent, escaped }) htmlString = htmlString.substring(0, index) + `` + htmlString.substring(index + match.length) } // Replace only special links in reverse order to preserve indices for (let i = linkMatches.length - 1; i >= 0; i--) { const { match, href, linkText } = linkMatches[i] let replacement = match // Check if the href is a YouTube URL if (isYouTubeUrl(href)) { const cleanedUrl = cleanUrl(href) replacement = `
` } // Check if the href is a relay URL else if (isWebsocketUrl(href)) { const relayPath = `/relays/${encodeURIComponent(href)}` replacement = `
${linkText}` } htmlString = htmlString.substring(0, linkMatches[i].index) + replacement + htmlString.substring(linkMatches[i].index + match.length) } // Handle YouTube URLs in plain text (not in tags) // Create a new regex instance to avoid state issues const youtubeRegex = new RegExp(YOUTUBE_URL_REGEX.source, YOUTUBE_URL_REGEX.flags) htmlString = htmlString.replace(youtubeRegex, (match) => { // Only replace if not already in a tag (basic check) if (!match.includes('<') && !match.includes('>') && isYouTubeUrl(match)) { const cleanedUrl = cleanUrl(match) return `
` } return match }) // Handle relay URLs in plain text (not in
tags) - convert to relay page links htmlString = htmlString.replace(WS_URL_REGEX, (match) => { // Only replace if not already in a tag (basic check) if (!match.includes('<') && !match.includes('>') && isWebsocketUrl(match)) { const relayPath = `/relays/${encodeURIComponent(match)}` return `${match}` } return match }) // Handle plain HTTP/HTTPS URLs in text nodes only (not inside existing HTML tags/attributes). // Regex-linkifying the full HTML string can corrupt existing anchor tags. htmlString = htmlString.replace(/>([^<]+) { const httpUrlRegex = /https?:\/\/[^\s<>"']+/g const replacedText = textContent.replace(httpUrlRegex, (rawUrl: string) => { // Skip URLs that are handled elsewhere. if (isYouTubeUrl(rawUrl) || isWebsocketUrl(rawUrl)) return rawUrl if (isImage(rawUrl) || isVideo(rawUrl) || isAudio(rawUrl)) return rawUrl const cleanedUrl = cleanUrl(rawUrl) if (!cleanedUrl) return rawUrl return `${rawUrl}` }) return `>${replacedText}<` }) setParsedHtml(htmlString) } catch (error) { logger.error('Failed to parse AsciiDoc', error as Error) setParsedHtml('

Error parsing AsciiDoc content

') } finally { if (!cancelled) { setIsLoading(false) } } } parseAsciidoc() return () => { cancelled = true } }, [processedContent]) // Store React roots for cleanup const reactRootsRef = useRef>(new Map()) // Track which placeholders have been processed to avoid re-processing const processedPlaceholdersRef = useRef>(new Set()) // Track citations for footnotes and endnotes sections const citationsRef = useRef>([]) const citationIndexRef = useRef(0) const citationAnchorPrefix = useMemo(() => event.id.toLowerCase(), [event.id]) // Post-process rendered HTML to inject React components for nostr: links and handle hashtags useEffect(() => { if (!contentRef.current || !parsedHtml || isLoading) return // Only clean up roots that are no longer in the DOM const rootsToCleanup: Array<[Element, Root]> = [] reactRootsRef.current.forEach((root, element) => { if (!element.isConnected) { rootsToCleanup.push([element, root]) reactRootsRef.current.delete(element) } }) // Unmount disconnected roots asynchronously to avoid race conditions if (rootsToCleanup.length > 0) { setTimeout(() => { rootsToCleanup.forEach(([, root]) => { try { root.unmount() } catch (err) { // Ignore errors during cleanup } }) }, 0) } // Process nostr: mentions - replace placeholders with React components (inline) const nostrMentions = contentRef.current.querySelectorAll('.nostr-mention-placeholder[data-nostr-mention]') nostrMentions.forEach((element) => { const bech32Id = element.getAttribute('data-nostr-mention') if (!bech32Id) { logger.warn('Nostr mention placeholder found but no bech32Id attribute') // Fallback: show as plain text const textNode = document.createTextNode(`nostr:${element.textContent || ''}`) element.parentNode?.replaceChild(textNode, element) return } // Create an inline container for React component (mentions should be inline) const container = document.createElement('span') container.className = 'inline-block' const parent = element.parentNode if (!parent) { logger.warn('Nostr mention placeholder has no parent node') return } parent.replaceChild(container, element) // Use React to render the component, with error handling try { const root = createRoot(container) root.render() reactRootsRef.current.set(container, root) } catch (error) { logger.error('Failed to render nostr mention', { bech32Id, error }) // Fallback: show as plain text const textNode = document.createTextNode(`nostr:${bech32Id}`) parent.replaceChild(textNode, container) } }) // Process nostr: notes - replace placeholders with React components const nostrNotes = contentRef.current.querySelectorAll('.nostr-note-placeholder[data-nostr-note]') nostrNotes.forEach((element) => { const bech32Id = element.getAttribute('data-nostr-note') if (!bech32Id) { logger.warn('Nostr note placeholder found but no bech32Id attribute') // Fallback: show as plain text const textNode = document.createTextNode(`nostr:${element.textContent || ''}`) element.parentNode?.replaceChild(textNode, element) return } // Create a block-level container for React component that fills width const container = document.createElement('div') container.className = 'w-full my-2' const parent = element.parentNode if (!parent) { logger.warn('Nostr note placeholder has no parent node') return } parent.replaceChild(container, element) // Use React to render the component, with error handling try { const root = createRoot(container) root.render() reactRootsRef.current.set(container, root) } catch (error) { logger.error('Failed to render nostr note', { bech32Id, error }) // Fallback: show as plain text const textNode = document.createTextNode(`nostr:${bech32Id}`) parent.replaceChild(textNode, container) } }) // Process payto: placeholders – replace with PaytoLink const paytoPlaceholders = contentRef.current.querySelectorAll('.payto-placeholder[data-payto-uri]') paytoPlaceholders.forEach((element) => { const paytoUri = element.getAttribute('data-payto-uri') if (!paytoUri) return const decoded = paytoUri.replace(/"/g, '"').replace(/&/g, '&').replace(/'/g, "'") const container = document.createElement('span') container.className = 'inline' const parent = element.parentNode if (!parent) return parent.replaceChild(container, element) try { const root = createRoot(container) root.render() reactRootsRef.current.set(container, root) } catch (error) { logger.error('Failed to render payto link', { paytoUri: decoded, error }) const textNode = document.createTextNode(decoded) parent.replaceChild(textNode, container) } }) // Process citations - replace placeholders with React components // First pass: collect all citations and assign indices const getCitationAnchorId = (index: number) => `citation-${citationAnchorPrefix}-${index}` const getCitationRefId = (index: number) => `citation-ref-${citationAnchorPrefix}-${index}` const footnotesSectionId = `footnotes-section-${citationAnchorPrefix}` const referencesSectionId = `references-section-${citationAnchorPrefix}` const citationPlaceholders = Array.from(contentRef.current.querySelectorAll('.citation-placeholder[data-citation]')) console.log('AsciidocArticle: Found citation placeholders', { count: citationPlaceholders.length, placeholders: citationPlaceholders.map(el => ({ id: el.getAttribute('data-citation'), type: el.getAttribute('data-citation-type') })) }) citationsRef.current = [] citationIndexRef.current = 0 citationPlaceholders.forEach((element) => { const citationId = element.getAttribute('data-citation') const citationType = element.getAttribute('data-citation-type') || 'end' if (!citationId) { console.warn('Citation placeholder found but no citation ID attribute') return } const citationIndex = citationIndexRef.current++ citationsRef.current.push({ id: getCitationAnchorId(citationIndex), type: citationType, citationId, index: citationIndex }) }) console.log('AsciidocArticle: Collected citations', { count: citationsRef.current.length, citations: citationsRef.current }) // Second pass: render citations based on type citationPlaceholders.forEach((element, idx) => { const citationId = element.getAttribute('data-citation') const citationType = element.getAttribute('data-citation-type') || 'end' if (!citationId) return const citation = citationsRef.current[idx] if (!citation) return const citationNumber = citation.index + 1 const parent = element.parentNode if (!parent) { logger.warn('Citation placeholder has no parent node') return } // Handle different citation types if (citationType === 'inline' || citationType === 'prompt-inline') { // Inline citations render as clickable text const container = document.createElement('span') container.className = 'inline' container.style.display = 'inline' container.style.whiteSpace = 'nowrap' parent.replaceChild(container, element) const root = createRoot(container) root.render( ) reactRootsRef.current.set(container, root) } else if (citationType === 'foot' || citationType === 'foot-end') { // Footnotes render as superscript numbers const sup = document.createElement('sup') sup.className = 'citation-ref' sup.style.display = 'inline' sup.style.whiteSpace = 'nowrap' const link = document.createElement('a') link.href = `#${getCitationAnchorId(citation.index)}` link.id = getCitationRefId(citation.index) link.className = 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline no-underline' link.textContent = `[${citationNumber}]` link.addEventListener('click', (e) => { e.preventDefault() const citationElement = document.getElementById(getCitationAnchorId(citation.index)) if (citationElement) { citationElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) } }) sup.appendChild(link) parent.replaceChild(sup, element) } else if (citationType === 'end' || citationType === 'prompt-end') { // Endnotes render as superscript numbers that link to references section const sup = document.createElement('sup') sup.className = 'citation-ref' sup.style.display = 'inline' sup.style.whiteSpace = 'nowrap' const link = document.createElement('a') link.href = `#${referencesSectionId}` link.id = getCitationRefId(citation.index) link.className = 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline no-underline' link.textContent = `[${citationNumber}]` link.addEventListener('click', (e) => { e.preventDefault() const refSection = document.getElementById(referencesSectionId) if (refSection) { refSection.scrollIntoView({ behavior: 'smooth', block: 'start' }) } }) sup.appendChild(link) parent.replaceChild(sup, element) } else if (citationType === 'quote') { // Quotes render as block-level citation cards const container = document.createElement('div') container.className = 'w-full my-2' parent.replaceChild(container, element) const root = createRoot(container) root.render( ) reactRootsRef.current.set(container, root) } }) // Render footnotes and references sections const footnotes = citationsRef.current.filter(c => c.type === 'foot' || c.type === 'foot-end') const endCitations = citationsRef.current.filter(c => c.type === 'end' || c.type === 'prompt-end') console.log('AsciidocArticle: Processing citations', { totalCitations: citationsRef.current.length, footnotesCount: footnotes.length, endCitationsCount: endCitations.length, allCitations: citationsRef.current }) if (!contentRef.current?.parentElement) { console.warn('AsciidocArticle: contentRef parent not found, cannot render footnotes/references') return } const parentContainer = contentRef.current.parentElement const externalReferencesContainer = footnotesContainerId ? document.getElementById(footnotesContainerId) : null const referencesTargetContainer = externalReferencesContainer ?? parentContainer // Footnotes stay at section-level. Endnotes can target publication-level container. const existingFootnotes = parentContainer.querySelector(`#${footnotesSectionId}`) const existingReferences = referencesTargetContainer.querySelector(`#${referencesSectionId}`) // If sections already exist and we have no new citations, preserve existing sections // This handles the case where useEffect runs again after placeholders are replaced if ((existingFootnotes || existingReferences) && citationsRef.current.length === 0) { console.log('AsciidocArticle: Sections already exist, preserving them', { hasFootnotes: !!existingFootnotes, hasReferences: !!existingReferences }) return } // Remove existing sections only if we're going to recreate them with new data if (existingFootnotes && footnotes.length > 0) { existingFootnotes.remove() } if (existingReferences && endCitations.length > 0) { existingReferences.remove() } console.log('AsciidocArticle: Rendering citation sections', { footnotesCount: footnotes.length, endCitationsCount: endCitations.length, totalCitations: citationsRef.current.length, parentContainer: parentContainer.tagName, hasContentRef: !!contentRef.current, hadExistingFootnotes: !!existingFootnotes, hadExistingReferences: !!existingReferences }) // Render footnotes section if (footnotes.length > 0) { const footnotesSection = document.createElement('div') footnotesSection.id = footnotesSectionId footnotesSection.className = 'asciidoc-footnotes-section mt-8 pt-4 border-t border-gray-300 dark:border-gray-700' const h3 = document.createElement('h3') h3.className = 'text-lg font-semibold mb-4' h3.textContent = 'Footnotes' footnotesSection.appendChild(h3) const ol = document.createElement('ol') // Academic style: proper list formatting with aligned numbers ol.className = 'list-decimal pl-6 space-y-3' ol.style.listStylePosition = 'outside' footnotes.forEach((citation) => { const li = document.createElement('li') li.id = citation.id li.className = 'text-sm pl-2' li.style.display = 'list-item' const citationContainer = document.createElement('span') citationContainer.className = 'inline' li.appendChild(citationContainer) const backLink = document.createElement('a') backLink.href = `#${getCitationRefId(citation.index)}` backLink.className = 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs ml-2 inline-flex items-center' backLink.setAttribute('aria-label', 'Return to citation') // Use hyperlink icon instead of emoji backLink.innerHTML = '' backLink.addEventListener('click', (e) => { e.preventDefault() const refElement = document.getElementById(getCitationRefId(citation.index)) if (refElement) { refElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) } }) li.appendChild(backLink) ol.appendChild(li) // Render citation component - use a wrapper div to position backlink const citationWrapperDiv = document.createElement('div') citationWrapperDiv.className = 'inline' citationWrapperDiv.style.display = 'inline' citationContainer.appendChild(citationWrapperDiv) const citationRoot = createRoot(citationWrapperDiv) citationRoot.render( ) reactRootsRef.current.set(citationWrapperDiv, citationRoot) // Insert backlink at end of first line after citation renders setTimeout(() => { const firstDiv = citationWrapperDiv.querySelector('div:first-child') as HTMLElement if (firstDiv) { firstDiv.style.display = 'inline' firstDiv.style.position = 'relative' backLink.style.position = 'absolute' backLink.style.right = '0' backLink.style.top = '0' firstDiv.appendChild(backLink) } else { citationWrapperDiv.appendChild(backLink) } }, 100) }) footnotesSection.appendChild(ol) // Footnotes always stay at the bottom of this section. contentRef.current.insertAdjacentElement('afterend', footnotesSection) // Verify insertion const insertedFootnotes = parentContainer.querySelector(`#${footnotesSectionId}`) console.log('AsciidocArticle: Footnotes section created and inserted', { footnotesCount: footnotes.length, parentTagName: parentContainer.tagName, sectionId: footnotesSection.id, isInDOM: !!insertedFootnotes, sectionVisible: insertedFootnotes ? window.getComputedStyle(insertedFootnotes).display !== 'none' : false, sectionText: insertedFootnotes?.textContent?.substring(0, 100) }) } // Render references section if (endCitations.length > 0) { const referencesSection = document.createElement('div') referencesSection.id = referencesSectionId referencesSection.className = 'asciidoc-references-section mt-8 pt-4 border-t border-gray-300 dark:border-gray-700' const h3 = document.createElement('h3') h3.className = 'text-lg font-semibold mb-4' h3.textContent = 'References' referencesSection.appendChild(h3) const ol = document.createElement('ol') // Academic style: proper list formatting with aligned numbers ol.className = 'list-decimal pl-6 space-y-3' ol.style.listStylePosition = 'outside' endCitations.forEach((citation) => { const li = document.createElement('li') li.id = `citation-end-${citationAnchorPrefix}-${citation.index}` li.className = 'text-sm pl-2' li.style.display = 'list-item' const citationWrapper = document.createElement('div') citationWrapper.className = 'inline-block w-full relative' li.appendChild(citationWrapper) const citationContainer = document.createElement('span') citationContainer.className = 'inline' citationWrapper.appendChild(citationContainer) const backLink = document.createElement('a') backLink.href = `#${getCitationRefId(citation.index)}` backLink.className = 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs ml-2 inline-flex items-center' backLink.setAttribute('aria-label', 'Return to citation') backLink.innerHTML = '' backLink.addEventListener('click', (e) => { e.preventDefault() const refElement = document.getElementById(getCitationRefId(citation.index)) if (refElement) { refElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) } }) ol.appendChild(li) // Render citation component const citationRoot = createRoot(citationContainer) citationRoot.render( ) reactRootsRef.current.set(citationContainer, citationRoot) // Insert backlink at end of first line after citation renders setTimeout(() => { const firstDiv = citationContainer.querySelector('div:first-child') as HTMLElement if (firstDiv) { firstDiv.style.display = 'inline' firstDiv.style.position = 'relative' backLink.style.position = 'absolute' backLink.style.right = '0' backLink.style.top = '0' firstDiv.appendChild(backLink) } else { citationWrapper.appendChild(backLink) } }, 100) }) referencesSection.appendChild(ol) const insertedFootnotesSection = parentContainer.querySelector(`#${footnotesSectionId}`) if (insertedFootnotesSection && !externalReferencesContainer) { insertedFootnotesSection.insertAdjacentElement('afterend', referencesSection) } else if (externalReferencesContainer) { externalReferencesContainer.appendChild(referencesSection) } else { contentRef.current.insertAdjacentElement('afterend', referencesSection) } // Verify insertion const insertedReferences = referencesTargetContainer.querySelector(`#${referencesSectionId}`) console.log('AsciidocArticle: References section created and inserted', { endCitationsCount: endCitations.length, hasFootnotesSection: !!insertedFootnotesSection, sectionId: referencesSection.id, isInDOM: !!insertedReferences, sectionHTML: insertedReferences?.outerHTML?.substring(0, 200) }) } // Process LaTeX math expressions - render with KaTeX const latexInlinePlaceholders = contentRef.current.querySelectorAll('.latex-inline-placeholder[data-latex-inline]') latexInlinePlaceholders.forEach((element) => { const latex = element.getAttribute('data-latex-inline') if (!latex) return try { // Render LaTeX with KaTeX const rendered = katex.renderToString(latex, { throwOnError: false, displayMode: false }) // Replace the placeholder with the rendered HTML element.outerHTML = rendered } catch (error) { logger.error('Error rendering LaTeX inline math:', error) // On error, show the raw LaTeX element.outerHTML = `$${latex}$` } }) const latexBlockPlaceholders = contentRef.current.querySelectorAll('.latex-block-placeholder[data-latex-block]') latexBlockPlaceholders.forEach((element) => { const latex = element.getAttribute('data-latex-block') if (!latex) return try { // Render LaTeX with KaTeX in display mode const rendered = katex.renderToString(latex, { throwOnError: false, displayMode: true }) // Replace the placeholder with the rendered HTML element.outerHTML = rendered } catch (error) { logger.error('Error rendering LaTeX block math:', error) // On error, show the raw LaTeX element.outerHTML = `
$$${latex}$$
` } }) // Process YouTube URLs - replace placeholders with React components const youtubePlaceholders = contentRef.current.querySelectorAll('.youtube-placeholder[data-youtube-url]') youtubePlaceholders.forEach((element) => { const youtubeUrl = element.getAttribute('data-youtube-url') if (!youtubeUrl) return // Create a container for React component const container = document.createElement('div') container.className = 'my-2' element.parentNode?.replaceChild(container, element) // Use React to render the component const root = createRoot(container) root.render() reactRootsRef.current.set(container, root) }) // Process bookstr wikilinks - replace placeholders with React components // Only process elements that are still placeholders (not already converted to containers) const bookstrPlaceholders = contentRef.current.querySelectorAll('.bookstr-placeholder[data-bookstr]') bookstrPlaceholders.forEach((element) => { const bookstrContent = element.getAttribute('data-bookstr') if (!bookstrContent) return // Create a unique key for this placeholder const placeholderKey = `bookstr-${bookstrContent}` // Check if this placeholder has already been converted to a container // Look for a sibling or nearby container with the same key const parent = element.parentElement if (parent) { // Escape the attribute value for use in CSS selector // If the value contains double quotes, use single quotes for the selector // Otherwise escape double quotes and backslashes let selector: string if (placeholderKey.includes('"')) { // Use single quotes and escape any single quotes in the value const escapedValue = placeholderKey.replace(/'/g, "\\'") selector = `.bookstr-container[data-bookstr-key='${escapedValue}']` } else { // Use double quotes and escape any double quotes and backslashes const escapedValue = placeholderKey.replace(/["\\]/g, '\\$&') selector = `.bookstr-container[data-bookstr-key="${escapedValue}"]` } const existingContainer = parent.querySelector(selector) if (existingContainer) { // Container already exists - check if it has a React root if (reactRootsRef.current.has(existingContainer)) { // Already has a React root, just remove this duplicate placeholder element.remove() return } else { // Container exists but no root - this shouldn't happen, but clean it up existingContainer.remove() } } } // Skip if already processed (to avoid duplicate processing) if (processedPlaceholdersRef.current.has(placeholderKey)) { // If we've processed this but the element still exists, remove it element.remove() return } // Mark as processed processedPlaceholdersRef.current.add(placeholderKey) // Prepend book:: prefix since BookstrContent expects it const wikilink = `book::${bookstrContent}` logger.debug('BookstrContent: Rendering component', { bookstrContent, wikilink }) // Create a container for React component const container = document.createElement('div') container.className = 'bookstr-container' container.setAttribute('data-bookstr-key', placeholderKey) element.parentNode?.replaceChild(container, element) // Use React to render the component - only render once per container // Check if this container already has a root to avoid re-rendering if (!reactRootsRef.current.has(container)) { const root = createRoot(container) root.render() reactRootsRef.current.set(container, root) } }) // Process wikilinks - replace placeholders with React components const wikilinks = contentRef.current.querySelectorAll('.wikilink-placeholder[data-wikilink]') wikilinks.forEach((element) => { const linkContent = element.getAttribute('data-wikilink') if (!linkContent) return // Skip if this is a bookstr wikilink (already processed) if (linkContent.startsWith('book::')) { return } // Parse wikilink: extract target and display text let target = linkContent.includes('|') ? linkContent.split('|')[0].trim() : linkContent.trim() let displayText = linkContent.includes('|') ? linkContent.split('|')[1].trim() : linkContent.trim() // Convert to d-tag format (same as MarkdownArticle) const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') // Create a container for React component const container = document.createElement('span') container.className = 'inline-block' element.parentNode?.replaceChild(container, element) // Use React to render the component const root = createRoot(container) root.render() reactRootsRef.current.set(container, root) }) // Process hashtags in text nodes - convert #tag to links const walker = document.createTreeWalker( contentRef.current, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { // Skip if parent is a link, code, or pre tag const parent = node.parentElement if (!parent) return NodeFilter.FILTER_ACCEPT if (parent.tagName === 'A' || parent.tagName === 'CODE' || parent.tagName === 'PRE') { return NodeFilter.FILTER_REJECT } return NodeFilter.FILTER_ACCEPT } } ) const textNodes: Text[] = [] let node while ((node = walker.nextNode())) { if (node.nodeType === Node.TEXT_NODE && node.textContent) { textNodes.push(node as Text) } } textNodes.forEach((textNode) => { const text = textNode.textContent || '' const hashtagRegex = /#([a-zA-Z0-9_]+)/g const matches = Array.from(text.matchAll(hashtagRegex)) if (matches.length > 0) { const fragment = document.createDocumentFragment() let lastIndex = 0 matches.forEach((match) => { if (match.index === undefined) return // Add text before hashtag if (match.index > lastIndex) { fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index))) } // Create hashtag link const link = document.createElement('a') link.href = `/notes?t=${match[1].toLowerCase()}` link.className = 'inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer' link.textContent = `#${match[1]}` link.addEventListener('click', (e) => { e.stopPropagation() e.preventDefault() navigateToHashtag(`/notes?t=${match[1].toLowerCase()}`) }) fragment.appendChild(link) lastIndex = match.index + match[0].length }) // Add remaining text if (lastIndex < text.length) { fragment.appendChild(document.createTextNode(text.slice(lastIndex))) } textNode.parentNode?.replaceChild(fragment, textNode) } }) // Handle all links - truncate display text and add click handlers for relay URLs const allLinks = contentRef.current.querySelectorAll('a[href]') allLinks.forEach((link) => { const href = link.getAttribute('href') if (!href) return // Get current link text (this might be the full URL or custom text) const linkText = link.textContent || '' // Truncate link text if it's longer than 200 characters if (linkText.length > 200) { const truncatedText = truncateLinkText(linkText) link.textContent = truncatedText // Store full text as title for tooltip if (!link.getAttribute('title')) { link.setAttribute('title', linkText) } } // Handle relay URL links - add click handlers to navigate to relay page const relayUrl = link.getAttribute('data-relay-url') if (relayUrl) { const relayPath = `/relays/${encodeURIComponent(relayUrl)}` link.setAttribute('href', relayPath) link.addEventListener('click', (e) => { e.stopPropagation() e.preventDefault() navigateToRelay(relayPath) }) } }) // No cleanup needed here - we only clean up disconnected roots above // Full cleanup happens on component unmount }, [parsedHtml, isLoading, navigateToHashtag, navigateToRelay, footnotesContainerId, citationAnchorPrefix, event.id]) // Cleanup on component unmount useEffect(() => { return () => { const rootsToCleanup = Array.from(reactRootsRef.current.values()) reactRootsRef.current.clear() processedPlaceholdersRef.current.clear() // Unmount asynchronously setTimeout(() => { rootsToCleanup.forEach((root) => { try { root.unmount() } catch (err) { // Ignore errors during cleanup } }) }, 0) } }, []) // Initialize syntax highlighting useEffect(() => { const initHighlight = async () => { if (typeof window !== 'undefined') { const hljs = await import('@/lib/highlight') if (contentRef.current) { contentRef.current.querySelectorAll('pre code').forEach((block) => { const element = block as HTMLElement element.style.color = 'inherit' element.classList.add('text-gray-900', 'dark:text-gray-100') hljs.default.highlightElement(element) element.style.color = 'inherit' }) } } } const timeoutId = setTimeout(initHighlight, 100) return () => clearTimeout(timeoutId) }, [parsedHtml]) return ( <>
{/* Metadata */} {!hideImagesAndInfo && metadata.title &&

{metadata.title}

} {!hideImagesAndInfo && !metadata.title && isBookstrEvent && (

{bookMetadata.book ? bookMetadata.book .split('-') .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' ') : 'Bookstr Publication'}

)} {!hideImagesAndInfo && isBookstrEvent && (
{bookMetadata.type && Type: {bookMetadata.type}} {bookMetadata.book && Book: {bookMetadata.book .split('-') .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' ')}} {bookMetadata.chapter && Chapter: {bookMetadata.chapter}} {bookMetadata.verse && Verse: {bookMetadata.verse}} {bookMetadata.version && Version: {bookMetadata.version.toUpperCase()}}
)} {!hideImagesAndInfo && metadata.summary && (

{metadata.summary}

)} {hideImagesAndInfo && metadata.title && (

{metadata.title}

)} {hideImagesAndInfo && !metadata.title && isBookstrEvent && (

{bookMetadata.book ? bookMetadata.book .split('-') .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' ') : 'Bookstr Publication'}

)} {hideImagesAndInfo && isBookstrEvent && (
{bookMetadata.type && Type: {bookMetadata.type}} {bookMetadata.book && Book: {bookMetadata.book .split('-') .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' ')}} {bookMetadata.chapter && Chapter: {bookMetadata.chapter}} {bookMetadata.verse && Verse: {bookMetadata.verse}} {bookMetadata.version && Version: {bookMetadata.version.toUpperCase()}}
)} {/* Metadata image */} {!hideImagesAndInfo && metadata.image && (() => { const cleanedMetadataImage = cleanUrl(metadata.image) const parentImageUrlCleaned = parentImageUrl ? cleanUrl(parentImageUrl) : null // Don't show if already in content if (cleanedMetadataImage && mediaUrlsInContent.has(cleanedMetadataImage)) { return null } // Don't show if it matches the parent publication's image (to avoid duplicate cover images) if (parentImageUrlCleaned && cleanedMetadataImage === parentImageUrlCleaned) { return null } const metadataImageIndex = imageIndexMap.get(cleanedMetadataImage) return ( { e.stopPropagation() if (metadataImageIndex !== undefined) { openLightbox(metadataImageIndex) } }} /> ) })()} {/* Media from tags (only if not in content) */} {leftoverTagMedia.length > 0 && (
{leftoverTagMedia.map((media) => { const cleaned = cleanUrl(media.url) const mediaIndex = imageIndexMap.get(cleaned) if (media.type === 'image') { return (
{ e.stopPropagation() if (mediaIndex !== undefined) { openLightbox(mediaIndex) } }} />
) } else if (media.type === 'video' || media.type === 'audio') { return (
) } return null })}
)} {/* YouTube URLs from tags (only if not in content) */} {leftoverTagYouTubeUrls.length > 0 && (
{leftoverTagYouTubeUrls.map((url) => { const cleaned = cleanUrl(url) return (
) })}
)} {/* Parsed AsciiDoc content */} {isLoading ? (
Loading content...
) : (
)} {/* Hashtags from metadata (only if not already in content) */} {!hideImagesAndInfo && leftoverMetadataTags.length > 0 && (
{leftoverMetadataTags.map((tag) => (
{ e.stopPropagation() push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) }} > #{tag}
))}
)} {/* Footnotes and References sections - rendered via useEffect after citations are processed */}
{/* Image gallery lightbox */} {allImages.length > 0 && createPortal(
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} > ({ src: preferBlossomPrimalDisplayUrl(url), alt: alt || url }))} plugins={[Zoom]} open={lightboxIndex >= 0} close={() => setLightboxIndex(-1)} controller={{ closeOnBackdropClick: false, closeOnPullUp: true, closeOnPullDown: true }} render={{ buttonPrev: allImages.length <= 1 ? () => null : undefined, buttonNext: allImages.length <= 1 ? () => null : undefined }} styles={{ toolbar: { paddingTop: '2.25rem' } }} carousel={{ finite: false }} />
, document.body )} ) }