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:
parent
6f779e8b24
commit
4c144c3da1
2 changed files with 110 additions and 13 deletions
|
|
@ -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)
|
||||
|
|
|
|||
74
src/components/MarkdownContent/remarkInlineContent.ts
Normal file
74
src/components/MarkdownContent/remarkInlineContent.ts
Normal 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[]))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue