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:
parent
fc8a160d9a
commit
cc1aa7f989
3 changed files with 196 additions and 7 deletions
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
117
src/components/MarkdownContent/index.tsx
Normal file
117
src/components/MarkdownContent/index.tsx
Normal 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
43
src/lib/markdown.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue