From cc1aa7f98913d06dfe46069891c212a46f56e09d Mon Sep 17 00:00:00 2001 From: codytseng Date: Wed, 4 Mar 2026 10:49:18 +0800 Subject: [PATCH] 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 --- src/components/Content/index.tsx | 43 +++++++-- src/components/MarkdownContent/index.tsx | 117 +++++++++++++++++++++++ src/lib/markdown.ts | 43 +++++++++ 3 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 src/components/MarkdownContent/index.tsx create mode 100644 src/lib/markdown.ts diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index 81ceaa0..a50862f 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -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 ( + <> +
+ +
+ {enableHighlight && ( + + )} + {enableHighlight && ( + + )} + + ) + } + + if (!nodes || nodes.length === 0) { + return null + } + let imageIndex = 0 return ( <> diff --git a/src/components/MarkdownContent/index.tsx b/src/components/MarkdownContent/index.tsx new file mode 100644 index 0000000..c5bf8dc --- /dev/null +++ b/src/components/MarkdownContent/index.tsx @@ -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 }) => , + a: ({ href, children }) => { + if (!href) return {children} + if (href.startsWith('note1') || href.startsWith('nevent1') || href.startsWith('naddr1')) { + return ( + + {children} + + ) + } + if (href.startsWith('npub1') || href.startsWith('nprofile1')) { + return ( + + {children} + + ) + } + return ( + e.stopPropagation()} + > + {children} + + ) + }, + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + h4: ({ children }) =>

{children}

, + h5: ({ children }) =>

{children}

, + h6: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, + img: ({ src }) => ( + + ), + pre: ({ children }) => ( +
{children}
+ ), + code: ({ children, className }) => { + // If inside a
, render as block code (className contains language info)
+          if (className) {
+            return {children}
+          }
+          return {children}
+        },
+        blockquote: ({ children }) => (
+          
+ {children} +
+ ), + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + table: ({ children }) => ( +
    + {children}
    +
    + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + {children} + ), + hr: () =>
    + }) as Components, + [event?.pubkey] + ) + + return ( +
    + { + if (url.startsWith('nostr:')) { + return url.slice(6) + } + return url + }} + components={components} + > + {content} + +
    + ) +} diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts new file mode 100644 index 0000000..6ce54bd --- /dev/null +++ b/src/lib/markdown.ts @@ -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 +}