Browse Source

fix markup display

imwald
Silberengel 4 months ago
parent
commit
f75a84f4e8
  1. 1
      package.json
  2. 303
      src/components/Note/Article/index.tsx
  3. 1094
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  4. 34
      src/components/Note/LongFormArticle/NostrNode.tsx
  5. 1272
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  6. 30
      src/components/Note/MarkdownArticle/NostrNode.tsx
  7. 207
      src/components/Note/MarkdownArticle/preprocessMarkup.ts
  8. 78
      src/components/Note/MarkdownArticle/preprocessMediaLinks.ts
  9. 83
      src/components/Note/MarkdownArticle/remarkHashtags.ts
  10. 90
      src/components/Note/MarkdownArticle/remarkNostr.ts
  11. 107
      src/components/Note/MarkdownArticle/remarkUnwrapImages.ts
  12. 60
      src/components/Note/MarkdownArticle/remarkUnwrapNostr.ts
  13. 19
      src/components/Note/MarkdownArticle/types.ts
  14. 18
      src/components/Note/SimpleContent/index.tsx
  15. 11
      src/components/Note/index.tsx

1
package.json

@ -85,7 +85,6 @@ @@ -85,7 +85,6 @@
"react-dom": "^18.3.1",
"react-i18next": "^15.2.0",
"react-katex": "^3.0.1",
"react-markdown": "^10.1.0",
"react-simple-pull-to-refresh": "^1.3.3",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",

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

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

1094
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

File diff suppressed because it is too large Load Diff

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

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

1272
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

File diff suppressed because it is too large Load Diff

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

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

207
src/components/Note/MarkdownArticle/preprocessMarkup.ts

