Browse Source

more parsing updates

imwald
Silberengel 5 months ago
parent
commit
f2ee10ae74
  1. 14
      src/components/Embedded/EmbeddedNote.tsx
  2. 94
      src/components/Note/Article/index.tsx
  3. 408
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  4. 4
      src/components/Note/Highlight/index.tsx
  5. 2
      src/components/Note/LongFormArticle/NostrNode.tsx
  6. 138
      src/components/Note/LongFormArticle/index.tsx
  7. 292
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  8. 29
      src/components/Note/MarkdownArticle/NostrNode.tsx
  9. 0
      src/components/Note/MarkdownArticle/remarkNostr.ts
  10. 0
      src/components/Note/MarkdownArticle/types.ts
  11. 25
      src/components/Note/index.tsx
  12. 2
      src/lib/event-metadata.ts
  13. 12
      src/lib/markup-detection.ts
  14. 245
      src/services/content-parser.service.ts

14
src/components/Embedded/EmbeddedNote.tsx

@ -51,12 +51,14 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className? @@ -51,12 +51,14 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className?
}
return (
<MainNoteCard
className={cn('w-full', className)}
event={finalEvent}
embedded
originalNoteId={noteId}
/>
<div data-embedded-note>
<MainNoteCard
className={cn('w-full', className)}
event={finalEvent}
embedded
originalNoteId={noteId}
/>
</div>
)
}

94
src/components/Note/Article/index.tsx

