diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx
index 1db5793..3194c49 100644
--- a/src/components/Embedded/EmbeddedNote.tsx
+++ b/src/components/Embedded/EmbeddedNote.tsx
@@ -51,12 +51,14 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className?
}
return (
-
+
+
+
)
}
diff --git a/src/components/Note/Article/index.tsx b/src/components/Note/Article/index.tsx
index ff9e360..f2975b5 100644
--- a/src/components/Note/Article/index.tsx
+++ b/src/components/Note/Article/index.tsx
@@ -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 = `Loading embedded event...
`
+ 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 = `@${handle.textContent}`
+ 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({
}
return (
-
+
{/* Article metadata */}
- {metadata.title}
- {metadata.summary && (
-
- {metadata.summary}
-
- )}
- {metadata.image && (
-
- )}
-
+
+ {metadata.title}
+ {metadata.summary && (
+
+ {metadata.summary}
+
+ )}
+ {metadata.image && (
+
+
+
+ )}
+
{/* Render AsciiDoc content (everything is now processed as AsciiDoc) */}
-
+
{/* 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({
)}
-
+
)
}
\ No newline at end of file
diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx
new file mode 100644
index 0000000..bbbf371
--- /dev/null
+++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx
@@ -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(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 = '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 = '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 = `Loading embedded event...
`
+ 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 = `@${handle.textContent}`
+ 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 = '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 = '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 (
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
Error loading content: {error.message}
+
+ )
+ }
+
+ if (!parsedContent) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {/* Article metadata */}
+
+ {metadata.title}
+ {metadata.summary && (
+
+ {metadata.summary}
+
+ )}
+ {metadata.image && (
+
+
+
+ )}
+
+
+ {/* Render AsciiDoc content (everything is now processed as AsciiDoc) */}
+
+
+ {/* 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) && (
+
+
+
+
+
+ {/* Media thumbnails */}
+ {parsedContent?.media?.length > 0 && (
+
+
Images in this article:
+
+ {parsedContent?.media?.map((media, index) => (
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Links summary with OpenGraph previews */}
+ {parsedContent?.links?.length > 0 && (
+
+
Links in this article:
+
+ {parsedContent?.links?.map((link, index) => (
+
+ ))}
+
+
+ )}
+
+ {/* Nostr links summary */}
+ {parsedContent?.nostrLinks?.length > 0 && (
+
+
Nostr references:
+
+ {parsedContent?.nostrLinks?.map((link, index) => (
+
+ {link.type}:{' '}
+ {link.id}
+
+ ))}
+
+
+ )}
+
+ {/* Highlight sources */}
+ {parsedContent?.highlightSources?.length > 0 && (
+
+
Highlight sources:
+
+ {parsedContent?.highlightSources?.map((source, index) => (
+
+ ))}
+
+
+ )}
+
+ {/* Hashtags */}
+ {parsedContent?.hashtags?.length > 0 && (
+
+
Tags:
+
+ {parsedContent?.hashtags?.map((tag) => (
+
{
+ e.stopPropagation()
+ push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
+ }}
+ >
+ #{tag}
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/Note/Highlight/index.tsx b/src/components/Note/Highlight/index.tsx
index 2693213..34893e4 100644
--- a/src/components/Note/Highlight/index.tsx
+++ b/src/components/Note/Highlight/index.tsx
@@ -136,7 +136,9 @@ export default function Highlight({
{
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"
>
diff --git a/src/components/Note/LongFormArticle/NostrNode.tsx b/src/components/Note/LongFormArticle/NostrNode.tsx
index b0d37ce..8b9251d 100644
--- a/src/components/Note/LongFormArticle/NostrNode.tsx
+++ b/src/components/Note/LongFormArticle/NostrNode.tsx
@@ -8,7 +8,7 @@ export default function NostrNode({ rawText, bech32Id }: ComponentProps getLongFormArticleMetadataFromEvent(event), [event])
-
- const components = useMemo(
- () =>
- ({
- nostr: ({ rawText, bech32Id }) => ,
- a: ({ href, children, ...props }) => {
- if (!href) {
- return
- }
- if (href.startsWith('note1') || href.startsWith('nevent1') || href.startsWith('naddr1')) {
- return (
-
- {children}
-
- )
- }
- if (href.startsWith('npub1') || href.startsWith('nprofile1')) {
- return (
-
- {children}
-
- )
- }
- return (
-
- {children}
-
- )
- },
- 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
- }
- }
- return
- },
- div: (props) => ,
- code: (props) => ,
- img: (props) => (
-
- )
- }) as Components,
- []
- )
-
- return (
-
-
{metadata.title}
- {metadata.summary && (
-
- {metadata.summary}
-
- )}
- {metadata.image && (
-
- )}
-
{
- if (url.startsWith('nostr:')) {
- return url.slice(6) // Remove 'nostr:' prefix for rendering
- }
- return url
- }}
- components={components}
- >
- {event.content}
-
- {metadata.tags.length > 0 && (
-
- {metadata.tags.map((tag) => (
-
{
- e.stopPropagation()
- push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
- }}
- >
- #{tag}
-
- ))}
-
- )}
-
- )
-}
diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx
new file mode 100644
index 0000000..4e51481
--- /dev/null
+++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx
@@ -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(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 }) => ,
+ a: ({ href, children, ...props }) => {
+ if (!href) {
+ return
+ }
+ if (href.startsWith('note1') || href.startsWith('nevent1') || href.startsWith('naddr1')) {
+ return (
+
+ {children}
+
+ )
+ }
+ if (href.startsWith('npub1') || href.startsWith('nprofile1')) {
+ return (
+
+ {children}
+
+ )
+ }
+ return (
+
+ {children}
+
+ )
+ },
+ 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
+ }
+ }
+ return
+ },
+ div: (props) => ,
+ code: ({ className, children, ...props }: any) => {
+ const match = /language-(\w+)/.exec(className || '')
+ const isInline = !match
+ return !isInline && match ? (
+
+
+ {children}
+
+
+ ) : (
+
+ {children}
+
+ )
+ },
+ 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(
+
+ #{hashtag}
+
+ )
+
+ lastIndex = match.index + match[0].length
+ }
+
+ // Add remaining text
+ if (lastIndex < children.length) {
+ parts.push(children.slice(lastIndex))
+ }
+
+ return <>{parts}>
+ }
+
+ return <>{children}>
+ },
+ img: (props) => (
+
+ )
+ }) as Components,
+ []
+ )
+
+ return (
+ <>
+
+
+ {metadata.title &&
{metadata.title}
}
+ {metadata.summary && (
+
+ {metadata.summary}
+
+ )}
+ {metadata.image && (
+
+ )}
+
{
+ if (url.startsWith('nostr:')) {
+ return url.slice(6) // Remove 'nostr:' prefix for rendering
+ }
+ return url
+ }}
+ components={components}
+ >
+ {event.content}
+
+ {metadata.tags.length > 0 && (
+
+ {metadata.tags.map((tag) => (
+
{
+ e.stopPropagation()
+ push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
+ }}
+ >
+ #{tag}
+
+ ))}
+
+ )}
+
+ >
+ )
+}
diff --git a/src/components/Note/MarkdownArticle/NostrNode.tsx b/src/components/Note/MarkdownArticle/NostrNode.tsx
new file mode 100644
index 0000000..8b9251d
--- /dev/null
+++ b/src/components/Note/MarkdownArticle/NostrNode.tsx
@@ -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) {
+ 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
+ }
+ return
+}
diff --git a/src/components/Note/LongFormArticle/remarkNostr.ts b/src/components/Note/MarkdownArticle/remarkNostr.ts
similarity index 100%
rename from src/components/Note/LongFormArticle/remarkNostr.ts
rename to src/components/Note/MarkdownArticle/remarkNostr.ts
diff --git a/src/components/Note/LongFormArticle/types.ts b/src/components/Note/MarkdownArticle/types.ts
similarity index 100%
rename from src/components/Note/LongFormArticle/types.ts
rename to src/components/Note/MarkdownArticle/types.ts
diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx
index 67bcd08..04896d5 100644
--- a/src/components/Note/index.tsx
+++ b/src/components/Note/index.tsx
@@ -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({
Context: {event.tags.find(tag => tag[0] === 'context')?.[1] || 'No context found'}
}
- } else if (event.kind === kinds.LongFormArticle) {
- content = showFull ? (
-
- ) : (
-
- )
} else if (event.kind === ExtendedKind.WIKI_ARTICLE) {
content = showFull ? (
-
+
) : (
)
} else if (event.kind === ExtendedKind.PUBLICATION) {
content = showFull ? (
-
+
) : (
)
+ } else if (event.kind === kinds.LongFormArticle) {
+ content = showFull ? (
+
+ ) : (
+
+ )
} else if (event.kind === kinds.LiveEvent) {
content =
} else if (event.kind === ExtendedKind.GROUP_METADATA) {
@@ -152,7 +152,8 @@ export default function Note({
} else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
content =
} else {
- content =
+ // Use MarkdownArticle for all other kinds (including kinds 1 and 11)
+ content =
}
return (
@@ -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))
diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts
index 2b4593d..a2baf08 100644
--- a/src/lib/event-metadata.ts
+++ b/src/lib/event-metadata.ts
@@ -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) }
diff --git a/src/lib/markup-detection.ts b/src/lib/markup-detection.ts
index 8bf333b..50cc860 100644
--- a/src/lib/markup-detection.ts
+++ b/src/lib/markup-detection.ts
@@ -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: <[>
@@ -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))
diff --git a/src/services/content-parser.service.ts b/src/services/content-parser.service.ts
index b510c95..4bd4dad 100644
--- a/src/services/content-parser.service.ts
+++ b/src/services/content-parser.service.ts
@@ -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 {
}
// 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 {
// 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 {
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 {
}
/**
- * 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 `${displayText}`
})
+
+ // 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 `]Loading embedded event...
`
+ } else if (nostrType === 'npub' || nostrType === 'nprofile') {
+ // Render as user handle
+ return `@${displayText}`
+ } else {
+ // Fallback to regular link
+ return `${displayText}`
+ }
+ })
+
+ return processed
}
/**
@@ -384,6 +563,16 @@ class ContentParserService {
return `${text} `
})
+ // 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">([^<]+)