feat: add markdown rendering support for Content component

Detect markdown-formatted posts (from bots etc.) and render them
with react-markdown instead of plain text, with feed-friendly
styles (flattened headings, compact lists, scrollable tables).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
codytseng 2026-03-04 10:49:18 +08:00
parent fc8a160d9a
commit cc1aa7f989
3 changed files with 196 additions and 7 deletions

View file

@ -10,6 +10,7 @@ import {
parseContent
} from '@/lib/content-parser'
import { getImetaInfosFromEvent } from '@/lib/event'
import { containsMarkdown } from '@/lib/markdown'
import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag } from '@/lib/tag'
import { cn } from '@/lib/utils'
import mediaUpload from '@/services/media-upload.service'
@ -27,6 +28,7 @@ import Emoji from '../Emoji'
import ExternalLink from '../ExternalLink'
import HighlightButton from '../HighlightButton'
import ImageGallery from '../ImageGallery'
import MarkdownContent from '../MarkdownContent'
import MediaPlayer from '../MediaPlayer'
import PostEditor from '../PostEditor'
import WebPreview from '../WebPreview'
@ -50,9 +52,11 @@ export default function Content({
const [showHighlightEditor, setShowHighlightEditor] = useState(false)
const [selectedText, setSelectedText] = useState('')
const translatedEvent = useTranslatedEvent(event?.id)
const resolvedContent = translatedEvent?.content ?? event?.content ?? content
const isMarkdown = useMemo(() => resolvedContent ? containsMarkdown(resolvedContent) : false, [resolvedContent])
const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => {
const _content = translatedEvent?.content ?? event?.content ?? content
if (!_content) return {}
if (!resolvedContent || isMarkdown) return {}
const _content = resolvedContent
const nodes = parseContent(_content, [
EmbeddedEventParser,
@ -96,17 +100,42 @@ export default function Content({
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
return { nodes, allImages, emojiInfos, lastNormalUrl }
}, [event, translatedEvent, content])
if (!nodes || nodes.length === 0) {
return null
}
}, [event, resolvedContent, isMarkdown])
const handleHighlight = (text: string) => {
setSelectedText(text)
setShowHighlightEditor(true)
}
if (!resolvedContent) {
return null
}
if (isMarkdown) {
return (
<>
<div ref={contentRef} className={cn('text-wrap break-words', className)}>
<MarkdownContent content={resolvedContent} event={event} />
</div>
{enableHighlight && (
<HighlightButton onHighlight={handleHighlight} containerRef={contentRef} />
)}
{enableHighlight && (
<PostEditor
highlightedText={selectedText}
parentStuff={event}
open={showHighlightEditor}
setOpen={setShowHighlightEditor}
/>
)}
</>
)
}
if (!nodes || nodes.length === 0) {
return null
}
let imageIndex = 0
return (
<>

View file

@ -0,0 +1,117 @@
import { SecondaryPageLink } from '@/PageManager'
import { toNote, toProfile } from '@/lib/link'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import ImageWithLightbox from '../ImageWithLightbox'
import NostrNode from '../Note/LongFormArticle/NostrNode'
import { remarkNostr } from '../Note/LongFormArticle/remarkNostr'
import { Components } from '../Note/LongFormArticle/types'
export default function MarkdownContent({
content,
event
}: {
content: string
event?: Event
}) {
const components = useMemo(
() =>
({
nostr: ({ rawText, bech32Id }) => <NostrNode rawText={rawText} bech32Id={bech32Id} />,
a: ({ href, children }) => {
if (!href) return <span>{children}</span>
if (href.startsWith('note1') || href.startsWith('nevent1') || href.startsWith('naddr1')) {
return (
<SecondaryPageLink to={toNote(href)} className="text-primary hover:underline">
{children}
</SecondaryPageLink>
)
}
if (href.startsWith('npub1') || href.startsWith('nprofile1')) {
return (
<SecondaryPageLink to={toProfile(href)} className="text-primary hover:underline">
{children}
</SecondaryPageLink>
)
}
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{children}
</a>
)
},
h1: ({ children }) => <p className="font-bold">{children}</p>,
h2: ({ children }) => <p className="font-bold">{children}</p>,
h3: ({ children }) => <p className="font-bold">{children}</p>,
h4: ({ children }) => <p className="font-bold">{children}</p>,
h5: ({ children }) => <p className="font-bold">{children}</p>,
h6: ({ children }) => <p className="font-bold">{children}</p>,
p: ({ children }) => <p>{children}</p>,
img: ({ src }) => (
<ImageWithLightbox
image={{ url: src || '', pubkey: event?.pubkey }}
className="max-h-[80vh] object-contain sm:max-h-[50vh]"
classNames={{ wrapper: 'w-fit max-w-full mt-2' }}
/>
),
pre: ({ children }) => (
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-sm">{children}</pre>
),
code: ({ children, className }) => {
// If inside a <pre>, render as block code (className contains language info)
if (className) {
return <code className="whitespace-pre-wrap break-words">{children}</code>
}
return <code className="rounded bg-muted px-1 py-0.5 text-sm">{children}</code>
},
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-muted-foreground/30 pl-3 text-muted-foreground">
{children}
</blockquote>
),
ul: ({ children }) => <ul className="list-disc pl-5">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-5">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
table: ({ children }) => (
<div className="overflow-x-auto">
<table className="border-collapse text-sm">{children}</table>
</div>
),
th: ({ children }) => (
<th className="whitespace-nowrap border border-border bg-muted px-3 py-1.5 text-left font-semibold">
{children}
</th>
),
td: ({ children }) => (
<td className="whitespace-nowrap border border-border px-3 py-1.5">{children}</td>
),
hr: () => <hr className="border-border" />
}) as Components,
[event?.pubkey]
)
return (
<div className="space-y-3">
<Markdown
remarkPlugins={[remarkGfm, remarkNostr]}
urlTransform={(url) => {
if (url.startsWith('nostr:')) {
return url.slice(6)
}
return url
}}
components={components}
>
{content}
</Markdown>
</div>
)
}

43
src/lib/markdown.ts Normal file
View file

@ -0,0 +1,43 @@
/**
* Detects whether a string contains meaningful Markdown formatting.
* Strips URLs and nostr: references first to avoid false positives.
*/
export function containsMarkdown(content: string): boolean {
// Remove URLs and nostr: references to avoid false positives
const cleaned = content
.replace(/https?:\/\/[^\s)>\]]+/g, '')
.replace(/nostr:[a-z0-9]+/g, '')
// Strong signals — any single one triggers markdown
const strongPatterns = [
/^```/m, // code fence
/\|[\s]*:?-+:?[\s]*\|/ // table separator |---|
]
for (const pattern of strongPatterns) {
if (pattern.test(cleaned)) return true
}
// Medium signals — need 2+ different types
const mediumPatterns = [
/^#{1,6}\s+\S/m, // ATX heading (# text), not #hashtag
/\*\*[^*\n]+\*\*/, // bold **text**
/__[^_\n]+__/, // bold __text__
/\[[^\]]+\]\([^)]+\)/, // link [text](url)
/^>\s+\S/m, // blockquote > text
/^[-*]\s+\S/m, // unordered list - item or * item
/^\d+\.\s+\S/m, // ordered list 1. item
/^---$/m, // horizontal rule
/~~[^~\n]+~~/ // strikethrough ~~text~~
]
let matchCount = 0
for (const pattern of mediumPatterns) {
if (pattern.test(cleaned)) {
matchCount++
if (matchCount >= 2) return true
}
}
return false
}