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 { SecondaryPageLink } from '@/PageManager'
|
||||||
|
import { X_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants'
|
||||||
import { toNote, toProfile } from '@/lib/link'
|
import { toNote, toProfile } from '@/lib/link'
|
||||||
|
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import Markdown from 'react-markdown'
|
import Markdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import { EmbeddedHashtag, EmbeddedLNInvoice } from '../Embedded'
|
||||||
|
import Emoji from '../Emoji'
|
||||||
|
import ExternalLink from '../ExternalLink'
|
||||||
import ImageWithLightbox from '../ImageWithLightbox'
|
import ImageWithLightbox from '../ImageWithLightbox'
|
||||||
import NostrNode from '../Note/LongFormArticle/NostrNode'
|
import NostrNode from '../Note/LongFormArticle/NostrNode'
|
||||||
import { remarkNostr } from '../Note/LongFormArticle/remarkNostr'
|
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({
|
export default function MarkdownContent({
|
||||||
content,
|
content,
|
||||||
|
|
@ -16,10 +32,20 @@ export default function MarkdownContent({
|
||||||
content: string
|
content: string
|
||||||
event?: Event
|
event?: Event
|
||||||
}) {
|
}) {
|
||||||
|
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event?.tags), [event?.tags])
|
||||||
|
|
||||||
const components = useMemo(
|
const components = useMemo(
|
||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
nostr: ({ rawText, bech32Id }) => <NostrNode rawText={rawText} bech32Id={bech32Id} />,
|
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 }) => {
|
a: ({ href, children }) => {
|
||||||
if (!href) return <span>{children}</span>
|
if (!href) return <span>{children}</span>
|
||||||
if (href.startsWith('note1') || href.startsWith('nevent1') || href.startsWith('naddr1')) {
|
if (href.startsWith('note1') || href.startsWith('nevent1') || href.startsWith('naddr1')) {
|
||||||
|
|
@ -36,16 +62,14 @@ export default function MarkdownContent({
|
||||||
</SecondaryPageLink>
|
</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 (
|
return (
|
||||||
<a
|
<ExternalLink url={href} justOpenLink />
|
||||||
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>,
|
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>
|
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-sm">{children}</pre>
|
||||||
),
|
),
|
||||||
code: ({ children, className }) => {
|
code: ({ children, className }) => {
|
||||||
// If inside a <pre>, render as block code (className contains language info)
|
|
||||||
if (className) {
|
if (className) {
|
||||||
return <code className="whitespace-pre-wrap break-words">{children}</code>
|
return <code className="whitespace-pre-wrap break-words">{children}</code>
|
||||||
}
|
}
|
||||||
|
|
@ -95,13 +118,13 @@ export default function MarkdownContent({
|
||||||
),
|
),
|
||||||
hr: () => <hr className="border-border" />
|
hr: () => <hr className="border-border" />
|
||||||
}) as Components,
|
}) as Components,
|
||||||
[event?.pubkey]
|
[event?.pubkey, emojiInfos]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Markdown
|
<Markdown
|
||||||
remarkPlugins={[remarkGfm, remarkNostr]}
|
remarkPlugins={[remarkGfm, remarkNostr, remarkInlineContent]}
|
||||||
urlTransform={(url) => {
|
urlTransform={(url) => {
|
||||||
if (url.startsWith('nostr:')) {
|
if (url.startsWith('nostr:')) {
|
||||||
return url.slice(6)
|
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