refactor: url parser

This commit is contained in:
codytseng
2025-08-24 15:54:13 +08:00
parent c53429fa6c
commit d6a5a82cf8
6 changed files with 77 additions and 49 deletions

View File

@@ -3,13 +3,10 @@ import {
EmbeddedEmojiParser, EmbeddedEmojiParser,
EmbeddedEventParser, EmbeddedEventParser,
EmbeddedHashtagParser, EmbeddedHashtagParser,
EmbeddedImageParser,
EmbeddedLNInvoiceParser, EmbeddedLNInvoiceParser,
EmbeddedMediaParser,
EmbeddedMentionParser, EmbeddedMentionParser,
EmbeddedNormalUrlParser, EmbeddedUrlParser,
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
EmbeddedYoutubeParser,
parseContent parseContent
} from '@/lib/content-parser' } from '@/lib/content-parser'
import { getImageInfosFromEvent } from '@/lib/event' import { getImageInfosFromEvent } from '@/lib/event'
@@ -40,10 +37,7 @@ const Content = memo(
if (!_content) return null if (!_content) return null
const nodes = parseContent(_content, [ const nodes = parseContent(_content, [
EmbeddedYoutubeParser, EmbeddedUrlParser,
EmbeddedImageParser,
EmbeddedMediaParser,
EmbeddedNormalUrlParser,
EmbeddedLNInvoiceParser, EmbeddedLNInvoiceParser,
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
EmbeddedEventParser, EmbeddedEventParser,

View File

@@ -1,9 +1,8 @@
import { import {
EmbeddedEmojiParser, EmbeddedEmojiParser,
EmbeddedEventParser, EmbeddedEventParser,
EmbeddedImageParser,
EmbeddedMentionParser, EmbeddedMentionParser,
EmbeddedMediaParser, EmbeddedUrlParser,
parseContent parseContent
} from '@/lib/content-parser' } from '@/lib/content-parser'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -25,8 +24,7 @@ export default function Content({
const { t } = useTranslation() const { t } = useTranslation()
const nodes = useMemo(() => { const nodes = useMemo(() => {
return parseContent(content, [ return parseContent(content, [
EmbeddedImageParser, EmbeddedUrlParser,
EmbeddedMediaParser,
EmbeddedEventParser, EmbeddedEventParser,
EmbeddedMentionParser, EmbeddedMentionParser,
EmbeddedEmojiParser EmbeddedEmojiParser
@@ -36,9 +34,6 @@ export default function Content({
return ( return (
<span className={cn('pointer-events-none', className)}> <span className={cn('pointer-events-none', className)}>
{nodes.map((node, index) => { {nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
if (node.type === 'image' || node.type === 'images') { if (node.type === 'image' || node.type === 'images') {
return index > 0 ? ` [${t('image')}]` : `[${t('image')}]` return index > 0 ? ` [${t('image')}]` : `[${t('image')}]`
} }
@@ -57,6 +52,7 @@ export default function Content({
if (!emoji) return node.data if (!emoji) return node.data
return <Emoji key={index} emoji={emoji} classNames={{ img: 'mb-1' }} /> return <Emoji key={index} emoji={emoji} classNames={{ img: 'mb-1' }} />
} }
return node.data
})} })}
</span> </span>
) )

View File

@@ -1,21 +1,21 @@
import { import {
EmbeddedHashtagParser, EmbeddedHashtagParser,
EmbeddedMentionParser, EmbeddedMentionParser,
EmbeddedNormalUrlParser, EmbeddedUrlParser,
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
parseContent parseContent
} from '@/lib/content-parser' } from '@/lib/content-parser'
import { detectLanguage } from '@/lib/utils' import { detectLanguage } from '@/lib/utils'
import { useTranslationService } from '@/providers/TranslationServiceProvider'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { import {
EmbeddedHashtag, EmbeddedHashtag,
EmbeddedMention, EmbeddedMention,
EmbeddedNormalUrl, EmbeddedNormalUrl,
EmbeddedWebsocketUrl EmbeddedWebsocketUrl
} from '../Embedded' } from '../Embedded'
import { useTranslationService } from '@/providers/TranslationServiceProvider'
import { toast } from 'sonner'
export default function ProfileAbout({ about, className }: { about?: string; className?: string }) { export default function ProfileAbout({ about, className }: { about?: string; className?: string }) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
@@ -33,14 +33,11 @@ export default function ProfileAbout({ about, className }: { about?: string; cla
const nodes = parseContent(translatedAbout ?? about, [ const nodes = parseContent(translatedAbout ?? about, [
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
EmbeddedNormalUrlParser, EmbeddedUrlParser,
EmbeddedHashtagParser, EmbeddedHashtagParser,
EmbeddedMentionParser EmbeddedMentionParser
]) ])
return nodes.map((node, index) => { return nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
if (node.type === 'url') { if (node.type === 'url') {
return <EmbeddedNormalUrl key={index} url={node.data} /> return <EmbeddedNormalUrl key={index} url={node.data} />
} }
@@ -53,6 +50,7 @@ export default function ProfileAbout({ about, className }: { about?: string; cla
if (node.type === 'mention') { if (node.type === 'mention') {
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} /> return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
} }
return node.data
}) })
}, [about, translatedAbout]) }, [about, translatedAbout])

View File

@@ -3,13 +3,12 @@ import {
EMBEDDED_MENTION_REGEX, EMBEDDED_MENTION_REGEX,
EMOJI_SHORT_CODE_REGEX, EMOJI_SHORT_CODE_REGEX,
HASHTAG_REGEX, HASHTAG_REGEX,
IMAGE_REGEX,
LN_INVOICE_REGEX, LN_INVOICE_REGEX,
URL_REGEX, URL_REGEX,
MEDIA_REGEX,
WS_URL_REGEX, WS_URL_REGEX,
YOUTUBE_URL_REGEX YOUTUBE_URL_REGEX
} from '@/constants' } from '@/constants'
import { isImage, isMedia } from './url'
export type TEmbeddedNodeType = export type TEmbeddedNodeType =
| 'text' | 'text'
@@ -36,7 +35,9 @@ export type TEmbeddedNode =
data: string[] data: string[]
} }
type TContentParser = { type: Exclude<TEmbeddedNodeType, 'images'>; regex: RegExp } type TContentParser =
| { type: Exclude<TEmbeddedNodeType, 'images'>; regex: RegExp }
| ((content: string) => TEmbeddedNode[])
export const EmbeddedHashtagParser: TContentParser = { export const EmbeddedHashtagParser: TContentParser = {
type: 'hashtag', type: 'hashtag',
@@ -58,31 +59,11 @@ export const EmbeddedEventParser: TContentParser = {
regex: EMBEDDED_EVENT_REGEX regex: EMBEDDED_EVENT_REGEX
} }
export const EmbeddedImageParser: TContentParser = {
type: 'image',
regex: IMAGE_REGEX
}
export const EmbeddedMediaParser: TContentParser = {
type: 'media',
regex: MEDIA_REGEX
}
export const EmbeddedWebsocketUrlParser: TContentParser = { export const EmbeddedWebsocketUrlParser: TContentParser = {
type: 'websocket-url', type: 'websocket-url',
regex: WS_URL_REGEX regex: WS_URL_REGEX
} }
export const EmbeddedNormalUrlParser: TContentParser = {
type: 'url',
regex: URL_REGEX
}
export const EmbeddedYoutubeParser: TContentParser = {
type: 'youtube',
regex: YOUTUBE_URL_REGEX
}
export const EmbeddedEmojiParser: TContentParser = { export const EmbeddedEmojiParser: TContentParser = {
type: 'emoji', type: 'emoji',
regex: EMOJI_SHORT_CODE_REGEX regex: EMOJI_SHORT_CODE_REGEX
@@ -93,6 +74,48 @@ export const EmbeddedLNInvoiceParser: TContentParser = {
regex: LN_INVOICE_REGEX regex: LN_INVOICE_REGEX
} }
export const EmbeddedUrlParser: TContentParser = (content: string) => {
const matches = content.matchAll(URL_REGEX)
const result: TEmbeddedNode[] = []
let lastIndex = 0
for (const match of matches) {
const matchStart = match.index!
// Add text before the match
if (matchStart > lastIndex) {
result.push({
type: 'text',
data: content.slice(lastIndex, matchStart)
})
}
const url = match[0]
let type: TEmbeddedNodeType = 'url'
if (isImage(url)) {
type = 'image'
} else if (isMedia(url)) {
type = 'media'
} else if (YOUTUBE_URL_REGEX.test(url)) {
type = 'youtube'
}
// Add the match as specific type
result.push({
type,
data: url
})
lastIndex = matchStart + url.length
}
// Add text after the last match
if (lastIndex < content.length) {
result.push({
type: 'text',
data: content.slice(lastIndex)
})
}
return result
}
export function parseContent(content: string, parsers: TContentParser[]) { export function parseContent(content: string, parsers: TContentParser[]) {
let nodes: TEmbeddedNode[] = [{ type: 'text', data: content.trim() }] let nodes: TEmbeddedNode[] = [{ type: 'text', data: content.trim() }]
@@ -100,6 +123,11 @@ export function parseContent(content: string, parsers: TContentParser[]) {
nodes = nodes nodes = nodes
.flatMap((node) => { .flatMap((node) => {
if (node.type !== 'text') return [node] if (node.type !== 'text') return [node]
if (typeof parser === 'function') {
return parser(node.data)
}
const matches = node.data.matchAll(parser.regex) const matches = node.data.matchAll(parser.regex)
const result: TEmbeddedNode[] = [] const result: TEmbeddedNode[] = []
let lastIndex = 0 let lastIndex = 0

View File

@@ -70,7 +70,7 @@ export function getProfileFromEvent(event: Event) {
created_at: event.created_at created_at: event.created_at
} }
} catch (err) { } catch (err) {
console.error(err) console.error(event.content, err)
return { return {
pubkey: event.pubkey, pubkey: event.pubkey,
npub: pubkeyToNpub(event.pubkey) ?? '', npub: pubkeyToNpub(event.pubkey) ?? '',

View File

@@ -117,10 +117,22 @@ export function isImage(url: string) {
} }
} }
export function isVideo(url: string) { export function isMedia(url: string) {
try { try {
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov'] const mediaExtensions = [
return videoExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext)) '.mp4',
'.webm',
'.ogg',
'.mov',
'.mp3',
'.wav',
'.flac',
'.aac',
'.m4a',
'.opus',
'.wma'
]
return mediaExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext))
} catch { } catch {
return false return false
} }