@ -101,6 +101,42 @@ export default function Article({ @@ -101,6 +101,42 @@ export default function Article({
}
}, [parsedContent])
// Process nostr addresses and other interactive elements after HTML is rendered
useEffect(() => {
if (!contentRef.current || !parsedContent) return
const processInteractiveElements = () => {
// Process embedded note containers
const embeddedNotes = contentRef.current?.querySelectorAll('[data-embedded-note]')
embeddedNotes?.forEach((container) => {
const bech32Id = container.getAttribute('data-embedded-note')
if (bech32Id) {
// Replace with actual EmbeddedNote component
const embeddedNoteElement = document.createElement('div')
embeddedNoteElement.innerHTML = `<div data-embedded-note="${bech32Id}">Loading embedded event...</div>`
container.parentNode?.replaceChild(embeddedNoteElement.firstChild!, container)
}
})
// Process user handles
const userHandles = contentRef.current?.querySelectorAll('[data-pubkey]')
userHandles?.forEach((handle) => {
const pubkey = handle.getAttribute('data-pubkey')
if (pubkey) {
// Replace with actual Username component
const usernameElement = document.createElement('span')
usernameElement.innerHTML = `<span class="user-handle" data-pubkey="${pubkey}">@${handle.textContent}</span>`
handle.parentNode?.replaceChild(usernameElement.firstChild!, handle)
}
})
}
// Process elements after a short delay to ensure content is rendered
const timeoutId = setTimeout(processInteractiveElements, 100)
return () => clearTimeout(timeoutId)
}, [parsedContent?.html])
// Add ToC return buttons to section headers
useEffect(() => {
if (!contentRef.current || !isArticleType || !parsedContent) return
@ -167,24 +203,50 @@ export default function Article({ @@ -167,24 +203,50 @@ export default function Article({
}
return (
<div className={`${parsedContent?.cssClasses || ''} ${className || ''}`}>
<article className={`prose prose-zinc max-w-none dark:prose-invert break-words leading-relaxed ${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] h-auto object-contain my-0"
/>
)}
<header className="mb-8">
<h1 className="break-words text-4xl font-bold mb-6 leading-tight">{metadata.title}</h1>
{metadata.summary && (
<blockquote className="border-l-4 border-primary pl-6 italic text-muted-foreground mb-8 text-lg leading-relaxed">
<p className="break-words">{metadata.summary}</p>
</blockquote>
)}
{metadata.image && (
<div className="mb-8">
<ImageWithLightbox
image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full max-w-[800px] h-auto object-contain rounded-lg shadow-lg mx-auto"
/>
</div>
)}
</header>
{/* Render AsciiDoc content (everything is now processed as AsciiDoc) */}
<div ref={contentRef} className={isArticleType ? "asciidoc-content" : "simple-content"} dangerouslySetInnerHTML={{ __html: parsedContent?.html || '' }} />
<div
ref={contentRef}
className={`prose prose-zinc max-w-none dark:prose-invert break-words leading-relaxed text-base ${isArticleType ? "asciidoc-content" : "simple-content"}`}
style={{
// Override any problematic AsciiDoc styles
'--tw-prose-body': 'inherit',
'--tw-prose-headings': 'inherit',
'--tw-prose-lead': 'inherit',
'--tw-prose-links': 'inherit',
'--tw-prose-bold': 'inherit',
'--tw-prose-counters': 'inherit',
'--tw-prose-bullets': 'inherit',
'--tw-prose-hr': 'inherit',
'--tw-prose-quotes': 'inherit',
'--tw-prose-quote-borders': 'inherit',
'--tw-prose-captions': 'inherit',
'--tw-prose-code': 'inherit',
'--tw-prose-pre-code': 'inherit',
'--tw-prose-pre-bg': 'inherit',
'--tw-prose-th-borders': 'inherit',
'--tw-prose-td-borders': 'inherit'
} as React.CSSProperties}
dangerouslySetInnerHTML={{ __html: parsedContent?.html || '' }}
/>
{/* Collapsible Article Info - only for article-type events */}
{isArticleType && (parsedContent?.media?.length > 0 || parsedContent?.links?.length > 0 || parsedContent?.nostrLinks?.length > 0 || parsedContent?.highlightSources?.length > 0 || parsedContent?.hashtags?.length > 0) && (
@ -287,6 +349,6 @@ export default function Article({ @@ -287,6 +349,6 @@ export default function Article({
</CollapsibleContent>
</Collapsible>
)}
</div>
</article>
)
}

408
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -0,0 +1,408 @@ @@ -0,0 +1,408 @@
import { useSecondaryPage } from '@/PageManager'
import ImageWithLightbox from '@/components/ImageWithLightbox'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { useMemo, useState, useEffect, useRef } from 'react'
import { useEventFieldParser } from '@/hooks/useContentParser'
import WebPreview from '../../WebPreview'
import HighlightSourcePreview from '../../UniversalContent/HighlightSourcePreview'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { ExtendedKind } from '@/constants'
export default function AsciidocArticle({
event,
className
}: {
event: Event
className?: string
}) {
const { push } = useSecondaryPage()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const [isInfoOpen, setIsInfoOpen] = useState(false)
// Determine if this is an article-type event that should show ToC and Article Info
const isArticleType = useMemo(() => {
return event.kind === kinds.LongFormArticle ||
event.kind === ExtendedKind.WIKI_ARTICLE ||
event.kind === ExtendedKind.PUBLICATION ||
event.kind === ExtendedKind.PUBLICATION_CONTENT
}, [event.kind])
// Use the comprehensive content parser
const { parsedContent, isLoading, error } = useEventFieldParser(event, 'content', {
enableMath: true,
enableSyntaxHighlighting: true
})
const contentRef = useRef<HTMLDivElement>(null)
// Handle wikilink clicks
useEffect(() => {
if (!contentRef.current) return
const handleWikilinkClick = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (target.classList.contains('wikilink')) {
event.preventDefault()
const dTag = target.getAttribute('data-dtag')
const displayText = target.getAttribute('data-display')
if (dTag && displayText) {
// Create a simple dropdown menu
const existingDropdown = document.querySelector('.wikilink-dropdown')
if (existingDropdown) {
existingDropdown.remove()
}
const dropdown = document.createElement('div')
dropdown.className = 'wikilink-dropdown fixed bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-50 p-2'
dropdown.style.left = `${event.pageX}px`
dropdown.style.top = `${event.pageY + 10}px`
const wikistrButton = document.createElement('button')
wikistrButton.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2'
wikistrButton.innerHTML = '<svg class="w-3 h-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>View on Wikistr'
wikistrButton.onclick = () => {
window.open(`https://wikistr.imwald.eu/${dTag}`, '_blank', 'noopener,noreferrer')
dropdown.remove()
}
const alexandriaButton = document.createElement('button')
alexandriaButton.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2'
alexandriaButton.innerHTML = '<svg class="w-3 h-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>View on Alexandria'
alexandriaButton.onclick = () => {
window.open(`https://next-alexandria.gitcitadel.eu/events?d=${dTag}`, '_blank', 'noopener,noreferrer')
dropdown.remove()
}
dropdown.appendChild(wikistrButton)
dropdown.appendChild(alexandriaButton)
document.body.appendChild(dropdown)
// Close dropdown when clicking outside
const closeDropdown = (e: MouseEvent) => {
if (!dropdown.contains(e.target as Node)) {
dropdown.remove()
document.removeEventListener('click', closeDropdown)
}
}
setTimeout(() => document.addEventListener('click', closeDropdown), 0)
}
}
}
contentRef.current.addEventListener('click', handleWikilinkClick)
return () => {
contentRef.current?.removeEventListener('click', handleWikilinkClick)
}
}, [parsedContent])
// Process nostr addresses and other interactive elements after HTML is rendered
useEffect(() => {
if (!contentRef.current || !parsedContent) return
const processInteractiveElements = () => {
// Process embedded note containers
const embeddedNotes = contentRef.current?.querySelectorAll('[data-embedded-note]')
embeddedNotes?.forEach((container) => {
const bech32Id = container.getAttribute('data-embedded-note')
if (bech32Id) {
// Replace with actual EmbeddedNote component
const embeddedNoteElement = document.createElement('div')
embeddedNoteElement.innerHTML = `<div data-embedded-note="${bech32Id}">Loading embedded event...</div>`
container.parentNode?.replaceChild(embeddedNoteElement.firstChild!, container)
}
})
// Process user handles
const userHandles = contentRef.current?.querySelectorAll('[data-pubkey]')
userHandles?.forEach((handle) => {
const pubkey = handle.getAttribute('data-pubkey')
if (pubkey) {
// Replace with actual Username component
const usernameElement = document.createElement('span')
usernameElement.innerHTML = `<span class="user-handle" data-pubkey="${pubkey}">@${handle.textContent}</span>`
handle.parentNode?.replaceChild(usernameElement.firstChild!, handle)
}
})
// Process wikilinks
const wikilinks = contentRef.current?.querySelectorAll('.wikilink')
wikilinks?.forEach((wikilink) => {
const dTag = wikilink.getAttribute('data-dtag')
const displayText = wikilink.getAttribute('data-display')
if (dTag && displayText) {
// Add click handler for wikilinks
wikilink.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
const mouseEvent = e as MouseEvent
// Create dropdown menu similar to the original implementation
const existingDropdown = document.querySelector('.wikilink-dropdown')
if (existingDropdown) {
existingDropdown.remove()
}
const dropdown = document.createElement('div')
dropdown.className = 'wikilink-dropdown fixed bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-50 p-2'
dropdown.style.left = `${mouseEvent.pageX}px`
dropdown.style.top = `${mouseEvent.pageY + 10}px`
const wikistrButton = document.createElement('button')
wikistrButton.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2'
wikistrButton.innerHTML = '<svg class="w-3 h-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>View on Wikistr'
wikistrButton.onclick = () => {
window.open(`https://wikistr.imwald.eu/${dTag}`, '_blank', 'noopener,noreferrer')
dropdown.remove()
}
const alexandriaButton = document.createElement('button')
alexandriaButton.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2'
alexandriaButton.innerHTML = '<svg class="w-3 h-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>View on Alexandria'
alexandriaButton.onclick = () => {
window.open(`https://next-alexandria.gitcitadel.eu/events?d=${dTag}`, '_blank', 'noopener,noreferrer')
dropdown.remove()
}
dropdown.appendChild(wikistrButton)
dropdown.appendChild(alexandriaButton)
document.body.appendChild(dropdown)
// Close dropdown when clicking outside
const closeDropdown = (e: MouseEvent) => {
if (!dropdown.contains(e.target as Node)) {
dropdown.remove()
document.removeEventListener('click', closeDropdown)
}
}
setTimeout(() => document.addEventListener('click', closeDropdown), 0)
})
}
})
}
// Process elements after a short delay to ensure content is rendered
const timeoutId = setTimeout(processInteractiveElements, 100)
return () => clearTimeout(timeoutId)
}, [parsedContent?.html])
// Add ToC return buttons to section headers
useEffect(() => {
if (!contentRef.current || !isArticleType || !parsedContent) return
const addTocReturnButtons = () => {
const headers = contentRef.current?.querySelectorAll('h1, h2, h3, h4, h5, h6')
if (!headers) return
headers.forEach((header) => {
// Skip if button already exists
if (header.querySelector('.toc-return-btn')) return
// Create the return button
const returnBtn = document.createElement('span')
returnBtn.className = 'toc-return-btn'
returnBtn.innerHTML = '↑ ToC'
returnBtn.title = 'Return to Table of Contents'
// Add click handler
returnBtn.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
// Scroll to the ToC
const tocElement = document.getElementById('toc')
if (tocElement) {
tocElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
})
// Add the button to the header
header.appendChild(returnBtn)
})
}
// Add buttons after a short delay to ensure content is rendered
const timeoutId = setTimeout(addTocReturnButtons, 100)
return () => clearTimeout(timeoutId)
}, [parsedContent?.html, isArticleType])
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 (
<article className={`prose prose-zinc max-w-none dark:prose-invert break-words leading-relaxed ${parsedContent?.cssClasses || ''} ${className || ''}`}>
{/* Article metadata */}
<header className="mb-8">
<h1 className="break-words text-4xl font-bold mb-6 leading-tight">{metadata.title}</h1>
{metadata.summary && (
<blockquote className="border-l-4 border-primary pl-6 italic text-muted-foreground mb-8 text-lg leading-relaxed">
<p className="break-words">{metadata.summary}</p>
</blockquote>
)}
{metadata.image && (
<div className="mb-8">
<ImageWithLightbox
image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full max-w-[800px] h-auto object-contain rounded-lg shadow-lg mx-auto"
/>
</div>
)}
</header>
{/* Render AsciiDoc content (everything is now processed as AsciiDoc) */}
<div
ref={contentRef}
className={`prose prose-zinc max-w-none dark:prose-invert break-words leading-relaxed text-base ${isArticleType ? "asciidoc-content" : "simple-content"}`}
style={{
// Override any problematic AsciiDoc styles
'--tw-prose-body': 'inherit',
'--tw-prose-headings': 'inherit',
'--tw-prose-lead': 'inherit',
'--tw-prose-links': 'inherit',
'--tw-prose-bold': 'inherit',
'--tw-prose-counters': 'inherit',
'--tw-prose-bullets': 'inherit',
'--tw-prose-hr': 'inherit',
'--tw-prose-quotes': 'inherit',
'--tw-prose-quote-borders': 'inherit',
'--tw-prose-captions': 'inherit',
'--tw-prose-code': 'inherit',
'--tw-prose-pre-code': 'inherit',
'--tw-prose-pre-bg': 'inherit',
'--tw-prose-th-borders': 'inherit',
'--tw-prose-td-borders': 'inherit'
} as React.CSSProperties}
dangerouslySetInnerHTML={{ __html: parsedContent?.html || '' }}
/>
{/* Collapsible Article Info - only for article-type events */}
{isArticleType && (parsedContent?.media?.length > 0 || parsedContent?.links?.length > 0 || parsedContent?.nostrLinks?.length > 0 || parsedContent?.highlightSources?.length > 0 || parsedContent?.hashtags?.length > 0) && (
<Collapsible open={isInfoOpen} onOpenChange={setIsInfoOpen} className="mt-4">
<CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-between">
<span>Article Info</span>
{isInfoOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 mt-2">
{/* Media thumbnails */}
{parsedContent?.media?.length > 0 && (
<div className="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="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="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>
)}
{/* Highlight sources */}
{parsedContent?.highlightSources?.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Highlight sources:</h4>
<div className="space-y-3">
{parsedContent?.highlightSources?.map((source, index) => (
<HighlightSourcePreview
key={index}
source={source}
className="w-full"
/>
))}
</div>
</div>
)}
{/* Hashtags */}
{parsedContent?.hashtags?.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Tags:</h4>
<div className="flex gap-2 flex-wrap">
{parsedContent?.hashtags?.map((tag) => (
<div
key={tag}
title={tag}
className="flex items-center rounded-full px-3 py-1 bg-background text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground transition-colors"
onClick={(e) => {
e.stopPropagation()
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
}}
>
#<span className="truncate">{tag}</span>
</div>
))}
</div>
</div>
)}
</CollapsibleContent>
</Collapsible>
)}
</article>
)
}

