From dd3b6c324b1c72a02e35952e9f49adcb72eb7c0c Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 29 Oct 2025 12:34:08 +0100 Subject: [PATCH] more article stuff: everything is Asciidoc wikilinks added --- package-lock.json | 166 +++++++++ package.json | 3 +- src/components/Note/Article/index.tsx | 267 ++++++++------ .../HighlightSourcePreview.tsx | 85 +++++ .../UniversalContent/ParsedContent.tsx | 95 ++--- .../UniversalContent/TableOfContents.tsx | 132 +++++++ src/components/UniversalContent/Wikilink.tsx | 60 ++++ .../UniversalContent/WikilinkProcessor.tsx | 47 +++ src/components/ui/collapsible.tsx | 9 + src/services/content-parser.service.ts | 332 +++++++++++------- 10 files changed, 873 insertions(+), 323 deletions(-) create mode 100644 src/components/UniversalContent/HighlightSourcePreview.tsx create mode 100644 src/components/UniversalContent/TableOfContents.tsx create mode 100644 src/components/UniversalContent/Wikilink.tsx create mode 100644 src/components/UniversalContent/WikilinkProcessor.tsx create mode 100644 src/components/ui/collapsible.tsx diff --git a/package-lock.json b/package-lock.json index 4478a70..73559d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-hover-card": "^1.1.4", @@ -2601,6 +2602,171 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz", diff --git a/package.json b/package.json index 4d0609f..959ca48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jumble-imwald", - "version": "10.15", + "version": "11.0", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "private": true, "type": "module", @@ -29,6 +29,7 @@ "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-hover-card": "^1.1.4", diff --git a/src/components/Note/Article/index.tsx b/src/components/Note/Article/index.tsx index a381085..ddbf3bf 100644 --- a/src/components/Note/Article/index.tsx +++ b/src/components/Note/Article/index.tsx @@ -1,21 +1,16 @@ import { useSecondaryPage } from '@/PageManager' import ImageWithLightbox from '@/components/ImageWithLightbox' -import ImageGallery from '@/components/ImageGallery' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNoteList } from '@/lib/link' -import { ExternalLink } from 'lucide-react' +import { ChevronDown, ChevronRight } from 'lucide-react' import { Event, kinds } from 'nostr-tools' -import { useMemo } from 'react' -import Markdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import remarkMath from 'remark-math' -import rehypeKatex from 'rehype-katex' -import NostrNode from '../LongFormArticle/NostrNode' -import { remarkNostr } from '../LongFormArticle/remarkNostr' -import { Components } from '../LongFormArticle/types' +import { useMemo, useState, useEffect, useRef } from 'react' import { useEventFieldParser } from '@/hooks/useContentParser' import WebPreview from '../../WebPreview' -import 'katex/dist/katex.min.css' +import HighlightSourcePreview from '../../UniversalContent/HighlightSourcePreview' +import TableOfContents from '../../UniversalContent/TableOfContents' +import { Button } from '@/components/ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' export default function Article({ event, @@ -26,6 +21,7 @@ export default function Article({ }) { const { push } = useSecondaryPage() const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) + const [isInfoOpen, setIsInfoOpen] = useState(false) // Use the comprehensive content parser const { parsedContent, isLoading, error } = useEventFieldParser(event, 'content', { @@ -33,48 +29,70 @@ export default function Article({ enableSyntaxHighlighting: true }) - const components = useMemo( - () => - ({ - nostr: ({ rawText, bech32Id }) => , - a: ({ href, children, ...props }) => { - if (href?.startsWith('nostr:')) { - return + 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() } - return ( - - {children} - - - ) - }, - p: (props) => { - // Check if paragraph contains only an image - if (props.children && typeof props.children === 'string' && props.children.match(/^!\[.*\]\(.*\)$/)) { - return
+ + 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() } - return

- }, - div: (props) =>

