diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index de00230..3e258d7 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -1,7 +1,6 @@ import { useSecondaryPage, useSmartHashtagNavigation, useSmartRelayNavigation } from '@/PageManager' import Image from '@/components/Image' import MediaPlayer from '@/components/MediaPlayer' -import WebPreview from '@/components/WebPreview' import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNoteList } from '@/lib/link' @@ -225,6 +224,28 @@ function convertMarkdownToAsciidoc(content: string): string { // 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) => { @@ -244,6 +265,11 @@ function convertMarkdownToAsciidoc(content: string): string { 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# @@ -714,8 +740,8 @@ export default function AsciidocArticle({ }) // Handle YouTube URLs and relay URLs in links - // Process all link matches first to determine which are standalone - const linkMatches: Array<{ match: string; href: string; linkText: string; index: number; isStandalone: boolean }> = [] + // Only replace links that need special handling - leave AsciiDoc-generated links alone + const linkMatches: Array<{ match: string; href: string; linkText: string; index: number }> = [] const linkRegex = /]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/g let linkMatch while ((linkMatch = linkRegex.exec(htmlString)) !== null) { @@ -724,67 +750,16 @@ export default function AsciidocArticle({ const linkText = linkMatch[2] const index = linkMatch.index - // Check if link is standalone (on its own line, not part of a sentence/list/quote) - let isStandalone = false - if (href.startsWith('http://') || href.startsWith('https://')) { - // Get context around the link - const beforeMatch = htmlString.substring(Math.max(0, index - 500), index) - const afterMatch = htmlString.substring(index + match.length, Math.min(htmlString.length, index + match.length + 500)) - - // Extract the parent paragraph/div content - const paragraphMatch = beforeMatch.match(/]*>([^<]*)$/) - const divMatch = beforeMatch.match(/]*>([^<]*)$/) - - // If link is in a paragraph, check if paragraph contains only the link - if (paragraphMatch) { - const paragraphEnd = afterMatch.match(/^([^<]*)<\/p>/) - const paragraphContent = paragraphMatch[1] + linkText + (paragraphEnd?.[1] || '') - const trimmedContent = paragraphContent.trim() - // If paragraph contains only the link (possibly with whitespace), it's standalone - if (trimmedContent === linkText.trim() || trimmedContent === '') { - // Check if it's in a list or blockquote by looking further back - const contextBefore = htmlString.substring(Math.max(0, index - 1000), index) - if (!contextBefore.match(/<[uo]l[^>]*>/) && !contextBefore.match(/]*>/)) { - isStandalone = true - } - } - } - - // If link is in a div and the div contains only the link, it's standalone - if (!isStandalone && divMatch) { - const divEnd = afterMatch.match(/^([^<]*)<\/div>/) - const divContent = divMatch[1] + linkText + (divEnd?.[1] || '') - const trimmedContent = divContent.trim() - if (trimmedContent === linkText.trim() || trimmedContent === '') { - const contextBefore = htmlString.substring(Math.max(0, index - 1000), index) - if (!contextBefore.match(/<[uo]l[^>]*>/) && !contextBefore.match(/]*>/)) { - isStandalone = true - } - } - } - - // If link appears to be on its own line (surrounded by block-level tags or whitespace) - if (!isStandalone) { - const beforeTrimmed = beforeMatch.replace(/\s*$/, '') - const afterTrimmed = afterMatch.replace(/^\s*/, '') - if ( - (beforeTrimmed.endsWith('

') || beforeTrimmed.endsWith('') || beforeTrimmed.endsWith('') || afterTrimmed.startsWith('') || afterTrimmed.startsWith(']*>/) && !contextBefore.match(/]*>/)) { - isStandalone = true - } - } - } + // 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 }) } - - linkMatches.push({ match, href, linkText, index, isStandalone }) } - // Replace links in reverse order to preserve indices + // Replace only special links in reverse order to preserve indices for (let i = linkMatches.length - 1; i >= 0; i--) { - const { match, href, linkText, isStandalone } = linkMatches[i] + const { match, href, linkText, index } = linkMatches[i] let replacement = match // Check if the href is a YouTube URL @@ -797,23 +772,6 @@ export default function AsciidocArticle({ const relayPath = `/relays/${encodeURIComponent(href)}` replacement = `${linkText}` } - // For regular HTTP/HTTPS links, check if standalone - else if (href.startsWith('http://') || href.startsWith('https://')) { - if (isStandalone) { - // Standalone link - render as WebPreview - const cleanedUrl = cleanUrl(href) - replacement = `
` - } else { - // Inline link - keep as regular link - const escapedLinkText = linkText.replace(/"/g, '"') - replacement = `${linkText}` - } - } - // For other links (like relative links), keep as-is but add data attribute - else { - const escapedLinkText = linkText.replace(/"/g, '"') - replacement = match.replace(/ tags, not YouTube, not relay) - convert to WebPreview placeholders + // Handle plain HTTP/HTTPS URLs in text (not in tags, not YouTube, not relay) - convert to regular links + // NO WebPreview conversion for AsciiDoc articles const httpUrlRegex = /https?:\/\/[^\s<>"']+/g htmlString = htmlString.replace(httpUrlRegex, (match) => { // Only replace if not already in a tag (basic check) @@ -853,8 +812,9 @@ export default function AsciidocArticle({ if (isImage(match) || isVideo(match) || isAudio(match)) { return match } + // Convert to regular link - NO WebPreview const cleanedUrl = cleanUrl(match) - return `
` + return `
${match}` } return match }) @@ -1059,23 +1019,6 @@ export default function AsciidocArticle({ reactRootsRef.current.set(container, root) }) - // Process WebPreview placeholders - replace with React components - const webpreviewPlaceholders = contentRef.current.querySelectorAll('.webpreview-placeholder[data-webpreview-url]') - webpreviewPlaceholders.forEach((element) => { - const url = element.getAttribute('data-webpreview-url') - if (!url) 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 hashtags in text nodes - convert #tag to links const walker = document.createTreeWalker( contentRef.current, @@ -1431,15 +1374,6 @@ export default function AsciidocArticle({ )} - {/* WebPreview cards for links from tags (only if not already in content) */} - {/* Note: Links in content are already rendered as links in the AsciiDoc HTML above, so we don't show WebPreview for them */} - {leftoverTagLinks.length > 0 && ( -
- {leftoverTagLinks.map((url, index) => ( - - ))} -
- )} {/* Image gallery lightbox */} diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 34177d3..b497fb3 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -106,207 +106,42 @@ function isYouTubeUrl(url: string): boolean { return regex.test(url) } -/** - * Parse inline markdown formatting while preserving newlines (for code blocks) - */ -function parseInlineMarkdownPreserveNewlines(text: string, keyPrefix: string): React.ReactNode[] { - const parts: React.ReactNode[] = [] - let lastIndex = 0 - const inlinePatterns: Array<{ index: number; end: number; type: string; data: any }> = [] - - // Bold: **text** (double asterisk) - allow newlines within - const doubleBoldAsteriskRegex = /\*\*([\s\S]+?)\*\*/g - const doubleBoldAsteriskMatches = Array.from(text.matchAll(doubleBoldAsteriskRegex)) - doubleBoldAsteriskMatches.forEach(match => { - if (match.index !== undefined) { - inlinePatterns.push({ - index: match.index, - end: match.index + match[0].length, - type: 'bold', - data: match[1] - }) - } - }) - - // Double underscore bold - allow newlines within - const doubleBoldUnderscoreRegex = /__([\s\S]+?)__/g - const doubleBoldUnderscoreMatches = Array.from(text.matchAll(doubleBoldUnderscoreRegex)) - doubleBoldUnderscoreMatches.forEach(match => { - if (match.index !== undefined) { - const isInOther = inlinePatterns.some(p => - (p.type === 'bold') && - match.index! >= p.index && - match.index! < p.end - ) - if (!isInOther) { - inlinePatterns.push({ - index: match.index, - end: match.index + match[0].length, - type: 'bold', - data: match[1] - }) - } - } - }) - - // Italic: _text_ (single underscore, not part of __bold__) - allow newlines within - const singleItalicUnderscoreRegex = /(? { - if (match.index !== undefined) { - const isInOther = inlinePatterns.some(p => - (p.type === 'bold') && - match.index! >= p.index && - match.index! < p.end - ) - if (!isInOther) { - inlinePatterns.push({ - index: match.index, - end: match.index + match[0].length, - type: 'italic', - data: match[1] - }) - } - } - }) - - // Sort by index - inlinePatterns.sort((a, b) => a.index - b.index) - - // Remove overlaps (keep first) - const filtered: typeof inlinePatterns = [] - let lastEnd = 0 - inlinePatterns.forEach(pattern => { - if (pattern.index >= lastEnd) { - filtered.push(pattern) - lastEnd = pattern.end - } - }) - - // Build React nodes, preserving newlines - filtered.forEach((pattern, i) => { - // Add text before pattern (preserving newlines) - if (pattern.index > lastIndex) { - const textBefore = text.substring(lastIndex, pattern.index) - if (textBefore) { - // Split by newlines and render each part - const lines = textBefore.split('\n') - lines.forEach((line, lineIdx) => { - if (lineIdx > 0) { - parts.push(
) - } - if (line) { - parts.push({line}) - } - }) - } - } - - // Render pattern (preserving newlines within the pattern) - if (pattern.type === 'bold') { - const boldLines = pattern.data.split('\n') - boldLines.forEach((line: string, lineIdx: number) => { - if (lineIdx > 0) { - parts.push(
) - } - if (line) { - parts.push({line}) - } - }) - } else if (pattern.type === 'italic') { - const italicLines = pattern.data.split('\n') - italicLines.forEach((line: string, lineIdx: number) => { - if (lineIdx > 0) { - parts.push(
) - } - if (line) { - parts.push({line}) - } - }) - } - - lastIndex = pattern.end - }) - - // Add remaining text (preserving newlines) - if (lastIndex < text.length) { - const remaining = text.substring(lastIndex) - const lines = remaining.split('\n') - lines.forEach((line, lineIdx) => { - if (lineIdx > 0) { - parts.push(
) - } - if (line) { - parts.push({line}) - } - }) - } - - return parts -} - /** * CodeBlock component that renders code with syntax highlighting using highlight.js - * Also processes inline markdown formatting (bold, italic) within the code */ function CodeBlock({ id, code, language }: { id: string; code: string; language: string }) { const codeRef = useRef(null) - // Check if code contains markdown formatting - const hasMarkdownFormatting = /\*\*.*?\*\*|__.*?__|_.*?_|\*.*?\*/.test(code) - - // Process inline markdown formatting (bold, italic) in code blocks while preserving newlines - const processedCode = useMemo(() => { - if (hasMarkdownFormatting) { - // Parse inline markdown while preserving newlines - return parseInlineMarkdownPreserveNewlines(code, `code-${id}`) - } - return code - }, [code, id, hasMarkdownFormatting]) - useEffect(() => { - // Only apply syntax highlighting if there's no markdown formatting - // (highlight.js would interfere with HTML formatting) - if (!hasMarkdownFormatting) { - const initHighlight = async () => { - if (typeof window !== 'undefined' && codeRef.current) { - try { - const hljs = await import('highlight.js') - const codeElement = codeRef.current.querySelector('code') - if (codeElement) { - hljs.default.highlightElement(codeElement) - } - } catch (error) { - logger.error('Error loading highlight.js:', error) + const initHighlight = async () => { + if (typeof window !== 'undefined' && codeRef.current) { + try { + const hljs = await import('highlight.js') + const codeElement = codeRef.current.querySelector('code') + if (codeElement) { + hljs.default.highlightElement(codeElement) } + } catch (error) { + logger.error('Error loading highlight.js:', error) } } - - // Small delay to ensure DOM is ready - const timeoutId = setTimeout(initHighlight, 0) - return () => clearTimeout(timeoutId) } - }, [code, language, hasMarkdownFormatting]) + + // Small delay to ensure DOM is ready + const timeoutId = setTimeout(initHighlight, 0) + return () => clearTimeout(timeoutId) + }, [code, language]) return (
         
- {hasMarkdownFormatting ? ( - - {processedCode} - - ) : ( - - {code} - - )} + + {code} +
diff --git a/src/components/Note/MarkdownArticle/preprocessMarkup.ts b/src/components/Note/MarkdownArticle/preprocessMarkup.ts index ef36008..29e521e 100644 --- a/src/components/Note/MarkdownArticle/preprocessMarkup.ts +++ b/src/components/Note/MarkdownArticle/preprocessMarkup.ts @@ -149,6 +149,13 @@ export function preprocessAsciidocMediaLinks(content: string): string { continue } + // Check if this URL is part of an AsciiDoc link format url[text] + // If URL is immediately followed by [text], it's already an AsciiDoc link - skip it + const contextAfter = content.substring(urlEnd, Math.min(content.length, urlEnd + 50)) + if (contextAfter.match(/^\s*\[[^\]]+\]/)) { + continue + } + const before = content.substring(Math.max(0, index - 30), index) // Check if this URL is already part of AsciiDoc syntax