4
src/components/Note/Highlight/index.tsx

@ -136,7 +136,9 @@ export default function Highlight({ @@ -136,7 +136,9 @@ export default function Highlight({
<span
onClick={(e) => {
e.stopPropagation()
navigateToNote(toNote(source.bech32))
const noteUrl = toNote(source.bech32)
console.log('Navigating to:', noteUrl, 'from source:', source)
navigateToNote(noteUrl)
}}
className="text-blue-500 hover:underline font-mono cursor-pointer"
>

2
src/components/Note/LongFormArticle/NostrNode.tsx

@ -8,7 +8,7 @@ export default function NostrNode({ rawText, bech32Id }: ComponentProps<Componen @@ -8,7 +8,7 @@ export default function NostrNode({ rawText, bech32Id }: ComponentProps<Componen
if (!bech32Id) return { type: 'invalid', id: '' }
try {
const { type } = nip19.decode(bech32Id)
if (type === 'npub') {
if (type === 'npub' || type === 'nprofile') {
return { type: 'mention', id: bech32Id }
}
if (type === 'nevent' || type === 'naddr' || type === 'note') {

138
src/components/Note/LongFormArticle/index.tsx

@ -1,138 +0,0 @@ @@ -1,138 +0,0 @@
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
import ImageWithLightbox from '@/components/ImageWithLightbox'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList, toProfile } from '@/lib/link'
import { ExternalLink } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import React, { useMemo } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import NostrNode from './NostrNode'
import { remarkNostr } from './remarkNostr'
import { Components } from './types'
export default function LongFormArticle({
event,
className
}: {
event: Event
className?: string
}) {
const { push } = useSecondaryPage()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const components = useMemo(
() =>
({
nostr: ({ rawText, bech32Id }) => <NostrNode rawText={rawText} bech32Id={bech32Id} />,
a: ({ href, children, ...props }) => {
if (!href) {
return <span {...props} className="break-words" />
}
if (href.startsWith('note1') || href.startsWith('nevent1') || href.startsWith('naddr1')) {
return (
<SecondaryPageLink
to={toNote(href)}
className="break-words underline text-foreground"
>
{children}
</SecondaryPageLink>
)
}
if (href.startsWith('npub1') || href.startsWith('nprofile1')) {
return (
<SecondaryPageLink
to={toProfile(href)}
className="break-words underline text-foreground"
>
{children}
</SecondaryPageLink>
)
}
return (
<a
{...props}
href={href}
target="_blank"
rel="noreferrer noopener"
className="break-words inline-flex items-baseline gap-1"
>
{children} <ExternalLink className="size-3" />
</a>
)
},
p: (props) => {
// Check if the paragraph contains only an image
const children = props.children
if (React.Children.count(children) === 1 && React.isValidElement(children)) {
const child = children as React.ReactElement
if (child.type === ImageWithLightbox) {
// Render image outside paragraph context
return <div {...props} className="break-words" />
}
}
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,
[]
)
return (
<div
className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}
>
<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"
/>
)}
<Markdown
remarkPlugins={[remarkGfm, remarkNostr]}
urlTransform={(url) => {
if (url.startsWith('nostr:')) {
return url.slice(6) // Remove 'nostr:' prefix for rendering
}
return url
}}
components={components}
>
{event.content}
</Markdown>
{metadata.tags.length > 0 && (
<div className="flex gap-2 flex-wrap pb-2">
{metadata.tags.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>
)}
</div>
)
}

292
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -0,0 +1,292 @@ @@ -0,0 +1,292 @@
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
import ImageWithLightbox from '@/components/ImageWithLightbox'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList, toProfile } from '@/lib/link'
import { ExternalLink } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import React, { useMemo, useEffect, useRef } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
import NostrNode from './NostrNode'
import { remarkNostr } from './remarkNostr'
import { Components } from './types'
export default function MarkdownArticle({
event,
className
}: {
event: Event
className?: string
}) {
const { push } = useSecondaryPage()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const contentRef = useRef<HTMLDivElement>(null)
// Initialize highlight.js for syntax highlighting
useEffect(() => {
const initHighlight = async () => {
if (typeof window !== 'undefined') {
const hljs = await import('highlight.js')
if (contentRef.current) {
contentRef.current.querySelectorAll('pre code').forEach((block) => {
// Ensure text color is visible before highlighting
const element = block as HTMLElement
element.style.color = 'inherit'
element.classList.add('text-gray-900', 'dark:text-gray-100')
hljs.default.highlightElement(element)
// Ensure text color remains visible after highlighting
element.style.color = 'inherit'
})
}
}
}
// Run highlight after a short delay to ensure content is rendered
const timeoutId = setTimeout(initHighlight, 100)
return () => clearTimeout(timeoutId)
}, [event.content])
const components = useMemo(
() =>
({
nostr: ({ rawText, bech32Id }) => <NostrNode rawText={rawText} bech32Id={bech32Id} />,
a: ({ href, children, ...props }) => {
if (!href) {
return <span {...props} className="break-words" />
}
if (href.startsWith('note1') || href.startsWith('nevent1') || href.startsWith('naddr1')) {
return (
<SecondaryPageLink
to={toNote(href)}
className="break-words underline text-foreground"
>
{children}
</SecondaryPageLink>
)
}
if (href.startsWith('npub1') || href.startsWith('nprofile1')) {
return (
<SecondaryPageLink
to={toProfile(href)}
className="break-words underline text-foreground"
>
{children}
</SecondaryPageLink>
)
}
return (
<a
{...props}
href={href}
target="_blank"
rel="noreferrer noopener"
className="break-words inline-flex items-baseline gap-1"
>
{children} <ExternalLink className="size-3" />
</a>
)
},
p: (props) => {
// Check if the paragraph contains only an image
const children = props.children
if (React.Children.count(children) === 1 && React.isValidElement(children)) {
const child = children as React.ReactElement
if (child.type === ImageWithLightbox) {
// Render image outside paragraph context
return <div {...props} className="break-words" />
}
}
return <p {...props} className="break-words" />
},
div: (props) => <div {...props} className="break-words" />,
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '')
const isInline = !match
return !isInline && match ? (
<pre className="bg-gray-100 dark:bg-gray-800 rounded-lg p-4 overflow-x-auto">
<code className={`language-${match[1]} ${className || ''} text-gray-900 dark:text-gray-100`} {...props}>
{children}
</code>
</pre>
) : (
<code className={`${className || ''} break-words whitespace-pre-wrap bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded text-gray-900 dark:text-gray-100`} {...props}>
{children}
</code>
)
},
text: ({ children }) => {
// Handle hashtags in text
if (typeof children === 'string') {
const hashtagRegex = /#(\w+)/g
const parts = []
let lastIndex = 0
let match
while ((match = hashtagRegex.exec(children)) !== null) {
// Add text before the hashtag
if (match.index > lastIndex) {
parts.push(children.slice(lastIndex, match.index))
}
// Add the hashtag as a clickable link
const hashtag = match[1]
parts.push(
<SecondaryPageLink
key={match.index}
to={toNoteList({ hashtag, kinds: [kinds.LongFormArticle] })}
className="text-green-600 dark:text-green-400 hover:underline"
>
#{hashtag}
</SecondaryPageLink>
)
lastIndex = match.index + match[0].length
}
// Add remaining text
if (lastIndex < children.length) {
parts.push(children.slice(lastIndex))
}
return <>{parts}</>
}
return <>{children}</>
},
img: (props) => (
<ImageWithLightbox
image={{ url: props.src || '', pubkey: event.pubkey }}
className="max-w-[400px] object-contain my-0"
classNames={{
wrapper: 'w-fit max-w-[400px]'
}}
/>
)
}) as Components,
[]
)
return (
<>
<style>{`
.hljs {
background: transparent !important;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-literal,
.hljs-title,
.hljs-section,
.hljs-doctag,
.hljs-type,
.hljs-name,
.hljs-strong {
color: #f85149 !important;
font-weight: bold !important;
}
.hljs-string,
.hljs-title.class_,
.hljs-attr,
.hljs-symbol,
.hljs-bullet,
.hljs-addition,
.hljs-code,
.hljs-regexp,
.hljs-selector-pseudo,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-id {
color: #0366d6 !important;
}
.hljs-comment,
.hljs-quote {
color: #8b949e !important;
}
.hljs-number,
.hljs-deletion {
color: #005cc5 !important;
}
.hljs-variable,
.hljs-template-variable,
.hljs-link {
color: #e36209 !important;
}
.hljs-meta {
color: #6f42c1 !important;
}
.hljs-built_in,
.hljs-class .hljs-title {
color: #005cc5 !important;
}
.hljs-params {
color: #f0f6fc !important;
}
.hljs-attribute {
color: #005cc5 !important;
}
.hljs-function .hljs-title {
color: #6f42c1 !important;
}
.hljs-subst {
color: #f0f6fc !important;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
`}</style>
<div
ref={contentRef}
className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}
>
{metadata.title && <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"
/>
)}
<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>
{metadata.tags.length > 0 && (
<div className="flex gap-2 flex-wrap pb-2">
{metadata.tags.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>
)}
</div>
</>
)
}

29
src/components/Note/MarkdownArticle/NostrNode.tsx

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
import { EmbeddedMention, EmbeddedNote } from '@/components/Embedded'
import { nip19 } from 'nostr-tools'
import { ComponentProps, useMemo } from 'react'
import { Components } from './types'
export default function NostrNode({ rawText, bech32Id }: ComponentProps<Components['nostr']>) {
const { type, id } = useMemo(() => {
if (!bech32Id) return { type: 'invalid', id: '' }
try {
const { type } = nip19.decode(bech32Id)
if (type === 'npub' || type === 'nprofile') {
return { type: 'mention', id: bech32Id }
}
if (type === 'nevent' || type === 'naddr' || type === 'note') {
return { type: 'note', id: bech32Id }
}
} catch (error) {
console.error('Invalid bech32 ID:', bech32Id, error)
}
return { type: 'invalid', id: '' }
}, [bech32Id])
if (type === 'invalid') return rawText
if (type === 'mention') {
return <EmbeddedMention userId={id} className="not-prose" />
}
return <EmbeddedNote noteId={id} className="not-prose" />
}

0
src/components/Note/LongFormArticle/remarkNostr.ts → src/components/Note/MarkdownArticle/remarkNostr.ts

0
src/components/Note/LongFormArticle/types.ts → src/components/Note/MarkdownArticle/types.ts

25
src/components/Note/index.tsx

@ -27,8 +27,8 @@ import Highlight from './Highlight' @@ -27,8 +27,8 @@ import Highlight from './Highlight'
import IValue from './IValue'
import LiveEvent from './LiveEvent'
import LongFormArticlePreview from './LongFormArticlePreview'
import Article from './Article'
import SimpleContent from './SimpleContent'
import MarkdownArticle from './MarkdownArticle/MarkdownArticle'
import AsciidocArticle from './AsciidocArticle/AsciidocArticle'
import PublicationCard from './PublicationCard'
import WikiCard from './WikiCard'
import MutedNote from './MutedNote'
@ -99,24 +99,24 @@ export default function Note({ @@ -99,24 +99,24 @@ export default function Note({
<div>Context: {event.tags.find(tag => tag[0] === 'context')?.[1] || 'No context found'}</div>
</div>
}
} else if (event.kind === kinds.LongFormArticle) {
content = showFull ? (
<Article className="mt-2" event={event} />
) : (
<LongFormArticlePreview className="mt-2" event={event} />
)
} else if (event.kind === ExtendedKind.WIKI_ARTICLE) {
content = showFull ? (
<Article className="mt-2" event={event} />
<AsciidocArticle className="mt-2" event={event} />
) : (
<WikiCard className="mt-2" event={event} />
)
} else if (event.kind === ExtendedKind.PUBLICATION) {
content = showFull ? (
<Article className="mt-2" event={event} />
<AsciidocArticle className="mt-2" event={event} />
) : (
<PublicationCard className="mt-2" event={event} />
)
} else if (event.kind === kinds.LongFormArticle) {
content = showFull ? (
<MarkdownArticle className="mt-2" event={event} />
) : (
<LongFormArticlePreview className="mt-2" event={event} />
)
} else if (event.kind === kinds.LiveEvent) {
content = <LiveEvent className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.GROUP_METADATA) {
@ -152,7 +152,8 @@ export default function Note({ @@ -152,7 +152,8 @@ export default function Note({
} else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
content = <Zap className="mt-2" event={event} />
} else {
content = <SimpleContent className="mt-2" event={event} />
// Use MarkdownArticle for all other kinds (including kinds 1 and 11)
content = <MarkdownArticle className="mt-2" event={event} />
}
return (
@ -161,7 +162,7 @@ export default function Note({ @@ -161,7 +162,7 @@ export default function Note({
onClick={(e) => {
// Don't navigate if clicking on interactive elements
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[role="button"]') || target.closest('a')) {
if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-embedded-note]')) {
return
}
navigateToNote(toNote(event))

2
src/lib/event-metadata.ts

@ -237,7 +237,7 @@ export function getLongFormArticleMetadataFromEvent(event: Event) { @@ -237,7 +237,7 @@ export function getLongFormArticleMetadataFromEvent(event: Event) {
})
if (!title) {
title = event.tags.find(tagNameEquals('d'))?.[1] ?? 'no title'
title = event.tags.find(tagNameEquals('d'))?.[1]
}
return { title, summary, image, tags: Array.from(tags) }

12
src/lib/markup-detection.ts

@ -9,14 +9,19 @@ export type MarkupType = 'asciidoc' | 'advanced-markdown' | 'basic-markdown' | ' @@ -9,14 +9,19 @@ export type MarkupType = 'asciidoc' | 'advanced-markdown' | 'basic-markdown' | '
*/
export function detectMarkupType(content: string, eventKind?: number): MarkupType {
// Publications and wikis use AsciiDoc
if (eventKind === 30040 || eventKind === 30041 || eventKind === 30818) {
if (eventKind === 30041 || eventKind === 30818) {
return 'asciidoc'
}
// Long Form Articles (kind 30023) should use markdown detection
if (eventKind === 30023) {
// Force markdown detection for long form articles
return 'advanced-markdown'
}
// Check for AsciiDoc syntax patterns
const asciidocPatterns = [
/^=+\s/, // Headers: = Title, == Section
/^\*+\s/, // Lists: * item
/^=+\s[^=]/, // Headers: = Title (but not == Requirements ==)
/^\.+\s/, // Lists: . item
/^\[\[/, // Cross-references: [[ref]]
/^<</, // Cross-references: <<ref>>
@ -49,6 +54,7 @@ export function detectMarkupType(content: string, eventKind?: number): MarkupTyp @@ -49,6 +54,7 @@ export function detectMarkupType(content: string, eventKind?: number): MarkupTyp
/\[\^[\w\d]+\]/, // Footnotes: [^1]
/\[\^[\w\d]+\]:/, // Footnote references: [^1]:
/\[\[[\w\-\s]+\]\]/, // Wikilinks: [[NIP-54]]
/^==\s+[^=]/, // Markdown-style headers: == Requirements ==
]
const hasAdvancedMarkdown = advancedMarkdownPatterns.some(pattern => pattern.test(content))

245
src/services/content-parser.service.ts

@ -131,20 +131,52 @@ class ContentParserService { @@ -131,20 +131,52 @@ class ContentParserService {
'toclevels': 6,
'toc-title': 'Table of Contents',
'source-highlighter': options.enableSyntaxHighlighting ? 'highlight.js' : 'none',
'stem': options.enableMath ? 'latexmath' : 'none'
'stem': options.enableMath ? 'latexmath' : 'none',
'data-uri': true,
'imagesdir': '',
'linkcss': false,
'stylesheet': '',
'stylesdir': '',
'prewrap': true,
'sectnums': false,
'sectnumlevels': 6,
'experimental': true,
'compat-mode': false,
'attribute-missing': 'warn',
'attribute-undefined': 'warn',
'skip-front-matter': true,
'source-indent': 0,
'indent': 0,
'tabsize': 2,
'tabwidth': 2,
'hardbreaks': false,
'paragraph-rewrite': 'normal',
'sectids': true,
'idprefix': '',
'idseparator': '-',
'sectidprefix': '',
'sectidseparator': '-'
}
})
const htmlString = typeof result === 'string' ? result : result.toString()
// Debug: log the AsciiDoc HTML output for troubleshooting
if (process.env.NODE_ENV === 'development') {
console.log('AsciiDoc HTML output:', htmlString.substring(0, 1000) + '...')
}
// Process wikilinks in the HTML output
const processedHtml = this.processWikilinksInHtml(htmlString)
// Clean up any leftover markdown syntax and hide raw ToC text
const cleanedHtml = this.cleanupMarkdown(processedHtml)
// Add proper CSS classes for styling
const styledHtml = this.addStylingClasses(cleanedHtml)
// Hide any raw AsciiDoc ToC text that might appear in the content
return this.hideRawTocText(cleanedHtml)
return this.hideRawTocText(styledHtml)
} catch (error) {
console.error('AsciiDoc parsing error:', error)
return this.parsePlainText(content)
@ -174,33 +206,114 @@ class ContentParserService { @@ -174,33 +206,114 @@ class ContentParserService {
}
// Process wikilinks for all content types
return this.processWikilinks(asciidoc)
let result = this.processWikilinks(asciidoc)
// Process nostr: addresses - convert them to proper AsciiDoc format
result = this.processNostrAddresses(result)
// Debug: log the converted AsciiDoc for troubleshooting
if (process.env.NODE_ENV === 'development') {
console.log('Converted AsciiDoc:', result)
}
return result
}
/**
* Convert Markdown to AsciiDoc format
*/
private convertMarkdownToAsciidoc(content: string): string {
let asciidoc = content
// Preprocess: convert escaped newlines to actual newlines
let asciidoc = content.replace(/\\n/g, '\n')
// Preprocess: Fix the specific issue where backticks are used for inline code but not as code blocks
// Look for patterns like `sqlite` (databased) and convert them properly
asciidoc = asciidoc.replace(/`([^`\n]+)`\s*\(([^)]+)\)/g, '`$1` ($2)')
// Fix spacing issues where text runs together
asciidoc = asciidoc.replace(/([a-zA-Z0-9])`([^`\n]+)`([a-zA-Z0-9])/g, '$1 `$2` $3')
asciidoc = asciidoc.replace(/([a-zA-Z0-9])`([^`\n]+)`\s*\(/g, '$1 `$2` (')
asciidoc = asciidoc.replace(/\)`([^`\n]+)`([a-zA-Z0-9])/g, ') `$1` $2')
// Fix specific pattern: text)text -> text) text
asciidoc = asciidoc.replace(/([a-zA-Z0-9])\)([a-zA-Z0-9])/g, '$1) $2')
// Fix specific pattern: text== -> text ==
asciidoc = asciidoc.replace(/([a-zA-Z0-9])==/g, '$1 ==')
// Handle nostr: addresses - preserve them as-is for now, they'll be processed later
// This prevents them from being converted to AsciiDoc link syntax
asciidoc = asciidoc.replace(/nostr:([a-z0-9]+)/g, 'nostr:$1')
// Convert headers
// Convert headers - process in order from most specific to least specific
asciidoc = asciidoc.replace(/^#{6}\s+(.+)$/gm, '====== $1 ======')
asciidoc = asciidoc.replace(/^#{5}\s+(.+)$/gm, '===== $1 =====')
asciidoc = asciidoc.replace(/^#{4}\s+(.+)$/gm, '==== $1 ====')
asciidoc = asciidoc.replace(/^#{3}\s+(.+)$/gm, '=== $1 ===')
asciidoc = asciidoc.replace(/^#{2}\s+(.+)$/gm, '== $1 ==')
asciidoc = asciidoc.replace(/^#{1}\s+(.+)$/gm, '= $1 =')
// Convert emphasis
asciidoc = asciidoc.replace(/\*\*(.+?)\*\*/g, '*$1*') // Bold
asciidoc = asciidoc.replace(/\*(.+?)\*/g, '_$1_') // Italic
// Convert markdown-style == headers to AsciiDoc
asciidoc = asciidoc.replace(/^==\s+(.+?)\s+==$/gm, '== $1 ==')
// Also handle inline == headers that might appear in the middle of text
asciidoc = asciidoc.replace(/\s==\s+([^=]+?)\s+==\s/g, ' == $1 == ')
// Convert emphasis - handle both single and double asterisks/underscores
asciidoc = asciidoc.replace(/\*\*(.+?)\*\*/g, '*$1*') // Bold **text**
asciidoc = asciidoc.replace(/__(.+?)__/g, '*$1*') // Bold __text__
asciidoc = asciidoc.replace(/\*(.+?)\*/g, '_$1_') // Italic *text*
asciidoc = asciidoc.replace(/_(.+?)_/g, '_$1_') // Italic _text_
asciidoc = asciidoc.replace(/~~(.+?)~~/g, '[line-through]#$1#') // Strikethrough
// Convert code
asciidoc = asciidoc.replace(/```(\w+)?\n([\s\S]*?)```/g, (_match, lang, code) => {
return `[source${lang ? ',' + lang : ''}]\n----\n${code.trim()}\n----`
asciidoc = asciidoc.replace(/~(.+?)~/g, '[subscript]#$1#') // Subscript
asciidoc = asciidoc.replace(/\^(.+?)\^/g, '[superscript]#$1#') // Superscript
// Convert code blocks - use more precise matching to avoid capturing regular text
asciidoc = asciidoc.replace(/```(\w+)?\n([\s\S]*?)\n```/g, (_match, lang, code) => {
// Ensure we don't capture too much content and it looks like actual code
const trimmedCode = code.trim()
if (trimmedCode.length === 0) return ''
// Check if this looks like actual code (has programming syntax patterns)
const hasCodePatterns = /[{}();=<>]|function|class|import|export|def |if |for |while |return |const |let |var |public |private |static |console\.log|var |let |const |if |for |while |return |function/.test(trimmedCode)
// Additional checks for common non-code patterns
const isLikelyText = /^[A-Za-z\s.,!?\-'"]+$/.test(trimmedCode) && trimmedCode.length > 50
const hasTooManySpaces = (trimmedCode.match(/\s{3,}/g) || []).length > 3
const hasMarkdownPatterns = /^#{1,6}\s|^\*\s|^\d+\.\s|^\>\s|^\|.*\|/.test(trimmedCode)
// If it doesn't look like code, has too many spaces, or looks like markdown, treat as regular text
if ((!hasCodePatterns && trimmedCode.length > 100) || isLikelyText || hasTooManySpaces || hasMarkdownPatterns) {
return _match // Return original markdown
}
return `[source${lang ? ',' + lang : ''}]\n----\n${trimmedCode}\n----`
})
asciidoc = asciidoc.replace(/`([^`]+)`/g, '`$1`') // Inline code
// Handle LaTeX math in inline code - preserve $...$ syntax
asciidoc = asciidoc.replace(/`\$([^$]+)\$`/g, '`$\\$1\\$$`')
// Convert images - use proper AsciiDoc image syntax
asciidoc = asciidoc.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, 'image::$2[$1,width=100%]')
// Also handle the specific format: image::url[alt,width=100%] that's already in the content
// This ensures it's properly formatted for AsciiDoc
asciidoc = asciidoc.replace(/image::([^\[]+)\[([^\]]+),width=100%\]/g, 'image::$1[$2,width=100%]')
// Convert links
asciidoc = asciidoc.replace(/\[([^\]]+)\]\(([^)]+)\)/g, 'link:$2[$1]')
// Convert horizontal rules
asciidoc = asciidoc.replace(/^---$/gm, '\n---\n')
// Convert unordered lists
asciidoc = asciidoc.replace(/^(\s*)\*\s+(.+)$/gm, '$1* $2')
asciidoc = asciidoc.replace(/^(\s*)-\s+(.+)$/gm, '$1* $2')
asciidoc = asciidoc.replace(/^(\s*)\+\s+(.+)$/gm, '$1* $2')
// Convert ordered lists
asciidoc = asciidoc.replace(/^(\s*)\d+\.\s+(.+)$/gm, '$1. $2')
// Convert blockquotes - handle multiline blockquotes properly with separate attribution
asciidoc = asciidoc.replace(/^(>\s+.+(?:\n>\s+.+)*)/gm, (match) => {
@ -263,8 +376,38 @@ class ContentParserService { @@ -263,8 +376,38 @@ class ContentParserService {
// Convert images
asciidoc = asciidoc.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, 'image::$2[$1]')
// Convert tables (basic support)
asciidoc = asciidoc.replace(/^\|(.+)\|$/gm, '|$1|')
// Convert tables (basic support) - handle markdown tables properly
asciidoc = asciidoc.replace(/^\|(.+)\|$/gm, (match, content) => {
// Check if this is a table row (not just a single cell)
const cells = content.split('|').map((cell: string) => cell.trim()).filter((cell: string) => cell)
if (cells.length > 1) {
return '|' + content + '|'
}
return match
})
// Fix table rendering by ensuring proper AsciiDoc table format
asciidoc = asciidoc.replace(/(\|.*\|[\r\n]+\|[\s\-\|]*[\r\n]+(\|.*\|[\r\n]+)*)/g, (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
// Convert to proper AsciiDoc table format
let tableAsciidoc = '[cols="1,1"]\n|===\n'
tableAsciidoc += headerRow + '\n'
dataRows.forEach(row => {
tableAsciidoc += row + '\n'
})
tableAsciidoc += '|==='
return tableAsciidoc
})
// Convert horizontal rules
asciidoc = asciidoc.replace(/^---$/gm, '\'\'\'')
@ -292,6 +435,22 @@ class ContentParserService { @@ -292,6 +435,22 @@ class ContentParserService {
return asciidoc
}
/**
* Process nostr: addresses in content
*/
private processNostrAddresses(content: string): string {
let processed = content
// Process nostr: addresses - convert them to AsciiDoc link format
// This regex matches nostr: followed by any valid bech32 string
processed = processed.replace(/nostr:([a-z0-9]+[a-z0-9]{6,})/g, (_match, bech32Id) => {
// Create AsciiDoc link with nostr: prefix
return `link:nostr:${bech32Id}[${bech32Id}]`
})
return processed
}
/**
* Process wikilinks in content (both standard and bookstr macro)
*/
@ -329,13 +488,33 @@ class ContentParserService { @@ -329,13 +488,33 @@ class ContentParserService {
}
/**
* Process wikilinks in HTML output
* Process wikilinks and nostr links in HTML output
*/
private processWikilinksInHtml(html: string): string {
let processed = html
// Convert wikilink:dtag[display] format to HTML with data attributes
return html.replace(/wikilink:([^[]+)\[([^\]]+)\]/g, (_match, dTag, displayText) => {
processed = processed.replace(/wikilink:([^[]+)\[([^\]]+)\]/g, (_match, dTag, displayText) => {
return `<span class="wikilink cursor-pointer text-blue-600 hover:text-blue-800 hover:underline border-b border-dotted border-blue-300" data-dtag="${dTag}" data-display="${displayText}">${displayText}</span>`
})
// Convert nostr: links to proper embedded components
processed = processed.replace(/link:nostr:([^[]+)\[([^\]]+)\]/g, (_match, bech32Id, displayText) => {
const nostrType = this.getNostrType(bech32Id)
if (nostrType === 'nevent' || nostrType === 'naddr' || nostrType === 'note') {
// Render as embedded event
return `<div data-embedded-note="${bech32Id}" class="embedded-note-container">Loading embedded event...</div>`
} else if (nostrType === 'npub' || nostrType === 'nprofile') {
// Render as user handle
return `<span class="user-handle" data-pubkey="${bech32Id}">@${displayText}</span>`
} else {
// Fallback to regular link
return `<a href="nostr:${bech32Id}" class="nostr-link text-blue-600 hover:text-blue-800 hover:underline" data-nostr-type="${nostrType}" data-bech32="${bech32Id}">${displayText}</a>`
}
})
return processed
}
/**
@ -384,6 +563,16 @@ class ContentParserService { @@ -384,6 +563,16 @@ class ContentParserService {
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>`
})
// Fix broken HTML attributes that are being rendered as text
cleaned = cleaned.replace(/" target="_blank" rel="noreferrer noopener" class="break-words inline-flex items-baseline gap-1">([^<]+) <svg[^>]*><path[^>]*><\/path><\/svg><\/a>/g, (_match, text) => {
return `" 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>`
})
// Fix broken image HTML
cleaned = cleaned.replace(/" alt="([^"]*)" class="max-w-\[400px\] object-contain my-0" \/>/g, (_match, alt) => {
return `" alt="${alt}" class="max-w-[400px] object-contain my-0" />`
})
// Clean up markdown table syntax
cleaned = this.cleanupMarkdownTables(cleaned)
@ -782,6 +971,28 @@ class ContentParserService { @@ -782,6 +971,28 @@ class ContentParserService {
}
}
/**
* Add proper CSS classes for styling
*/
private addStylingClasses(html: string): string {
let styled = html
// Add strikethrough styling
styled = styled.replace(/<span class="line-through">([^<]+)<\/span>/g, '<span class="line-through line-through-2">$1</span>')
// Add subscript styling
styled = styled.replace(/<span class="subscript">([^<]+)<\/span>/g, '<span class="subscript text-xs align-sub">$1</span>')
// Add superscript styling
styled = styled.replace(/<span class="superscript">([^<]+)<\/span>/g, '<span class="superscript text-xs align-super">$1</span>')
// Add code highlighting classes
styled = styled.replace(/<pre class="highlightjs[^"]*">/g, '<pre class="highlightjs hljs">')
styled = styled.replace(/<code class="highlightjs[^"]*">/g, '<code class="highlightjs hljs">')
return styled
}
/**
* Hide raw AsciiDoc ToC text that might appear in the content
*/

Loading…
Cancel
Save