, - code: (props) => , - img: (props) => ( - - ) - }) as Components, - [event.pubkey] - ) + + 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]) + if (isLoading) { return ( @@ -117,26 +135,14 @@ export default function Article({ )} - {/* Render content based on markup type */} - {parsedContent.markupType === 'asciidoc' ? ( - // AsciiDoc content (already processed to HTML) -
- ) : ( - // Markdown content (let react-markdown handle it) - { - if (url.startsWith('nostr:')) { - return url.slice(6) // Remove 'nostr:' prefix for rendering - } - return url - }} - components={components} - > - {event.content} - - )} + {/* Table of Contents */} + + + {/* Render AsciiDoc content (everything is now processed as AsciiDoc) */} +
{/* Hashtags */} {parsedContent.hashtags.length > 0 && ( @@ -157,55 +163,84 @@ export default function Article({
)} - {/* Media thumbnails */} - {parsedContent.media.length > 0 && ( -
-

Images in this article:

-
- {parsedContent.media.map((media, index) => ( -
- + {/* Collapsible Article Info */} + {(parsedContent.media.length > 0 || parsedContent.links.length > 0 || parsedContent.nostrLinks.length > 0 || parsedContent.highlightSources.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) => ( - - ))} -
-
- )} + {/* 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} + {/* 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) => ( + + ))} +
+
+ )} + + )}
) diff --git a/src/components/UniversalContent/HighlightSourcePreview.tsx b/src/components/UniversalContent/HighlightSourcePreview.tsx new file mode 100644 index 0000000..8195dcf --- /dev/null +++ b/src/components/UniversalContent/HighlightSourcePreview.tsx @@ -0,0 +1,85 @@ +/** + * Component to display highlight sources (e/a tags or URLs) as embedded events or OpenGraph previews + */ + +import { useMemo } from 'react' +import { nip19 } from 'nostr-tools' +import WebPreview from '../WebPreview' +import { EmbeddedNote } from '../Embedded/EmbeddedNote' +import { ExternalLink } from 'lucide-react' + +interface HighlightSourcePreviewProps { + source: { + type: 'event' | 'addressable' | 'url' + value: string + bech32: string + } + className?: string +} + +export default function HighlightSourcePreview({ source, className }: HighlightSourcePreviewProps) { + const alexandriaUrl = useMemo(() => { + if (source.type === 'url') { + return source.value + } + return `https://next-alexandria.gitcitadel.eu/events?id=${source.bech32}` + }, [source]) + + if (source.type === 'event') { + // For events, try to decode and show as embedded note + try { + const decoded = nip19.decode(source.bech32) + if (decoded.type === 'nevent' || decoded.type === 'note') { + return ( +
+ +
+ ) + } + } catch (error) { + console.warn('Failed to decode nostr event:', error) + } + } + + if (source.type === 'addressable') { + // For addressable events, try to decode and show as embedded note + try { + const decoded = nip19.decode(source.bech32) + if (decoded.type === 'naddr') { + return ( +
+ +
+ ) + } + } catch (error) { + console.warn('Failed to decode nostr addressable event:', error) + } + } + + // Fallback: show as Alexandria link or WebPreview for URLs + if (source.type === 'url') { + return ( +
+ +
+ ) + } + + // For nostr events that couldn't be embedded, show as Alexandria link + return ( + + ) +} diff --git a/src/components/UniversalContent/ParsedContent.tsx b/src/components/UniversalContent/ParsedContent.tsx index 27d56ce..b9e6951 100644 --- a/src/components/UniversalContent/ParsedContent.tsx +++ b/src/components/UniversalContent/ParsedContent.tsx @@ -5,18 +5,9 @@ import { useEventFieldParser } from '@/hooks/useContentParser' import { Event } from 'nostr-tools' -import { useMemo } from 'react' -import Markdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import remarkMath from 'remark-math' -import rehypeKatex from 'rehype-katex' -import { Components } from '../Note/LongFormArticle/types' -import NostrNode from '../Note/LongFormArticle/NostrNode' import ImageWithLightbox from '../ImageWithLightbox' -import ImageGallery from '../ImageGallery' -import { ExternalLink } from 'lucide-react' import WebPreview from '../WebPreview' -import 'katex/dist/katex.min.css' +import HighlightSourcePreview from './HighlightSourcePreview' interface ParsedContentProps { event: Event @@ -28,7 +19,7 @@ interface ParsedContentProps { showLinks?: boolean showHashtags?: boolean showNostrLinks?: boolean - maxImageWidth?: string + showHighlightSources?: boolean } export default function ParsedContent({ @@ -41,55 +32,13 @@ export default function ParsedContent({ showLinks = false, showHashtags = false, showNostrLinks = false, - maxImageWidth = '400px' + showHighlightSources = false, }: ParsedContentProps) { const { parsedContent, isLoading, error } = useEventFieldParser(event, field, { enableMath, enableSyntaxHighlighting }) - const components = useMemo( - () => - ({ - nostr: ({ rawText, bech32Id }) => , - a: ({ href, children, ...props }) => { - if (href?.startsWith('nostr:')) { - return - } - return ( - - {children} - - - ) - }, - p: (props) => { - // Check if paragraph contains only an image - if (props.children && typeof props.children === 'string' && props.children.match(/^!\[.*\]\(.*\)$/)) { - return
- } - return

- }, - div: (props) =>

