Browse Source

bug-fix markdown rendering

imwald
Silberengel 4 months ago
parent
commit
a537e3646c
  1. 57
      src/components/Content/index.tsx
  2. 10
      src/components/ExternalLink/index.tsx
  3. 685
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  4. 2
      src/components/YoutubeEmbeddedPlayer/index.tsx
  5. 11
      src/lib/content-parser.ts

57
src/components/Content/index.tsx

@ -30,6 +30,16 @@ import MediaPlayer from '../MediaPlayer' @@ -30,6 +30,16 @@ import MediaPlayer from '../MediaPlayer'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
import WebPreview from '../WebPreview'
import { toNote } from '@/lib/link'
import { YOUTUBE_URL_REGEX } from '@/constants'
// Helper function to check if a URL is a YouTube URL
function isYouTubeUrl(url: string): boolean {
if (!url) return false
// Create a new regex instance without global flag for testing
const flags = YOUTUBE_URL_REGEX.flags.replace('g', '')
const regex = new RegExp(YOUTUBE_URL_REGEX.source, flags)
return regex.test(url)
}
const REDIRECT_REGEX = /Read (naddr1[a-z0-9]+) instead\./i
@ -93,6 +103,7 @@ export default function Content({ @@ -93,6 +103,7 @@ export default function Content({
}, [_content, event])
// Extract HTTP/HTTPS links from content nodes (in order of appearance) for WebPreview cards at bottom
// Exclude YouTube URLs, images, and media (they're rendered separately)
const contentLinks = useMemo(() => {
if (!nodes) return []
const links: string[] = []
@ -101,7 +112,7 @@ export default function Content({ @@ -101,7 +112,7 @@ export default function Content({
nodes.forEach((node) => {
if (node.type === 'url') {
const url = node.data
if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url)) {
if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url) && !isYouTubeUrl(url)) {
const cleaned = cleanUrl(url)
if (cleaned && !seenUrls.has(cleaned)) {
links.push(cleaned)
@ -114,7 +125,33 @@ export default function Content({ @@ -114,7 +125,33 @@ export default function Content({
return links
}, [nodes])
// Extract HTTP/HTTPS links from r tags (excluding those already in content)
// Extract YouTube URLs from r tags to render as players
const youtubeUrlsFromTags = useMemo(() => {
if (!event) return []
const urls: string[] = []
const seenUrls = new Set<string>()
// Check if YouTube URL is already in content
const hasYouTubeInContent = nodes?.some(node => node.type === 'youtube') || false
event.tags
.filter(tag => tag[0] === 'r' && tag[1])
.forEach(tag => {
const url = tag[1]
if (isYouTubeUrl(url)) {
const cleaned = cleanUrl(url)
// Only include if not already in content and not already seen
if (cleaned && !hasYouTubeInContent && !seenUrls.has(cleaned)) {
urls.push(cleaned)
seenUrls.add(cleaned)
}
}
})
return urls
}, [event, nodes])
// Extract HTTP/HTTPS links from r tags (excluding those already in content, YouTube URLs, images, and media)
const tagLinks = useMemo(() => {
if (!event) return []
const links: string[] = []
@ -127,7 +164,7 @@ export default function Content({ @@ -127,7 +164,7 @@ export default function Content({
.filter(tag => tag[0] === 'r' && tag[1])
.forEach(tag => {
const url = tag[1]
if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url)) {
if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url) && !isYouTubeUrl(url)) {
const cleaned = cleanUrl(url)
// Only include if not already in content links and not already seen in tags
if (cleaned && !contentLinkUrls.has(cleaned) && !seenUrls.has(cleaned)) {
@ -310,10 +347,20 @@ export default function Content({ @@ -310,10 +347,20 @@ export default function Content({
/>
))}
{/* Render YouTube URLs from r tags that don't appear in content */}
{youtubeUrlsFromTags.map((url) => (
<YoutubeEmbeddedPlayer
key={`tag-youtube-${url}`}
url={url}
className="mt-2"
mustLoad={mustLoadMedia}
/>
))}
{nodes && nodes.length > 0 && nodes.map((node, index) => {
if (node.type === 'text') {
// Skip empty text nodes
if (!node.data || node.data.trim() === '') {
// Skip only completely empty text nodes, but preserve whitespace (important for spacing)
if (!node.data || node.data.length === 0) {
return null
}
return renderRedirectText(node.data, index)

10
src/components/ExternalLink/index.tsx

@ -1,15 +1,17 @@ @@ -1,15 +1,17 @@
import { cn } from '@/lib/utils'
import { cleanUrl } from '@/lib/url'
export default function ExternalLink({ url, className }: { url: string; className?: string }) {
const cleanedUrl = cleanUrl(url)
return (
<a
className={cn('text-primary hover:underline', className)}
href={url}
className={cn('text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline', className)}
href={cleanedUrl}
target="_blank"
onClick={(e) => e.stopPropagation()}
rel="noreferrer"
rel="noreferrer noopener"
>
{url}
{cleanedUrl}
</a>
)
}

685
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -64,180 +64,17 @@ function parseMarkdownContent( @@ -64,180 +64,17 @@ function parseMarkdownContent(
const footnotes = new Map<string, string>()
let lastIndex = 0
// 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)
const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g
const imageMatches = Array.from(content.matchAll(markdownImageRegex))
imageMatches.forEach(match => {
if (match.index !== undefined) {
patterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'markdown-image',
data: { alt: match[1], url: match[2] }
})
}
})
// Markdown links: [text](url) - but not images
const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g
const linkMatches = Array.from(content.matchAll(markdownLinkRegex))
linkMatches.forEach(match => {
if (match.index !== undefined) {
// Skip if this is already an image
const isImage = content.substring(Math.max(0, match.index - 1), match.index) === '!'
if (!isImage) {
patterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'markdown-link',
data: { text: match[1], url: match[2] }
})
}
}
})
// YouTube URLs - not in markdown links
const youtubeUrlMatches = Array.from(content.matchAll(YOUTUBE_URL_REGEX))
youtubeUrlMatches.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
)
// Only process if not in markdown link
if (!isInMarkdown && isYouTubeUrl(url)) {
patterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'youtube-url',
data: { url }
})
}
}
})
// 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 or YouTube URL
const isInMarkdown = patterns.some(p =>
(p.type === 'markdown-link' || p.type === 'markdown-image' || p.type === 'youtube-url') &&
match.index! >= p.index &&
match.index! < p.end
)
// 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, relay URLs, or YouTube 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, relay URL, or YouTube URL
const isInOther = patterns.some(p =>
(p.type === 'markdown-link' || p.type === 'markdown-image' || p.type === 'relay-url' || p.type === 'youtube-url') &&
match.index! >= p.index &&
match.index! < p.end
)
if (!isInOther) {
patterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'nostr',
data: match[1]
})
}
}
})
// 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 => {
if (match.index !== undefined) {
// Only add if not already covered by another pattern
const isInOther = patterns.some(p =>
match.index! >= p.index &&
match.index! < p.end
)
if (!isInOther) {
patterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'hashtag',
data: match[1]
})
}
}
})
// Wikilinks ([[link]] or [[link|display]])
const wikilinkRegex = /\[\[([^\]]+)\]\]/g
const wikilinkMatches = Array.from(content.matchAll(wikilinkRegex))
wikilinkMatches.forEach(match => {
if (match.index !== undefined) {
// Only add if not already covered by another pattern
const isInOther = patterns.some(p =>
match.index! >= p.index &&
match.index! < p.end
)
if (!isInOther) {
patterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'wikilink',
data: match[1]
})
}
}
})
// Footnote references ([^1], [^note], etc.) - but not definitions
const footnoteRefRegex = /\[\^([^\]]+)\]/g
const footnoteRefMatches = Array.from(content.matchAll(footnoteRefRegex))
footnoteRefMatches.forEach(match => {
if (match.index !== undefined) {
// Skip if this is a footnote definition (has : after the closing bracket)
const afterMatch = content.substring(match.index + match[0].length, match.index + match[0].length + 2)
if (afterMatch.startsWith(']:')) {
return // This is a definition, not a reference
}
// Only add if not already covered by another pattern
const isInOther = patterns.some(p =>
match.index! >= p.index &&
match.index! < p.end
)
if (!isInOther) {
patterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'footnote-ref',
data: match[1] // footnote ID
})
}
}
})
// Helper function to check if an index range falls within any block-level pattern
const isWithinBlockPattern = (start: number, end: number, blockPatterns: Array<{ index: number; end: number }>): boolean => {
return blockPatterns.some(blockPattern =>
(start >= blockPattern.index && start < blockPattern.end) ||
(end > blockPattern.index && end <= blockPattern.end) ||
(start <= blockPattern.index && end >= blockPattern.end)
)
}
// Block-level patterns: headers, lists, horizontal rules, tables, footnotes - must be at start of line
// Process line by line to detect block-level elements
// STEP 1: First detect all block-level patterns (headers, lists, blockquotes, tables, etc.)
// Block-level patterns must be detected first so we can exclude inline patterns within them
const lines = content.split('\n')
let currentIndex = 0
const blockPatterns: Array<{ index: number; end: number; type: string; data: any }> = []
@ -383,6 +220,60 @@ function parseMarkdownContent( @@ -383,6 +220,60 @@ function parseMarkdownContent(
})
}
}
// Blockquotes (> text or >)
else if (line.match(/^>\s*/)) {
// Collect consecutive blockquote lines
const blockquoteLines: string[] = []
const blockquoteStartIndex = lineStartIndex
let blockquoteLineIdx = lineIdx
let tempIndex = lineStartIndex
while (blockquoteLineIdx < lines.length) {
const blockquoteLine = lines[blockquoteLineIdx]
if (blockquoteLine.match(/^>\s*/)) {
// Strip the > prefix and optional space
const content = blockquoteLine.replace(/^>\s?/, '')
blockquoteLines.push(content)
blockquoteLineIdx++
tempIndex += blockquoteLine.length + 1 // +1 for newline
} else if (blockquoteLine.trim() === '') {
// Empty line without > - this ALWAYS ends the blockquote
// Even if the next line is another blockquote, we want separate blockquotes
break
} else {
// Non-empty line that doesn't start with > - ends the blockquote
break
}
}
if (blockquoteLines.length > 0) {
// Filter out trailing empty lines (but keep internal empty lines for spacing)
while (blockquoteLines.length > 0 && blockquoteLines[blockquoteLines.length - 1].trim() === '') {
blockquoteLines.pop()
blockquoteLineIdx--
// Recalculate tempIndex by subtracting the last line's length
if (blockquoteLineIdx >= lineIdx) {
tempIndex -= (lines[blockquoteLineIdx].length + 1)
}
}
if (blockquoteLines.length > 0) {
// Calculate end index: tempIndex - 1 (subtract 1 because we don't want the trailing newline)
const blockquoteEndIndex = tempIndex - 1
blockPatterns.push({
index: blockquoteStartIndex,
end: blockquoteEndIndex,
type: 'blockquote',
data: { lines: blockquoteLines, lineNum: lineIdx }
})
// Update currentIndex and skip processed lines (similar to table handling)
currentIndex = blockquoteEndIndex + 1
lineIdx = blockquoteLineIdx
continue
}
}
}
// Footnote definition (already extracted, but mark it so we don't render it in content)
else if (line.match(/^\[\^([^\]]+)\]:\s+.+$/)) {
blockPatterns.push({
@ -397,29 +288,238 @@ function parseMarkdownContent( @@ -397,29 +288,238 @@ function parseMarkdownContent(
lineIdx++
}
// Add block patterns to main patterns array
// STEP 2: Now detect inline patterns (images, links, URLs, hashtags, etc.)
// But exclude any that fall within block-level patterns
const patterns: Array<{ index: number; end: number; type: string; data: any }> = []
// Add block patterns to main patterns array first
blockPatterns.forEach(pattern => {
patterns.push(pattern)
})
// Markdown links: [text](url) or [![](image)](url) - detect FIRST to handle nested images
// We detect links first because links can contain images, and we want the link pattern to take precedence
const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g
const linkMatches = Array.from(content.matchAll(markdownLinkRegex))
const linkPatterns: Array<{ index: number; end: number; type: string; data: any }> = []
linkMatches.forEach(match => {
if (match.index !== undefined) {
const start = match.index
const end = match.index + match[0].length
// Skip if within a block-level pattern
if (!isWithinBlockPattern(start, end, blockPatterns)) {
// Check if the link text contains an image markdown syntax
const linkText = match[1]
const hasImage = /^!\[/.test(linkText.trim())
linkPatterns.push({
index: start,
end: end,
type: hasImage ? 'markdown-image-link' : 'markdown-link',
data: { text: match[1], url: match[2] }
})
}
}
})
// Markdown images: ![](url) or ![alt](url) - but not if they're inside a markdown link
const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g
const imageMatches = Array.from(content.matchAll(markdownImageRegex))
imageMatches.forEach(match => {
if (match.index !== undefined) {
const start = match.index
const end = match.index + match[0].length
// Skip if within a block-level pattern
if (isWithinBlockPattern(start, end, blockPatterns)) {
return
}
// Skip if this image is inside a markdown link
const isInsideLink = linkPatterns.some(linkPattern =>
start >= linkPattern.index && end <= linkPattern.end
)
if (!isInsideLink) {
patterns.push({
index: start,
end: end,
type: 'markdown-image',
data: { alt: match[1], url: match[2] }
})
}
}
})
// Add markdown links to patterns
linkPatterns.forEach(linkPattern => {
patterns.push(linkPattern)
})
// YouTube URLs - not in markdown links
const youtubeUrlMatches = Array.from(content.matchAll(YOUTUBE_URL_REGEX))
youtubeUrlMatches.forEach(match => {
if (match.index !== undefined) {
const url = match[0]
const start = match.index
const end = match.index + match[0].length
// Only add if not already covered by a markdown link/image-link/image and not in block pattern
const isInMarkdown = patterns.some(p =>
(p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image') &&
start >= p.index &&
start < p.end
)
if (!isInMarkdown && !isWithinBlockPattern(start, end, blockPatterns) && isYouTubeUrl(url)) {
patterns.push({
index: start,
end: end,
type: 'youtube-url',
data: { url }
})
}
}
})
// 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]
const start = match.index
const end = match.index + match[0].length
// Only add if not already covered by a markdown link/image-link/image or YouTube URL and not in block pattern
const isInMarkdown = patterns.some(p =>
(p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || p.type === 'youtube-url') &&
start >= p.index &&
start < p.end
)
if (!isInMarkdown && !isWithinBlockPattern(start, end, blockPatterns) && isWebsocketUrl(url)) {
patterns.push({
index: start,
end: end,
type: 'relay-url',
data: { url }
})
}
}
})
// Nostr addresses (nostr:npub1..., nostr:note1..., etc.)
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) {
const start = match.index
const end = match.index + match[0].length
// Only add if not already covered by other patterns and not in block pattern
const isInOther = patterns.some(p =>
(p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || p.type === 'relay-url' || p.type === 'youtube-url') &&
start >= p.index &&
start < p.end
)
if (!isInOther && !isWithinBlockPattern(start, end, blockPatterns)) {
patterns.push({
index: start,
end: end,
type: 'nostr',
data: match[1]
})
}
}
})
// 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 => {
if (match.index !== undefined) {
const start = match.index
const end = match.index + match[0].length
// Only add if not already covered by another pattern and not in block pattern
// Note: hashtags inside block patterns will be handled by parseInlineMarkdown
const isInOther = patterns.some(p =>
start >= p.index &&
start < p.end
)
if (!isInOther && !isWithinBlockPattern(start, end, blockPatterns)) {
patterns.push({
index: start,
end: end,
type: 'hashtag',
data: match[1]
})
}
}
})
// Wikilinks ([[link]] or [[link|display]]) - but not inside markdown links
const wikilinkRegex = /\[\[([^\]]+)\]\]/g
const wikilinkMatches = Array.from(content.matchAll(wikilinkRegex))
wikilinkMatches.forEach(match => {
if (match.index !== undefined) {
const start = match.index
const end = match.index + match[0].length
// Only add if not already covered by another pattern and not in block pattern
const isInOther = patterns.some(p =>
start >= p.index &&
start < p.end
)
if (!isInOther && !isWithinBlockPattern(start, end, blockPatterns)) {
patterns.push({
index: start,
end: end,
type: 'wikilink',
data: match[1]
})
}
}
})
// Footnote references ([^1], [^note], etc.) - but not definitions
const footnoteRefRegex = /\[\^([^\]]+)\]/g
const footnoteRefMatches = Array.from(content.matchAll(footnoteRefRegex))
footnoteRefMatches.forEach(match => {
if (match.index !== undefined) {
// Skip if this is a footnote definition (has : after the closing bracket)
const afterMatch = content.substring(match.index + match[0].length, match.index + match[0].length + 2)
if (afterMatch.startsWith(']:')) {
return // This is a definition, not a reference
}
const start = match.index
const end = match.index + match[0].length
// Only add if not already covered by another pattern and not in block pattern
const isInOther = patterns.some(p =>
start >= p.index &&
start < p.end
)
if (!isInOther && !isWithinBlockPattern(start, end, blockPatterns)) {
patterns.push({
index: start,
end: end,
type: 'footnote-ref',
data: match[1] // footnote ID
})
}
}
})
// Sort patterns by index
patterns.sort((a, b) => a.index - b.index)
// Remove overlapping patterns (keep the first one)
// Block-level patterns (headers, lists, horizontal rules, tables) take priority
// Block-level patterns (headers, lists, horizontal rules, tables, blockquotes) take priority
const filteredPatterns: typeof patterns = []
const blockLevelTypes = ['header', 'horizontal-rule', 'bullet-list-item', 'numbered-list-item', 'table', 'footnote-definition']
const blockLevelPatterns = patterns.filter(p => blockLevelTypes.includes(p.type))
const blockLevelTypes = ['header', 'horizontal-rule', 'bullet-list-item', 'numbered-list-item', 'table', 'blockquote', 'footnote-definition']
const blockLevelPatternsFromAll = patterns.filter(p => blockLevelTypes.includes(p.type))
const otherPatterns = patterns.filter(p => !blockLevelTypes.includes(p.type))
// First add all block-level patterns
blockLevelPatterns.forEach(pattern => {
blockLevelPatternsFromAll.forEach(pattern => {
filteredPatterns.push(pattern)
})
// Then add other patterns that don't overlap with block-level patterns
otherPatterns.forEach(pattern => {
const overlapsWithBlock = blockLevelPatterns.some(blockPattern =>
const overlapsWithBlock = blockLevelPatternsFromAll.some(blockPattern =>
(pattern.index >= blockPattern.index && pattern.index < blockPattern.end) ||
(pattern.end > blockPattern.index && pattern.end <= blockPattern.end) ||
(pattern.index <= blockPattern.index && pattern.end >= blockPattern.end)
@ -440,16 +540,45 @@ function parseMarkdownContent( @@ -440,16 +540,45 @@ function parseMarkdownContent(
// Re-sort by index
filteredPatterns.sort((a, b) => a.index - b.index)
// Helper function to check if a pattern type is inline
const isInlinePatternType = (patternType: string, patternData?: any): boolean => {
if (patternType === 'hashtag' || patternType === 'wikilink' || patternType === 'footnote-ref' || patternType === 'relay-url') {
return true
}
if (patternType === 'markdown-link' && patternData) {
const { url } = patternData
// Markdown links are inline only if they're not YouTube or WebPreview
return !isYouTubeUrl(url) && !isWebsocketUrl(url)
}
if (patternType === 'nostr' && patternData) {
const bech32Id = patternData
// Nostr addresses are inline only if they're profile types (not events)
return bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')
}
return false
}
// Track the last rendered pattern type to determine if whitespace should be preserved
let lastRenderedPatternType: string | null = null
let lastRenderedPatternData: any = null
// Build React nodes from patterns
filteredPatterns.forEach((pattern, patternIdx) => {
// Add text before pattern
if (pattern.index > lastIndex) {
const text = content.slice(lastIndex, pattern.index)
// Skip whitespace-only text to avoid empty spans between block elements
if (text && text.trim()) {
// Check if this pattern and the last rendered pattern are both inline patterns
// Inline patterns should preserve whitespace between them (like spaces between hashtags)
const currentIsInline = isInlinePatternType(pattern.type, pattern.data)
const prevIsInline = lastRenderedPatternType !== null && isInlinePatternType(lastRenderedPatternType, lastRenderedPatternData)
// Preserve whitespace between inline patterns, but skip it between block elements
const shouldPreserveWhitespace = currentIsInline && prevIsInline
if (text && (shouldPreserveWhitespace || text.trim())) {
// Process text for inline formatting (bold, italic, etc.)
// But skip if this text is part of a table (tables are handled as block patterns)
const isInTable = blockLevelPatterns.some(p =>
const isInTable = blockLevelPatternsFromAll.some(p =>
p.type === 'table' &&
lastIndex >= p.index &&
lastIndex < p.end
@ -497,11 +626,79 @@ function parseMarkdownContent( @@ -497,11 +626,79 @@ function parseMarkdownContent(
</div>
)
}
} else if (pattern.type === 'markdown-image-link') {
// Link containing an image: [![](image)](url)
const { text, url } = pattern.data
// Extract image URL from the link text (which contains ![](imageUrl))
const imageMatch = text.match(/!\[([^\]]*)\]\(([^)]+)\)/)
if (imageMatch) {
const imageUrl = imageMatch[2]
const cleaned = cleanUrl(imageUrl)
if (isImage(cleaned)) {
// Render as a block-level clickable image that links to the URL
// Clicking the image should navigate to the URL (standard markdown behavior)
parts.push(
<div key={`image-link-${patternIdx}`} className="my-2 block">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="block"
onClick={(e) => {
e.stopPropagation()
// Allow normal link navigation
}}
>
<Image
image={{ url: imageUrl, pubkey: eventPubkey }}
className="max-w-[400px] rounded-lg cursor-pointer"
classNames={{
wrapper: 'rounded-lg block',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
onClick={(e) => {
// Don't prevent default - let the link handle navigation
e.stopPropagation()
}}
/>
</a>
</div>
)
} else {
// Not an image, render as regular link
parts.push(
<a
key={`link-${patternIdx}`}
href={url}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
target="_blank"
rel="noopener noreferrer"
>
{text}
</a>
)
}
} else {
// Fallback: render as regular link
parts.push(
<a
key={`link-${patternIdx}`}
href={url}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
target="_blank"
rel="noopener noreferrer"
>
{text}
</a>
)
}
} else if (pattern.type === 'markdown-link') {
const { text, url } = pattern.data
const displayText = truncateLinkText(text)
// Check if it's a relay URL - if so, link to relay page instead
// Markdown links should always be rendered as inline links, not block-level components
// This ensures they don't break up the content flow when used in paragraphs
if (isWebsocketUrl(url)) {
// Relay URLs link to relay page
const relayPath = `/relays/${encodeURIComponent(url)}`
parts.push(
<a
@ -515,26 +712,21 @@ function parseMarkdownContent( @@ -515,26 +712,21 @@ function parseMarkdownContent(
}}
title={text.length > 200 ? text : undefined}
>
{displayText}
{text}
</a>
)
} else if (isYouTubeUrl(url)) {
// Render YouTube URL as embedded player
parts.push(
<div key={`youtube-${patternIdx}`} className="my-2">
<YoutubeEmbeddedPlayer
url={url}
className="max-w-[400px]"
mustLoad={false}
/>
</div>
)
} else {
// Render as WebPreview component (shows opengraph data or fallback card)
// Regular markdown links render as simple inline links (green to match theme)
parts.push(
<div key={`link-${patternIdx}`} className="my-2">
<WebPreview url={url} className="w-full" />
</div>
<a
key={`link-${patternIdx}`}
href={url}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
target="_blank"
rel="noopener noreferrer"
>
{text}
</a>
)
}
} else if (pattern.type === 'youtube-url') {
@ -647,6 +839,51 @@ function parseMarkdownContent( @@ -647,6 +839,51 @@ function parseMarkdownContent(
</div>
)
}
} else if (pattern.type === 'blockquote') {
const { lines } = pattern.data
// Group lines into paragraphs (consecutive non-empty lines form a paragraph, empty lines separate paragraphs)
const paragraphs: string[][] = []
let currentParagraph: string[] = []
lines.forEach((line: string) => {
if (line.trim() === '') {
// Empty line - if we have a current paragraph, finish it and start a new one
if (currentParagraph.length > 0) {
paragraphs.push(currentParagraph)
currentParagraph = []
}
} else {
// Non-empty line - add to current paragraph
currentParagraph.push(line)
}
})
// Add the last paragraph if it exists
if (currentParagraph.length > 0) {
paragraphs.push(currentParagraph)
}
// Render paragraphs
const blockquoteContent = paragraphs.map((paragraphLines: string[], paraIdx: number) => {
// Join paragraph lines with spaces (or preserve line breaks if needed)
const paragraphText = paragraphLines.join(' ')
const paragraphContent = parseInlineMarkdown(paragraphText, `blockquote-${patternIdx}-para-${paraIdx}`, footnotes)
return (
<p key={`blockquote-${patternIdx}-para-${paraIdx}`} className="mb-2 last:mb-0">
{paragraphContent}
</p>
)
})
parts.push(
<blockquote
key={`blockquote-${patternIdx}`}
className="border-l-4 border-gray-400 dark:border-gray-500 pl-4 pr-2 py-2 my-4 italic text-gray-700 dark:text-gray-300 bg-gray-50/50 dark:bg-gray-800/30"
>
{blockquoteContent}
</blockquote>
)
} else if (pattern.type === 'footnote-definition') {
// Don't render footnote definitions in the main content - they'll be rendered at the bottom
// Just skip this pattern
@ -659,7 +896,7 @@ function parseMarkdownContent( @@ -659,7 +896,7 @@ function parseMarkdownContent(
<a
href={`#footnote-${footnoteId}`}
id={`footnote-ref-${footnoteId}`}
className="text-blue-600 dark:text-blue-400 hover:underline no-underline"
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline no-underline"
onClick={(e) => {
e.preventDefault()
const footnoteElement = document.getElementById(`footnote-${footnoteId}`)
@ -729,6 +966,12 @@ function parseMarkdownContent( @@ -729,6 +966,12 @@ function parseMarkdownContent(
)
}
// Update tracking for the last rendered pattern (skip footnote-definition as it's not rendered)
if (pattern.type !== 'footnote-definition') {
lastRenderedPatternType = pattern.type
lastRenderedPatternData = pattern.data
}
lastIndex = pattern.end
})
@ -739,7 +982,7 @@ function parseMarkdownContent( @@ -739,7 +982,7 @@ function parseMarkdownContent(
if (text && text.trim()) {
// Process text for inline formatting
// But skip if this text is part of a table
const isInTable = blockLevelPatterns.some(p =>
const isInTable = blockLevelPatternsFromAll.some((p: { type: string; index: number; end: number }) =>
p.type === 'table' &&
lastIndex >= p.index &&
lastIndex < p.end
@ -841,7 +1084,7 @@ function parseMarkdownContent( @@ -841,7 +1084,7 @@ function parseMarkdownContent(
{' '}
<a
href={`#footnote-ref-${id}`}
className="text-blue-600 dark:text-blue-400 hover:underline text-xs"
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs"
onClick={(e) => {
e.preventDefault()
const refElement = document.getElementById(`footnote-ref-${id}`)
@ -1053,6 +1296,28 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<st @@ -1053,6 +1296,28 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<st
// Note: __text__ is bold by default, but if user wants it italic, we can add it
// For now, we'll keep __text__ as bold only, and _text_ as italic
// Markdown links: [text](url) - but not images (process after code/bold/italic to avoid conflicts)
const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g
const markdownLinkMatches = Array.from(text.matchAll(markdownLinkRegex))
markdownLinkMatches.forEach(match => {
if (match.index !== undefined) {
// Skip if already in code, bold, italic, or strikethrough
const isInOther = inlinePatterns.some(p =>
(p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough') &&
match.index! >= p.index &&
match.index! < p.end
)
if (!isInOther) {
inlinePatterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'link',
data: { text: match[1], url: match[2] }
})
}
}
})
// Sort by index
inlinePatterns.sort((a, b) => a.index - b.index)
@ -1089,6 +1354,20 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<st @@ -1089,6 +1354,20 @@ function parseInlineMarkdown(text: string, keyPrefix: string, _footnotes: Map<st
{pattern.data}
</code>
)
} else if (pattern.type === 'link') {
// Render markdown links as inline links (green to match theme)
const { text, url } = pattern.data
parts.push(
<a
key={`${keyPrefix}-link-${i}`}
href={url}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
target="_blank"
rel="noopener noreferrer"
>
{text}
</a>
)
}
lastIndex = pattern.end

2
src/components/YoutubeEmbeddedPlayer/index.tsx

@ -91,7 +91,7 @@ export default function YoutubeEmbeddedPlayer({ @@ -91,7 +91,7 @@ export default function YoutubeEmbeddedPlayer({
if (!mustLoad && !display) {
return (
<div
className="text-primary hover:underline truncate w-fit cursor-pointer"
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline truncate w-fit cursor-pointer"
onClick={(e) => {
e.stopPropagation()
setDisplay(true)

11
src/lib/content-parser.ts

@ -78,6 +78,15 @@ export const EmbeddedUrlParser: TContentParser = (content: string) => { @@ -78,6 +78,15 @@ export const EmbeddedUrlParser: TContentParser = (content: string) => {
const matches = content.matchAll(URL_REGEX)
const result: TEmbeddedNode[] = []
let lastIndex = 0
// Helper function to check if URL is YouTube (use non-global regex to avoid state issues)
const isYouTubeUrl = (url: string): boolean => {
if (!url) return false
const flags = YOUTUBE_URL_REGEX.flags.replace('g', '')
const regex = new RegExp(YOUTUBE_URL_REGEX.source, flags)
return regex.test(url)
}
for (const match of matches) {
const matchStart = match.index!
// Add text before the match
@ -94,7 +103,7 @@ export const EmbeddedUrlParser: TContentParser = (content: string) => { @@ -94,7 +103,7 @@ export const EmbeddedUrlParser: TContentParser = (content: string) => {
type = 'image'
} else if (isMedia(url)) {
type = 'media'
} else if (url.match(YOUTUBE_URL_REGEX)) {
} else if (isYouTubeUrl(url)) {
type = 'youtube'
}

Loading…
Cancel
Save