Browse Source

bug-fixed markup rendering

imwald
Silberengel 4 months ago
parent
commit
a9a598af94
  1. 133
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  2. 180
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx

133
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -1,11 +1,11 @@ @@ -1,11 +1,11 @@
import { useSecondaryPage, useSmartHashtagNavigation } from '@/PageManager'
import { useSecondaryPage, useSmartHashtagNavigation, useSmartRelayNavigation } from '@/PageManager'
import Image from '@/components/Image'
import MediaPlayer from '@/components/MediaPlayer'
import WebPreview from '@/components/WebPreview'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link'
import { useMediaExtraction } from '@/hooks'
import { cleanUrl, isImage, isMedia, isVideo, isAudio } from '@/lib/url'
import { cleanUrl, isImage, isMedia, isVideo, isAudio, isWebsocketUrl } from '@/lib/url'
import { getImetaInfosFromEvent } from '@/lib/event'
import { Event, kinds } from 'nostr-tools'
import { useMemo, useState, useCallback, useEffect, useRef } from 'react'
@ -17,6 +17,17 @@ import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded' @@ -17,6 +17,17 @@ import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded'
import Wikilink from '@/components/UniversalContent/Wikilink'
import { preprocessAsciidocMediaLinks } from '../MarkdownArticle/preprocessMarkup'
import logger from '@/lib/logger'
import { WS_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) + '...'
}
export default function AsciidocArticle({
event,
@ -29,6 +40,7 @@ export default function AsciidocArticle({ @@ -29,6 +40,7 @@ export default function AsciidocArticle({
}) {
const { push } = useSecondaryPage()
const { navigateToHashtag } = useSmartHashtagNavigation()
const { navigateToRelay } = useSmartRelayNavigation()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const contentRef = useRef<HTMLDivElement>(null)
@ -213,6 +225,31 @@ export default function AsciidocArticle({ @@ -213,6 +225,31 @@ export default function AsciidocArticle({
})
}, [tagMedia, mediaUrlsInContent, metadata.image, hideImagesAndInfo])
// Filter tag links to only show what's not in content (to avoid duplicate WebPreview cards)
const leftoverTagLinks = useMemo(() => {
const contentLinksSet = new Set(contentLinks.map(link => cleanUrl(link)).filter(Boolean))
return tagLinks.filter(link => {
const cleaned = cleanUrl(link)
return cleaned && !contentLinksSet.has(cleaned)
})
}, [tagLinks, contentLinks])
// Extract hashtags from content (for deduplication with metadata tags)
const hashtagsInContent = useMemo(() => {
const tags = new Set<string>()
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<string>('')
const [isLoading, setIsLoading] = useState(true)
@ -294,6 +331,28 @@ export default function AsciidocArticle({ @@ -294,6 +331,28 @@ export default function AsciidocArticle({
return `<span data-wikilink="${escaped}" class="wikilink-placeholder"></span>`
})
// Handle relay URLs (wss:// or ws://) in links - convert to relay page links
htmlString = htmlString.replace(/<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/g, (match, href, linkText) => {
// Check if the href is a relay URL
if (isWebsocketUrl(href)) {
const relayPath = `/relays/${encodeURIComponent(href)}`
return `<a href="${relayPath}" class="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer" data-relay-url="${href}" data-original-text="${linkText.replace(/"/g, '&quot;')}">${linkText}</a>`
}
// For regular links, store original text for truncation in DOM manipulation
const escapedLinkText = linkText.replace(/"/g, '&quot;')
return match.replace(/<a/, `<a data-original-text="${escapedLinkText}"`)
})
// Handle relay URLs in plain text (not in <a> 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 `<a href="${relayPath}" class="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer" data-relay-url="${match}" data-original-text="${match.replace(/"/g, '&quot;')}">${match}</a>`
}
return match
})
setParsedHtml(htmlString)
} catch (error) {
logger.error('Failed to parse AsciiDoc', error as Error)
@ -454,6 +513,38 @@ export default function AsciidocArticle({ @@ -454,6 +513,38 @@ export default function AsciidocArticle({
}
})
// 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)
})
}
})
// Cleanup function
return () => {
reactRootsRef.current.forEach((root) => {
@ -461,7 +552,7 @@ export default function AsciidocArticle({ @@ -461,7 +552,7 @@ export default function AsciidocArticle({
})
reactRootsRef.current.clear()
}
}, [parsedHtml, isLoading, navigateToHashtag])
}, [parsedHtml, isLoading, navigateToHashtag, navigateToRelay])
// Initialize syntax highlighting
useEffect(() => {
@ -558,10 +649,12 @@ export default function AsciidocArticle({ @@ -558,10 +649,12 @@ export default function AsciidocArticle({
color: #5eead4 !important;
}
.asciidoc-content img {
display: block;
max-width: 400px;
height: auto;
border-radius: 0.5rem;
cursor: zoom-in;
margin: 0.5rem 0;
}
.asciidoc-content a[href^="/notes?t="] {
color: #16a34a !important;
@ -671,10 +764,10 @@ export default function AsciidocArticle({ @@ -671,10 +764,10 @@ export default function AsciidocArticle({
/>
)}
{/* Hashtags from metadata */}
{!hideImagesAndInfo && metadata.tags.length > 0 && (
{/* Hashtags from metadata (only if not already in content) */}
{!hideImagesAndInfo && leftoverMetadataTags.length > 0 && (
<div className="flex gap-2 flex-wrap pb-2 mt-4">
{metadata.tags.map((tag) => (
{leftoverMetadataTags.map((tag) => (
<div
key={tag}
title={tag}
@ -690,25 +783,15 @@ export default function AsciidocArticle({ @@ -690,25 +783,15 @@ export default function AsciidocArticle({
</div>
)}
{/* WebPreview cards for links from content */}
{contentLinks.length > 0 && (
<div className="space-y-3 mt-6 pt-4 border-t">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Links</h3>
{contentLinks.map((url, index) => (
<WebPreview key={`content-${index}-${url}`} url={url} className="w-full" />
))}
</div>
)}
{/* WebPreview cards for links from tags */}
{tagLinks.length > 0 && (
<div className="space-y-3 mt-6 pt-4 border-t">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Related Links</h3>
{tagLinks.map((url, index) => (
<WebPreview key={`tag-${index}-${url}`} url={url} className="w-full" />
))}
</div>
)}
{/* 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 && (
<div className="space-y-3 mt-6">
{leftoverTagLinks.map((url, index) => (
<WebPreview key={`tag-${index}-${url}`} url={url} className="w-full" />
))}
</div>
)}
</div>
{/* Image gallery lightbox */}

180
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { useSecondaryPage, useSmartHashtagNavigation } from '@/PageManager'
import { useSecondaryPage, useSmartHashtagNavigation, useSmartRelayNavigation } from '@/PageManager'
import Image from '@/components/Image'
import MediaPlayer from '@/components/MediaPlayer'
import Wikilink from '@/components/UniversalContent/Wikilink'
@ -6,10 +6,10 @@ import WebPreview from '@/components/WebPreview' @@ -6,10 +6,10 @@ import WebPreview from '@/components/WebPreview'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link'
import { useMediaExtraction } from '@/hooks'
import { cleanUrl, isImage, isMedia, isVideo, isAudio } from '@/lib/url'
import { cleanUrl, isImage, isMedia, isVideo, isAudio, isWebsocketUrl } from '@/lib/url'
import { getImetaInfosFromEvent } from '@/lib/event'
import { Event, kinds } from 'nostr-tools'
import { ExtendedKind } from '@/constants'
import { ExtendedKind, WS_URL_REGEX } from '@/constants'
import React, { useMemo, useState, useCallback } from 'react'
import { createPortal } from 'react-dom'
import Lightbox from 'yet-another-react-lightbox'
@ -17,11 +17,23 @@ import Zoom from 'yet-another-react-lightbox/plugins/zoom' @@ -17,11 +17,23 @@ import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded'
import { preprocessMarkdownMediaLinks } from './preprocessMarkup'
/**
* 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) + '...'
}
/**
* Parse markdown content and render with post-processing for nostr: links and hashtags
* Post-processes:
* - nostr: links -> EmbeddedNote or EmbeddedMention
* - #hashtags -> green hyperlinks to /notes?t=hashtag
* - wss:// and ws:// URLs -> hyperlinks to /relays/{url}
* Returns both rendered nodes and a set of hashtags found in content (for deduplication)
*/
function parseMarkdownContent(
content: string,
@ -30,13 +42,15 @@ function parseMarkdownContent( @@ -30,13 +42,15 @@ function parseMarkdownContent(
imageIndexMap: Map<string, number>
openLightbox: (index: number) => void
navigateToHashtag: (href: string) => void
navigateToRelay: (url: string) => void
}
): React.ReactNode[] {
const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag } = options
): { nodes: React.ReactNode[]; hashtagsInContent: Set<string> } {
const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay } = options
const parts: React.ReactNode[] = []
const hashtagsInContent = new Set<string>()
let lastIndex = 0
// Find all patterns: markdown images, markdown links, nostr addresses, hashtags, wikilinks
// Find all patterns: markdown images, markdown links, relay URLs, nostr addresses, hashtags, wikilinks
const patterns: Array<{ index: number; end: number; type: string; data: any }> = []
// Markdown images: ![](url) or ![alt](url)
@ -71,18 +85,41 @@ function parseMarkdownContent( @@ -71,18 +85,41 @@ function parseMarkdownContent(
}
})
// Nostr addresses (nostr:npub1..., nostr:note1..., etc.) - not in markdown links
const nostrRegex = /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
const nostrMatches = Array.from(content.matchAll(nostrRegex))
nostrMatches.forEach(match => {
// Relay URLs (wss:// or ws://) - not in markdown links
const relayUrlMatches = Array.from(content.matchAll(WS_URL_REGEX))
relayUrlMatches.forEach(match => {
if (match.index !== undefined) {
const url = match[0]
// Only add if not already covered by a markdown link/image
const isInMarkdown = patterns.some(p =>
(p.type === 'markdown-link' || p.type === 'markdown-image') &&
match.index! >= p.index &&
match.index! < p.end
)
if (!isInMarkdown) {
// Only process valid websocket URLs
if (!isInMarkdown && isWebsocketUrl(url)) {
patterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'relay-url',
data: { url }
})
}
}
})
// Nostr addresses (nostr:npub1..., nostr:note1..., etc.) - not in markdown links or relay URLs
const nostrRegex = /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
const nostrMatches = Array.from(content.matchAll(nostrRegex))
nostrMatches.forEach(match => {
if (match.index !== undefined) {
// Only add if not already covered by a markdown link/image or relay URL
const isInOther = patterns.some(p =>
(p.type === 'markdown-link' || p.type === 'markdown-image' || p.type === 'relay-url') &&
match.index! >= p.index &&
match.index! < p.end
)
if (!isInOther) {
patterns.push({
index: match.index,
end: match.index + match[0].length,
@ -93,7 +130,7 @@ function parseMarkdownContent( @@ -93,7 +130,7 @@ function parseMarkdownContent(
}
})
// Hashtags (#tag) - but not inside markdown links or nostr addresses
// Hashtags (#tag) - but not inside markdown links, relay URLs, or nostr addresses
const hashtagRegex = /#([a-zA-Z0-9_]+)/g
const hashtagMatches = Array.from(content.matchAll(hashtagRegex))
hashtagMatches.forEach(match => {
@ -165,12 +202,12 @@ function parseMarkdownContent( @@ -165,12 +202,12 @@ function parseMarkdownContent(
const imageIndex = imageIndexMap.get(cleaned)
if (isImage(cleaned)) {
parts.push(
<div key={`img-${i}`} className="my-2 inline-block">
<div key={`img-${i}`} className="my-2 block">
<Image
image={{ url, pubkey: eventPubkey }}
className="max-w-[400px] rounded-lg cursor-zoom-in"
classNames={{
wrapper: 'rounded-lg inline-block',
wrapper: 'rounded-lg block',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
onClick={(e) => {
@ -195,17 +232,58 @@ function parseMarkdownContent( @@ -195,17 +232,58 @@ function parseMarkdownContent(
}
} else if (pattern.type === 'markdown-link') {
const { text, url } = pattern.data
// Render as green link (will show WebPreview at bottom for HTTP/HTTPS)
const displayText = truncateLinkText(text)
// Check if it's a relay URL - if so, link to relay page instead
if (isWebsocketUrl(url)) {
const relayPath = `/relays/${encodeURIComponent(url)}`
parts.push(
<a
key={`relay-${i}`}
href={relayPath}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
navigateToRelay(relayPath)
}}
title={text.length > 200 ? text : undefined}
>
{displayText}
</a>
)
} else {
// Render as green link (will show WebPreview at bottom for HTTP/HTTPS)
parts.push(
<a
key={`link-${i}`}
href={url}
target="_blank"
rel="noreferrer noopener"
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
onClick={(e) => e.stopPropagation()}
title={text.length > 200 ? text : undefined}
>
{displayText}
</a>
)
}
} else if (pattern.type === 'relay-url') {
const { url } = pattern.data
const relayPath = `/relays/${encodeURIComponent(url)}`
const displayText = truncateLinkText(url)
parts.push(
<a
key={`link-${i}`}
href={url}
target="_blank"
rel="noreferrer noopener"
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
onClick={(e) => e.stopPropagation()}
key={`relay-${i}`}
href={relayPath}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
navigateToRelay(relayPath)
}}
title={url.length > 200 ? url : undefined}
>
{text}
{displayText}
</a>
)
} else if (pattern.type === 'nostr') {
@ -229,15 +307,17 @@ function parseMarkdownContent( @@ -229,15 +307,17 @@ function parseMarkdownContent(
}
} else if (pattern.type === 'hashtag') {
const tag = pattern.data
const tagLower = tag.toLowerCase()
hashtagsInContent.add(tagLower) // Track hashtags rendered inline
parts.push(
<a
key={`hashtag-${i}`}
href={`/notes?t=${tag.toLowerCase()}`}
href={`/notes?t=${tagLower}`}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
navigateToHashtag(`/notes?t=${tag.toLowerCase()}`)
navigateToHashtag(`/notes?t=${tagLower}`)
}}
>
#{tag}
@ -272,10 +352,10 @@ function parseMarkdownContent( @@ -272,10 +352,10 @@ function parseMarkdownContent(
// If no patterns, just return the content as text
if (parts.length === 0) {
return [<span key="text-only">{content}</span>]
return { nodes: [<span key="text-only">{content}</span>], hashtagsInContent }
}
return parts
return { nodes: parts, hashtagsInContent }
}
export default function MarkdownArticle({
@ -289,6 +369,7 @@ export default function MarkdownArticle({ @@ -289,6 +369,7 @@ export default function MarkdownArticle({
}) {
const { push } = useSecondaryPage()
const { navigateToHashtag } = useSmartHashtagNavigation()
const { navigateToRelay } = useSmartRelayNavigation()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
// Extract all media from event
@ -459,20 +540,35 @@ export default function MarkdownArticle({ @@ -459,20 +540,35 @@ export default function MarkdownArticle({
})
}, [tagMedia, mediaUrlsInContent, metadata.image, hideMetadata])
// Filter tag links to only show what's not in content (to avoid duplicate WebPreview cards)
const leftoverTagLinks = useMemo(() => {
const contentLinksSet = new Set(contentLinks.map(link => cleanUrl(link)).filter(Boolean))
return tagLinks.filter(link => {
const cleaned = cleanUrl(link)
return cleaned && !contentLinksSet.has(cleaned)
})
}, [tagLinks, contentLinks])
// Preprocess content to convert URLs to markdown syntax
const preprocessedContent = useMemo(() => {
return preprocessMarkdownMediaLinks(event.content)
}, [event.content])
// Parse markdown content with post-processing for nostr: links and hashtags
const parsedContent = useMemo(() => {
const { nodes: parsedContent, hashtagsInContent } = useMemo(() => {
return parseMarkdownContent(preprocessedContent, {
eventPubkey: event.pubkey,
imageIndexMap,
openLightbox,
navigateToHashtag
navigateToHashtag,
navigateToRelay
})
}, [preprocessedContent, event.pubkey, imageIndexMap, openLightbox, navigateToHashtag])
}, [preprocessedContent, event.pubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay])
// 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])
return (
<>
@ -563,10 +659,10 @@ export default function MarkdownArticle({ @@ -563,10 +659,10 @@ export default function MarkdownArticle({
{parsedContent}
</div>
{/* Hashtags from metadata */}
{metadata.tags.length > 0 && (
{/* Hashtags from metadata (only if not already in content) */}
{leftoverMetadataTags.length > 0 && (
<div className="flex gap-2 flex-wrap pb-2 mt-4">
{metadata.tags.map((tag) => (
{leftoverMetadataTags.map((tag) => (
<div
key={tag}
title={tag}
@ -582,21 +678,11 @@ export default function MarkdownArticle({ @@ -582,21 +678,11 @@ export default function MarkdownArticle({
</div>
)}
{/* WebPreview cards for links from content */}
{contentLinks.length > 0 && (
<div className="space-y-3 mt-6 pt-4 border-t">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Links</h3>
{contentLinks.map((url, index) => (
<WebPreview key={`content-${index}-${url}`} url={url} className="w-full" />
))}
</div>
)}
{/* WebPreview cards for links from tags */}
{tagLinks.length > 0 && (
<div className="space-y-3 mt-6 pt-4 border-t">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Related Links</h3>
{tagLinks.map((url, index) => (
{/* WebPreview cards for links from tags (only if not already in content) */}
{/* Note: Links in content are already rendered as green hyperlinks above, so we don't show WebPreview for them */}
{leftoverTagLinks.length > 0 && (
<div className="space-y-3 mt-6">
{leftoverTagLinks.map((url, index) => (
<WebPreview key={`tag-${index}-${url}`} url={url} className="w-full" />
))}
</div>

Loading…
Cancel
Save