, - code: (props) => , - img: (props) => ( - - ) - }) as Components, - [event.pubkey, maxImageWidth] - ) if (isLoading) { return ( @@ -118,26 +67,8 @@ export default function ParsedContent({ return (
- {/* Render content based on markup type */} - {parsedContent.markupType === 'asciidoc' ? ( - // AsciiDoc content (already processed to HTML) -
- ) : ( - // Markdown content (let react-markdown handle it) - { - if (url.startsWith('nostr:')) { - return url.slice(6) // Remove 'nostr:' prefix for rendering - } - return url - }} - components={components} - > - {field === 'content' ? event.content : event.tags?.find(tag => tag[0] === field)?.[1] || ''} - - )} + {/* Render AsciiDoc content (everything is now processed as AsciiDoc) */} +
{/* Media thumbnails */} {showMedia && parsedContent.media.length > 0 && ( @@ -204,6 +135,22 @@ export default function ParsedContent({
)} + + {/* Highlight sources */} + {showHighlightSources && parsedContent.highlightSources.length > 0 && ( +
+

Highlight sources:

+
+ {parsedContent.highlightSources.map((source, index) => ( + + ))} +
+
+ )}
) } diff --git a/src/components/UniversalContent/TableOfContents.tsx b/src/components/UniversalContent/TableOfContents.tsx new file mode 100644 index 0000000..a3d4ef9 --- /dev/null +++ b/src/components/UniversalContent/TableOfContents.tsx @@ -0,0 +1,132 @@ +/** + * 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/components/UniversalContent/Wikilink.tsx b/src/components/UniversalContent/Wikilink.tsx new file mode 100644 index 0000000..0fb27d0 --- /dev/null +++ b/src/components/UniversalContent/Wikilink.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react' +import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' + +interface WikilinkProps { + dTag: string + displayText: string + className?: string +} + +export default function Wikilink({ dTag, displayText, className }: WikilinkProps) { + const [isOpen, setIsOpen] = useState(false) + + const handleWikistrClick = () => { + const url = `https://wikistr.imwald.eu/${dTag}` + window.open(url, '_blank', 'noopener,noreferrer') + } + + const handleAlexandriaClick = () => { + const url = `https://next-alexandria.gitcitadel.eu/events?d=${dTag}` + window.open(url, '_blank', 'noopener,noreferrer') + } + + return ( + + + + + +
+ + +
+
+
+ ) +} diff --git a/src/components/UniversalContent/WikilinkProcessor.tsx b/src/components/UniversalContent/WikilinkProcessor.tsx new file mode 100644 index 0000000..a1cae55 --- /dev/null +++ b/src/components/UniversalContent/WikilinkProcessor.tsx @@ -0,0 +1,47 @@ +import { useEffect, useRef } from 'react' +import Wikilink from './Wikilink' + +interface WikilinkProcessorProps { + htmlContent: string + className?: string +} + +export default function WikilinkProcessor({ htmlContent, className }: WikilinkProcessorProps) { + const containerRef = useRef(null) + + useEffect(() => { + if (!containerRef.current) return + + // Find all wikilink spans and replace them with Wikilink components + const wikilinkSpans = containerRef.current.querySelectorAll('span.wikilink') + + wikilinkSpans.forEach((span) => { + const dTag = span.getAttribute('data-dtag') + const displayText = span.getAttribute('data-display') + + if (dTag && displayText) { + // Create a container for the Wikilink component + const container = document.createElement('div') + container.className = 'inline-block' + + // Replace the span with the container + span.parentNode?.replaceChild(container, span) + + // Render the Wikilink component into the container + // We'll use React's createRoot for this + import('react-dom/client').then(({ createRoot }) => { + const root = createRoot(container) + root.render() + }) + } + }) + }, [htmlContent]) + + return ( +
+ ) +} diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..7cee61e --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,9 @@ +import * as CollapsiblePrimitive from '@radix-ui/react-collapsible' + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/src/services/content-parser.service.ts b/src/services/content-parser.service.ts index 802b2da..c220a8d 100644 --- a/src/services/content-parser.service.ts +++ b/src/services/content-parser.service.ts @@ -4,7 +4,7 @@ */ import { detectMarkupType, getMarkupClasses, MarkupType } from '@/lib/markup-detection' -import { Event } from 'nostr-tools' +import { Event, nip19 } from 'nostr-tools' import { getImetaInfosFromEvent } from '@/lib/event' import { URL_REGEX } from '@/constants' import { TImetaInfo } from '@/types' @@ -18,6 +18,7 @@ export interface ParsedContent { links: Array<{ url: string; text: string; isExternal: boolean }> hashtags: string[] nostrLinks: Array<{ type: 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note'; id: string; text: string }> + highlightSources: Array<{ type: 'event' | 'addressable' | 'url'; value: string; bech32: string }> } export interface ParseOptions { @@ -68,54 +69,38 @@ class ContentParserService { const cssClasses = getMarkupClasses(markupType) // Extract all content elements - const media = this.extractAllMedia(content, event) - const links = this.extractLinks(content) - const hashtags = this.extractHashtags(content) - const nostrLinks = this.extractNostrLinks(content) + const media = this.extractAllMedia(content, event) + const links = this.extractLinks(content) + const hashtags = this.extractHashtags(content) + const nostrLinks = this.extractNostrLinks(content) + const highlightSources = event ? this.extractHighlightSources(event) : [] // Check for LaTeX math const hasMath = enableMath && this.hasMathContent(content) let html = '' - let processedContent = content try { - switch (markupType) { - case 'asciidoc': - html = await this.parseAsciidoc(content, { enableMath, enableSyntaxHighlighting }) - break - - case 'advanced-markdown': - processedContent = this.preprocessAdvancedMarkdown(content) - html = await this.parseAdvancedMarkdown(processedContent, { enableMath, enableSyntaxHighlighting }) - break - - case 'basic-markdown': - processedContent = this.preprocessBasicMarkdown(content) - html = await this.parseBasicMarkdown(processedContent) - break - - case 'plain-text': - default: - html = this.parsePlainText(content) - break - } + // Convert everything to AsciiDoc format and process as AsciiDoc + const asciidocContent = this.convertToAsciidoc(content, markupType) + html = await this.parseAsciidoc(asciidocContent, { enableMath, enableSyntaxHighlighting }) } catch (error) { console.error('Content parsing error:', error) // Fallback to plain text html = this.parsePlainText(content) } - return { - html, - markupType, - cssClasses, - hasMath, - media, - links, - hashtags, - nostrLinks - } + return { + html, + markupType: 'asciidoc', + cssClasses, + hasMath, + media, + links, + hashtags, + nostrLinks, + highlightSources + } } /** @@ -143,8 +128,11 @@ class ContentParserService { const htmlString = typeof result === 'string' ? result : result.toString() + // Process wikilinks in the HTML output + const processedHtml = this.processWikilinksInHtml(htmlString) + // Clean up any leftover markdown syntax - return this.cleanupMarkdown(htmlString) + return this.cleanupMarkdown(processedHtml) } catch (error) { console.error('AsciiDoc parsing error:', error) return this.parsePlainText(content) @@ -152,141 +140,149 @@ class ContentParserService { } /** - * Parse advanced Markdown content + * Convert content to AsciiDoc format based on markup type */ - private async parseAdvancedMarkdown(content: string, _options: { enableMath: boolean; enableSyntaxHighlighting: boolean }): Promise { - // This will be handled by react-markdown with plugins - // Return the processed content for react-markdown to handle - return content + private convertToAsciidoc(content: string, markupType: string): string { + let asciidoc = '' + + switch (markupType) { + case 'asciidoc': + asciidoc = content + break + + case 'advanced-markdown': + case 'basic-markdown': + asciidoc = this.convertMarkdownToAsciidoc(content) + break + + case 'plain-text': + default: + asciidoc = this.convertPlainTextToAsciidoc(content) + break + } + + // Process wikilinks for all content types + return this.processWikilinks(asciidoc) } /** - * Parse basic Markdown content + * Convert Markdown to AsciiDoc format */ - private parseBasicMarkdown(content: string): string { - // Basic markdown processing - let processed = content + private convertMarkdownToAsciidoc(content: string): string { + let asciidoc = content + + // Convert headers + 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 + 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, '`$1`') // Inline code - // Headers - processed = processed.replace(/^### (.*$)/gim, '

$1

') - processed = processed.replace(/^## (.*$)/gim, '

$1

') - processed = processed.replace(/^# (.*$)/gim, '

$1

') + // Convert blockquotes + asciidoc = asciidoc.replace(/^>\s+(.+)$/gm, '____\n$1\n____') - // Bold and italic - processed = processed.replace(/\*\*(.*?)\*\*/g, '$1') - processed = processed.replace(/\*(.*?)\*/g, '$1') - processed = processed.replace(/_(.*?)_/g, '$1') - processed = processed.replace(/~(.*?)~/g, '$1') + // Convert lists + asciidoc = asciidoc.replace(/^(\s*)\*\s+(.+)$/gm, '$1* $2') // Unordered lists + asciidoc = asciidoc.replace(/^(\s*)\d+\.\s+(.+)$/gm, '$1. $2') // Ordered lists - // Links and images - processed = this.processLinks(processed) - processed = this.processImages(processed) + // Convert links + asciidoc = asciidoc.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1[$2]') - // Lists - processed = this.processLists(processed) + // Convert images + asciidoc = asciidoc.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, 'image::$2[$1]') - // Blockquotes - processed = processed.replace(/^> (.*$)/gim, '
$1
') + // Convert tables (basic support) + asciidoc = asciidoc.replace(/^\|(.+)\|$/gm, '|$1|') - // Line breaks - processed = processed.replace(/\n\n/g, '

') - processed = `

${processed}

` + // Convert horizontal rules + asciidoc = asciidoc.replace(/^---$/gm, '\'\'\'') - return processed + return asciidoc } /** - * Parse plain text content + * Process wikilinks in content (both standard and bookstr macro) */ - private parsePlainText(content: string): string { - // Convert line breaks to HTML - return content - .replace(/\n\n/g, '

') - .replace(/\n/g, '
') - .replace(/^/, '

') - .replace(/$/, '

') - } + private processWikilinks(content: string): string { + let processed = content - /** - * Preprocess advanced Markdown content - */ - private preprocessAdvancedMarkdown(content: string): string { - // Handle wikilinks: [[NIP-54]] -> [NIP-54](https://next-alexandria.gitcitadel.eu/publication?d=nip-54) - content = content.replace(/\[\[([^\]]+)\]\]/g, (_match, text) => { - const slug = text.toLowerCase().replace(/\s+/g, '-') - return `[${text}](https://next-alexandria.gitcitadel.eu/publication?d=${slug})` + // Process bookstr macro wikilinks: [[book:...]] where ... can be any book type and reference + processed = processed.replace(/\[\[book:([^\]]+)\]\]/g, (_match, bookContent) => { + const cleanContent = bookContent.trim() + const dTag = this.normalizeDtag(cleanContent) + + return `wikilink:${dTag}[${cleanContent}]` }) - // Handle hashtags: #hashtag -> [#hashtag](/hashtag/hashtag) - content = content.replace(/#([a-zA-Z0-9_]+)/g, (_match, tag) => { - return `[#${tag}](/hashtag/${tag})` + // Process standard wikilinks: [[Target Page]] or [[target page|see this]] + processed = processed.replace(/\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g, (_match, target, displayText) => { + const cleanTarget = target.trim() + const cleanDisplay = displayText ? displayText.trim() : cleanTarget + const dTag = this.normalizeDtag(cleanTarget) + + return `wikilink:${dTag}[${cleanDisplay}]` }) - return content + return processed } /** - * Preprocess basic Markdown content + * Normalize text to d-tag format (lowercase, non-letters to dashes) */ - private preprocessBasicMarkdown(content: string): string { - // Handle hashtags - content = content.replace(/#([a-zA-Z0-9_]+)/g, (_match, tag) => { - return `[#${tag}](/hashtag/${tag})` - }) - - // Handle emoji shortcodes - content = content.replace(/:([a-zA-Z0-9_]+):/g, (_match, _emoji) => { - // This would need an emoji mapping - for now just return as-is - return _match - }) - - return content + private normalizeDtag(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') } /** - * Process markdown links + * Process wikilinks in HTML output */ - private processLinks(content: string): string { - return content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { - // Check if it's already an HTML link - if (content.includes(`href="${url}"`)) { - return match - } - - // Handle nostr: prefixes - if (url.startsWith('nostr:')) { - return `${text}` - } - - return `${text} ` + private processWikilinksInHtml(html: string): string { + // Convert wikilink:dtag[display] format to HTML with data attributes + return html.replace(/wikilink:([^[]+)\[([^\]]+)\]/g, (_match, dTag, displayText) => { + return `${displayText}` }) } /** - * Process markdown images + * Convert plain text to AsciiDoc format */ - private processImages(content: string): string { - return content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => { - const altText = alt || '' - return `${altText}` - }) + private convertPlainTextToAsciidoc(content: string): string { + // Convert line breaks to AsciiDoc format + return content + .replace(/\n\n/g, '\n\n') + .replace(/\n/g, ' +\n') } + /** - * Process markdown lists + * Parse plain text content */ - private processLists(content: string): string { - // Unordered lists - content = content.replace(/^[\s]*\* (.+)$/gm, '
  • $1
  • ') - content = content.replace(/(
  • .*<\/li>)/s, '
      $1
    ') - - // Ordered lists - content = content.replace(/^[\s]*\d+\. (.+)$/gm, '
  • $1
  • ') - content = content.replace(/(
  • .*<\/li>)/s, '
      $1
    ') - + private parsePlainText(content: string): string { + // Convert line breaks to HTML return content + .replace(/\n\n/g, '

    ') + .replace(/\n/g, '
    ') + .replace(/^/, '

    ') + .replace(/$/, '

    ') } + + /** * Clean up leftover markdown syntax after AsciiDoc processing */ @@ -562,6 +558,77 @@ class ContentParserService { return url.startsWith('nostr:') || this.getNostrType(url) !== null } + /** + * Extract highlight sources from event tags + */ + private extractHighlightSources(event: Event): Array<{ type: 'event' | 'addressable' | 'url'; value: string; bech32: string }> { + const sources: Array<{ type: 'event' | 'addressable' | 'url'; value: string; bech32: string }> = [] + + // Check for 'source' marker first (highest priority) + let sourceTag: string[] | undefined + for (const tag of event.tags) { + if (tag[2] === 'source' || tag[3] === 'source') { + sourceTag = tag + break + } + } + + // If no 'source' marker found, process tags in priority order: e > a > r + if (!sourceTag) { + for (const tag of event.tags) { + // Give 'e' tags highest priority + if (tag[0] === 'e') { + sourceTag = tag + continue + } + + // Give 'a' tags second priority (but don't override 'e' tags) + if (tag[0] === 'a' && (!sourceTag || sourceTag[0] !== 'e')) { + sourceTag = tag + continue + } + + // Give 'r' tags lowest priority + if (tag[0] === 'r' && (!sourceTag || sourceTag[0] === 'r')) { + sourceTag = tag + continue + } + } + } + + // Process the selected source tag + if (sourceTag) { + if (sourceTag[0] === 'e') { + sources.push({ + type: 'event', + value: sourceTag[1], + bech32: nip19.noteEncode(sourceTag[1]) + }) + } else if (sourceTag[0] === 'a') { + const [kind, pubkey, identifier] = sourceTag[1].split(':') + const relay = sourceTag[2] + sources.push({ + type: 'addressable', + value: sourceTag[1], + bech32: nip19.naddrEncode({ + kind: parseInt(kind), + pubkey, + identifier: identifier || '', + relays: relay ? [relay] : [] + }) + }) + } else if (sourceTag[0] === 'r') { + sources.push({ + type: 'url', + value: sourceTag[1], + bech32: sourceTag[1] + }) + } + } + + return sources + } + /** * Get Nostr identifier type */ @@ -605,7 +672,8 @@ class ContentParserService { media: [], links: [], hashtags: [], - nostrLinks: [] + nostrLinks: [], + highlightSources: [] } }