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 ( +
+
Loading content...
+
+ ) + } + + if (error) { + return ( +
+
Error loading content: {error.message}
+
+ ) + } + + if (!parsedContent) { + return ( +
+
No content available
+
+ ) + } + + 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]] /^<> @@ -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">([^<]+) ]*>]*><\/path><\/svg><\/a>/g, (_match, text) => { + return `" target="_blank" rel="noreferrer noopener" class="break-words inline-flex items-baseline gap-1">${text} ` + }) + + // 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 { } } + /** + * Add proper CSS classes for styling + */ + private addStylingClasses(html: string): string { + let styled = html + + // Add strikethrough styling + styled = styled.replace(/([^<]+)<\/span>/g, '$1') + + // Add subscript styling + styled = styled.replace(/([^<]+)<\/span>/g, '$1') + + // Add superscript styling + styled = styled.replace(/([^<]+)<\/span>/g, '$1') + + // Add code highlighting classes + styled = styled.replace(/
/g, '
')
+    styled = styled.replace(//g, '')
+    
+    return styled
+  }
+
   /**
    * Hide raw AsciiDoc ToC text that might appear in the content
    */