@ -0,0 +1,207 @@ @@ -0,0 +1,207 @@
import { isImage, isVideo, isAudio } from '@/lib/url'
import { URL_REGEX } from '@/constants'
/**
* Preprocess content to convert raw media URLs and hyperlinks to markdown syntax
* - Images: https://example.com/image.png -> ![](https://example.com/image.png)
* - Videos: https://example.com/video.mp4 -> ![](https://example.com/video.mp4)
* - Audio: https://example.com/audio.mp3 -> ![](https://example.com/audio.mp3)
* - Hyperlinks: https://example.com/page -> [https://example.com/page](https://example.com/page)
*/
export function preprocessMarkdownMediaLinks(content: string): string {
let processed = content
// Find all URLs but process them in reverse order to preserve indices
const allMatches: Array<{ url: string; index: number }> = []
let match
const regex = new RegExp(URL_REGEX.source, URL_REGEX.flags)
while ((match = regex.exec(content)) !== null) {
const index = match.index
const url = match[0]
const before = content.substring(Math.max(0, index - 20), index)
// Check if this URL is already part of markdown syntax
// Skip if preceded by: [text](url, ![text](url, or ](url
if (before.match(/\[[^\]]*$/) || before.match(/\]\([^)]*$/) || before.match(/!\[[^\]]*$/)) {
continue
}
allMatches.push({ url, index })
}
// Process in reverse order to preserve indices
for (let i = allMatches.length - 1; i >= 0; i--) {
const { url, index } = allMatches[i]
// Check if URL is in code block
const beforeUrl = content.substring(0, index)
const backticksCount = (beforeUrl.match(/```/g) || []).length
if (backticksCount % 2 === 1) {
continue // In code block
}
// Check if URL is in inline code
const lastBacktick = beforeUrl.lastIndexOf('`')
if (lastBacktick !== -1) {
const afterUrl = content.substring(index + url.length)
const nextBacktick = afterUrl.indexOf('`')
if (nextBacktick !== -1) {
const codeBefore = beforeUrl.substring(lastBacktick + 1)
const codeAfter = afterUrl.substring(0, nextBacktick)
// If no newlines between backticks, it's inline code
if (!codeBefore.includes('\n') && !codeAfter.includes('\n')) {
continue
}
}
}
// Check if it's a media URL
const isImageUrl = isImage(url)
const isVideoUrl = isVideo(url)
const isAudioUrl = isAudio(url)
let replacement: string
if (isImageUrl || isVideoUrl || isAudioUrl) {
// Media URLs: convert to ![](url)
replacement = `![](${url})`
} else {
// Regular hyperlinks: convert to [url](url) format
replacement = `[${url}](${url})`
}
// Replace the URL
processed = processed.substring(0, index) + replacement + processed.substring(index + url.length)
}
return processed
}
/**
* Preprocess content to convert raw media URLs and hyperlinks to AsciiDoc syntax
* - Images: https://example.com/image.png -> image::https://example.com/image.png[]
* - Videos: https://example.com/video.mp4 -> video::https://example.com/video.mp4[]
* - Audio: https://example.com/audio.mp3 -> audio::https://example.com/audio.mp3[]
* - Hyperlinks: https://example.com/page -> https://example.com/page[link text]
* - Wikilinks: [[link]] or [[link|display]] -> +++WIKILINK:link|display+++ (passthrough for post-processing)
*/
export function preprocessAsciidocMediaLinks(content: string): string {
let processed = content
// First, protect wikilinks by converting them to passthrough format
// This prevents AsciiDoc from processing them and prevents URLs inside from being processed
const wikilinkRegex = /\[\[([^\]]+)\]\]/g
const wikilinkRanges: Array<{ start: number; end: number }> = []
const wikilinkMatches = Array.from(content.matchAll(wikilinkRegex))
wikilinkMatches.forEach(match => {
if (match.index !== undefined) {
wikilinkRanges.push({
start: match.index,
end: match.index + match[0].length
})
}
})
processed = processed.replace(wikilinkRegex, (_match, linkContent) => {
// Convert to AsciiDoc passthrough format so it's preserved
return `+++WIKILINK:${linkContent}+++`
})
// Find all URLs but process them in reverse order to preserve indices
const allMatches: Array<{ url: string; index: number }> = []
let match
const regex = new RegExp(URL_REGEX.source, URL_REGEX.flags)
while ((match = regex.exec(content)) !== null) {
const index = match.index
const url = match[0]
const urlEnd = index + url.length
// Skip URLs that are inside wikilinks
const isInWikilink = wikilinkRanges.some(range =>
index >= range.start && urlEnd <= range.end
)
if (isInWikilink) {
continue
}
const before = content.substring(Math.max(0, index - 30), index)
// Check if this URL is already part of AsciiDoc syntax
// Skip if preceded by: image::, video::, audio::, or link:
if (before.match(/image::\s*$/) ||
before.match(/video::\s*$/) ||
before.match(/audio::\s*$/) ||
before.match(/link:\S+\[/) ||
before.match(/https?:\/\/[^\s]*\[/)) {
continue
}
allMatches.push({ url, index })
}
// Process in reverse order to preserve indices
for (let i = allMatches.length - 1; i >= 0; i--) {
const { url, index } = allMatches[i]
// Check if URL is in code block
const beforeUrl = content.substring(0, index)
const codeBlockCount = (beforeUrl.match(/----/g) || []).length
if (codeBlockCount % 2 === 1) {
continue // In code block
}
// Check if it's a media URL
const isImageUrl = isImage(url)
const isVideoUrl = isVideo(url)
const isAudioUrl = isAudio(url)
let replacement: string
if (isImageUrl) {
// Images: convert to image::url[]
replacement = `image::${url}[]`
} else if (isVideoUrl) {
// Videos: convert to video::url[]
replacement = `video::${url}[]`
} else if (isAudioUrl) {
// Audio: convert to audio::url[]
replacement = `audio::${url}[]`
} else {
// Regular hyperlinks: convert to link:url[url]
replacement = `link:${url}[${url}]`
}
// Replace the URL
processed = processed.substring(0, index) + replacement + processed.substring(index + url.length)
}
return processed
}
/**
* Post-process content to convert nostr: links and hashtags
* This should be applied AFTER markup processing
*/
export function postProcessNostrLinks(content: string): string {
let processed = content
// Convert nostr: prefixed links to embedded format
// nostr:npub1... -> [nostr:npub1...]
// nostr:note1... -> [nostr:note1...]
// etc.
const nostrRegex = /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
processed = processed.replace(nostrRegex, (match) => {
// Already in a link? Don't double-wrap
// Check if it's already in markdown link syntax [text](nostr:...)
// or AsciiDoc link syntax link:nostr:...[text]
return match // Keep as is for now, will be processed by the parser
})
// Convert hashtags to links
// #tag -> link:/notes?t=tag[#tag] (for AsciiDoc) or [#tag](/notes?t=tag) (for Markdown)
// But only if not already in a link
// We'll handle this in the rendering phase to avoid breaking markup
return processed
}

78
src/components/Note/MarkdownArticle/preprocessMediaLinks.ts

@ -1,78 +0,0 @@ @@ -1,78 +0,0 @@
import { isImage, isVideo, isAudio } from '@/lib/url'
/**
* Preprocess markdown content to convert plain media URLs to proper markdown syntax
* - Images: `https://example.com/image.png` -> `![](https://example.com/image.png)`
* - Videos: `https://example.com/video.mp4` -> `![](https://example.com/video.mp4)`
* - Audio: `https://example.com/audio.mp3` -> `![](https://example.com/audio.mp3)`
*/
export function preprocessMediaLinks(content: string): string {
let processed = content
// Find all matches but process them manually to avoid complex regex lookbehind
const allMatches: Array<{ url: string; index: number }> = []
let match
// Find all candidate URLs
const tempRegex = /https?:\/\/[^\s<>"']+/gi
while ((match = tempRegex.exec(content)) !== null) {
const index = match.index
const url = match[0]
const before = content.substring(Math.max(0, index - 10), index)
// Check if this URL is already part of markdown syntax
// Skip if preceded by: [text](url, ![text](url, or ](url
if (before.match(/\[[^\]]*$/) || before.match(/\]\([^)]*$/) || before.match(/!\[[^\]]*$/)) {
continue
}
allMatches.push({ url, index })
}
// Process in reverse order to preserve indices
for (let i = allMatches.length - 1; i >= 0; i--) {
const { url, index } = allMatches[i]
// Check if URL is in code block
const beforeUrl = content.substring(0, index)
const backticksCount = (beforeUrl.match(/```/g) || []).length
if (backticksCount % 2 === 1) {
continue // In code block
}
// Check if URL is in inline code
const lastBacktick = beforeUrl.lastIndexOf('`')
if (lastBacktick !== -1) {
const afterUrl = content.substring(index + url.length)
const nextBacktick = afterUrl.indexOf('`')
if (nextBacktick !== -1) {
const codeBefore = beforeUrl.substring(lastBacktick + 1)
const codeAfter = afterUrl.substring(0, nextBacktick)
// If no newlines between backticks, it's inline code
if (!codeBefore.includes('\n') && !codeAfter.includes('\n')) {
continue
}
}
}
// Check if it's a media URL
const isImageUrl = isImage(url)
const isVideoUrl = isVideo(url)
const isAudioUrl = isAudio(url)
let replacement: string
if (isImageUrl || isVideoUrl || isAudioUrl) {
// Media URLs: convert to ![](url)
replacement = `![](${url})`
} else {
// Don't convert non-media URLs - let autolink handle them
continue
}
// Replace the URL
processed = processed.substring(0, index) + replacement + processed.substring(index + url.length)
}
return processed
}

83
src/components/Note/MarkdownArticle/remarkHashtags.ts

@ -1,83 +0,0 @@ @@ -1,83 +0,0 @@
import type { PhrasingContent, Root, Text } from 'mdast'
import type { Plugin } from 'unified'
import { visit } from 'unist-util-visit'
const HASHTAG_REGEX = /#([a-zA-Z0-9_]+)/g
export const remarkHashtags: Plugin<[], Root> = () => {
return (tree) => {
visit(tree, 'text', (node: Text, index, parent) => {
if (!parent || typeof index !== 'number') return
const text = node.value
const matches = Array.from(text.matchAll(HASHTAG_REGEX))
if (matches.length === 0) return
const children: PhrasingContent[] = []
let lastIndex = 0
matches.forEach((match) => {
const matchStart = match.index!
const matchEnd = matchStart + match[0].length
const hashtag = match[1]
// Add text before the hashtag
// Normalize whitespace to prevent paragraph breaks around hashtags
if (matchStart > lastIndex) {
const beforeText = text.slice(lastIndex, matchStart)
// Replace ALL newlines with spaces to keep hashtags inline
// This prevents markdown from treating newlines as paragraph breaks
const normalized = beforeText.replace(/\s*\n+\s*/g, ' ')
if (normalized.trim()) {
children.push({
type: 'text',
value: normalized
})
}
}
// Create a link node for the hashtag
children.push({
type: 'link',
url: `/notes?t=${hashtag.toLowerCase()}`,
children: [
{
type: 'text',
value: `#${hashtag}`
}
]
})
lastIndex = matchEnd
})
// Add remaining text after the last match
// Normalize whitespace to prevent paragraph breaks
if (lastIndex < text.length) {
const afterText = text.slice(lastIndex)
// Replace ALL newlines with spaces to keep hashtags inline
// This prevents markdown from treating newlines as paragraph breaks
const normalized = afterText.replace(/\s*\n+\s*/g, ' ')
if (normalized.trim()) {
children.push({
type: 'text',
value: normalized
})
}
}
// Filter out empty text nodes to prevent paragraph breaks
const filteredChildren = children.filter((child) => {
if (child.type === 'text') {
return child.value.trim().length > 0
}
return true
})
// Replace the text node with the processed children
parent.children.splice(index, 1, ...filteredChildren)
})
}
}

90
src/components/Note/MarkdownArticle/remarkNostr.ts

@ -1,90 +0,0 @@ @@ -1,90 +0,0 @@
import type { PhrasingContent, Root, Text } from 'mdast'
import type { Plugin } from 'unified'
import { visit } from 'unist-util-visit'
import { NostrNode } from './types'
const NOSTR_REGEX =
/nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
const NOSTR_REFERENCE_REGEX =
/\[[^\]]+\]\[(nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+))\]/g
export const remarkNostr: Plugin<[], Root> = () => {
return (tree) => {
visit(tree, 'text', (node: Text, index, parent) => {
if (!parent || typeof index !== 'number') return
const text = node.value
// First, handle reference-style nostr links [text][nostr:...]
const refMatches = Array.from(text.matchAll(NOSTR_REFERENCE_REGEX))
// Then, handle direct nostr links that are not part of reference links
const directMatches = Array.from(text.matchAll(NOSTR_REGEX)).filter((directMatch) => {
return !refMatches.some(
(refMatch) =>
directMatch.index! >= refMatch.index! &&
directMatch.index! < refMatch.index! + refMatch[0].length
)
})
// Combine and sort matches by position
const allMatches = [
...refMatches.map((match) => ({
...match,
type: 'reference' as const,
bech32Id: match[2],
rawText: match[0]
})),
...directMatches.map((match) => ({
...match,
type: 'direct' as const,
bech32Id: match[1],
rawText: match[0]
}))
].sort((a, b) => a.index! - b.index!)
if (allMatches.length === 0) return
const children: (Text | NostrNode)[] = []
let lastIndex = 0
allMatches.forEach((match) => {
const matchStart = match.index!
const matchEnd = matchStart + match[0].length
// Add text before the match
if (matchStart > lastIndex) {
children.push({
type: 'text',
value: text.slice(lastIndex, matchStart)
})
}
// Create custom nostr node with type information
const nostrNode: NostrNode = {
type: 'nostr',
data: {
hName: 'nostr',
hProperties: {
bech32Id: match.bech32Id,
rawText: match.rawText
}
}
}
children.push(nostrNode)
lastIndex = matchEnd
})
// Add remaining text after the last match
if (lastIndex < text.length) {
children.push({
type: 'text',
value: text.slice(lastIndex)
})
}
// Type assertion to tell TypeScript these are valid AST nodes
parent.children.splice(index, 1, ...(children as PhrasingContent[]))
})
}
}

107
src/components/Note/MarkdownArticle/remarkUnwrapImages.ts

@ -1,107 +0,0 @@ @@ -1,107 +0,0 @@
import type { Paragraph, Root, Image, Link, RootContent } from 'mdast'
import type { Plugin } from 'unified'
import { visit } from 'unist-util-visit'
/**
* Remark plugin to unwrap images from paragraphs
* This prevents the DOM nesting warning where <div> (Image component) appears inside <p>
*
* Markdown wraps standalone images in paragraphs. This plugin unwraps them at the AST level
* so they render directly without a <p> wrapper.
*/
export const remarkUnwrapImages: Plugin<[], Root> = () => {
return (tree) => {
visit(tree, 'paragraph', (node: Paragraph, index, parent) => {
if (!parent || typeof index !== 'number') return
const children = node.children
// Case 1: Paragraph contains only an image: ![alt](url)
if (children.length === 1 && children[0].type === 'image') {
// Replace the paragraph with the image directly
const image = children[0] as Image
parent.children.splice(index, 1, image as unknown as RootContent)
return
}
// Case 2: Paragraph contains only a link with an image: [![alt](url)](link)
if (children.length === 1 && children[0].type === 'link') {
const link = children[0] as Link
if (link.children.length === 1 && link.children[0].type === 'image') {
// Keep the link but remove the paragraph wrapper
parent.children.splice(index, 1, link as unknown as RootContent)
return
}
}
// Case 3: Paragraph contains images mixed with text
// Split the paragraph: extract images as separate block elements, keep text in paragraph
const imageIndices: number[] = []
children.forEach((child, i) => {
if (child.type === 'image') {
imageIndices.push(i)
} else if (child.type === 'link' && child.children.some(c => c.type === 'image')) {
imageIndices.push(i)
}
})
if (imageIndices.length > 0) {
// We have images in the paragraph - need to split it
const newNodes: RootContent[] = []
let lastIndex = 0
imageIndices.forEach((imgIndex) => {
// Add text before the image as a paragraph (if any)
if (imgIndex > lastIndex) {
const textBefore = children.slice(lastIndex, imgIndex)
if (textBefore.length > 0 && textBefore.some(c => c.type === 'text' && c.value.trim())) {
newNodes.push({
type: 'paragraph',
children: textBefore
} as unknown as RootContent)
}
}
// Add the image as a separate block element
const imageChild = children[imgIndex]
if (imageChild.type === 'image') {
newNodes.push(imageChild as unknown as RootContent)
} else if (imageChild.type === 'link') {
newNodes.push(imageChild as unknown as RootContent)
}
lastIndex = imgIndex + 1
})
// Add remaining text after the last image (if any)
if (lastIndex < children.length) {
const textAfter = children.slice(lastIndex)
if (textAfter.length > 0 && textAfter.some(c => c.type === 'text' && c.value.trim())) {
newNodes.push({
type: 'paragraph',
children: textAfter
} as unknown as RootContent)
}
}
// If we only had images and whitespace, just use the images
if (newNodes.length === 0) {
// All content was images, extract them
children.forEach(child => {
if (child.type === 'image') {
newNodes.push(child as unknown as RootContent)
} else if (child.type === 'link' && child.children.some(c => c.type === 'image')) {
newNodes.push(child as unknown as RootContent)
}
})
}
// Replace the paragraph with the split nodes
if (newNodes.length > 0) {
parent.children.splice(index, 1, ...newNodes)
}
}
})
}
}

60
src/components/Note/MarkdownArticle/remarkUnwrapNostr.ts

@ -1,60 +0,0 @@ @@ -1,60 +0,0 @@
import type { Paragraph, Root, RootContent } from 'mdast'
import type { Plugin } from 'unified'
import { visit } from 'unist-util-visit'
import { NostrNode } from './types'
/**
* Remark plugin to unwrap nostr nodes from paragraphs
* This prevents the DOM nesting warning where <div> (EmbeddedNote/EmbeddedMention) appears inside <p>
*
* Markdown wraps standalone nostr references in paragraphs. This plugin unwraps them at the AST level
* so they render directly without a <p> wrapper.
*/
export const remarkUnwrapNostr: Plugin<[], Root> = () => {
return (tree) => {
visit(tree, 'paragraph', (node: Paragraph, index, parent) => {
if (!parent || typeof index !== 'number') return
const children = node.children
// Type guard to check if a node is a NostrNode
const isNostrNode = (node: any): node is NostrNode => {
return node && node.type === 'nostr'
}
// Case 1: Paragraph contains only a nostr node
if (children.length === 1 && isNostrNode(children[0])) {
// Replace the paragraph with the nostr node directly
// Cast to RootContent since we're promoting it to block level
const nostrNode = children[0] as unknown as RootContent
parent.children.splice(index, 1, nostrNode)
return
}
// Case 2: Paragraph contains text and a nostr node
// If the paragraph only contains whitespace and a nostr node, unwrap it
const hasOnlyNostrAndWhitespace = children.every(child => {
if (isNostrNode(child)) return true
if (child.type === 'text') {
return !child.value.trim() // Only whitespace
}
return false
})
if (hasOnlyNostrAndWhitespace) {
// Find the nostr node and unwrap it
const nostrNode = children.find(isNostrNode)
if (nostrNode) {
// Cast to RootContent since we're promoting it to block level
parent.children.splice(index, 1, nostrNode as unknown as RootContent)
return
}
}
// Case 3: Paragraph contains mixed content (text + nostr node)
// We'll leave these as-is since they're mixed content
// The paragraph handler in the component will convert them to divs
})
}
}

19
src/components/Note/MarkdownArticle/types.ts

@ -1,19 +0,0 @@ @@ -1,19 +0,0 @@
import { ComponentProps } from 'react'
import type { Components as RmComponents } from 'react-markdown'
import type { Data, Node } from 'unist'
// Extend the Components interface to include your custom component
export interface Components extends RmComponents {
nostr: React.ComponentType<{
rawText: string
bech32Id?: string
}>
}
export interface NostrNode extends Node {
type: 'nostr'
data: Data & {
hName: string
hProperties: ComponentProps<Components['nostr']>
}
}

18
src/components/Note/SimpleContent/index.tsx

@ -1,18 +0,0 @@ @@ -1,18 +0,0 @@
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { parseNostrContent, renderNostrContent } from '@/lib/nostr-parser.tsx'
import { cn } from '@/lib/utils'
export default function SimpleContent({
event,
className
}: {
event: Event
className?: string
}) {
const parsedContent = useMemo(() => {
return parseNostrContent(event.content, event)
}, [event.content, event])
return renderNostrContent(parsedContent, cn('prose prose-sm prose-zinc max-w-none break-words dark:prose-invert w-full', className))
}

11
src/components/Note/index.tsx

@ -107,7 +107,7 @@ export default function Note({ @@ -107,7 +107,7 @@ export default function Note({
)
} else if (event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) {
content = showFull ? (
<MarkdownArticle className="mt-2" event={event} showImageGallery={true} />
<MarkdownArticle className="mt-2" event={event} />
) : (
<WikiCard className="mt-2" event={event} />
)
@ -125,7 +125,7 @@ export default function Note({ @@ -125,7 +125,7 @@ export default function Note({
)
} else if (event.kind === kinds.LongFormArticle) {
content = showFull ? (
<MarkdownArticle className="mt-2" event={event} showImageGallery={true} />
<MarkdownArticle className="mt-2" event={event} />
) : (
<LongFormArticlePreview className="mt-2" event={event} />
)
@ -168,12 +168,7 @@ export default function Note({ @@ -168,12 +168,7 @@ export default function Note({
content = <MarkdownArticle className="mt-2" event={event} hideMetadata={true} />
} else {
// Use MarkdownArticle for all other kinds
// Only 30023, 30041, 30817, and 30818 will show image gallery and article info
const showImageGallery = event.kind === kinds.LongFormArticle ||
event.kind === ExtendedKind.PUBLICATION_CONTENT ||
event.kind === ExtendedKind.WIKI_ARTICLE ||
event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN
content = <MarkdownArticle className="mt-2" event={event} showImageGallery={showImageGallery} />
content = <MarkdownArticle className="mt-2" event={event} />
}
return (

Loading…
Cancel
Save