From c320bc972857c52cc2c139e0f5f2f35bc5c659c0 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 29 Oct 2025 16:48:09 +0100 Subject: [PATCH] more parsing updates --- src/components/ImageWithLightbox/index.tsx | 2 +- src/components/Note/Article/index.tsx | 105 +++++++--- src/components/Note/SimpleContent/index.tsx | 31 +++ src/components/Note/index.tsx | 11 +- .../UniversalContent/TableOfContents.tsx | 132 ------------ src/index.css | 191 ++++++++++++++++++ src/services/content-parser.service.ts | 125 +++++++++++- 7 files changed, 417 insertions(+), 180 deletions(-) create mode 100644 src/components/Note/SimpleContent/index.tsx delete mode 100644 src/components/UniversalContent/TableOfContents.tsx diff --git a/src/components/ImageWithLightbox/index.tsx b/src/components/ImageWithLightbox/index.tsx index 1c180fb..3642c04 100644 --- a/src/components/ImageWithLightbox/index.tsx +++ b/src/components/ImageWithLightbox/index.tsx @@ -62,7 +62,7 @@ export default function ImageWithLightbox({ key={0} className={className} classNames={{ - wrapper: cn('rounded-lg border cursor-zoom-in', classNames.wrapper), + wrapper: cn('rounded-lg cursor-zoom-in', classNames.wrapper), errorPlaceholder: 'aspect-square h-[30vh]' }} image={image} diff --git a/src/components/Note/Article/index.tsx b/src/components/Note/Article/index.tsx index ddbf3bf..3af8c49 100644 --- a/src/components/Note/Article/index.tsx +++ b/src/components/Note/Article/index.tsx @@ -8,9 +8,9 @@ import { useMemo, useState, useEffect, useRef } from 'react' import { useEventFieldParser } from '@/hooks/useContentParser' import WebPreview from '../../WebPreview' import HighlightSourcePreview from '../../UniversalContent/HighlightSourcePreview' -import TableOfContents from '../../UniversalContent/TableOfContents' import { Button } from '@/components/ui/button' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { ExtendedKind } from '@/constants' export default function Article({ event, @@ -23,6 +23,14 @@ export default function Article({ 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, @@ -93,6 +101,46 @@ export default function Article({ } }, [parsedContent]) + // 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 ( @@ -130,41 +178,16 @@ export default function Article({ {metadata.image && ( )} - {/* Table of Contents */} - - {/* Render AsciiDoc content (everything is now processed as AsciiDoc) */} -
- - {/* Hashtags */} - {parsedContent.hashtags.length > 0 && ( -
- {parsedContent.hashtags.map((tag) => ( -
{ - e.stopPropagation() - push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) - }} - > - #{tag} -
- ))} -
- )} +
- {/* Collapsible Article Info */} - {(parsedContent.media.length > 0 || parsedContent.links.length > 0 || parsedContent.nostrLinks.length > 0 || parsedContent.highlightSources.length > 0) && ( + {/* 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) && (
)} + + {/* Hashtags */} + {parsedContent.hashtags.length > 0 && ( +
+

Tags:

+
+ {parsedContent.hashtags.map((tag) => ( +
{ + e.stopPropagation() + push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) + }} + > + #{tag} +
+ ))} +
+
+ )} )} diff --git a/src/components/Note/SimpleContent/index.tsx b/src/components/Note/SimpleContent/index.tsx new file mode 100644 index 0000000..ce248fc --- /dev/null +++ b/src/components/Note/SimpleContent/index.tsx @@ -0,0 +1,31 @@ +import { Event } from 'nostr-tools' +import { useEventFieldParser } from '@/hooks/useContentParser' + +export default function SimpleContent({ + event, + className +}: { + event: Event + className?: string +}) { + // Use the comprehensive content parser but without ToC + const { parsedContent, isLoading, error } = useEventFieldParser(event, 'content', { + enableMath: true, + enableSyntaxHighlighting: true + }) + + if (isLoading) { + return
Loading...
+ } + + if (error) { + return
Error loading content
+ } + + return ( +
+ {/* Render content without ToC and Article Info */} +
+
+ ) +} diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index bed9430..a183296 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -28,6 +28,7 @@ import IValue from './IValue' import LiveEvent from './LiveEvent' import LongFormArticlePreview from './LongFormArticlePreview' import Article from './Article' +import SimpleContent from './SimpleContent' import PublicationCard from './PublicationCard' import WikiCard from './WikiCard' import MutedNote from './MutedNote' @@ -110,14 +111,6 @@ export default function Note({ ) : ( ) - } else if (event.kind === ExtendedKind.WIKI_CHAPTER) { - content = showFull ? ( -
- ) : ( -
-
Wiki Chapter (part of publication)
-
- ) } else if (event.kind === ExtendedKind.PUBLICATION) { content = showFull ? (
@@ -159,7 +152,7 @@ export default function Note({ } else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) { content = } else { - content = + content = } return ( diff --git a/src/components/UniversalContent/TableOfContents.tsx b/src/components/UniversalContent/TableOfContents.tsx deleted file mode 100644 index a3d4ef9..0000000 --- a/src/components/UniversalContent/TableOfContents.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Compact Table of Contents component for articles - */ - -import { useEffect, useState } from 'react' -import { ChevronDown, ChevronRight, Hash } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' - -interface TocItem { - id: string - text: string - level: number -} - -interface TableOfContentsProps { - content: string - className?: string -} - -export default function TableOfContents({ content, className }: TableOfContentsProps) { - const [isOpen, setIsOpen] = useState(false) - const [tocItems, setTocItems] = useState([]) - - useEffect(() => { - // Parse content for headings - const parser = new DOMParser() - const doc = parser.parseFromString(content, 'text/html') - - const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6') - const items: TocItem[] = [] - - headings.forEach((heading, index) => { - const level = parseInt(heading.tagName.charAt(1)) - const text = heading.textContent?.trim() || '' - - if (text) { - // Use existing ID if available, otherwise generate one - const existingId = heading.id - const id = existingId || `heading-${index}-${text.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` - - items.push({ - id, - text, - level - }) - } - }) - - setTocItems(items) - }, [content]) - - if (tocItems.length === 0) { - return null - } - - const scrollToHeading = (item: TocItem) => { - // Try to find the element in the actual DOM - const element = document.getElementById(item.id) - if (element) { - element.scrollIntoView({ - behavior: 'smooth', - block: 'start', - inline: 'nearest' - }) - setIsOpen(false) - } else { - // Fallback: try to find by text content - const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6') - for (const heading of headings) { - if (heading.textContent?.trim() === item.text) { - heading.scrollIntoView({ - behavior: 'smooth', - block: 'start', - inline: 'nearest' - }) - setIsOpen(false) - break - } - } - } - } - - return ( - - - - - -
-
- {tocItems.map((item) => ( - - ))} -
-
-
-
- ) -} diff --git a/src/index.css b/src/index.css index ca18fa8..cd8d258 100644 --- a/src/index.css +++ b/src/index.css @@ -140,6 +140,197 @@ } } +/* AsciiDoc Table of Contents Styling */ +#toc { + @apply bg-muted/30 rounded-lg p-3 sm:p-4 mb-4 sm:mb-6; +} + +#toc h2 { + @apply text-lg font-semibold mb-3 text-foreground border-b border-border pb-2; +} + +#toc ul { + @apply space-y-1; +} + +#toc li { + @apply list-none; +} + +#toc a { + @apply text-sm text-muted-foreground hover:text-foreground transition-colors duration-200 block py-1 px-2 rounded hover:bg-primary/10 hover:border-l-2 hover:border-primary hover:pl-3; +} + +#toc .sectlevel1 a { + @apply font-medium text-foreground; +} + +#toc .sectlevel2 a { + @apply ml-4; +} + +#toc .sectlevel3 a { + @apply ml-8; +} + +#toc .sectlevel4 a { + @apply ml-12; +} + +#toc .sectlevel5 a { + @apply ml-16; +} + +#toc .sectlevel6 a { + @apply ml-20; +} + +/* Hide the raw AsciiDoc ToC text when styled ToC is present */ +.asciidoc-toc-raw { + display: none; +} + +/* AsciiDoc Section Headers Styling */ +.asciidoc-content h1, +.asciidoc-content h2, +.asciidoc-content h3, +.asciidoc-content h4, +.asciidoc-content h5, +.asciidoc-content h6 { + @apply scroll-mt-24; /* Add padding when scrolling to headers - increased for better visibility */ + scroll-behavior: smooth; + position: relative; +} + +/* ToC return button styling */ +.asciidoc-content h1 .toc-return-btn, +.asciidoc-content h2 .toc-return-btn, +.asciidoc-content h3 .toc-return-btn, +.asciidoc-content h4 .toc-return-btn, +.asciidoc-content h5 .toc-return-btn, +.asciidoc-content h6 .toc-return-btn { + @apply absolute right-0 top-1/2 -translate-y-1/2 opacity-0 transition-all duration-200 text-muted-foreground hover:text-foreground cursor-pointer; + font-size: 0.75rem; + line-height: 1; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + background: hsl(var(--background) / 0.9); + backdrop-filter: blur(8px); + border: 1px solid hsl(var(--border)); + white-space: nowrap; + z-index: 10; + box-shadow: 0 1px 3px hsl(var(--foreground) / 0.1); +} + +.asciidoc-content h1:hover .toc-return-btn, +.asciidoc-content h2:hover .toc-return-btn, +.asciidoc-content h3:hover .toc-return-btn, +.asciidoc-content h4:hover .toc-return-btn, +.asciidoc-content h5:hover .toc-return-btn, +.asciidoc-content h6:hover .toc-return-btn { + @apply opacity-100; + transform: translateY(-50%) scale(1.05); + background: hsl(var(--primary) / 0.1); + border-color: hsl(var(--primary) / 0.3); +} + +/* Show button on mobile when header is tapped */ +@media (hover: none) { + .asciidoc-content h1:active .toc-return-btn, + .asciidoc-content h2:active .toc-return-btn, + .asciidoc-content h3:active .toc-return-btn, + .asciidoc-content h4:active .toc-return-btn, + .asciidoc-content h5:active .toc-return-btn, + .asciidoc-content h6:active .toc-return-btn { + @apply opacity-100; + } +} + +/* AsciiDoc Footnotes Styling */ +.asciidoc-content .footnote { + @apply text-xs text-muted-foreground; + margin-bottom: 0.5rem; /* Add some spacing between footnotes */ +} + +.asciidoc-content .footnote a { + @apply text-primary hover:underline; +} + +.asciidoc-content .footnote-backref { + @apply ml-1 text-primary hover:underline; +} + +.asciidoc-content .footnoteref { + @apply text-primary hover:underline; + vertical-align: super; + font-size: 0.75em; + text-decoration: none; + font-weight: 500; +} + +/* Add scroll padding to footnote anchors and content elements */ +.asciidoc-content [id^="footnote-"], +.asciidoc-content [id*="footnote"], +.asciidoc-content [id*="_footnote"], +.asciidoc-content [id*="_ref"], +.asciidoc-content p, +.asciidoc-content li { + scroll-margin-top: 8rem; /* Scroll padding for footnote anchors and content */ +} + +/* Also add scroll padding to any element that might be a footnote target */ +.asciidoc-content *[id] { + scroll-margin-top: 8rem; +} + +.asciidoc-content .footnoteref:hover { + @apply underline; +} + +.asciidoc-content .footnotes { + @apply mt-8 pt-4 border-t border-border; + scroll-margin-top: 4rem; /* Ensure footnotes section doesn't scroll out of view */ +} + +.asciidoc-content .footnotes ol { + @apply space-y-3; /* Increased spacing between footnote entries */ +} + +.asciidoc-content .footnotes li { + @apply text-sm text-muted-foreground; + display: block; /* Ensure each footnote is on its own line */ + margin-bottom: 0.75rem; /* Additional spacing for better readability */ +} + +.asciidoc-content .footnotes li p { + @apply mb-2; + margin-top: 0; /* Remove top margin for cleaner look */ +} + +.asciidoc-content h1 { + @apply text-xl sm:text-2xl font-bold mt-6 sm:mt-8 mb-3 sm:mb-4 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200; +} + +.asciidoc-content h2 { + @apply text-lg sm:text-xl font-semibold mt-5 sm:mt-6 mb-2 sm:mb-3 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200; +} + +.asciidoc-content h3 { + @apply text-base sm:text-lg font-medium mt-4 sm:mt-5 mb-2 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200; +} + +.asciidoc-content h4 { + @apply text-sm sm:text-base font-medium mt-3 sm:mt-4 mb-2 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200; +} + +.asciidoc-content h5 { + @apply text-xs sm:text-sm font-medium mt-3 mb-2 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200; +} + +.asciidoc-content h6 { + @apply text-xs font-medium mt-3 mb-2 text-foreground hover:bg-primary/5 hover:px-2 hover:py-1 hover:rounded transition-all duration-200; +} + @keyframes progressFill { 0% { width: 0%; diff --git a/src/services/content-parser.service.ts b/src/services/content-parser.service.ts index c220a8d..b510c95 100644 --- a/src/services/content-parser.service.ts +++ b/src/services/content-parser.service.ts @@ -4,9 +4,9 @@ */ import { detectMarkupType, getMarkupClasses, MarkupType } from '@/lib/markup-detection' -import { Event, nip19 } from 'nostr-tools' +import { Event, kinds, nip19 } from 'nostr-tools' import { getImetaInfosFromEvent } from '@/lib/event' -import { URL_REGEX } from '@/constants' +import { URL_REGEX, ExtendedKind } from '@/constants' import { TImetaInfo } from '@/types' export interface ParsedContent { @@ -69,7 +69,13 @@ class ContentParserService { const cssClasses = getMarkupClasses(markupType) // Extract all content elements - const media = this.extractAllMedia(content, event) + // For article-type events, don't extract media as it should be rendered inline + const isArticleType = eventKind === kinds.LongFormArticle || + eventKind === ExtendedKind.WIKI_ARTICLE || + eventKind === ExtendedKind.PUBLICATION || + eventKind === ExtendedKind.PUBLICATION_CONTENT + + const media = isArticleType ? [] : this.extractAllMedia(content, event) const links = this.extractLinks(content) const hashtags = this.extractHashtags(content) const nostrLinks = this.extractNostrLinks(content) @@ -121,6 +127,9 @@ class ContentParserService { 'showtitle': true, 'sectanchors': true, 'sectlinks': true, + 'toc': 'left', + 'toclevels': 6, + 'toc-title': 'Table of Contents', 'source-highlighter': options.enableSyntaxHighlighting ? 'highlight.js' : 'none', 'stem': options.enableMath ? 'latexmath' : 'none' } @@ -131,8 +140,11 @@ class ContentParserService { // Process wikilinks in the HTML output const processedHtml = this.processWikilinksInHtml(htmlString) - // Clean up any leftover markdown syntax - return this.cleanupMarkdown(processedHtml) + // Clean up any leftover markdown syntax and hide raw ToC text + const cleanedHtml = this.cleanupMarkdown(processedHtml) + + // Hide any raw AsciiDoc ToC text that might appear in the content + return this.hideRawTocText(cleanedHtml) } catch (error) { console.error('AsciiDoc parsing error:', error) return this.parsePlainText(content) @@ -190,15 +202,63 @@ class ContentParserService { }) asciidoc = asciidoc.replace(/`([^`]+)`/g, '`$1`') // Inline code - // Convert blockquotes - asciidoc = asciidoc.replace(/^>\s+(.+)$/gm, '____\n$1\n____') + // Convert blockquotes - handle multiline blockquotes properly with separate attribution + asciidoc = asciidoc.replace(/^(>\s+.+(?:\n>\s+.+)*)/gm, (match) => { + const lines = match.split('\n').map(line => line.replace(/^>\s*/, '')) // Remove '>' and optional space from each line + + let quoteBodyLines: string[] = [] + let attributionLine: string | undefined + + // Find the last line that looks like an attribution (starts with '—' or '--') + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim() + if (line.startsWith('—') || line.startsWith('--')) { + attributionLine = line + quoteBodyLines = lines.slice(0, i) // Everything before the attribution is the quote body + break + } + } + + const quoteContent = quoteBodyLines.filter(l => l.trim() !== '').join('\n').trim() + + if (attributionLine) { + // Remove leading '—' or '--' from the attribution line + let cleanedAttribution = attributionLine.replace(/^[—-]+/, '').trim() + + let author = '' + let source = '' + + // Try to find a link:url[text] pattern (already converted from markdown links) + // Example: "George Bernard Shaw, link:https://www.goodreads.com/work/quotes/376394[Man and Superman]" + const linkMatch = cleanedAttribution.match(/^(.*?),?\s*link:([^[\\]]+)\[([^\\]]+)\]$/) + + if (linkMatch) { + author = linkMatch[1].trim() + // Use the AsciiDoc link format directly in the source attribute + source = `link:${linkMatch[2].trim()}[${linkMatch[3].trim()}]` + } else { + // If no link, assume the whole thing is author or author, sourceText + const parts = cleanedAttribution.split(',').map(p => p.trim()) + author = parts[0] + if (parts.length > 1) { + source = parts.slice(1).join(', ').trim() + } + } + + // AsciiDoc blockquote with attribution: [quote, author, source] + return `[quote, ${author}, ${source}]\n____\n${quoteContent}\n____` + } else { + // If no attribution line is found, render as a regular AsciiDoc blockquote + return `____\n${quoteContent}\n____` + } + }) // Convert lists asciidoc = asciidoc.replace(/^(\s*)\*\s+(.+)$/gm, '$1* $2') // Unordered lists asciidoc = asciidoc.replace(/^(\s*)\d+\.\s+(.+)$/gm, '$1. $2') // Ordered lists // Convert links - asciidoc = asciidoc.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1[$2]') + asciidoc = asciidoc.replace(/\[([^\]]+)\]\(([^)]+)\)/g, 'link:$2[$1]') // Convert images asciidoc = asciidoc.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, 'image::$2[$1]') @@ -209,6 +269,26 @@ class ContentParserService { // Convert horizontal rules asciidoc = asciidoc.replace(/^---$/gm, '\'\'\'') + // Convert footnotes - handle both references and definitions for auto-numbering + const footnoteDefinitions: { [id: string]: string } = {} + let tempAsciidoc = asciidoc + + // First, extract all footnote definitions and remove them from the content + // This regex captures [^id]: text including multi-line content + tempAsciidoc = tempAsciidoc.replace(/^\[\^([^\]]+)\]:\s*([\s\S]*?)(?=\n\[\^|\n---|\n##|\n###|\n####|\n#####|\n######|$)/gm, (_, id, text) => { + footnoteDefinitions[id] = text.trim() + return '' // Remove the definition line from the content + }) + + // Then, replace all footnote references [^id] with AsciiDoc's auto-numbered footnote syntax + // using the extracted definitions. + asciidoc = tempAsciidoc.replace(/\[\^([^\]]+)\]/g, (match, id) => { + if (footnoteDefinitions[id]) { + return `footnote:[${footnoteDefinitions[id]}]` + } + return match // If definition not found, leave as is + }) + return asciidoc } @@ -701,6 +781,35 @@ class ContentParserService { return '' } } + + /** + * Hide raw AsciiDoc ToC text that might appear in the content + */ + private hideRawTocText(html: string): string { + // Hide any raw ToC text that might be generated by AsciiDoc + // This includes patterns like "# Table of Contents (5)" and plain text lists + let cleaned = html + + // Hide raw ToC headings and content + cleaned = cleaned.replace( + /]*>.*?Table of Contents.*?\(\d+\).*?<\/h[1-6]>/gi, + '' + ) + + // Hide raw ToC lists that might appear as plain text + cleaned = cleaned.replace( + /]*>.*?Table of Contents.*?\(\d+\).*?<\/p>/gi, + '' + ) + + // Hide any remaining raw ToC text patterns + cleaned = cleaned.replace( + /]*>.*?Assumptions.*?\[n=0\].*?<\/p>/gi, + '' + ) + + return cleaned + } } // Export singleton instance