feat: support custom emoji, hashtag, invoice, YouTube/X embeds in markdown

Add remarkInlineContent plugin to detect hashtags, custom emojis, and
lightning invoices in text nodes. Handle YouTube/X URLs in the link
component. Fix bug where a text node fully matching a single pattern
was not replaced due to segments.length === 1 check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
codytseng 2026-03-04 11:15:27 +08:00
parent 6f779e8b24
commit 4c144c3da1
2 changed files with 110 additions and 13 deletions

View file

@ -1,13 +1,29 @@
import { SecondaryPageLink } from '@/PageManager'
import { X_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants'
import { toNote, toProfile } from '@/lib/link'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { EmbeddedHashtag, EmbeddedLNInvoice } from '../Embedded'
import Emoji from '../Emoji'
import ExternalLink from '../ExternalLink'
import ImageWithLightbox from '../ImageWithLightbox'
import NostrNode from '../Note/LongFormArticle/NostrNode'
import { remarkNostr } from '../Note/LongFormArticle/remarkNostr'
import { Components } from '../Note/LongFormArticle/types'
import { Components as BaseComponents } from '../Note/LongFormArticle/types'
type InlineComponent = React.ComponentType<{ value: string }>
interface Components extends BaseComponents {
hashtag: InlineComponent
emoji: InlineComponent
invoice: InlineComponent
}
import XEmbeddedPost from '../XEmbeddedPost'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
import { remarkInlineContent } from './remarkInlineContent'
export default function MarkdownContent({
content,
@ -16,10 +32,20 @@ export default function MarkdownContent({
content: string
event?: Event
}) {
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event?.tags), [event?.tags])
const components = useMemo(
() =>
({
nostr: ({ rawText, bech32Id }) => <NostrNode rawText={rawText} bech32Id={bech32Id} />,
hashtag: ({ value }) => <EmbeddedHashtag hashtag={value} />,
emoji: ({ value }) => {
const shortcode = value.slice(1, -1)
const emojiInfo = emojiInfos.find((e) => e.shortcode === shortcode)
if (!emojiInfo) return value
return <Emoji classNames={{ img: 'mb-1' }} emoji={emojiInfo} />
},
invoice: ({ value }) => <EmbeddedLNInvoice invoice={value} className="mt-2" />,
a: ({ href, children }) => {
if (!href) return <span>{children}</span>
if (href.startsWith('note1') || href.startsWith('nevent1') || href.startsWith('naddr1')) {
@ -36,16 +62,14 @@ export default function MarkdownContent({
</SecondaryPageLink>
)
}
if (YOUTUBE_URL_REGEX.test(href)) {
return <YoutubeEmbeddedPlayer url={href} className="mt-2" />
}
if (X_URL_REGEX.test(href)) {
return <XEmbeddedPost url={href} className="mt-2" />
}
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{children}
</a>
<ExternalLink url={href} justOpenLink />
)
},
h1: ({ children }) => <p className="font-bold">{children}</p>,
@ -66,7 +90,6 @@ export default function MarkdownContent({
<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>
}
@ -95,13 +118,13 @@ export default function MarkdownContent({
),
hr: () => <hr className="border-border" />
}) as Components,
[event?.pubkey]
[event?.pubkey, emojiInfos]
)
return (
<div className="space-y-3">
<Markdown
remarkPlugins={[remarkGfm, remarkNostr]}
remarkPlugins={[remarkGfm, remarkNostr, remarkInlineContent]}
urlTransform={(url) => {
if (url.startsWith('nostr:')) {
return url.slice(6)

View file

@ -0,0 +1,74 @@
import { EMOJI_SHORT_CODE_REGEX, HASHTAG_REGEX, LN_INVOICE_REGEX } from '@/constants'
import type { PhrasingContent, Root, Text } from 'mdast'
import type { Plugin } from 'unified'
import type { Node } from 'unist'
import { visit } from 'unist-util-visit'
interface InlineContentNode extends Node {
type: 'hashtag' | 'emoji' | 'invoice'
data: {
hName: string
hProperties: { value: string }
}
}
const PATTERNS: { type: InlineContentNode['type']; regex: RegExp }[] = [
{ type: 'invoice', regex: LN_INVOICE_REGEX },
{ type: 'hashtag', regex: HASHTAG_REGEX },
{ type: 'emoji', regex: EMOJI_SHORT_CODE_REGEX }
]
export const remarkInlineContent: Plugin<[], Root> = () => {
return (tree) => {
visit(tree, 'text', (node: Text, index, parent) => {
if (!parent || typeof index !== 'number') return
let segments: (Text | InlineContentNode)[] = [{ type: 'text', value: node.value }]
for (const { type, regex } of PATTERNS) {
const nextSegments: (Text | InlineContentNode)[] = []
for (const segment of segments) {
if (segment.type !== 'text') {
nextSegments.push(segment)
continue
}
const text = (segment as Text).value
const localRegex = new RegExp(regex.source, regex.flags)
const matches = Array.from(text.matchAll(localRegex))
if (matches.length === 0) {
nextSegments.push(segment)
continue
}
let lastIndex = 0
for (const match of matches) {
const matchStart = match.index!
if (matchStart > lastIndex) {
nextSegments.push({ type: 'text', value: text.slice(lastIndex, matchStart) })
}
nextSegments.push({
type,
data: {
hName: type,
hProperties: { value: match[0] }
}
} as InlineContentNode)
lastIndex = matchStart + match[0].length
}
if (lastIndex < text.length) {
nextSegments.push({ type: 'text', value: text.slice(lastIndex) })
}
}
segments = nextSegments
}
if (segments.length > 1 || (segments.length === 1 && segments[0].type !== 'text')) {
parent.children.splice(index, 1, ...(segments as PhrasingContent[]))
}
})
}
}