17 changed files with 2412 additions and 268 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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