{code}
{code}
)
}
/**
* Normalize backticks in markdown content:
* - Inline code: normalize to single backtick (`code`)
* - Code blocks: normalize to triple backticks (```code```)
* This handles cases where content uses 2, 3, or 4 backticks inconsistently
*/
function normalizeBackticks(content: string): string {
let normalized = content
// First, protect code blocks by temporarily replacing them
// Match code blocks with 3 or 4 backticks - need to handle multiline content
const codeBlockPlaceholders: string[] = []
const lines = normalized.split('\n')
const processedLines: string[] = []
let i = 0
while (i < lines.length) {
const line = lines[i]
// Check if this line starts a code block (3 or 4 backticks, optionally with language)
const codeBlockStartMatch = line.match(/^(`{3,4})(\w*)\s*$/)
if (codeBlockStartMatch) {
const language = codeBlockStartMatch[2] || ''
const codeLines: string[] = [line]
i++
let foundEnd = false
// Look for the closing backticks
while (i < lines.length) {
const codeLine = lines[i]
codeLines.push(codeLine)
// Check if this line has the closing backticks
if (codeLine.match(/^`{3,4}\s*$/)) {
foundEnd = true
i++
break
}
i++
}
if (foundEnd) {
// Normalize to triple backticks
const placeholder = `__CODE_BLOCK_${codeBlockPlaceholders.length}__`
const normalizedBlock = `\`\`\`${language}\n${codeLines.slice(1, -1).join('\n')}\n\`\`\``
codeBlockPlaceholders.push(normalizedBlock)
processedLines.push(placeholder)
continue
}
}
processedLines.push(line)
i++
}
normalized = processedLines.join('\n')
// Normalize inline code: replace double backticks with single backticks
// But only if they're not part of a code block (which we've already protected)
// Use a more precise regex that doesn't match triple+ backticks
normalized = normalized.replace(/``([^`\n]+?)``/g, '`$1`')
// Restore code blocks
codeBlockPlaceholders.forEach((block, index) => {
normalized = normalized.replace(`__CODE_BLOCK_${index}__`, block)
})
return normalized
}
/**
* Convert Setext-style headers to markdown format
* H1: "Text\n======\n" -> "# Text\n"
* H2: "Text\n------\n" -> "## Text\n"
* This handles the Setext-style header format (both equals and dashes)
*
* Note: Only converts if the text line has at least 2 characters to avoid
* creating headers from fragments like "D\n------" which would become "## D"
*/
/**
* Normalize excessive newlines - reduce 3+ consecutive newlines (with optional whitespace) to exactly 2
*/
function normalizeNewlines(content: string): string {
// Match sequences of 3 or more newlines with optional whitespace between them
// Pattern: newline, optional whitespace, newline, optional whitespace, one or more newlines
// Replace with exactly 2 newlines
return content.replace(/\n\s*\n\s*\n+/g, '\n\n')
}
/**
* Normalize single newlines within bold/italic spans to spaces
* This allows bold/italic formatting to work across single line breaks
*/
function normalizeInlineFormattingNewlines(content: string): string {
let normalized = content
// Match bold spans: **text** that may contain single newlines
// Replace single newlines (but not double newlines) within these spans with spaces
normalized = normalized.replace(/\*\*([^*]*?)\*\*/g, (match, innerContent) => {
// Check if this span contains double newlines (paragraph break) - if so, don't modify
if (innerContent.includes('\n\n')) {
return match // Keep original if it has paragraph breaks
}
// Replace single newlines with spaces
return '**' + innerContent.replace(/\n/g, ' ') + '**'
})
// Match bold spans: __text__ that may contain single newlines
normalized = normalized.replace(/__([^_]*?)__/g, (match, innerContent) => {
// Check if this span contains double newlines (paragraph break) - if so, don't modify
if (innerContent.includes('\n\n')) {
return match // Keep original if it has paragraph breaks
}
// Replace single newlines with spaces
return '__' + innerContent.replace(/\n/g, ' ') + '__'
})
// Match italic spans: _text_ (single underscore, not part of __bold__)
// Use a more careful pattern to avoid matching __bold__
normalized = normalized.replace(/(? {
// Check if preceded by another underscore (would be __bold__)
if (offset > 0 && string[offset - 1] === '_') {
return match // Don't modify if part of __bold__
}
// Check if this span contains double newlines (paragraph break) - if so, don't modify
if (innerContent.includes('\n\n')) {
return match
}
// Replace single newlines with spaces (though italic shouldn't have newlines due to [^_\n])
return '_' + innerContent.replace(/\n/g, ' ') + '_'
})
return normalized
}
function normalizeSetextHeaders(content: string): string {
const lines = content.split('\n')
const result: string[] = []
let i = 0
while (i < lines.length) {
const currentLine = lines[i]
const nextLine = i + 1 < lines.length ? lines[i + 1] : ''
const currentLineTrimmed = currentLine.trim()
// Check if next line is all equals signs (at least 3) - H1
const equalsMatch = nextLine.match(/^={3,}\s*$/)
if (equalsMatch && currentLineTrimmed.length > 0) {
// Only convert if the text has at least 2 characters (avoid fragments like "D")
if (currentLineTrimmed.length >= 2) {
// Convert to markdown H1
result.push(`# ${currentLineTrimmed}`)
i += 2 // Skip both lines
continue
}
}
// Check if next line is all dashes (at least 3) - H2
// But make sure it's not a horizontal rule (which would be on its own line)
const dashesMatch = nextLine.match(/^-{3,}\s*$/)
if (dashesMatch && currentLineTrimmed.length > 0) {
// Only convert if the text has at least 2 characters (avoid fragments like "D")
if (currentLineTrimmed.length >= 2) {
// Convert to markdown H2
result.push(`## ${currentLineTrimmed}`)
i += 2 // Skip both lines
continue
}
}
result.push(currentLine)
i++
}
return result.join('\n')
}
/**
* 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)
*/
// Deprecated legacy parser kept only as a fallback reference during migration.
export function parseMarkdownContentLegacy(
content: string,
options: {
eventPubkey: string
imageIndexMap: Map). const textAfterOnSameLineRaw = content.substring(pattern.end, lineEndIndex) hasTextAfterOnSameLine = textAfterOnSameLineRaw.trim().length > 0 if (!shouldMergeHashtag && hasTextAfterOnSameLine) { shouldMergeHashtag = true } } // Merge if: // 1. There's text on the same line before the pattern (e.g., "via [TFTC](url)" or "things that #AI") // 2. OR there's text before the pattern and no double newline (paragraph break) // 3. OR (for hashtags) the line contains only hashtags, so they should stay together // This ensures links and hashtags in sentences stay together with their text if (pattern.type === 'hashtag' && shouldMergeHashtag) { // For hashtags on a line with only hashtags, or hashtags on adjacent lines, merge them together if (line.replace(/#[a-zA-Z0-9_]+/g, '').trim().length === 0 && line.trim().length > 0) { // Line contains only hashtags - merge the entire line // Also check if we need to merge adjacent lines with hashtags let mergeEndIndex = lineEndIndex let mergeStartIndex = lineStart // If there are hashtags on adjacent lines, extend the merge range if (hasHashtagsOnAdjacentLines) { // Find the start of the first hashtag line in this sequence let checkStart = lineStart while (checkStart > 0) { const prevLineStart = content.lastIndexOf('\n', checkStart - 2) + 1 if (prevLineStart >= 0 && prevLineStart < checkStart) { const prevLineEnd = checkStart - 1 const prevLine = content.substring(prevLineStart, prevLineEnd) const hasHashtagOnPrevLine = filteredPatterns.some((p, idx) => idx < patternIdx && p.type === 'hashtag' && p.index >= prevLineStart && p.index < prevLineEnd ) if (hasHashtagOnPrevLine && prevLine.replace(/#[a-zA-Z0-9_]+/g, '').trim().length === 0) { mergeStartIndex = prevLineStart checkStart = prevLineStart } else { break } } else { break } } // Find the end of the last hashtag line in this sequence let checkEnd = lineEndIndex while (checkEnd < content.length) { const nextLineStart = checkEnd + 1 if (nextLineStart < content.length) { const nextLineEnd = content.indexOf('\n', nextLineStart) const nextLineEndIndex = nextLineEnd === -1 ? content.length : nextLineEnd const nextLine = content.substring(nextLineStart, nextLineEndIndex) const hasHashtagOnNextLine = filteredPatterns.some((p, idx) => idx > patternIdx && p.type === 'hashtag' && p.index >= nextLineStart && p.index < nextLineEndIndex ) if (hasHashtagOnNextLine && nextLine.replace(/#[a-zA-Z0-9_]+/g, '').trim().length === 0) { mergeEndIndex = nextLineEndIndex checkEnd = nextLineEndIndex } else { break } } else { break } } } // Reconstruct text to include everything from lastIndex to the end of the merged range const textBeforeMerge = content.slice(lastIndex, mergeStartIndex) const mergedContent = content.substring(mergeStartIndex, mergeEndIndex) // Replace single newlines with spaces in the merged content to keep hashtags together const normalizedMergedContent = mergedContent.replace(/\n(?!\n)/g, ' ') text = textBeforeMerge + normalizedMergedContent textEndIndex = mergeEndIndex === content.length ? content.length : mergeEndIndex + 1 // Mark all hashtags in the merged range as merged (so they don't render separately) filteredPatterns.forEach((p, idx) => { if (p.type === 'hashtag' && p.index >= mergeStartIndex && p.index < mergeEndIndex) { const tag = p.data const tagLower = tag.toLowerCase() hashtagsInContent.add(tagLower) mergedPatterns.add(idx) } }) // Also update lastIndex immediately to prevent processing of patterns in this range lastIndex = textEndIndex } else if (hasTextOnSameLine || hasTextBefore || hasTextAfterOnSameLine) { // Hashtag is part of text - merge this hashtag and all following hashtags/text on same line (avoids hard break between #hashtag #other) const patternMarkdown = content.substring(pattern.index, pattern.end) const textAfterPattern = content.substring(pattern.end, lineEndIndex) text = text + patternMarkdown + textAfterPattern textEndIndex = lineEndIndex === content.length ? content.length : lineEndIndex + 1 // Mark every hashtag in this merged range so we don't render them as separate blocks const mergeStartIndex = pattern.index const mergeEndIndex = lineEndIndex filteredPatterns.forEach((p, idx) => { if (p.type === 'hashtag' && p.index >= mergeStartIndex && p.index < mergeEndIndex) { const tag = p.data hashtagsInContent.add(tag.toLowerCase()) mergedPatterns.add(idx) } }) } } else if ( (pattern.type === 'markdown-link' || pattern.type === 'relay-url') && (hasTextOnSameLine || hasTextBefore || content.substring(pattern.end, lineEndIndex).trim().length > 0) ) { // Leading link/relay + text on the same line (e.g. autolink preprocess → "[url](url) rest"): // merge so parseInlineMarkdown emits one
; otherwise we render bare then for the tail
// and the block forces a visual line break.
// Get the original pattern syntax from the content
const patternMarkdown = content.substring(pattern.index, pattern.end)
// Get text after the pattern on the same line
const textAfterPattern = content.substring(pattern.end, lineEndIndex)
// Extend the text to include the pattern and any text after it on the same line
text = text + patternMarkdown + textAfterPattern
textEndIndex = lineEndIndex === content.length ? content.length : lineEndIndex + 1
// Mark this pattern as merged so we don't render it separately later
mergedPatterns.add(patternIdx)
} else if (pattern.type === 'nostr') {
// Only merge profile types (npub/nprofile) inline; event types (note/nevent/naddr) remain block-level.
// Same idea as hashtags: if the mention is first on the line but more text follows on that line,
// merge into the paragraph — otherwise we emit a bare and the rest in , which looks
// like a spurious hard return (block after inline-block mention).
const bech32Id = pattern.data
const isProfileType = bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')
const hasTextAfterNostrOnSameLine =
isProfileType && content.substring(pattern.end, lineEndIndex).trim().length > 0
if (isProfileType && (hasTextOnSameLine || hasTextBefore || hasTextAfterNostrOnSameLine)) {
const patternMarkdown = content.substring(pattern.index, pattern.end)
const textAfterPattern = content.substring(pattern.end, lineEndIndex)
text = text + patternMarkdown + textAfterPattern
textEndIndex = lineEndIndex === content.length ? content.length : lineEndIndex + 1
mergedPatterns.add(patternIdx)
}
}
}
if (text) {
// Skip if this text is part of a table (tables are handled as block patterns)
const isInTable = blockLevelPatternsFromAll.some(p =>
p.type === 'table' &&
lastIndex >= p.index &&
lastIndex < p.end
)
if (!isInTable) {
// Split text into paragraphs (double newlines create paragraph breaks)
// Single newlines within paragraphs should be converted to spaces
const paragraphs = text.split(/\n\n+/)
paragraphs.forEach((paragraph, paraIdx) => {
// Check for markdown images in this paragraph and extract them
const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g
const imageMatches = Array.from(paragraph.matchAll(markdownImageRegex))
if (imageMatches.length > 0) {
// Process text and images separately
let paraLastIndex = 0
imageMatches.forEach((match, imgIdx) => {
if (match.index !== undefined) {
const imgStart = match.index
const imgEnd = match.index + match[0].length
const imgUrl = match[2]
const cleaned = cleanUrl(imgUrl)
// Add text before this image
if (imgStart > paraLastIndex) {
const textBefore = paragraph.slice(paraLastIndex, imgStart)
let normalizedText = textBefore.replace(/\n/g, ' ')
normalizedText = normalizedText.replace(/[ \t]{2,}/g, ' ')
normalizedText = normalizedText.trim()
if (normalizedText) {
const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-img-${imgIdx}`, footnotes, emojiInfos)
parts.push(
{textContent}
{textContent}
{paraContent}
{paragraphContent}
{paraContent}
{paraContent}
{paraContent}
{paraContent}
/
{renderInlineTokens(lexInlineProtected(line) as any[], `${key}-line-inline-${lineIdx}`)}
{renderInlineTokens(lexInlineProtected(line) as any[], `${key}-line-fallback-inline-${lineIdx}`)}
{parseInlineMarkdown(before, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag)}
{parseInlineMarkdown(after, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag)}
{renderInlineTokens(inlineSegment, `${key}-media-inline-segment-${segmentIdx}`)}
{renderInlineTokens(inlineSegment, `${key}-nostr-inline-segment-${segmentIdx}`)}
(avoids invalid DOM nesting for media players).
if (Array.isArray(paragraphTokens) && paragraphTokens.length === 1 && paragraphTokens[0]?.type === 'image') {
const imageToken = paragraphTokens[0]
const src = String(imageToken.href ?? '')
const cleaned = cleanUrl(src)
if (cleaned) {
if (isVideo(cleaned) || isAudio(cleaned)) {
const poster = videoPosterMap?.get(cleaned)
return (
{renderInlineTokens(paragraphTokens, `${key}-img-inline-fallback`)}
{inlineNodes}
{renderInlineTokens(lexInlineProtected(String(token.text ?? token.raw ?? '')) as any[], `${key}-fallback-inline`)}
{metadata.summary}
)
}
}
})
// Update lastIndex to the end of the processed text (including link if merged)
// Only update if we haven't already updated it (e.g., for hashtag-only lines)
if (textEndIndex > lastIndex) {
lastIndex = textEndIndex
}
} else {
// Still update lastIndex even if in table
lastIndex = textEndIndex
}
} else {
// No text before pattern, but still update lastIndex if we merged a pattern
if (mergedPatterns.has(patternIdx)) {
// textEndIndex should have been set during the merge logic above
if (textEndIndex > lastIndex) {
lastIndex = textEndIndex
}
// Skip rendering since it was merged
return
}
}
} else {
// Pattern starts at or before lastIndex - check if it was merged
// This can happen if a previous pattern's merge extended past this pattern
if (mergedPatterns.has(patternIdx)) {
// This pattern was already merged (e.g., as part of a hashtag-only line)
// Skip it and don't update lastIndex (it was already updated)
return
}
}
// Skip rendering if this pattern was merged into a paragraph
// (lastIndex was already updated when we merged it above)
// This is a final safety check
if (mergedPatterns.has(patternIdx)) {
return
}
// Render pattern
if (pattern.type === 'markdown-image') {
const { url } = pattern.data
const cleaned = cleanUrl(url)
// Look up image index - try by URL first, then by identifier for cross-domain matching
let imageIndex = imageIndexMap.get(cleaned)
if (imageIndex === undefined && getImageIdentifier) {
const identifier = getImageIdentifier(cleaned)
if (identifier) {
imageIndex = imageIndexMap.get(`__img_id:${identifier}`)
}
}
if (isImage(cleaned)) {
// Check if there's a thumbnail available for this image
// Use thumbnail for display, but original URL for lightbox
let thumbnailUrl: string | undefined
if (imageThumbnailMap) {
thumbnailUrl = imageThumbnailMap.get(cleaned)
// Also check by identifier for cross-domain matching
if (!thumbnailUrl && getImageIdentifier) {
const identifier = getImageIdentifier(cleaned)
if (identifier) {
thumbnailUrl = imageThumbnailMap.get(`__img_id:${identifier}`)
}
}
}
// Don't use thumbnails in notes - use original URL
const displayUrl = url
const hasThumbnail = false
parts.push(
)
} else if (pattern.type === 'bullet-list-item') {
const { text } = pattern.data
const listContent = parseInlineMarkdown(text, `bullet-${patternIdx}`, footnotes, emojiInfos)
parts.push(
{headerRow.map((cell: string, cellIdx: number) => (
{dataRows.map((row: string[], rowIdx: number) => (
{parseInlineMarkdown(cell, `table-header-${patternIdx}-${cellIdx}`, footnotes, emojiInfos)}
))}
{row.map((cell: string, cellIdx: number) => (
))}
{parseInlineMarkdown(cell, `table-cell-${patternIdx}-${rowIdx}-${cellIdx}`, footnotes, emojiInfos)}
))}
{blockquoteContent}
)
} else if (pattern.type === 'greentext') {
const { lines } = pattern.data
// Join all greentext lines with
to preserve line breaks
// Each line should have the > prefix preserved
const greentextContent = lines.map((line: string, lineIdx: number) => {
// Parse inline markdown for each line (for links, hashtags, etc.)
const lineContent = parseInlineMarkdown(line, `greentext-${patternIdx}-line-${lineIdx}`, footnotes, emojiInfos)
return (
}
>{lineContent}
or
tags
const wrappedParts: React.ReactNode[] = []
let partIdx = 0
while (partIdx < filteredParts.length) {
const part = filteredParts[partIdx]
// Check if this is a list item
if (React.isValidElement(part) && part.type === 'li') {
// Determine if it's a bullet or numbered list
const isBullet = part.key && part.key.toString().startsWith('bullet-')
const isNumbered = part.key && part.key.toString().startsWith('numbered-')
if (isBullet || isNumbered) {
// Collect consecutive list items of the same type
const listItems: React.ReactNode[] = [part]
partIdx++
while (partIdx < filteredParts.length) {
const nextPart = filteredParts[partIdx]
if (React.isValidElement(nextPart) && nextPart.type === 'li') {
const nextIsBullet = nextPart.key && nextPart.key.toString().startsWith('bullet-')
const nextIsNumbered = nextPart.key && nextPart.key.toString().startsWith('numbered-')
if ((isBullet && nextIsBullet) || (isNumbered && nextIsNumbered)) {
listItems.push(nextPart)
partIdx++
} else {
break
}
} else {
break
}
}
// Only wrap in
or
if there's more than one item
// Single-item lists should not be formatted as lists
if (listItems.length > 1) {
if (isBullet) {
wrappedParts.push(
{listItems}
)
} else {
wrappedParts.push(
{listItems}
)
}
} else {
// Single item - render the original line text (including marker) as plain text
// Extract pattern index from the key to look up original line
const listItem = listItems[0]
if (React.isValidElement(listItem) && listItem.key) {
const keyStr = listItem.key.toString()
const patternIndexMatch = keyStr.match(/(?:bullet|numbered)-(\d+)/)
if (patternIndexMatch) {
const patternIndex = parseInt(patternIndexMatch[1], 10)
const originalLine = listItemOriginalLines.get(patternIndex)
if (originalLine) {
// Render the original line with inline markdown processing
const lineContent = parseInlineMarkdown(originalLine, `single-list-item-${partIdx}`, footnotes, emojiInfos)
wrappedParts.push(
{lineContent}
)
} else {
// Fallback: render the list item content
wrappedParts.push(
{listItem.props.children}
)
}
} else {
// Fallback: render the list item content
wrappedParts.push(
{listItem.props.children}
)
}
} else {
wrappedParts.push(listItem)
}
}
continue
}
}
wrappedParts.push(part)
partIdx++
}
// Add footnotes section at the end if there are any footnotes
if (footnotes.size > 0) {
wrappedParts.push(
Footnotes
{Array.from(footnotes.entries()).map(([id, text]) => (
Citations
{footCitations.map((citation, idx) => (
References
{endCitations.map((citation, idx) => (
{renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? '' }], `${key}-del`)}
)
break
case 'codespan':
out.push(
)
break
case 'image': {
const src = String(token.href ?? '')
const cleaned = cleanUrl(src)
if (!cleaned) break
// Inline context: avoid block image/media mounts inside / .
// Standalone image paragraphs are handled separately in renderParagraph().
const label = String(token.text ?? src)
if (isVideo(cleaned) || isAudio(cleaned)) {
out.push(
{label}
)
break
}
if (!isImage(cleaned) || !isSafeMediaUrl(cleaned)) {
out.push({label})
break
}
out.push(
{label}
)
break
}
default: {
const txt = String(token.raw ?? token.text ?? '')
if (txt) {
collectHashtags(txt)
out.push(
...parseInlineMarkdownLegacy(txt, `${key}-fallback`, footnotes, emojiInfos, navigateToHashtag)
)
}
}
}
}
return out
}
const renderParagraph = (token: any, key: string): React.ReactNode => {
const paragraphText = String(token.text ?? '').trim()
const rawParagraphText = String(token.text ?? '')
const standaloneMath = parseDelimitedMath(rawParagraphText.trim())
if (standaloneMath) {
return (
)
break
case 'code': {
const codeText = String(token.text ?? '')
const codeLang = String(token.lang ?? '')
const parsedMath = parseDelimitedMath(codeText.trim())
if (parsedMath || isMathLanguage(codeLang)) {
nodes.push(
: null}
{renderBlockTokens(token.tokens ?? [], `${key}-bq-inner`)}
)
}
break
}
case 'list': {
const ListTag = token.ordered ? 'ol' : 'ul'
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(
lexInlineProtected(String(single.text ?? '')),
`${itemKey}-inline`
)
}
if (single.type === 'paragraph') {
return renderInlineTokens(
lexInlineProtected(String(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) => (
{(token.header ?? []).map((cell: any, cIdx: number) => (
{(token.rows ?? []).map((row: any[], rIdx: number) => (
{renderInlineTokens(lexInlineProtected(String(cell.text ?? '')), `${key}-th-inline-${cIdx}`)}
))}
{row.map((cell: any, cIdx: number) => (
))}
{renderInlineTokens(
lexInlineProtected(String(cell.text ?? '')),
`${key}-td-inline-${rIdx}-${cIdx}`
)}
))}
Footnotes
{Array.from(footnotes.entries()).map(([id, text]) => (
{renderTokens(token.tokens ?? [{ type: 'text', text: token.text ?? '' }], `${tokenKey}-del`)}
)
continue
}
if (token.type === 'codespan') {
out.push(
)
continue
}
// Unknown/HTML token: treat as text to avoid unsafe HTML injection.
out.push(
...parseInlineMarkdownLegacy(
String(token.raw ?? token.text ?? ''),
`${keyPrefix}-${tokenKey}-fallback`,
_footnotes,
emojiInfos,
navigateToHashtag
)
)
}
return out
}
const rendered = renderTokens(tokens, `${keyPrefix}-md`)
return rendered.length > 0
? rendered
: parseInlineMarkdownLegacy(normalized, keyPrefix, _footnotes, emojiInfos, navigateToHashtag)
}
function parseInlineMarkdownLegacy(
text: string,
keyPrefix: string,
_footnotes: Map{metadata.title}
}
{!hideMetadata && metadata.summary && (
)}
{hideMetadata && metadata.title && event.kind !== ExtendedKind.DISCUSSION && (
{metadata.title}
)}
{/* Metadata image */}
{!hideMetadata && metadata.image && (() => {
const cleanedMetadataImage = cleanUrl(metadata.image)
const parentImageUrlCleaned = parentImageUrl ? cleanUrl(parentImageUrl) : null
// Don't show if already in content (check by URL and by identifier)
if (cleanedMetadataImage) {
if (mediaUrlsInContent.has(cleanedMetadataImage)) return null
const identifier = getImageIdentifier(cleanedMetadataImage)
if (identifier && mediaUrlsInContent.has(`__img_id:${identifier}`)) return null
}
// Don't show if it matches the parent publication's image (to avoid duplicate cover images)
if (parentImageUrlCleaned && cleanedMetadataImage === parentImageUrlCleaned) return null
const metadataImageIndex = imageIndexMap.get(cleanedMetadataImage)
return (