@@ -2169,6 +2286,18 @@ export function parseMarkdownContentLegacy(
)
} else if (pattern.type === 'fenced-code-block') {
const { code, language } = pattern.data
+ const parsedMath = parseDelimitedMath(String(code ?? '').trim())
+ if (parsedMath || isMathLanguage(String(language ?? ''))) {
+ parts.push(
+
+ )
+ return
+ }
// Render code block with syntax highlighting
// We'll use a ref and useEffect to apply highlight.js after render
const codeBlockId = `code-block-${patternIdx}`
@@ -2993,6 +3122,18 @@ function parseMarkdownContentMarked(
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 (
+
+ )
+ }
const isNostrEventBech32 = (value: string): boolean =>
value.startsWith('note') || value.startsWith('nevent') || value.startsWith('naddr')
const standaloneNostr = paragraphText.match(/^nostr:([a-z0-9]{8,})$/i)
@@ -3052,7 +3193,6 @@ function parseMarkdownContentMarked(
// Mixed paragraphs can contain normal text plus one or more standalone nostr lines.
// Render standalone special lines (nostr refs, relay links, plain URLs/media) as dedicated blocks
// even when they are not the entire paragraph.
- const rawParagraphText = String(token.text ?? '')
if (rawParagraphText.includes('\n')) {
const lines = rawParagraphText.split('\n').map((line) => line.trim()).filter((line) => line.length > 0)
const hasStandaloneSpecialLine = lines.some(
@@ -3130,7 +3270,7 @@ function parseMarkdownContentMarked(
return (
- {renderInlineTokens(marked.Lexer.lexInline(line) as any[], `${key}-line-inline-${lineIdx}`)}
+ {renderInlineTokens(lexInlineProtected(line) as any[], `${key}-line-inline-${lineIdx}`)}
)
}
@@ -3161,7 +3301,7 @@ function parseMarkdownContentMarked(
return (
- {renderInlineTokens(marked.Lexer.lexInline(line) as any[], `${key}-line-fallback-inline-${lineIdx}`)}
+ {renderInlineTokens(lexInlineProtected(line) as any[], `${key}-line-fallback-inline-${lineIdx}`)}
)
})
@@ -3265,7 +3405,7 @@ function parseMarkdownContentMarked(
}
}
- const paragraphTokens = token.tokens ?? marked.Lexer.lexInline(token.text ?? '')
+ const paragraphTokens = lexInlineProtected(String(token.text ?? token.raw ?? ''))
const parseNostrHref = (href: string): string | null => {
if (!href.toLowerCase().startsWith('nostr:')) return null
const raw = href.slice(6).trim()
@@ -3451,7 +3591,7 @@ function parseMarkdownContentMarked(
React.createElement(
`h${Math.min(Math.max(level, 1), 6)}`,
{ key: `${key}-h`, className: `font-bold break-words block mt-4 mb-2 ${headingClass}` },
- renderInlineTokens(token.tokens ?? marked.Lexer.lexInline(token.text ?? ''), `${key}-h-inline`)
+ renderInlineTokens(lexInlineProtected(String(token.text ?? '')), `${key}-h-inline`)
)
)
break
@@ -3459,16 +3599,31 @@ function parseMarkdownContentMarked(
case 'hr':
nodes.push(
)
break
- case 'code':
+ case 'code': {
+ const codeText = String(token.text ?? '')
+ const codeLang = String(token.lang ?? '')
+ const parsedMath = parseDelimitedMath(codeText.trim())
+ if (parsedMath || isMathLanguage(codeLang)) {
+ nodes.push(
+
+ )
+ break
+ }
nodes.push(
)
break
+ }
case 'blockquote': {
const rawLines = String(token.raw ?? '')
.split('\n')
@@ -3481,7 +3636,7 @@ function parseMarkdownContentMarked(
{lines.map((line, idx) => (
- {renderInlineTokens(marked.Lexer.lexInline(line) as any[], `${key}-gt-inline-${idx}`)}
+ {renderInlineTokens(lexInlineProtected(line) as any[], `${key}-gt-inline-${idx}`)}
{idx < lines.length - 1 ?
: null}
))}
@@ -3510,13 +3665,13 @@ function parseMarkdownContentMarked(
const single = itemTokens[0]
if (single.type === 'text') {
return renderInlineTokens(
- single.tokens ?? marked.Lexer.lexInline(single.text ?? ''),
+ lexInlineProtected(String(single.text ?? '')),
`${itemKey}-inline`
)
}
if (single.type === 'paragraph') {
return renderInlineTokens(
- single.tokens ?? marked.Lexer.lexInline(single.text ?? ''),
+ lexInlineProtected(String(single.text ?? '')),
`${itemKey}-inline`
)
}
@@ -3547,7 +3702,7 @@ function parseMarkdownContentMarked(
key={`${key}-th-${cIdx}`}
className="border border-gray-300 dark:border-gray-700 px-4 py-2 bg-gray-100 dark:bg-gray-800 font-semibold text-left"
>
- {renderInlineTokens(cell.tokens ?? marked.Lexer.lexInline(cell.text ?? ''), `${key}-th-inline-${cIdx}`)}
+ {renderInlineTokens(lexInlineProtected(String(cell.text ?? '')), `${key}-th-inline-${cIdx}`)}
))}
@@ -3558,7 +3713,7 @@ function parseMarkdownContentMarked(
{row.map((cell: any, cIdx: number) => (
{renderInlineTokens(
- cell.tokens ?? marked.Lexer.lexInline(cell.text ?? ''),
+ lexInlineProtected(String(cell.text ?? '')),
`${key}-td-inline-${rIdx}-${cIdx}`
)}
@@ -3577,7 +3732,7 @@ function parseMarkdownContentMarked(
} else if (typeof token.text === 'string' && token.text.trim()) {
nodes.push(
- {renderInlineTokens(marked.Lexer.lexInline(token.text) as any[], `${key}-fallback-inline`)}
+ {renderInlineTokens(lexInlineProtected(String(token.text ?? token.raw ?? '')) as any[], `${key}-fallback-inline`)}
)
}
@@ -3638,7 +3793,7 @@ function parseInlineMarkdown(
navigateToHashtag?: (href: string) => void
): React.ReactNode[] {
const normalized = text.replace(/\n/g, ' ').replace(/[ \t]{2,}/g, ' ')
- const tokens = marked.Lexer.lexInline(normalized) as any[]
+ const tokens = lexInlineProtected(normalized) as any[]
const hasMarkdownSyntax = tokens.some((token) => token.type !== 'text' && token.type !== 'escape')
// Fast path: keep old behavior when there is no markdown syntax.
@@ -3790,6 +3945,10 @@ function parseInlineMarkdownLegacy(
const parts: React.ReactNode[] = []
let lastIndex = 0
const inlinePatterns: Array<{ index: number; end: number; type: string; data: any }> = []
+
+ collectMathInlinePatterns(text).forEach((pattern) => {
+ inlinePatterns.push(pattern)
+ })
// Legacy helper is intentionally narrowed to non-standard enrichments.
// Standard markdown emphasis/code is handled by marked in parseInlineMarkdown().
@@ -3800,7 +3959,7 @@ function parseInlineMarkdownLegacy(
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') &&
+ (p.type === 'code' || p.type === 'bold' || p.type === 'italic' || p.type === 'strikethrough' || p.type === 'math-inline' || p.type === 'math-block') &&
match.index! >= p.index &&
match.index! < p.end
)
@@ -3824,7 +3983,7 @@ function parseInlineMarkdownLegacy(
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') &&
+ (p.type === 'link' || p.type === 'hashtag' || p.type === 'relay-url' || p.type === 'nostr' || p.type === 'payto' || p.type === 'math-inline' || p.type === 'math-block') &&
match.index! >= p.index &&
match.index! < p.end
)
@@ -3846,7 +4005,7 @@ function parseInlineMarkdownLegacy(
if (match.index !== undefined) {
// Skip if already in another inline custom pattern
const isInOther = inlinePatterns.some(p =>
- (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' || p.type === 'math-inline' || p.type === 'math-block') &&
match.index! >= p.index &&
match.index! < p.end
)
@@ -3870,7 +4029,7 @@ function parseInlineMarkdownLegacy(
if (isWebsocketUrl(url)) {
// Skip if already in another inline custom pattern
const isInOther = inlinePatterns.some(p =>
- (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' || p.type === 'math-inline' || p.type === 'math-block') &&
match.index! >= p.index &&
match.index! < p.end
)
@@ -3899,7 +4058,7 @@ function parseInlineMarkdownLegacy(
if (isProfileType) {
// Skip if already in another inline custom pattern
const isInOther = inlinePatterns.some(p =>
- (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' || p.type === 'math-inline' || p.type === 'math-block') &&
match.index! >= p.index &&
match.index! < p.end
)
@@ -3923,7 +4082,7 @@ function parseInlineMarkdownLegacy(
const parsed = parsePaytoUri(fullMatch)
if (!parsed) return
const isInOther = inlinePatterns.some(p =>
- (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' || p.type === 'math-inline' || p.type === 'math-block') &&
match.index! >= p.index &&
match.index! < p.end
)
@@ -3943,7 +4102,7 @@ function parseInlineMarkdownLegacy(
emojiMatches.forEach(match => {
if (match.index !== undefined) {
const isInOther = inlinePatterns.some(p =>
- (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' || p.type === 'math-inline' || p.type === 'math-block') &&
match.index! >= p.index &&
match.index! < p.end
)
@@ -3973,6 +4132,7 @@ function parseInlineMarkdownLegacy(
// Build nodes
filtered.forEach((pattern, i) => {
+ let consumeEnd = pattern.end
// Add text before pattern
if (pattern.index > lastIndex) {
let textBefore = text.slice(lastIndex, pattern.index)
@@ -4107,9 +4267,50 @@ function parseInlineMarkdownLegacy(
parts.push({`:${shortcode}:`})
}
}
+ } else if (pattern.type === 'math-inline' || pattern.type === 'math-block') {
+ if (pattern.type === 'math-block') {
+ const after = text.slice(pattern.end)
+ const punctMatch = after.match(/^\s*([.,;:!?])\s*$/)
+ if (punctMatch) {
+ consumeEnd = pattern.end + punctMatch[0].length
+ parts.push(
+
+
+ {punctMatch[1]}
+
+ )
+ } else {
+ parts.push(
+
+ )
+ }
+ } else {
+ parts.push(
+
+ )
+ }
}
- lastIndex = pattern.end
+ lastIndex = consumeEnd
})
// Add remaining text
diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx
index b2f619b0..43c2a19e 100644
--- a/src/hooks/useProfileTimeline.tsx
+++ b/src/hooks/useProfileTimeline.tsx
@@ -76,6 +76,34 @@ function postProcessEvents(
})
let events = Array.from(dedupMap.values()).filter((e) => !isEventDeleted(e))
+
+ // Parameterized replaceable events (kinds 30000-39999) should be unique by pubkey+kind+d.
+ // Keep only the latest version so profile feeds don't show multiple revisions of one article.
+ const latestAddressableByKey = new Map()
+ const nonAddressableEvents: Event[] = []
+ events.forEach((evt) => {
+ const isAddressable = evt.kind >= 30000 && evt.kind < 40000
+ if (!isAddressable) {
+ nonAddressableEvents.push(evt)
+ return
+ }
+ const d = evt.tags.find((t) => t[0] === 'd')?.[1]?.trim()
+ if (!d) {
+ nonAddressableEvents.push(evt)
+ return
+ }
+ const key = `${evt.pubkey}:${evt.kind}:${d}`
+ const existing = latestAddressableByKey.get(key)
+ if (
+ !existing ||
+ evt.created_at > existing.created_at ||
+ (evt.created_at === existing.created_at && evt.id > existing.id)
+ ) {
+ latestAddressableByKey.set(key, evt)
+ }
+ })
+ events = [...nonAddressableEvents, ...latestAddressableByKey.values()]
+
if (filterPredicate) {
events = events.filter(filterPredicate)
}