@@ -3072,9 +3170,65 @@ function parseMarkdownContentMarked(
}
}
+ // Inline nostr event IDs can appear as plain text inside a sentence (not link tokens).
+ // Split paragraph around those IDs so event references render as embedded cards.
+ const rawInlineNostrMatches = Array.from(rawParagraphText.matchAll(new RegExp(NOSTR_URI_INLINE_REGEX.source, NOSTR_URI_INLINE_REGEX.flags)))
+ .filter((m) => m.index !== undefined && isNostrEventBech32((m[1] ?? '').toLowerCase()))
+ if (rawInlineNostrMatches.length > 0) {
+ const nodes: React.ReactNode[] = []
+ let cursor = 0
+ let segmentIdx = 0
+ for (const match of rawInlineNostrMatches) {
+ const start = match.index!
+ const end = start + match[0].length
+ const bech32Id = String(match[1] ?? '')
+ const before = rawParagraphText.slice(cursor, start)
+ if (before.trim().length > 0) {
+ nodes.push(
+
+ {parseInlineMarkdown(before, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag)}
+
+ )
+ }
+ if (bech32Id.startsWith('naddr') && fullCalendarInvite && bech32Id === fullCalendarInvite.naddr) {
+ nodes.push(
+
+
+
+ )
+ } else {
+ nodes.push(
+
+
+
+ )
+ }
+ cursor = end
+ }
+ const after = rawParagraphText.slice(cursor)
+ if (after.trim().length > 0) {
+ nodes.push(
+
+ {parseInlineMarkdown(after, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag)}
+
+ )
+ }
+ if (nodes.length > 0) {
+ return
{nodes}
+ }
+ }
+
if (/^https?:\/\/\S+$/i.test(paragraphText)) {
const cleaned = cleanUrl(paragraphText)
if (cleaned) {
+ if (isVideo(cleaned) || isAudio(cleaned)) {
+ const poster = videoPosterMap?.get(cleaned)
+ return (
+
+
+
+ )
+ }
if (isPseudoNostrHttpsUrl(cleaned)) {
return (
@@ -3100,9 +3254,119 @@ function parseMarkdownContentMarked(
}
}
+ const paragraphTokens = token.tokens ?? marked.Lexer.lexInline(token.text ?? '')
+ const parseNostrHref = (href: string): string | null => {
+ if (!href.toLowerCase().startsWith('nostr:')) return null
+ const raw = href.slice(6).trim()
+ if (!raw) return null
+ const bech32 = raw.split(/[?#]/)[0]?.replace(/\/+$/, '') || ''
+ return bech32 || null
+ }
+
+ // Inline nostr event links (e.g. "… nostr:naddr1…") should render embedded cards.
+ // Split paragraph into inline text segments + block embeds to avoid invalid
trees.
+ if (Array.isArray(paragraphTokens) && paragraphTokens.length > 0) {
+ const hasInlineMediaImageToken = paragraphTokens.some((t) => {
+ if (t?.type !== 'image') return false
+ const cleaned = cleanUrl(String(t.href ?? ''))
+ return !!cleaned && (isVideo(cleaned) || isAudio(cleaned))
+ })
+ if (hasInlineMediaImageToken) {
+ const nodes: React.ReactNode[] = []
+ let inlineSegment: any[] = []
+ const flushInlineSegment = (segmentIdx: number) => {
+ if (inlineSegment.length === 0) return
+ nodes.push(
+
+ {renderInlineTokens(inlineSegment, `${key}-media-inline-segment-${segmentIdx}`)}
+
+ )
+ inlineSegment = []
+ }
+
+ let segmentIdx = 0
+ paragraphTokens.forEach((t: any, idx: number) => {
+ if (t?.type !== 'image') {
+ inlineSegment.push(t)
+ return
+ }
+ const src = String(t.href ?? '')
+ const cleaned = cleanUrl(src)
+ if (!cleaned || (!isVideo(cleaned) && !isAudio(cleaned))) {
+ inlineSegment.push(t)
+ return
+ }
+ flushInlineSegment(segmentIdx++)
+ const poster = videoPosterMap?.get(cleaned)
+ nodes.push(
+
+
+
+ )
+ })
+
+ flushInlineSegment(segmentIdx++)
+ if (nodes.length > 0) {
+ return
{nodes}
+ }
+ }
+
+ const hasInlineNostrEventLink = paragraphTokens.some((t) => {
+ if (t?.type !== 'link') return false
+ const bech32 = parseNostrHref(String(t.href ?? ''))
+ return !!bech32 && isNostrEventBech32(bech32)
+ })
+ if (hasInlineNostrEventLink) {
+ const nodes: React.ReactNode[] = []
+ let inlineSegment: any[] = []
+ const flushInlineSegment = (segmentIdx: number) => {
+ if (inlineSegment.length === 0) return
+ nodes.push(
+
+ {renderInlineTokens(inlineSegment, `${key}-nostr-inline-segment-${segmentIdx}`)}
+
+ )
+ inlineSegment = []
+ }
+
+ let segmentIdx = 0
+ paragraphTokens.forEach((t: any, idx: number) => {
+ if (t?.type !== 'link') {
+ inlineSegment.push(t)
+ return
+ }
+ const href = String(t.href ?? '')
+ const bech32 = parseNostrHref(href)
+ if (!bech32 || !isNostrEventBech32(bech32)) {
+ inlineSegment.push(t)
+ return
+ }
+
+ flushInlineSegment(segmentIdx++)
+ if (bech32.startsWith('naddr') && fullCalendarInvite && bech32 === fullCalendarInvite.naddr) {
+ nodes.push(
+
+
+
+ )
+ } else {
+ nodes.push(
+
+
+
+ )
+ }
+ })
+
+ flushInlineSegment(segmentIdx++)
+ if (nodes.length > 0) {
+ return
{nodes}
+ }
+ }
+ }
+
// If the paragraph is a single markdown image token, render it as block media/image
// instead of wrapping in
(avoids invalid DOM nesting for media players).
- const paragraphTokens = token.tokens ?? marked.Lexer.lexInline(token.text ?? '')
if (Array.isArray(paragraphTokens) && paragraphTokens.length === 1 && paragraphTokens[0]?.type === 'image') {
const imageToken = paragraphTokens[0]
const src = String(imageToken.href ?? '')
@@ -3116,6 +3380,13 @@ function parseMarkdownContentMarked(
)
}
+ if (!isImage(cleaned) || !isSafeMediaUrl(cleaned)) {
+ return (
+
+ {renderInlineTokens(paragraphTokens, `${key}-img-inline-fallback`)}
+
+ )
+ }
const identifier = getImageIdentifier?.(cleaned)
const thumbnail =
imageThumbnailMap?.get(cleaned) ??
@@ -3219,14 +3490,35 @@ function parseMarkdownContentMarked(
}
case 'list': {
const ListTag = token.ordered ? 'ol' : 'ul'
- const listClass = token.ordered ? 'list-decimal list-outside my-2 ml-6' : 'list-disc list-inside my-2 space-y-1'
+ const listClass = token.ordered
+ ? 'list-decimal list-outside my-2 ml-6'
+ : 'list-disc list-outside my-2 ml-6 space-y-1'
+ const renderListItemContent = (item: any, itemKey: string): React.ReactNode => {
+ const itemTokens = item.tokens ?? [{ type: 'text', text: item.text ?? '' }]
+ if (itemTokens.length === 1) {
+ const single = itemTokens[0]
+ if (single.type === 'text') {
+ return renderInlineTokens(
+ single.tokens ?? marked.Lexer.lexInline(single.text ?? ''),
+ `${itemKey}-inline`
+ )
+ }
+ if (single.type === 'paragraph') {
+ return renderInlineTokens(
+ single.tokens ?? marked.Lexer.lexInline(single.text ?? ''),
+ `${itemKey}-inline`
+ )
+ }
+ }
+ return renderBlockTokens(itemTokens, itemKey)
+ }
nodes.push(
React.createElement(
ListTag,
{ key: `${key}-list`, className: listClass },
(token.items ?? []).map((item: any, itemIdx: number) => (
- {renderBlockTokens(item.tokens ?? [{ type: 'text', text: item.text ?? '' }], `${key}-li-${itemIdx}`)}
+ {renderListItemContent(item, `${key}-li-${itemIdx}`)}
))
)
@@ -3285,6 +3577,34 @@ function parseMarkdownContentMarked(
}
const nodes = renderBlockTokens(blockTokens, 'marked-root')
+ if (footnotes.size > 0) {
+ nodes.push(
+
+
Footnotes
+
+ {Array.from(footnotes.entries()).map(([id, text]) => (
+
+ ))}
+
+
+ )
+ }
return { nodes, hashtagsInContent, footnotes, citations }
}
@@ -3374,9 +3694,9 @@ function parseInlineMarkdown(
if (token.type === 'link') {
const href = String(token.href ?? '')
- const children = renderTokens(
- token.tokens ?? [{ type: 'text', text: token.text ?? href }],
- `${tokenKey}-link`
+ const children = stripNestedAnchorsFromNodes(
+ renderTokens(token.tokens ?? [{ type: 'text', text: token.text ?? href }], `${tokenKey}-link`),
+ `${tokenKey}-link-sanitized`
)
if (href.startsWith('payto://')) {
out.push(
@@ -3460,165 +3780,16 @@ function parseInlineMarkdownLegacy(
let lastIndex = 0
const inlinePatterns: Array<{ index: number; end: number; type: string; data: any }> = []
- // Inline code: ``code`` (double backtick) or `code` (single backtick) - process first to avoid conflicts
- // Double backticks first
- const doubleCodeRegex = /``([^`\n]+?)``/g
- const doubleCodeMatches = Array.from(text.matchAll(doubleCodeRegex))
- doubleCodeMatches.forEach(match => {
- if (match.index !== undefined) {
- inlinePatterns.push({
- index: match.index,
- end: match.index + match[0].length,
- type: 'code',
- data: match[1]
- })
- }
- })
-
- // Single backtick (but not if already in double backtick)
- const singleCodeRegex = /`([^`\n]+?)`/g
- const singleCodeMatches = Array.from(text.matchAll(singleCodeRegex))
- singleCodeMatches.forEach(match => {
- if (match.index !== undefined) {
- const isInDoubleCode = inlinePatterns.some(p =>
- p.type === 'code' &&
- match.index! >= p.index &&
- match.index! < p.end
- )
- if (!isInDoubleCode) {
- inlinePatterns.push({
- index: match.index,
- end: match.index + match[0].length,
- type: 'code',
- data: match[1]
- })
- }
- }
- })
-
- // Bold: **text** (double asterisk) or __text__ (double underscore) - process first
- // Also handle *text* (single asterisk) as bold
- // Allow single newlines within bold spans (but not double newlines which indicate paragraph breaks)
- const doubleBoldAsteriskRegex = /\*\*((?:[^\n]|\n(?!\n))+\n?)\*\*/g
- const doubleBoldAsteriskMatches = Array.from(text.matchAll(doubleBoldAsteriskRegex))
- doubleBoldAsteriskMatches.forEach(match => {
- if (match.index !== undefined) {
- // Skip if already in code
- const isInCode = inlinePatterns.some(p =>
- p.type === 'code' &&
- match.index! >= p.index &&
- match.index! < p.end
- )
- if (!isInCode) {
- inlinePatterns.push({
- index: match.index,
- end: match.index + match[0].length,
- type: 'bold',
- data: match[1]
- })
- }
- }
- })
-
- // Double underscore bold (but check if it's already italic)
- // Allow single newlines within bold spans (but not double newlines which indicate paragraph breaks)
- const doubleBoldUnderscoreRegex = /__((?:[^\n]|\n(?!\n))+\n?)__/g
- const doubleBoldUnderscoreMatches = Array.from(text.matchAll(doubleBoldUnderscoreRegex))
- doubleBoldUnderscoreMatches.forEach(match => {
- if (match.index !== undefined) {
- // Skip if already in code or bold
- const isInOther = inlinePatterns.some(p =>
- (p.type === 'code' || 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]
- })
- }
- }
- })
-
- // Single asterisk bold: *text* (not part of **bold**)
- const singleBoldAsteriskRegex = /(? {
- if (match.index !== undefined) {
- // Skip if already in code, double bold, or strikethrough
- const isInOther = inlinePatterns.some(p =>
- (p.type === 'code' || p.type === 'bold' || 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: 'bold',
- data: match[1]
- })
- }
- }
- })
-
- // Strikethrough: ~~text~~ (double tilde) or ~text~ (single tilde)
- // Double tildes first
- const doubleStrikethroughRegex = /~~(.+?)~~/g
- const doubleStrikethroughMatches = Array.from(text.matchAll(doubleStrikethroughRegex))
- doubleStrikethroughMatches.forEach(match => {
- if (match.index !== undefined) {
- // Skip if already in code or bold
- const isInOther = inlinePatterns.some(p =>
- (p.type === 'code' || 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: 'strikethrough',
- data: match[1]
- })
- }
- }
- })
-
- // Single tilde strikethrough
- const singleStrikethroughRegex = /(? {
- if (match.index !== undefined) {
- // Skip if already in code, bold, or double strikethrough
- const isInOther = inlinePatterns.some(p =>
- (p.type === 'code' || p.type === 'bold' || 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: 'strikethrough',
- data: match[1]
- })
- }
- }
- })
-
- // Italic: _text_ (single underscore) or __text__ (double underscore, but bold takes priority)
- // Single underscore italic (not part of __bold__)
- const singleItalicUnderscoreRegex = /(? {
+ // Legacy helper is intentionally narrowed to non-standard enrichments.
+ // Standard markdown emphasis/code is handled by marked in parseInlineMarkdown().
+ // Markdown links are still recognized here for plain-text/fallback inline fragments.
+ const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g
+ const markdownLinkMatches = Array.from(text.matchAll(markdownLinkRegex))
+ markdownLinkMatches.forEach(match => {
if (match.index !== undefined) {
- // Skip if already in code, bold, or strikethrough
+ // Skip if already in code, bold, italic, or strikethrough
const isInOther = inlinePatterns.some(p =>
- (p.type === 'code' || p.type === 'bold' || p.type === 'strikethrough') &&
+ (p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough') &&
match.index! >= p.index &&
match.index! < p.end
)
@@ -3626,34 +3797,32 @@ function parseInlineMarkdownLegacy(
inlinePatterns.push({
index: match.index,
end: match.index + match[0].length,
- type: 'italic',
- data: match[1]
+ type: 'link',
+ data: { text: match[1], url: match[2] }
})
}
}
})
-
- // Double underscore italic (only if not already bold)
- // 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 => {
+
+ // Footnote references: [^id]
+ // Only render as clickable refs when the referenced definition exists.
+ const footnoteRefRegex = /\[\^([^\]]+)\]/g
+ const footnoteRefMatches = Array.from(text.matchAll(footnoteRefRegex))
+ footnoteRefMatches.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 &&
+ const footnoteId = match[1]
+ if (!_footnotes.has(footnoteId)) return
+ const isInOther = inlinePatterns.some(p =>
+ (p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') &&
+ 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] }
+ type: 'footnote-ref',
+ data: footnoteId
})
}
}
@@ -3664,9 +3833,9 @@ function parseInlineMarkdownLegacy(
const hashtagMatches = Array.from(text.matchAll(hashtagRegex))
hashtagMatches.forEach(match => {
if (match.index !== undefined) {
- // Skip if already in code, bold, italic, strikethrough, link, relay-url, nostr, or payto
+ // Skip if already in another inline custom pattern
const isInOther = inlinePatterns.some(p =>
- (p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough' || p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') &&
+ (p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') &&
match.index! >= p.index &&
match.index! < p.end
)
@@ -3688,9 +3857,9 @@ function parseInlineMarkdownLegacy(
const url = match[0]
// Only process if it's actually a websocket URL
if (isWebsocketUrl(url)) {
- // Skip if already in code, bold, italic, strikethrough, link, hashtag, or nostr
+ // Skip if already in another inline custom pattern
const isInOther = inlinePatterns.some(p =>
- (p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough' || p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr') &&
+ (p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') &&
match.index! >= p.index &&
match.index! < p.end
)
@@ -3717,9 +3886,9 @@ function parseInlineMarkdownLegacy(
const isProfileType = bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')
if (isProfileType) {
- // Skip if already in code, bold, italic, strikethrough, link, hashtag, or relay-url
+ // Skip if already in another inline custom pattern
const isInOther = inlinePatterns.some(p =>
- (p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough' || p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') &&
+ (p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') &&
match.index! >= p.index &&
match.index! < p.end
)
@@ -3743,7 +3912,7 @@ function parseInlineMarkdownLegacy(
const parsed = parsePaytoUri(fullMatch)
if (!parsed) return
const isInOther = inlinePatterns.some(p =>
- (p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough' || p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') &&
+ (p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto') &&
match.index! >= p.index &&
match.index! < p.end
)
@@ -3763,7 +3932,7 @@ function parseInlineMarkdownLegacy(
emojiMatches.forEach(match => {
if (match.index !== undefined) {
const isInOther = inlinePatterns.some(p =>
- (p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough' || p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto' || p.type === 'emoji') &&
+ (p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto' || p.type === 'emoji') &&
match.index! >= p.index &&
match.index! < p.end
)
@@ -3809,22 +3978,8 @@ function parseInlineMarkdownLegacy(
}
}
- // Render pattern
- if (pattern.type === 'bold') {
- parts.push(
{pattern.data})
- } else if (pattern.type === 'italic') {
- parts.push(
{pattern.data})
- } else if (pattern.type === 'strikethrough') {
- parts.push(
{pattern.data})
- } else if (pattern.type === 'code') {
- parts.push(
-
- )
- } else if (pattern.type === 'link') {
+ // Render custom inline pattern
+ if (pattern.type === 'link') {
const { text, url } = pattern.data
if (url.startsWith('payto://')) {
parts.push(
@@ -3870,6 +4025,26 @@ function parseInlineMarkdownLegacy(
#{tag}
)
+ } else if (pattern.type === 'footnote-ref') {
+ const footnoteId = pattern.data
+ parts.push(
+
+
+
+ )
} else if (pattern.type === 'relay-url') {
// Render relay URLs as inline links (green to match theme)
const url = pattern.data
@@ -4363,7 +4538,13 @@ export default function MarkdownArticle({
suppressStandaloneWebPreviewCleanedUrls:
webPreviewSuppressCleanedSet.size > 0 ? webPreviewSuppressCleanedSet : undefined
}
- const result = parseMarkdownContentMarked(preprocessedContent, parseOptions)
+ let result
+ try {
+ result = parseMarkdownContentMarked(preprocessedContent, parseOptions)
+ } catch (error) {
+ logger.error('Marked parser failed, falling back to legacy parser:', error)
+ result = parseMarkdownContentLegacy(preprocessedContent, parseOptions)
+ }
// Return nodes and hashtags (footnotes are already included in nodes)
return { nodes: result.nodes, hashtagsInContent: result.hashtagsInContent }
}, [
diff --git a/src/components/ProfileAbout/index.tsx b/src/components/ProfileAbout/index.tsx
index 4963da98..1d5377ee 100644
--- a/src/components/ProfileAbout/index.tsx
+++ b/src/components/ProfileAbout/index.tsx
@@ -8,6 +8,7 @@ import {
} from '@/lib/content-parser'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import PaytoLink from '@/components/PaytoLink'
+import { marked } from 'marked'
import {
EmbeddedHashtag,
EmbeddedMention,
@@ -17,36 +18,199 @@ import {
export default function ProfileAbout({ about, className }: { about?: string; className?: string }) {
const normalized = replaceStandardEmojiShortcodesInContent(about ?? '', [])
- const aboutNodes = parseContent(normalized, [
- EmbeddedWebsocketUrlParser,
- EmbeddedUrlParser,
- EmbeddedPaytoParser,
- EmbeddedHashtagParser,
- EmbeddedMentionParser
- ]).map((node, index) => {
- if (node.type === 'url') {
- return
+ if (!normalized.trim()) return null
+
+ const renderEnrichedText = (text: string, keyPrefix: string): React.ReactNode[] => {
+ if (text.length === 0) return []
+ const leadingWs = text.match(/^\s+/)?.[0] ?? ''
+ const trailingWs = text.match(/\s+$/)?.[0] ?? ''
+ const coreStart = leadingWs.length
+ const coreEnd = text.length - trailingWs.length
+ const core = text.slice(coreStart, coreEnd)
+
+ const out: React.ReactNode[] = []
+ if (leadingWs) {
+ out.push(
+
+ {leadingWs}
+
+ )
}
- if (node.type === 'websocket-url') {
- return
+
+ if (core) {
+ const coreNodes = parseContent(core, [
+ EmbeddedWebsocketUrlParser,
+ EmbeddedUrlParser,
+ EmbeddedPaytoParser,
+ EmbeddedHashtagParser,
+ EmbeddedMentionParser
+ ]).map((node, index) => {
+ if (node.type === 'url') {
+ return
+ }
+ if (node.type === 'websocket-url') {
+ return
+ }
+ if (node.type === 'payto') {
+ return (
+
+ )
+ }
+ if (node.type === 'hashtag') {
+ return
+ }
+ if (node.type === 'mention') {
+ return
+ }
+ return
{node.data}
+ })
+ out.push(...coreNodes)
}
- if (node.type === 'payto') {
- return (
-
+
+ if (trailingWs) {
+ out.push(
+
+ {trailingWs}
+
)
}
- if (node.type === 'hashtag') {
- return
+
+ return out
+ }
+
+ const renderInlineTokens = (tokens: any[], keyPrefix: string): React.ReactNode[] => {
+ const out: React.ReactNode[] = []
+ for (let i = 0; i < tokens.length; i++) {
+ const token = tokens[i]
+ const key = `${keyPrefix}-${i}`
+ if (token.type === 'text' || token.type === 'escape') {
+ out.push(...renderEnrichedText(String(token.text ?? token.raw ?? ''), `${key}-txt`))
+ } else if (token.type === 'strong') {
+ out.push(
+
+ {renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? '' }], `${key}-strong`)}
+
+ )
+ } else if (token.type === 'em') {
+ out.push(
+
+ {renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? '' }], `${key}-em`)}
+
+ )
+ } else if (token.type === 'del') {
+ out.push(
+
+ {renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? '' }], `${key}-del`)}
+
+ )
+ } else if (token.type === 'codespan') {
+ out.push(
+
+ {String(token.text ?? '')}
+
+ )
+ } else if (token.type === 'br') {
+ out.push(
)
+ } else if (token.type === 'link') {
+ const href = String(token.href ?? '')
+ const label = String(token.text ?? href)
+ if (href.startsWith('payto://')) {
+ out.push(
+
+ {label}
+
+ )
+ } else {
+ out.push(
+
+ {label}
+
+ )
+ }
+ } else {
+ out.push(...renderEnrichedText(String(token.raw ?? token.text ?? ''), `${key}-fallback`))
+ }
}
- if (node.type === 'mention') {
- return
+ return out
+ }
+
+ const renderBlocks = (content: string): React.ReactNode[] => {
+ const blocks = marked.lexer(content, { gfm: true, breaks: true }) as any[]
+ const nodes: React.ReactNode[] = []
+ for (let i = 0; i < blocks.length; i++) {
+ const token = blocks[i]
+ const key = `about-block-${i}`
+ if (token.type === 'space') continue
+ if (token.type === 'paragraph') {
+ nodes.push(
+
+ {renderInlineTokens(token.tokens ?? marked.Lexer.lexInline(token.text ?? ''), `${key}-inline`)}
+
+ )
+ continue
+ }
+ if (token.type === 'list') {
+ const ListTag = token.ordered ? 'ol' : 'ul'
+ const listClass = token.ordered ? 'list-decimal list-outside ml-5 my-1' : 'list-disc list-outside ml-5 my-1'
+ nodes.push(
+
+ {(token.items ?? []).map((item: any, idx: number) => (
+
+ {renderInlineTokens(item.tokens ?? marked.Lexer.lexInline(item.text ?? ''), `${key}-li-${idx}`)}
+
+ ))}
+
+ )
+ continue
+ }
+ if (token.type === 'heading') {
+ const level = Math.min(Math.max(Number(token.depth || 1), 1), 6)
+ const HeadingTag = `h${level}` as keyof JSX.IntrinsicElements
+ nodes.push(
+
+ {renderInlineTokens(token.tokens ?? marked.Lexer.lexInline(token.text ?? ''), `${key}-heading-inline`)}
+
+ )
+ continue
+ }
+ if (token.type === 'blockquote') {
+ nodes.push(
+
+ {renderBlocks(String(token.text ?? token.raw ?? ''))}
+
+ )
+ continue
+ }
+ if (token.type === 'code') {
+ nodes.push(
+
+ {String(token.text ?? '')}
+
+ )
+ continue
+ }
+ nodes.push(
+
+ {renderInlineTokens(marked.Lexer.lexInline(String(token.text ?? token.raw ?? '')), `${key}-fallback-inline`)}
+
+ )
}
- return node.data
- })
+ return nodes
+ }
- return
{aboutNodes}
+ return
{renderBlocks(normalized)}
}
diff --git a/src/components/WebPreview/index.tsx b/src/components/WebPreview/index.tsx
index 1625e5de..ea844c9f 100644
--- a/src/components/WebPreview/index.tsx
+++ b/src/components/WebPreview/index.tsx
@@ -21,6 +21,7 @@ import { FAST_READ_RELAY_URLS } from '@/constants'
import { getImetaInfosFromEvent } from '@/lib/event'
import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle'
import AsciidocArticle from '../Note/AsciidocArticle/AsciidocArticle'
+import ProfileAbout from '@/components/ProfileAbout'
// Helper function to get event type name
function getEventTypeName(kind: number): string {
@@ -666,9 +667,10 @@ export default function WebPreview({ url, className }: { url: string; className?
- {fetchedProfile?.about && (
-