17 changed files with 2412 additions and 268 deletions
@ -0,0 +1,212 @@
@@ -0,0 +1,212 @@
|
||||
import { useSecondaryPage } from '@/PageManager' |
||||
import ImageWithLightbox from '@/components/ImageWithLightbox' |
||||
import ImageGallery from '@/components/ImageGallery' |
||||
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' |
||||
import { toNoteList } from '@/lib/link' |
||||
import { ExternalLink } from 'lucide-react' |
||||
import { Event, kinds } from 'nostr-tools' |
||||
import { useMemo } from 'react' |
||||
import Markdown from 'react-markdown' |
||||
import remarkGfm from 'remark-gfm' |
||||
import remarkMath from 'remark-math' |
||||
import rehypeKatex from 'rehype-katex' |
||||
import NostrNode from '../LongFormArticle/NostrNode' |
||||
import { remarkNostr } from '../LongFormArticle/remarkNostr' |
||||
import { Components } from '../LongFormArticle/types' |
||||
import { useEventFieldParser } from '@/hooks/useContentParser' |
||||
import WebPreview from '../../WebPreview' |
||||
import 'katex/dist/katex.min.css' |
||||
|
||||
export default function Article({ |
||||
event, |
||||
className |
||||
}: { |
||||
event: Event |
||||
className?: string |
||||
}) { |
||||
const { push } = useSecondaryPage() |
||||
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) |
||||
|
||||
// Use the comprehensive content parser
|
||||
const { parsedContent, isLoading, error } = useEventFieldParser(event, 'content', { |
||||
enableMath: true, |
||||
enableSyntaxHighlighting: true |
||||
}) |
||||
|
||||
const components = useMemo( |
||||
() => |
||||
({ |
||||
nostr: ({ rawText, bech32Id }) => <NostrNode rawText={rawText} bech32Id={bech32Id} />, |
||||
a: ({ href, children, ...props }) => { |
||||
if (href?.startsWith('nostr:')) { |
||||
return <NostrNode rawText={href} bech32Id={href.slice(6)} /> |
||||
} |
||||
return ( |
||||
<a |
||||
href={href} |
||||
target="_blank" |
||||
rel="noreferrer noopener" |
||||
className="break-words inline-flex items-baseline gap-1" |
||||
{...props} |
||||
> |
||||
{children} |
||||
<ExternalLink className="size-3" /> |
||||
</a> |
||||
) |
||||
}, |
||||
p: (props) => { |
||||
// Check if paragraph contains only an image
|
||||
if (props.children && typeof props.children === 'string' && props.children.match(/^!\[.*\]\(.*\)$/)) { |
||||
return <div {...props} /> |
||||
} |
||||
return <p {...props} className="break-words" /> |
||||
}, |
||||
div: (props) => <div {...props} className="break-words" />, |
||||
code: (props) => <code {...props} className="break-words whitespace-pre-wrap" />, |
||||
img: (props) => ( |
||||
<ImageWithLightbox |
||||
image={{ url: props.src || '', pubkey: event.pubkey }} |
||||
className="max-h-[80vh] sm:max-h-[50vh] object-contain my-0 max-w-[400px]" |
||||
classNames={{ |
||||
wrapper: 'w-fit max-w-[400px]' |
||||
}} |
||||
/> |
||||
) |
||||
}) as Components, |
||||
[event.pubkey] |
||||
) |
||||
|
||||
if (isLoading) { |
||||
return ( |
||||
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words ${className || ''}`}> |
||||
<div>Loading content...</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (error) { |
||||
return ( |
||||
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words ${className || ''}`}> |
||||
<div className="text-red-500">Error loading content: {error.message}</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (!parsedContent) { |
||||
return ( |
||||
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words ${className || ''}`}> |
||||
<div>No content available</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div className={`${parsedContent.cssClasses} ${className || ''}`}> |
||||
{/* Article metadata */} |
||||
<h1 className="break-words">{metadata.title}</h1> |
||||
{metadata.summary && ( |
||||
<blockquote> |
||||
<p className="break-words">{metadata.summary}</p> |
||||
</blockquote> |
||||
)} |
||||
{metadata.image && ( |
||||
<ImageWithLightbox |
||||
image={{ url: metadata.image, pubkey: event.pubkey }} |
||||
className="w-full max-w-[400px] aspect-[3/1] object-cover my-0" |
||||
/> |
||||
)} |
||||
|
||||
|
||||
{/* Render content based on markup type */} |
||||
{parsedContent.markupType === 'asciidoc' ? ( |
||||
// AsciiDoc content (already processed to HTML)
|
||||
<div dangerouslySetInnerHTML={{ __html: parsedContent.html }} /> |
||||
) : ( |
||||
// Markdown content (let react-markdown handle it)
|
||||
<Markdown |
||||
remarkPlugins={[remarkGfm, remarkMath, remarkNostr]} |
||||
rehypePlugins={[rehypeKatex]} |
||||
urlTransform={(url) => { |
||||
if (url.startsWith('nostr:')) { |
||||
return url.slice(6) // Remove 'nostr:' prefix for rendering
|
||||
} |
||||
return url |
||||
}} |
||||
components={components} |
||||
> |
||||
{event.content} |
||||
</Markdown> |
||||
)} |
||||
|
||||
{/* Hashtags */} |
||||
{parsedContent.hashtags.length > 0 && ( |
||||
<div className="flex gap-2 flex-wrap pb-2"> |
||||
{parsedContent.hashtags.map((tag) => ( |
||||
<div |
||||
key={tag} |
||||
title={tag} |
||||
className="flex items-center rounded-full px-3 bg-muted text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground" |
||||
onClick={(e) => { |
||||
e.stopPropagation() |
||||
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) |
||||
}} |
||||
> |
||||
#<span className="truncate">{tag}</span> |
||||
</div> |
||||
))} |
||||
</div> |
||||
)} |
||||
|
||||
{/* Media thumbnails */} |
||||
{parsedContent.media.length > 0 && ( |
||||
<div className="mt-4 p-4 bg-muted rounded-lg"> |
||||
<h4 className="text-sm font-semibold mb-3">Images in this article:</h4> |
||||
<div className="grid grid-cols-8 sm:grid-cols-12 md:grid-cols-16 gap-1"> |
||||
{parsedContent.media.map((media, index) => ( |
||||
<div key={index} className="aspect-square"> |
||||
<ImageWithLightbox |
||||
image={media} |
||||
className="w-full h-full object-cover rounded cursor-pointer hover:opacity-80 transition-opacity" |
||||
classNames={{ |
||||
wrapper: 'w-full h-full' |
||||
}} |
||||
/> |
||||
</div> |
||||
))} |
||||
</div> |
||||
</div> |
||||
)} |
||||
|
||||
{/* Links summary with OpenGraph previews */} |
||||
{parsedContent.links.length > 0 && ( |
||||
<div className="mt-4 p-4 bg-muted rounded-lg"> |
||||
<h4 className="text-sm font-semibold mb-3">Links in this article:</h4> |
||||
<div className="space-y-3"> |
||||
{parsedContent.links.map((link, index) => ( |
||||
<WebPreview |
||||
key={index} |
||||
url={link.url} |
||||
className="w-full" |
||||
/> |
||||
))} |
||||
</div> |
||||
</div> |
||||
)} |
||||
|
||||
{/* Nostr links summary */} |
||||
{parsedContent.nostrLinks.length > 0 && ( |
||||
<div className="mt-4 p-4 bg-muted rounded-lg"> |
||||
<h4 className="text-sm font-semibold mb-2">Nostr references:</h4> |
||||
<div className="space-y-1"> |
||||
{parsedContent.nostrLinks.map((link, index) => ( |
||||
<div key={index} className="text-sm"> |
||||
<span className="font-mono text-blue-600">{link.type}:</span>{' '} |
||||
<span className="font-mono">{link.id}</span> |
||||
</div> |
||||
))} |
||||
</div> |
||||
</div> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,143 @@
@@ -0,0 +1,143 @@
|
||||
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' |
||||
import { toNote, toNoteList } from '@/lib/link' |
||||
import { useSecondaryPage } from '@/PageManager' |
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider' |
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||
import { Event, kinds } from 'nostr-tools' |
||||
import { nip19 } from 'nostr-tools' |
||||
import { useMemo } from 'react' |
||||
import { BookOpen } from 'lucide-react' |
||||
import Image from '../Image' |
||||
|
||||
export default function PublicationCard({ |
||||
event, |
||||
className |
||||
}: { |
||||
event: Event |
||||
className?: string |
||||
}) { |
||||
const { isSmallScreen } = useScreenSize() |
||||
const { push } = useSecondaryPage() |
||||
const { autoLoadMedia } = useContentPolicy() |
||||
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) |
||||
|
||||
// Generate naddr for Alexandria URL
|
||||
const naddr = useMemo(() => { |
||||
try { |
||||
const dTag = event.tags.find(tag => tag[0] === 'd')?.[1] || '' |
||||
const relays = event.tags |
||||
.filter(tag => tag[0] === 'relay') |
||||
.map(tag => tag[1]) |
||||
.filter(Boolean) |
||||
|
||||
return nip19.naddrEncode({ |
||||
kind: event.kind, |
||||
pubkey: event.pubkey, |
||||
identifier: dTag, |
||||
relays: relays.length > 0 ? relays : undefined |
||||
}) |
||||
} catch (error) { |
||||
console.error('Error generating naddr:', error) |
||||
return '' |
||||
} |
||||
}, [event]) |
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => { |
||||
e.stopPropagation() |
||||
push(toNote(event.id)) |
||||
} |
||||
|
||||
const handleAlexandriaClick = (e: React.MouseEvent) => { |
||||
e.stopPropagation() |
||||
if (naddr) { |
||||
window.open(`https://next-alexandria.gitcitadel.eu/publication/naddr/${naddr}`, '_blank', 'noopener,noreferrer') |
||||
} |
||||
} |
||||
|
||||
const titleComponent = <div className="text-xl font-semibold line-clamp-2">{metadata.title}</div> |
||||
|
||||
const tagsComponent = metadata.tags.length > 0 && ( |
||||
<div className="flex gap-1 flex-wrap"> |
||||
{metadata.tags.map((tag) => ( |
||||
<div |
||||
key={tag} |
||||
className="flex items-center rounded-full text-xs px-2.5 py-0.5 bg-muted text-muted-foreground max-w-32 cursor-pointer hover:bg-accent hover:text-accent-foreground" |
||||
onClick={(e) => { |
||||
e.stopPropagation() |
||||
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) |
||||
}} |
||||
> |
||||
#<span className="truncate">{tag}</span> |
||||
</div> |
||||
))} |
||||
</div> |
||||
) |
||||
|
||||
const summaryComponent = metadata.summary && ( |
||||
<div className="text-sm text-muted-foreground line-clamp-4">{metadata.summary}</div> |
||||
) |
||||
|
||||
const alexandriaButton = naddr && ( |
||||
<button |
||||
onClick={handleAlexandriaClick} |
||||
className="flex items-center gap-2 px-3 py-2 text-sm bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 text-blue-800 dark:text-blue-200 rounded-md transition-colors" |
||||
> |
||||
<BookOpen className="w-4 h-4" /> |
||||
View in Alexandria |
||||
</button> |
||||
) |
||||
|
||||
if (isSmallScreen) { |
||||
return ( |
||||
<div className={className}> |
||||
<div
|
||||
className="cursor-pointer rounded-lg border p-4 hover:bg-muted/50 transition-colors" |
||||
onClick={handleCardClick} |
||||
> |
||||
{metadata.image && autoLoadMedia && ( |
||||
<Image |
||||
image={{ url: metadata.image, pubkey: event.pubkey }} |
||||
className="w-full max-w-[400px] aspect-video mb-3" |
||||
hideIfError |
||||
/> |
||||
)} |
||||
<div className="space-y-2"> |
||||
{titleComponent} |
||||
{summaryComponent} |
||||
{tagsComponent} |
||||
<div className="flex justify-end"> |
||||
{alexandriaButton} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div className={className}> |
||||
<div
|
||||
className="cursor-pointer rounded-lg border p-4 hover:bg-muted/50 transition-colors" |
||||
onClick={handleCardClick} |
||||
> |
||||
<div className="flex gap-4"> |
||||
{metadata.image && autoLoadMedia && ( |
||||
<Image |
||||
image={{ url: metadata.image, pubkey: event.pubkey }} |
||||
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44 max-w-[400px]" |
||||
hideIfError |
||||
/> |
||||
)} |
||||
<div className="flex-1 w-0 space-y-2"> |
||||
{titleComponent} |
||||
{summaryComponent} |
||||
{tagsComponent} |
||||
<div className="flex justify-end"> |
||||
{alexandriaButton} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,167 @@
@@ -0,0 +1,167 @@
|
||||
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' |
||||
import { toNote, toNoteList } from '@/lib/link' |
||||
import { useSecondaryPage } from '@/PageManager' |
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider' |
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||
import { Event, kinds } from 'nostr-tools' |
||||
import { nip19 } from 'nostr-tools' |
||||
import { useMemo } from 'react' |
||||
import { BookOpen, Globe } from 'lucide-react' |
||||
import Image from '../Image' |
||||
|
||||
export default function WikiCard({ |
||||
event, |
||||
className |
||||
}: { |
||||
event: Event |
||||
className?: string |
||||
}) { |
||||
const { isSmallScreen } = useScreenSize() |
||||
const { push } = useSecondaryPage() |
||||
const { autoLoadMedia } = useContentPolicy() |
||||
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) |
||||
|
||||
// Extract d-tag for Wikistr URL
|
||||
const dTag = useMemo(() => { |
||||
return event.tags.find(tag => tag[0] === 'd')?.[1] || '' |
||||
}, [event]) |
||||
|
||||
// Generate naddr for Alexandria URL
|
||||
const naddr = useMemo(() => { |
||||
try { |
||||
const relays = event.tags |
||||
.filter(tag => tag[0] === 'relay') |
||||
.map(tag => tag[1]) |
||||
.filter(Boolean) |
||||
|
||||
return nip19.naddrEncode({ |
||||
kind: event.kind, |
||||
pubkey: event.pubkey, |
||||
identifier: dTag, |
||||
relays: relays.length > 0 ? relays : undefined |
||||
}) |
||||
} catch (error) { |
||||
console.error('Error generating naddr:', error) |
||||
return '' |
||||
} |
||||
}, [event, dTag]) |
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => { |
||||
e.stopPropagation() |
||||
push(toNote(event.id)) |
||||
} |
||||
|
||||
const handleWikistrClick = (e: React.MouseEvent) => { |
||||
e.stopPropagation() |
||||
if (dTag) { |
||||
window.open(`https://wikistr.imwald.eu/${dTag}*${event.pubkey}`, '_blank', 'noopener,noreferrer') |
||||
} |
||||
} |
||||
|
||||
const handleAlexandriaClick = (e: React.MouseEvent) => { |
||||
e.stopPropagation() |
||||
if (naddr) { |
||||
window.open(`https://next-alexandria.gitcitadel.eu/publication/naddr/${naddr}`, '_blank', 'noopener,noreferrer') |
||||
} |
||||
} |
||||
|
||||
const titleComponent = <div className="text-xl font-semibold line-clamp-2">{metadata.title}</div> |
||||
|
||||
const tagsComponent = metadata.tags.length > 0 && ( |
||||
<div className="flex gap-1 flex-wrap"> |
||||
{metadata.tags.map((tag) => ( |
||||
<div |
||||
key={tag} |
||||
className="flex items-center rounded-full text-xs px-2.5 py-0.5 bg-muted text-muted-foreground max-w-32 cursor-pointer hover:bg-accent hover:text-accent-foreground" |
||||
onClick={(e) => { |
||||
e.stopPropagation() |
||||
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) |
||||
}} |
||||
> |
||||
#<span className="truncate">{tag}</span> |
||||
</div> |
||||
))} |
||||
</div> |
||||
) |
||||
|
||||
const summaryComponent = metadata.summary && ( |
||||
<div className="text-sm text-muted-foreground line-clamp-4">{metadata.summary}</div> |
||||
) |
||||
|
||||
const buttons = ( |
||||
<div className="flex gap-2 flex-wrap"> |
||||
{dTag && ( |
||||
<button |
||||
onClick={handleWikistrClick} |
||||
className="flex items-center gap-2 px-3 py-2 text-sm bg-green-100 hover:bg-green-200 dark:bg-green-900 dark:hover:bg-green-800 text-green-800 dark:text-green-200 rounded-md transition-colors" |
||||
> |
||||
<Globe className="w-4 h-4" /> |
||||
View in Wikistr |
||||
</button> |
||||
)} |
||||
{naddr && ( |
||||
<button |
||||
onClick={handleAlexandriaClick} |
||||
className="flex items-center gap-2 px-3 py-2 text-sm bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 text-blue-800 dark:text-blue-200 rounded-md transition-colors" |
||||
> |
||||
<BookOpen className="w-4 h-4" /> |
||||
View in Alexandria |
||||
</button> |
||||
)} |
||||
</div> |
||||
) |
||||
|
||||
if (isSmallScreen) { |
||||
return ( |
||||
<div className={className}> |
||||
<div
|
||||
className="cursor-pointer rounded-lg border p-4 hover:bg-muted/50 transition-colors" |
||||
onClick={handleCardClick} |
||||
> |
||||
{metadata.image && autoLoadMedia && ( |
||||
<Image |
||||
image={{ url: metadata.image, pubkey: event.pubkey }} |
||||
className="w-full max-w-[400px] aspect-video mb-3" |
||||
hideIfError |
||||
/> |
||||
)} |
||||
<div className="space-y-2"> |
||||
{titleComponent} |
||||
{summaryComponent} |
||||
{tagsComponent} |
||||
<div className="flex justify-end"> |
||||
{buttons} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div className={className}> |
||||
<div
|
||||
className="cursor-pointer rounded-lg border p-4 hover:bg-muted/50 transition-colors" |
||||
onClick={handleCardClick} |
||||
> |
||||
<div className="flex gap-4"> |
||||
{metadata.image && autoLoadMedia && ( |
||||
<Image |
||||
image={{ url: metadata.image, pubkey: event.pubkey }} |
||||
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44 max-w-[400px]" |
||||
hideIfError |
||||
/> |
||||
)} |
||||
<div className="flex-1 w-0 space-y-2"> |
||||
{titleComponent} |
||||
{summaryComponent} |
||||
{tagsComponent} |
||||
<div className="flex justify-end"> |
||||
{buttons} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,216 @@
@@ -0,0 +1,216 @@
|
||||
/** |
||||
* Enhanced content component that uses the content parser service |
||||
* while maintaining compatibility with existing embedded content |
||||
*/ |
||||
|
||||
import { useTranslatedEvent } from '@/hooks' |
||||
import { |
||||
EmbeddedEmojiParser, |
||||
EmbeddedEventParser, |
||||
EmbeddedHashtagParser, |
||||
EmbeddedLNInvoiceParser, |
||||
EmbeddedMentionParser, |
||||
EmbeddedUrlParser, |
||||
EmbeddedWebsocketUrlParser, |
||||
parseContent |
||||
} from '@/lib/content-parser' |
||||
import logger from '@/lib/logger' |
||||
import { getImetaInfosFromEvent } from '@/lib/event' |
||||
import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag } from '@/lib/tag' |
||||
import { cn } from '@/lib/utils' |
||||
import { cleanUrl } from '@/lib/url' |
||||
import mediaUpload from '@/services/media-upload.service' |
||||
import { TImetaInfo } from '@/types' |
||||
import { Event } from 'nostr-tools' |
||||
import { useMemo } from 'react' |
||||
import { |
||||
EmbeddedHashtag, |
||||
EmbeddedLNInvoice, |
||||
EmbeddedMention, |
||||
EmbeddedNormalUrl, |
||||
EmbeddedNote, |
||||
EmbeddedWebsocketUrl |
||||
} from '../Embedded' |
||||
import Emoji from '../Emoji' |
||||
import ImageGallery from '../ImageGallery' |
||||
import MediaPlayer from '../MediaPlayer' |
||||
import WebPreview from '../WebPreview' |
||||
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer' |
||||
import ParsedContent from './ParsedContent' |
||||
|
||||
export default function EnhancedContent({ |
||||
event, |
||||
content, |
||||
className, |
||||
mustLoadMedia, |
||||
useEnhancedParsing = false |
||||
}: { |
||||
event?: Event |
||||
content?: string |
||||
className?: string |
||||
mustLoadMedia?: boolean |
||||
useEnhancedParsing?: boolean |
||||
}) { |
||||
const translatedEvent = useTranslatedEvent(event?.id) |
||||
|
||||
// If enhanced parsing is enabled and we have an event, use the new parser
|
||||
if (useEnhancedParsing && event) { |
||||
return ( |
||||
<ParsedContent |
||||
event={event} |
||||
field="content" |
||||
className={className} |
||||
showMedia={true} |
||||
showLinks={false} |
||||
showHashtags={false} |
||||
showNostrLinks={false} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
// Fallback to original parsing logic
|
||||
const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => { |
||||
const _content = translatedEvent?.content ?? event?.content ?? content |
||||
if (!_content) return {} |
||||
|
||||
const nodes = parseContent(_content, [ |
||||
EmbeddedUrlParser, |
||||
EmbeddedLNInvoiceParser, |
||||
EmbeddedWebsocketUrlParser, |
||||
EmbeddedEventParser, |
||||
EmbeddedMentionParser, |
||||
EmbeddedHashtagParser, |
||||
EmbeddedEmojiParser |
||||
]) |
||||
|
||||
const imetaInfos = event ? getImetaInfosFromEvent(event) : [] |
||||
const allImages = nodes |
||||
.map((node) => { |
||||
if (node.type === 'image') { |
||||
// Always ensure we have a valid image info object
|
||||
const imageInfo = imetaInfos.find((image) => image.url === node.data) |
||||
if (imageInfo) { |
||||
return imageInfo |
||||
} |
||||
|
||||
// Try to get imeta from media upload service
|
||||
const tag = mediaUpload.getImetaTagByUrl(node.data) |
||||
if (tag) { |
||||
const parsedImeta = getImetaInfoFromImetaTag(tag, event?.pubkey) |
||||
if (parsedImeta) { |
||||
return parsedImeta |
||||
} |
||||
} |
||||
|
||||
// Fallback: always create a basic image info object with cleaned URL
|
||||
return { url: cleanUrl(node.data), pubkey: event?.pubkey } |
||||
} |
||||
if (node.type === 'images') { |
||||
const urls = Array.isArray(node.data) ? node.data : [node.data] |
||||
return urls.map((url) => { |
||||
const imageInfo = imetaInfos.find((image) => image.url === url) |
||||
if (imageInfo) { |
||||
return imageInfo |
||||
} |
||||
|
||||
// Try to get imeta from media upload service
|
||||
const tag = mediaUpload.getImetaTagByUrl(url) |
||||
if (tag) { |
||||
const parsedImeta = getImetaInfoFromImetaTag(tag, event?.pubkey) |
||||
if (parsedImeta) { |
||||
return parsedImeta |
||||
} |
||||
} |
||||
|
||||
// Fallback: always create a basic image info object with cleaned URL
|
||||
return { url: cleanUrl(url), pubkey: event?.pubkey } |
||||
}) |
||||
} |
||||
return null |
||||
}) |
||||
.filter(Boolean) |
||||
.flat() as TImetaInfo[] |
||||
|
||||
const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags) |
||||
|
||||
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url') |
||||
const lastNormalUrl = |
||||
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined |
||||
|
||||
return { nodes, allImages, emojiInfos, lastNormalUrl } |
||||
}, [event, translatedEvent, content]) |
||||
|
||||
if (!nodes || nodes.length === 0) { |
||||
return null |
||||
} |
||||
|
||||
let imageIndex = 0 |
||||
logger.debug('[Content] Parsed content:', { nodeCount: nodes.length, allImages: allImages.length, nodes: nodes.map(n => ({ type: n.type, data: Array.isArray(n.data) ? n.data.length : n.data })) }) |
||||
return ( |
||||
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}> |
||||
{nodes.map((node, index) => { |
||||
if (node.type === 'text') { |
||||
return node.data |
||||
} |
||||
if (node.type === 'image' || node.type === 'images') { |
||||
const start = imageIndex |
||||
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1) |
||||
imageIndex = end |
||||
logger.debug('[Content] Creating ImageGallery:', { nodeType: node.type, start, end, totalImages: allImages.length, nodeData: Array.isArray(node.data) ? node.data.length : node.data }) |
||||
return ( |
||||
<ImageGallery |
||||
className="mt-2" |
||||
key={index} |
||||
images={allImages} |
||||
start={start} |
||||
end={end} |
||||
mustLoad={mustLoadMedia} |
||||
/> |
||||
) |
||||
} |
||||
if (node.type === 'media') { |
||||
return ( |
||||
<MediaPlayer className="mt-2" key={index} src={node.data} mustLoad={mustLoadMedia} /> |
||||
) |
||||
} |
||||
if (node.type === 'url') { |
||||
return <EmbeddedNormalUrl url={node.data} key={index} /> |
||||
} |
||||
if (node.type === 'invoice') { |
||||
return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" /> |
||||
} |
||||
if (node.type === 'websocket-url') { |
||||
return <EmbeddedWebsocketUrl url={node.data} key={index} /> |
||||
} |
||||
if (node.type === 'event') { |
||||
const id = node.data.split(':')[1] |
||||
return <EmbeddedNote key={index} noteId={id} className="mt-2" /> |
||||
} |
||||
if (node.type === 'mention') { |
||||
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} /> |
||||
} |
||||
if (node.type === 'hashtag') { |
||||
return <EmbeddedHashtag hashtag={node.data} key={index} /> |
||||
} |
||||
if (node.type === 'emoji') { |
||||
const shortcode = node.data.split(':')[1] |
||||
const emoji = emojiInfos.find((e) => e.shortcode === shortcode) |
||||
if (!emoji) return node.data |
||||
return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} /> |
||||
} |
||||
if (node.type === 'youtube') { |
||||
return ( |
||||
<YoutubeEmbeddedPlayer |
||||
key={index} |
||||
url={node.data} |
||||
className="mt-2" |
||||
mustLoad={mustLoadMedia} |
||||
/> |
||||
) |
||||
} |
||||
return null |
||||
})} |
||||
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />} |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,209 @@
@@ -0,0 +1,209 @@
|
||||
/** |
||||
* Universal content component that uses the content parser service |
||||
* for all Nostr content fields |
||||
*/ |
||||
|
||||
import { useEventFieldParser } from '@/hooks/useContentParser' |
||||
import { Event } from 'nostr-tools' |
||||
import { useMemo } from 'react' |
||||
import Markdown from 'react-markdown' |
||||
import remarkGfm from 'remark-gfm' |
||||
import remarkMath from 'remark-math' |
||||
import rehypeKatex from 'rehype-katex' |
||||
import { Components } from '../Note/LongFormArticle/types' |
||||
import NostrNode from '../Note/LongFormArticle/NostrNode' |
||||
import ImageWithLightbox from '../ImageWithLightbox' |
||||
import ImageGallery from '../ImageGallery' |
||||
import { ExternalLink } from 'lucide-react' |
||||
import WebPreview from '../WebPreview' |
||||
import 'katex/dist/katex.min.css' |
||||
|
||||
interface ParsedContentProps { |
||||
event: Event |
||||
field: 'content' | 'title' | 'summary' | 'description' |
||||
className?: string |
||||
enableMath?: boolean |
||||
enableSyntaxHighlighting?: boolean |
||||
showMedia?: boolean |
||||
showLinks?: boolean |
||||
showHashtags?: boolean |
||||
showNostrLinks?: boolean |
||||
maxImageWidth?: string |
||||
} |
||||
|
||||
export default function ParsedContent({ |
||||
event, |
||||
field, |
||||
className = '', |
||||
enableMath = true, |
||||
enableSyntaxHighlighting = true, |
||||
showMedia = true, |
||||
showLinks = false, |
||||
showHashtags = false, |
||||
showNostrLinks = false, |
||||
maxImageWidth = '400px' |
||||
}: ParsedContentProps) { |
||||
const { parsedContent, isLoading, error } = useEventFieldParser(event, field, { |
||||
enableMath, |
||||
enableSyntaxHighlighting |
||||
}) |
||||
|
||||
const components = useMemo( |
||||
() => |
||||
({ |
||||
nostr: ({ rawText, bech32Id }) => <NostrNode rawText={rawText} bech32Id={bech32Id} />, |
||||
a: ({ href, children, ...props }) => { |
||||
if (href?.startsWith('nostr:')) { |
||||
return <NostrNode rawText={href} bech32Id={href.slice(6)} /> |
||||
} |
||||
return ( |
||||
<a |
||||
href={href} |
||||
target="_blank" |
||||
rel="noreferrer noopener" |
||||
className="break-words inline-flex items-baseline gap-1" |
||||
{...props} |
||||
> |
||||
{children} |
||||
<ExternalLink className="size-3" /> |
||||
</a> |
||||
) |
||||
}, |
||||
p: (props) => { |
||||
// Check if paragraph contains only an image
|
||||
if (props.children && typeof props.children === 'string' && props.children.match(/^!\[.*\]\(.*\)$/)) { |
||||
return <div {...props} /> |
||||
} |
||||
return <p {...props} className="break-words" /> |
||||
}, |
||||
div: (props) => <div {...props} className="break-words" />, |
||||
code: (props) => <code {...props} className="break-words whitespace-pre-wrap" />, |
||||
img: (props) => ( |
||||
<ImageWithLightbox |
||||
image={{ url: props.src || '', pubkey: event.pubkey }} |
||||
className={`max-h-[80vh] sm:max-h-[50vh] object-contain my-0`} |
||||
classNames={{ |
||||
wrapper: 'w-fit' |
||||
}} |
||||
/> |
||||
) |
||||
}) as Components, |
||||
[event.pubkey, maxImageWidth] |
||||
) |
||||
|
||||
if (isLoading) { |
||||
return ( |
||||
<div className={`animate-pulse ${className}`}> |
||||
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div> |
||||
<div className="h-4 bg-muted rounded w-1/2"></div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (error) { |
||||
return ( |
||||
<div className={`text-red-500 text-sm ${className}`}> |
||||
Error loading content: {error.message} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (!parsedContent) { |
||||
return ( |
||||
<div className={`text-muted-foreground text-sm ${className}`}> |
||||
No content available |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div className={`${parsedContent.cssClasses} ${className}`}> |
||||
{/* Render content based on markup type */} |
||||
{parsedContent.markupType === 'asciidoc' ? ( |
||||
// AsciiDoc content (already processed to HTML)
|
||||
<div dangerouslySetInnerHTML={{ __html: parsedContent.html }} /> |
||||
) : ( |
||||
// Markdown content (let react-markdown handle it)
|
||||
<Markdown |
||||
remarkPlugins={[remarkGfm, remarkMath]} |
||||
rehypePlugins={[rehypeKatex]} |
||||
urlTransform={(url) => { |
||||
if (url.startsWith('nostr:')) { |
||||
return url.slice(6) // Remove 'nostr:' prefix for rendering
|
||||
} |
||||
return url |
||||
}} |
||||
components={components} |
||||
> |
||||
{field === 'content' ? event.content : event.tags?.find(tag => tag[0] === field)?.[1] || ''} |
||||
</Markdown> |
||||
)} |
||||
|
||||
{/* Media thumbnails */} |
||||
{showMedia && parsedContent.media.length > 0 && ( |
||||
<div className="mt-4 p-4 bg-muted rounded-lg"> |
||||
<h4 className="text-sm font-semibold mb-3">Images in this content:</h4> |
||||
<div className="grid grid-cols-8 sm:grid-cols-12 md:grid-cols-16 gap-1"> |
||||
{parsedContent.media.map((media, index) => ( |
||||
<div key={index} className="aspect-square"> |
||||
<ImageWithLightbox |
||||
image={media} |
||||
className="w-full h-full object-cover rounded cursor-pointer hover:opacity-80 transition-opacity" |
||||
classNames={{ |
||||
wrapper: 'w-full h-full' |
||||
}} |
||||
/> |
||||
</div> |
||||
))} |
||||
</div> |
||||
</div> |
||||
)} |
||||
|
||||
{/* Links summary with OpenGraph previews */} |
||||
{showLinks && parsedContent.links.length > 0 && ( |
||||
<div className="mt-4 p-4 bg-muted rounded-lg"> |
||||
<h4 className="text-sm font-semibold mb-3">Links in this content:</h4> |
||||
<div className="space-y-3"> |
||||
{parsedContent.links.map((link, index) => ( |
||||
<WebPreview |
||||
key={index} |
||||
url={link.url} |
||||
className="w-full" |
||||
/> |
||||
))} |
||||
</div> |
||||
</div> |
||||
)} |
||||
|
||||
{/* Hashtags */} |
||||
{showHashtags && parsedContent.hashtags.length > 0 && ( |
||||
<div className="flex gap-2 flex-wrap pb-2"> |
||||
{parsedContent.hashtags.map((tag) => ( |
||||
<div |
||||
key={tag} |
||||
title={tag} |
||||
className="flex items-center rounded-full px-3 bg-muted text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground" |
||||
> |
||||
#<span className="truncate">{tag}</span> |
||||
</div> |
||||
))} |
||||
</div> |
||||
)} |
||||
|
||||
{/* Nostr links summary */} |
||||
{showNostrLinks && parsedContent.nostrLinks.length > 0 && ( |
||||
<div className="mt-4 p-4 bg-muted rounded-lg"> |
||||
<h4 className="text-sm font-semibold mb-2">Nostr references:</h4> |
||||
<div className="space-y-1"> |
||||
{parsedContent.nostrLinks.map((link, index) => ( |
||||
<div key={index} className="text-sm"> |
||||
<span className="font-mono text-blue-600">{link.type}:</span>{' '} |
||||
<span className="font-mono">{link.id}</span> |
||||
</div> |
||||
))} |
||||
</div> |
||||
</div> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,154 @@
@@ -0,0 +1,154 @@
|
||||
/** |
||||
* React hook for content parsing |
||||
*/ |
||||
|
||||
import { useState, useEffect } from 'react' |
||||
import { Event } from 'nostr-tools' |
||||
import { contentParserService, ParsedContent, ParseOptions } from '@/services/content-parser.service' |
||||
|
||||
export interface UseContentParserOptions extends ParseOptions { |
||||
autoParse?: boolean |
||||
} |
||||
|
||||
export interface UseContentParserReturn { |
||||
parsedContent: ParsedContent | null |
||||
isLoading: boolean |
||||
error: Error | null |
||||
parse: () => Promise<void> |
||||
} |
||||
|
||||
/** |
||||
* Hook for parsing content with automatic detection and processing |
||||
*/ |
||||
export function useContentParser( |
||||
content: string, |
||||
options: UseContentParserOptions = {} |
||||
): UseContentParserReturn { |
||||
const { autoParse = true, ...parseOptions } = options |
||||
const [parsedContent, setParsedContent] = useState<ParsedContent | null>(null) |
||||
const [isLoading, setIsLoading] = useState(false) |
||||
const [error, setError] = useState<Error | null>(null) |
||||
|
||||
const parse = async () => { |
||||
if (!content.trim()) { |
||||
setParsedContent(null) |
||||
return |
||||
} |
||||
|
||||
try { |
||||
setIsLoading(true) |
||||
setError(null) |
||||
const result = await contentParserService.parseContent(content, parseOptions) |
||||
setParsedContent(result) |
||||
} catch (err) { |
||||
setError(err instanceof Error ? err : new Error('Unknown parsing error')) |
||||
setParsedContent(null) |
||||
} finally { |
||||
setIsLoading(false) |
||||
} |
||||
} |
||||
|
||||
useEffect(() => { |
||||
if (autoParse) { |
||||
parse() |
||||
} |
||||
}, [content, autoParse, JSON.stringify(parseOptions)]) |
||||
|
||||
return { |
||||
parsedContent, |
||||
isLoading, |
||||
error, |
||||
parse |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Hook for parsing Nostr event fields |
||||
*/ |
||||
export function useEventFieldParser( |
||||
event: Event, |
||||
field: 'content' | 'title' | 'summary' | 'description', |
||||
options: Omit<UseContentParserOptions, 'eventKind' | 'field'> = {} |
||||
): UseContentParserReturn { |
||||
const [parsedContent, setParsedContent] = useState<ParsedContent | null>(null) |
||||
const [isLoading, setIsLoading] = useState(false) |
||||
const [error, setError] = useState<Error | null>(null) |
||||
|
||||
const { autoParse = true, ...parseOptions } = options |
||||
|
||||
const parse = async () => { |
||||
try { |
||||
setIsLoading(true) |
||||
setError(null) |
||||
const result = await contentParserService.parseEventField(event, field, parseOptions) |
||||
setParsedContent(result) |
||||
} catch (err) { |
||||
setError(err instanceof Error ? err : new Error('Unknown parsing error')) |
||||
setParsedContent(null) |
||||
} finally { |
||||
setIsLoading(false) |
||||
} |
||||
} |
||||
|
||||
useEffect(() => { |
||||
if (autoParse) { |
||||
parse() |
||||
} |
||||
}, [event.id, field, autoParse, JSON.stringify(parseOptions)]) |
||||
|
||||
return { |
||||
parsedContent, |
||||
isLoading, |
||||
error, |
||||
parse |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Hook for parsing multiple event fields at once |
||||
*/ |
||||
export function useEventFieldsParser( |
||||
event: Event, |
||||
fields: Array<'content' | 'title' | 'summary' | 'description'>, |
||||
options: Omit<UseContentParserOptions, 'eventKind' | 'field'> = {} |
||||
) { |
||||
const [parsedFields, setParsedFields] = useState<Record<string, ParsedContent | null>>({}) |
||||
const [isLoading, setIsLoading] = useState(false) |
||||
const [error, setError] = useState<Error | null>(null) |
||||
|
||||
const { autoParse = true, ...parseOptions } = options |
||||
|
||||
const parse = async () => { |
||||
try { |
||||
setIsLoading(true) |
||||
setError(null) |
||||
|
||||
const results: Record<string, ParsedContent | null> = {} |
||||
|
||||
for (const field of fields) { |
||||
const result = await contentParserService.parseEventField(event, field, parseOptions) |
||||
results[field] = result |
||||
} |
||||
|
||||
setParsedFields(results) |
||||
} catch (err) { |
||||
setError(err instanceof Error ? err : new Error('Unknown parsing error')) |
||||
setParsedFields({}) |
||||
} finally { |
||||
setIsLoading(false) |
||||
} |
||||
} |
||||
|
||||
useEffect(() => { |
||||
if (autoParse) { |
||||
parse() |
||||
} |
||||
}, [event.id, JSON.stringify(fields), autoParse, JSON.stringify(parseOptions)]) |
||||
|
||||
return { |
||||
parsedFields, |
||||
isLoading, |
||||
error, |
||||
parse |
||||
} |
||||
} |
||||
@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
|
||||
import { getImetaInfosFromEvent } from './event' |
||||
import { TImetaInfo } from '@/types' |
||||
import { Event } from 'nostr-tools' |
||||
|
||||
/** |
||||
* Extract all media URLs from an article event |
||||
*/ |
||||
export function extractArticleMedia(event: Event): TImetaInfo[] { |
||||
const media: TImetaInfo[] = [] |
||||
const seenUrls = new Set<string>() |
||||
|
||||
// Extract from imeta tags
|
||||
const imetaInfos = getImetaInfosFromEvent(event) |
||||
imetaInfos.forEach(imeta => { |
||||
if (!seenUrls.has(imeta.url)) { |
||||
seenUrls.add(imeta.url) |
||||
media.push(imeta) |
||||
} |
||||
}) |
||||
|
||||
// Extract from metadata tags
|
||||
const imageTag = event.tags.find(tag => tag[0] === 'image')?.[1] |
||||
if (imageTag && !seenUrls.has(imageTag)) { |
||||
seenUrls.add(imageTag) |
||||
media.push({ |
||||
url: imageTag, |
||||
pubkey: event.pubkey |
||||
}) |
||||
} |
||||
|
||||
// Extract URLs from content (image/video extensions)
|
||||
const contentUrls = extractUrlsFromContent(event.content) |
||||
contentUrls.forEach(url => { |
||||
if (!seenUrls.has(url)) { |
||||
seenUrls.add(url) |
||||
media.push({ |
||||
url, |
||||
pubkey: event.pubkey |
||||
}) |
||||
} |
||||
}) |
||||
|
||||
return media |
||||
} |
||||
|
||||
/** |
||||
* Extract URLs from content that look like media files |
||||
*/ |
||||
function extractUrlsFromContent(content: string): string[] { |
||||
const urls: string[] = [] |
||||
|
||||
// Match URLs in content
|
||||
const urlRegex = /https?:\/\/[^\s<>"']+/g |
||||
const matches = content.match(urlRegex) || [] |
||||
|
||||
matches.forEach(url => { |
||||
try { |
||||
const urlObj = new URL(url) |
||||
const pathname = urlObj.pathname.toLowerCase() |
||||
|
||||
// Check if it's a media file
|
||||
const mediaExtensions = [ |
||||
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.tiff', |
||||
'.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv', '.flv', |
||||
'.mp3', '.wav', '.flac', '.aac', '.m4a' |
||||
] |
||||
|
||||
const isMediaFile = mediaExtensions.some(ext => pathname.endsWith(ext)) |
||||
|
||||
if (isMediaFile) { |
||||
urls.push(url) |
||||
} |
||||
} catch { |
||||
// Invalid URL, skip
|
||||
} |
||||
}) |
||||
|
||||
return urls |
||||
} |
||||
@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
|
||||
/** |
||||
* Markdown cleanup utility for leftover markdown syntax after Asciidoc rendering |
||||
*/ |
||||
|
||||
export function cleanupMarkdown(html: string): string { |
||||
let cleaned = html |
||||
|
||||
// Clean up markdown image syntax: 
|
||||
cleaned = cleaned.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => { |
||||
const altText = alt || '' |
||||
return `<img src="${url}" alt="${altText}" class="max-w-[400px] object-contain my-0" />` |
||||
}) |
||||
|
||||
// Clean up markdown link syntax: [text](url)
|
||||
cleaned = cleaned.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => { |
||||
// Check if it's already an HTML link
|
||||
if (cleaned.includes(`href="${url}"`)) { |
||||
return _match |
||||
} |
||||
return `<a href="${url}" target="_blank" rel="noreferrer noopener" class="break-words inline-flex items-baseline gap-1">${text} <svg class="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg></a>` |
||||
}) |
||||
|
||||
// Clean up markdown table syntax
|
||||
cleaned = cleanupMarkdownTables(cleaned) |
||||
|
||||
return cleaned |
||||
} |
||||
|
||||
function cleanupMarkdownTables(html: string): string { |
||||
// Simple markdown table detection and conversion
|
||||
const tableRegex = /(\|.*\|[\r\n]+\|[\s\-\|]*[\r\n]+(\|.*\|[\r\n]+)*)/g |
||||
|
||||
return html.replace(tableRegex, (match) => { |
||||
const lines = match.trim().split('\n').filter(line => line.trim()) |
||||
if (lines.length < 2) return match |
||||
|
||||
const headerRow = lines[0] |
||||
const separatorRow = lines[1] |
||||
const dataRows = lines.slice(2) |
||||
|
||||
// Check if it's actually a table (has separator row with dashes)
|
||||
if (!separatorRow.includes('-')) return match |
||||
|
||||
const headers = headerRow.split('|').map(cell => cell.trim()).filter(cell => cell) |
||||
const rows = dataRows.map(row =>
|
||||
row.split('|').map(cell => cell.trim()).filter(cell => cell) |
||||
) |
||||
|
||||
let tableHtml = '<table class="min-w-full border-collapse border border-gray-300 my-4">\n' |
||||
|
||||
// Header
|
||||
tableHtml += ' <thead>\n <tr>\n' |
||||
headers.forEach(header => { |
||||
tableHtml += ` <th class="border border-gray-300 px-4 py-2 bg-gray-50 font-semibold text-left">${header}</th>\n` |
||||
}) |
||||
tableHtml += ' </tr>\n </thead>\n' |
||||
|
||||
// Body
|
||||
tableHtml += ' <tbody>\n' |
||||
rows.forEach(row => { |
||||
tableHtml += ' <tr>\n' |
||||
row.forEach((cell, index) => { |
||||
const tag = index < headers.length ? 'td' : 'td' |
||||
tableHtml += ` <${tag} class="border border-gray-300 px-4 py-2">${cell}</${tag}>\n` |
||||
}) |
||||
tableHtml += ' </tr>\n' |
||||
}) |
||||
tableHtml += ' </tbody>\n' |
||||
tableHtml += '</table>' |
||||
|
||||
return tableHtml |
||||
}) |
||||
} |
||||
@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
|
||||
/** |
||||
* Markup detection and processing utilities |
||||
*/ |
||||
|
||||
export type MarkupType = 'asciidoc' | 'advanced-markdown' | 'basic-markdown' | 'plain-text' |
||||
|
||||
/** |
||||
* Detect the type of markup used in content |
||||
*/ |
||||
export function detectMarkupType(content: string, eventKind?: number): MarkupType { |
||||
// Publications and wikis use AsciiDoc
|
||||
if (eventKind === 30040 || eventKind === 30041 || eventKind === 30818) { |
||||
return 'asciidoc' |
||||
} |
||||
|
||||
// Check for AsciiDoc syntax patterns
|
||||
const asciidocPatterns = [ |
||||
/^=+\s/, // Headers: = Title, == Section
|
||||
/^\*+\s/, // Lists: * item
|
||||
/^\.+\s/, // Lists: . item
|
||||
/^\[\[/, // Cross-references: [[ref]]
|
||||
/^<</, // Cross-references: <<ref>>
|
||||
/^include::/, // Includes: include::file[]
|
||||
/^image::/, // Images: image::url[alt,width]
|
||||
/^link:/, // Links: link:url[text]
|
||||
/^footnote:/, // Footnotes: footnote:[text]
|
||||
/^NOTE:/, // Admonitions: NOTE:, TIP:, WARNING:, etc.
|
||||
/^TIP:/, |
||||
/^WARNING:/, |
||||
/^IMPORTANT:/, |
||||
/^CAUTION:/, |
||||
/^\[source,/, // Source blocks: [source,javascript]
|
||||
/^----/, // Delimited blocks: ----, ++++, etc.
|
||||
/^\+\+\+\+/, |
||||
/^\|\|/, // Tables: || cell ||
|
||||
/^\[\[.*\]\]/, // Wikilinks: [[NIP-54]]
|
||||
] |
||||
|
||||
const hasAsciidocSyntax = asciidocPatterns.some(pattern => pattern.test(content.trim())) |
||||
if (hasAsciidocSyntax) { |
||||
return 'asciidoc' |
||||
} |
||||
|
||||
// Check for advanced Markdown features
|
||||
const advancedMarkdownPatterns = [ |
||||
/```[\s\S]*?```/, // Code blocks
|
||||
/`[^`]+`/, // Inline code
|
||||
/^\|.*\|.*\|/, // Tables
|
||||
/\[\^[\w\d]+\]/, // Footnotes: [^1]
|
||||
/\[\^[\w\d]+\]:/, // Footnote references: [^1]:
|
||||
/\[\[[\w\-\s]+\]\]/, // Wikilinks: [[NIP-54]]
|
||||
] |
||||
|
||||
const hasAdvancedMarkdown = advancedMarkdownPatterns.some(pattern => pattern.test(content)) |
||||
if (hasAdvancedMarkdown) { |
||||
return 'advanced-markdown' |
||||
} |
||||
|
||||
// Check for basic Markdown features
|
||||
const basicMarkdownPatterns = [ |
||||
/^#+\s/, // Headers: # Title
|
||||
/^\*\s/, // Lists: * item
|
||||
/^\d+\.\s/, // Ordered lists: 1. item
|
||||
/\[.*?\]\(.*?\)/, // Links: [text](url)
|
||||
/!\[.*?\]\(.*?\)/, // Images: 
|
||||
/^\>\s/, // Blockquotes: > text
|
||||
/\*.*?\*/, // Bold: *text*
|
||||
/_.*?_/, // Italic: _text_
|
||||
/~.*?~/, // Strikethrough: ~text~
|
||||
/#[\w]+/, // Hashtags: #hashtag
|
||||
/:[\w]+:/, // Emoji: :smile:
|
||||
] |
||||
|
||||
const hasBasicMarkdown = basicMarkdownPatterns.some(pattern => pattern.test(content)) |
||||
if (hasBasicMarkdown) { |
||||
return 'basic-markdown' |
||||
} |
||||
|
||||
return 'plain-text' |
||||
} |
||||
|
||||
/** |
||||
* Get the appropriate CSS classes for the detected markup type |
||||
*/ |
||||
export function getMarkupClasses(markupType: MarkupType): string { |
||||
const baseClasses = "prose prose-zinc max-w-none dark:prose-invert break-words" |
||||
|
||||
switch (markupType) { |
||||
case 'asciidoc': |
||||
return `${baseClasses} asciidoc-content` |
||||
case 'advanced-markdown': |
||||
return `${baseClasses} markdown-content advanced` |
||||
case 'basic-markdown': |
||||
return `${baseClasses} markdown-content basic` |
||||
case 'plain-text': |
||||
return `${baseClasses} plain-text` |
||||
default: |
||||
return baseClasses |
||||
} |
||||
} |
||||
@ -0,0 +1,640 @@
@@ -0,0 +1,640 @@
|
||||
/** |
||||
* Comprehensive content parsing service for all Nostr content fields |
||||
* Supports AsciiDoc, Advanced Markdown, Basic Markdown, and LaTeX |
||||
*/ |
||||
|
||||
import { detectMarkupType, getMarkupClasses, MarkupType } from '@/lib/markup-detection' |
||||
import { Event } from 'nostr-tools' |
||||
import { getImetaInfosFromEvent } from '@/lib/event' |
||||
import { URL_REGEX } from '@/constants' |
||||
import { TImetaInfo } from '@/types' |
||||
|
||||
export interface ParsedContent { |
||||
html: string |
||||
markupType: MarkupType |
||||
cssClasses: string |
||||
hasMath: boolean |
||||
media: TImetaInfo[] |
||||
links: Array<{ url: string; text: string; isExternal: boolean }> |
||||
hashtags: string[] |
||||
nostrLinks: Array<{ type: 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note'; id: string; text: string }> |
||||
} |
||||
|
||||
export interface ParseOptions { |
||||
eventKind?: number |
||||
field?: 'content' | 'title' | 'summary' | 'description' |
||||
maxWidth?: string |
||||
enableMath?: boolean |
||||
enableSyntaxHighlighting?: boolean |
||||
} |
||||
|
||||
class ContentParserService { |
||||
private asciidoctor: any = null |
||||
private isAsciidoctorLoaded = false |
||||
|
||||
/** |
||||
* Initialize AsciiDoctor (lazy loading) |
||||
*/ |
||||
private async loadAsciidoctor() { |
||||
if (this.isAsciidoctorLoaded) return this.asciidoctor |
||||
|
||||
try { |
||||
const Asciidoctor = await import('@asciidoctor/core') |
||||
this.asciidoctor = Asciidoctor.default() |
||||
this.isAsciidoctorLoaded = true |
||||
return this.asciidoctor |
||||
} catch (error) { |
||||
console.warn('Failed to load AsciiDoctor:', error) |
||||
return null |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Parse content with appropriate markup processor |
||||
*/ |
||||
async parseContent( |
||||
content: string,
|
||||
options: ParseOptions = {}, |
||||
event?: Event |
||||
): Promise<ParsedContent> { |
||||
const { |
||||
eventKind, |
||||
enableMath = true, |
||||
enableSyntaxHighlighting = true |
||||
} = options |
||||
|
||||
// Detect markup type
|
||||
const markupType = detectMarkupType(content, eventKind) |
||||
const cssClasses = getMarkupClasses(markupType) |
||||
|
||||
// Extract all content elements
|
||||
const media = this.extractAllMedia(content, event) |
||||
const links = this.extractLinks(content) |
||||
const hashtags = this.extractHashtags(content) |
||||
const nostrLinks = this.extractNostrLinks(content) |
||||
|
||||
// Check for LaTeX math
|
||||
const hasMath = enableMath && this.hasMathContent(content) |
||||
|
||||
let html = '' |
||||
let processedContent = content |
||||
|
||||
try { |
||||
switch (markupType) { |
||||
case 'asciidoc': |
||||
html = await this.parseAsciidoc(content, { enableMath, enableSyntaxHighlighting }) |
||||
break |
||||
|
||||
case 'advanced-markdown': |
||||
processedContent = this.preprocessAdvancedMarkdown(content) |
||||
html = await this.parseAdvancedMarkdown(processedContent, { enableMath, enableSyntaxHighlighting }) |
||||
break |
||||
|
||||
case 'basic-markdown': |
||||
processedContent = this.preprocessBasicMarkdown(content) |
||||
html = await this.parseBasicMarkdown(processedContent) |
||||
break |
||||
|
||||
case 'plain-text': |
||||
default: |
||||
html = this.parsePlainText(content) |
||||
break |
||||
} |
||||
} catch (error) { |
||||
console.error('Content parsing error:', error) |
||||
// Fallback to plain text
|
||||
html = this.parsePlainText(content) |
||||
} |
||||
|
||||
return { |
||||
html, |
||||
markupType, |
||||
cssClasses, |
||||
hasMath, |
||||
media, |
||||
links, |
||||
hashtags, |
||||
nostrLinks |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Parse AsciiDoc content |
||||
*/ |
||||
private async parseAsciidoc(content: string, options: { enableMath: boolean; enableSyntaxHighlighting: boolean }): Promise<string> { |
||||
const asciidoctor = await this.loadAsciidoctor() |
||||
if (!asciidoctor) { |
||||
return this.parsePlainText(content) |
||||
} |
||||
|
||||
try { |
||||
const result = asciidoctor.convert(content, { |
||||
safe: 'safe', |
||||
backend: 'html5', |
||||
doctype: 'article', |
||||
attributes: { |
||||
'showtitle': true, |
||||
'sectanchors': true, |
||||
'sectlinks': true, |
||||
'source-highlighter': options.enableSyntaxHighlighting ? 'highlight.js' : 'none', |
||||
'stem': options.enableMath ? 'latexmath' : 'none' |
||||
} |
||||
}) |
||||
|
||||
const htmlString = typeof result === 'string' ? result : result.toString() |
||||
|
||||
// Clean up any leftover markdown syntax
|
||||
return this.cleanupMarkdown(htmlString) |
||||
} catch (error) { |
||||
console.error('AsciiDoc parsing error:', error) |
||||
return this.parsePlainText(content) |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Parse advanced Markdown content |
||||
*/ |
||||
private async parseAdvancedMarkdown(content: string, _options: { enableMath: boolean; enableSyntaxHighlighting: boolean }): Promise<string> { |
||||
// This will be handled by react-markdown with plugins
|
||||
// Return the processed content for react-markdown to handle
|
||||
return content |
||||
} |
||||
|
||||
/** |
||||
* Parse basic Markdown content |
||||
*/ |
||||
private parseBasicMarkdown(content: string): string { |
||||
// Basic markdown processing
|
||||
let processed = content |
||||
|
||||
// Headers
|
||||
processed = processed.replace(/^### (.*$)/gim, '<h3>$1</h3>') |
||||
processed = processed.replace(/^## (.*$)/gim, '<h2>$1</h2>') |
||||
processed = processed.replace(/^# (.*$)/gim, '<h1>$1</h1>') |
||||
|
||||
// Bold and italic
|
||||
processed = processed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') |
||||
processed = processed.replace(/\*(.*?)\*/g, '<em>$1</em>') |
||||
processed = processed.replace(/_(.*?)_/g, '<em>$1</em>') |
||||
processed = processed.replace(/~(.*?)~/g, '<del>$1</del>') |
||||
|
||||
// Links and images
|
||||
processed = this.processLinks(processed) |
||||
processed = this.processImages(processed) |
||||
|
||||
// Lists
|
||||
processed = this.processLists(processed) |
||||
|
||||
// Blockquotes
|
||||
processed = processed.replace(/^> (.*$)/gim, '<blockquote>$1</blockquote>') |
||||
|
||||
// Line breaks
|
||||
processed = processed.replace(/\n\n/g, '</p><p>') |
||||
processed = `<p>${processed}</p>` |
||||
|
||||
return processed |
||||
} |
||||
|
||||
/** |
||||
* Parse plain text content |
||||
*/ |
||||
private parsePlainText(content: string): string { |
||||
// Convert line breaks to HTML
|
||||
return content |
||||
.replace(/\n\n/g, '</p><p>') |
||||
.replace(/\n/g, '<br>') |
||||
.replace(/^/, '<p>') |
||||
.replace(/$/, '</p>') |
||||
} |
||||
|
||||
/** |
||||
* Preprocess advanced Markdown content |
||||
*/ |
||||
private preprocessAdvancedMarkdown(content: string): string { |
||||
// Handle wikilinks: [[NIP-54]] -> [NIP-54](https://next-alexandria.gitcitadel.eu/publication?d=nip-54)
|
||||
content = content.replace(/\[\[([^\]]+)\]\]/g, (_match, text) => { |
||||
const slug = text.toLowerCase().replace(/\s+/g, '-') |
||||
return `[${text}](https://next-alexandria.gitcitadel.eu/publication?d=${slug})` |
||||
}) |
||||
|
||||
// Handle hashtags: #hashtag -> [#hashtag](/hashtag/hashtag)
|
||||
content = content.replace(/#([a-zA-Z0-9_]+)/g, (_match, tag) => { |
||||
return `[#${tag}](/hashtag/${tag})` |
||||
}) |
||||
|
||||
return content |
||||
} |
||||
|
||||
/** |
||||
* Preprocess basic Markdown content |
||||
*/ |
||||
private preprocessBasicMarkdown(content: string): string { |
||||
// Handle hashtags
|
||||
content = content.replace(/#([a-zA-Z0-9_]+)/g, (_match, tag) => { |
||||
return `[#${tag}](/hashtag/${tag})` |
||||
}) |
||||
|
||||
// Handle emoji shortcodes
|
||||
content = content.replace(/:([a-zA-Z0-9_]+):/g, (_match, _emoji) => { |
||||
// This would need an emoji mapping - for now just return as-is
|
||||
return _match |
||||
}) |
||||
|
||||
return content |
||||
} |
||||
|
||||
/** |
||||
* Process markdown links |
||||
*/ |
||||
private processLinks(content: string): string { |
||||
return content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { |
||||
// Check if it's already an HTML link
|
||||
if (content.includes(`href="${url}"`)) { |
||||
return match |
||||
} |
||||
|
||||
// Handle nostr: prefixes
|
||||
if (url.startsWith('nostr:')) { |
||||
return `<span class="nostr-link" data-nostr="${url}">${text}</span>` |
||||
} |
||||
|
||||
return `<a href="${url}" target="_blank" rel="noreferrer noopener" class="break-words inline-flex items-baseline gap-1">${text} <svg class="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg></a>` |
||||
}) |
||||
} |
||||
|
||||
/** |
||||
* Process markdown images |
||||
*/ |
||||
private processImages(content: string): string { |
||||
return content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => { |
||||
const altText = alt || '' |
||||
return `<img src="${url}" alt="${altText}" class="max-w-[400px] object-contain my-0" />` |
||||
}) |
||||
} |
||||
|
||||
/** |
||||
* Process markdown lists |
||||
*/ |
||||
private processLists(content: string): string { |
||||
// Unordered lists
|
||||
content = content.replace(/^[\s]*\* (.+)$/gm, '<li>$1</li>') |
||||
content = content.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>') |
||||
|
||||
// Ordered lists
|
||||
content = content.replace(/^[\s]*\d+\. (.+)$/gm, '<li>$1</li>') |
||||
content = content.replace(/(<li>.*<\/li>)/s, '<ol>$1</ol>') |
||||
|
||||
return content |
||||
} |
||||
|
||||
/** |
||||
* Clean up leftover markdown syntax after AsciiDoc processing |
||||
*/ |
||||
private cleanupMarkdown(html: string): string { |
||||
let cleaned = html |
||||
|
||||
// Clean up markdown image syntax: 
|
||||
cleaned = cleaned.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => { |
||||
const altText = alt || '' |
||||
return `<img src="${url}" alt="${altText}" class="max-w-[400px] object-contain my-0" />` |
||||
}) |
||||
|
||||
// Clean up markdown link syntax: [text](url)
|
||||
cleaned = cleaned.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => { |
||||
// Check if it's already an HTML link
|
||||
if (cleaned.includes(`href="${url}"`)) { |
||||
return _match |
||||
} |
||||
return `<a href="${url}" target="_blank" rel="noreferrer noopener" class="break-words inline-flex items-baseline gap-1">${text} <svg class="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg></a>` |
||||
}) |
||||
|
||||
// Clean up markdown table syntax
|
||||
cleaned = this.cleanupMarkdownTables(cleaned) |
||||
|
||||
return cleaned |
||||
} |
||||
|
||||
/** |
||||
* Clean up markdown tables |
||||
*/ |
||||
private cleanupMarkdownTables(html: string): string { |
||||
const tableRegex = /(\|.*\|[\r\n]+\|[\s\-\|]*[\r\n]+(\|.*\|[\r\n]+)*)/g |
||||
|
||||
return html.replace(tableRegex, (match) => { |
||||
const lines = match.trim().split('\n').filter(line => line.trim()) |
||||
if (lines.length < 2) return match |
||||
|
||||
const headerRow = lines[0] |
||||
const separatorRow = lines[1] |
||||
const dataRows = lines.slice(2) |
||||
|
||||
// Check if it's actually a table (has separator row with dashes)
|
||||
if (!separatorRow.includes('-')) return match |
||||
|
||||
const headers = headerRow.split('|').map(cell => cell.trim()).filter(cell => cell) |
||||
const rows = dataRows.map(row =>
|
||||
row.split('|').map(cell => cell.trim()).filter(cell => cell) |
||||
) |
||||
|
||||
let tableHtml = '<table class="min-w-full border-collapse border border-gray-300 my-4">\n' |
||||
|
||||
// Header
|
||||
tableHtml += ' <thead>\n <tr>\n' |
||||
headers.forEach(header => { |
||||
tableHtml += ` <th class="border border-gray-300 px-4 py-2 bg-gray-50 font-semibold text-left">${header}</th>\n` |
||||
}) |
||||
tableHtml += ' </tr>\n </thead>\n' |
||||
|
||||
// Body
|
||||
tableHtml += ' <tbody>\n' |
||||
rows.forEach(row => { |
||||
tableHtml += ' <tr>\n' |
||||
row.forEach((cell, index) => { |
||||
const tag = index < headers.length ? 'td' : 'td' |
||||
tableHtml += ` <${tag} class="border border-gray-300 px-4 py-2">${cell}</${tag}>\n` |
||||
}) |
||||
tableHtml += ' </tr>\n' |
||||
}) |
||||
tableHtml += ' </tbody>\n' |
||||
tableHtml += '</table>' |
||||
|
||||
return tableHtml |
||||
}) |
||||
} |
||||
|
||||
/** |
||||
* Extract all media from content and event |
||||
*/ |
||||
private extractAllMedia(content: string, event?: Event): TImetaInfo[] { |
||||
const media: TImetaInfo[] = [] |
||||
const seenUrls = new Set<string>() |
||||
|
||||
// 1. Extract from imeta tags if event is provided
|
||||
if (event) { |
||||
const imetaMedia = getImetaInfosFromEvent(event) |
||||
imetaMedia.forEach(item => { |
||||
if (!seenUrls.has(item.url)) { |
||||
media.push(item) |
||||
seenUrls.add(item.url) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
// 2. Extract from markdown images: 
|
||||
const imageMatches = content.match(/!\[[^\]]*\]\(([^)]+)\)/g) || [] |
||||
imageMatches.forEach(match => { |
||||
const url = match.match(/!\[[^\]]*\]\(([^)]+)\)/)?.[1] |
||||
if (url && !seenUrls.has(url)) { |
||||
const isVideo = /\.(mp4|webm|ogg)$/i.test(url) |
||||
media.push({
|
||||
url,
|
||||
pubkey: event?.pubkey || '',
|
||||
m: isVideo ? 'video/*' : 'image/*'
|
||||
}) |
||||
seenUrls.add(url) |
||||
} |
||||
}) |
||||
|
||||
// 3. Extract from asciidoc images: image::url[alt,width]
|
||||
const asciidocImageMatches = content.match(/image::([^\[]+)\[/g) || [] |
||||
asciidocImageMatches.forEach(match => { |
||||
const url = match.match(/image::([^\[]+)\[/)?.[1] |
||||
if (url && !seenUrls.has(url)) { |
||||
const isVideo = /\.(mp4|webm|ogg)$/i.test(url) |
||||
media.push({
|
||||
url,
|
||||
pubkey: event?.pubkey || '',
|
||||
m: isVideo ? 'video/*' : 'image/*'
|
||||
}) |
||||
seenUrls.add(url) |
||||
} |
||||
}) |
||||
|
||||
// 4. Extract raw URLs from content
|
||||
const rawUrls = content.match(URL_REGEX) || [] |
||||
rawUrls.forEach(url => { |
||||
if (!seenUrls.has(url)) { |
||||
const isImage = /\.(jpeg|jpg|png|gif|webp|svg)$/i.test(url) |
||||
const isVideo = /\.(mp4|webm|ogg)$/i.test(url) |
||||
if (isImage || isVideo) { |
||||
media.push({
|
||||
url,
|
||||
pubkey: event?.pubkey || '',
|
||||
m: isVideo ? 'video/*' : 'image/*'
|
||||
}) |
||||
seenUrls.add(url) |
||||
} |
||||
} |
||||
}) |
||||
|
||||
return media |
||||
} |
||||
|
||||
/** |
||||
* Extract all links from content |
||||
*/ |
||||
private extractLinks(content: string): Array<{ url: string; text: string; isExternal: boolean }> { |
||||
const links: Array<{ url: string; text: string; isExternal: boolean }> = [] |
||||
const seenUrls = new Set<string>() |
||||
|
||||
// Extract markdown links: [text](url)
|
||||
const markdownLinks = content.match(/\[([^\]]+)\]\(([^)]+)\)/g) || [] |
||||
markdownLinks.forEach(_match => { |
||||
const linkMatch = _match.match(/\[([^\]]+)\]\(([^)]+)\)/) |
||||
if (linkMatch) { |
||||
const [, text, url] = linkMatch |
||||
if (!seenUrls.has(url)) { |
||||
links.push({ |
||||
url, |
||||
text, |
||||
isExternal: this.isExternalUrl(url) |
||||
}) |
||||
seenUrls.add(url) |
||||
} |
||||
} |
||||
}) |
||||
|
||||
// Extract asciidoc links: link:url[text]
|
||||
const asciidocLinks = content.match(/link:([^\[]+)\[([^\]]+)\]/g) || [] |
||||
asciidocLinks.forEach(_match => { |
||||
const linkMatch = _match.match(/link:([^\[]+)\[([^\]]+)\]/) |
||||
if (linkMatch) { |
||||
const [, url, text] = linkMatch |
||||
if (!seenUrls.has(url)) { |
||||
links.push({ |
||||
url, |
||||
text, |
||||
isExternal: this.isExternalUrl(url) |
||||
}) |
||||
seenUrls.add(url) |
||||
} |
||||
} |
||||
}) |
||||
|
||||
// Extract raw URLs
|
||||
const rawUrls = content.match(URL_REGEX) || [] |
||||
rawUrls.forEach(url => { |
||||
if (!seenUrls.has(url) && !this.isNostrUrl(url)) { |
||||
links.push({ |
||||
url, |
||||
text: url, |
||||
isExternal: this.isExternalUrl(url) |
||||
}) |
||||
seenUrls.add(url) |
||||
} |
||||
}) |
||||
|
||||
return links |
||||
} |
||||
|
||||
/** |
||||
* Extract hashtags from content |
||||
*/ |
||||
private extractHashtags(content: string): string[] { |
||||
const hashtags: string[] = [] |
||||
const seenTags = new Set<string>() |
||||
|
||||
// Extract hashtags: #hashtag
|
||||
const hashtagMatches = content.match(/#([a-zA-Z0-9_]+)/g) || [] |
||||
hashtagMatches.forEach(_match => { |
||||
const tag = _match.substring(1) // Remove #
|
||||
if (!seenTags.has(tag)) { |
||||
hashtags.push(tag) |
||||
seenTags.add(tag) |
||||
} |
||||
}) |
||||
|
||||
return hashtags |
||||
} |
||||
|
||||
/** |
||||
* Extract Nostr links from content |
||||
*/ |
||||
private extractNostrLinks(content: string): Array<{ type: 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note'; id: string; text: string }> { |
||||
const nostrLinks: Array<{ type: 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note'; id: string; text: string }> = [] |
||||
|
||||
// Extract nostr: prefixed links
|
||||
const nostrMatches = content.match(/nostr:([a-z0-9]+[a-z0-9]{6,})/g) || [] |
||||
nostrMatches.forEach(_match => { |
||||
const id = _match.substring(6) // Remove 'nostr:'
|
||||
const type = this.getNostrType(id) |
||||
if (type) { |
||||
nostrLinks.push({ |
||||
type, |
||||
id, |
||||
text: _match |
||||
}) |
||||
} |
||||
}) |
||||
|
||||
// Extract raw nostr identifiers
|
||||
const rawNostrMatches = content.match(/([a-z0-9]+[a-z0-9]{6,})/g) || [] |
||||
rawNostrMatches.forEach(_match => { |
||||
const type = this.getNostrType(_match) |
||||
if (type && !nostrLinks.some(link => link.id === _match)) { |
||||
nostrLinks.push({ |
||||
type, |
||||
id: _match, |
||||
text: _match |
||||
}) |
||||
} |
||||
}) |
||||
|
||||
return nostrLinks |
||||
} |
||||
|
||||
/** |
||||
* Check if URL is external |
||||
*/ |
||||
private isExternalUrl(url: string): boolean { |
||||
try { |
||||
const urlObj = new URL(url) |
||||
return urlObj.hostname !== window.location.hostname |
||||
} catch { |
||||
return true |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Check if URL is a Nostr URL |
||||
*/ |
||||
private isNostrUrl(url: string): boolean { |
||||
return url.startsWith('nostr:') || this.getNostrType(url) !== null |
||||
} |
||||
|
||||
/** |
||||
* Get Nostr identifier type |
||||
*/ |
||||
private getNostrType(id: string): 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note' | null { |
||||
if (id.startsWith('npub')) return 'npub' |
||||
if (id.startsWith('nprofile')) return 'nprofile' |
||||
if (id.startsWith('nevent')) return 'nevent' |
||||
if (id.startsWith('naddr')) return 'naddr' |
||||
if (id.startsWith('note')) return 'note' |
||||
return null |
||||
} |
||||
|
||||
/** |
||||
* Check if content has LaTeX math |
||||
*/ |
||||
private hasMathContent(content: string): boolean { |
||||
// Check for inline math: $...$ or \(...\)
|
||||
const inlineMath = /\$[^$]+\$|\\\([^)]+\\\)/.test(content) |
||||
|
||||
// Check for block math: $$...$$ or \[...\]
|
||||
const blockMath = /\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]/.test(content) |
||||
|
||||
return inlineMath || blockMath |
||||
} |
||||
|
||||
/** |
||||
* Parse content for a specific Nostr event field |
||||
*/ |
||||
async parseEventField( |
||||
event: Event,
|
||||
field: 'content' | 'title' | 'summary' | 'description', |
||||
options: Omit<ParseOptions, 'eventKind' | 'field'> = {} |
||||
): Promise<ParsedContent> { |
||||
const content = this.getFieldContent(event, field) |
||||
if (!content) { |
||||
return { |
||||
html: '', |
||||
markupType: 'plain-text', |
||||
cssClasses: getMarkupClasses('plain-text'), |
||||
hasMath: false, |
||||
media: [], |
||||
links: [], |
||||
hashtags: [], |
||||
nostrLinks: [] |
||||
} |
||||
} |
||||
|
||||
return this.parseContent(content, { |
||||
...options, |
||||
eventKind: event.kind, |
||||
field |
||||
}, event) |
||||
} |
||||
|
||||
/** |
||||
* Get content from specific event field |
||||
*/ |
||||
private getFieldContent(event: Event, field: 'content' | 'title' | 'summary' | 'description'): string { |
||||
switch (field) { |
||||
case 'content': |
||||
return event.content |
||||
case 'title': |
||||
return event.tags.find(tag => tag[0] === 'title')?.[1] || '' |
||||
case 'summary': |
||||
return event.tags.find(tag => tag[0] === 'summary')?.[1] || '' |
||||
case 'description': |
||||
return event.tags.find(tag => tag[0] === 'd')?.[1] || '' |
||||
default: |
||||
return '' |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Export singleton instance
|
||||
export const contentParserService = new ContentParserService() |
||||
export default contentParserService |
||||
Loading…
Reference in new issue