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