Browse Source

correct markup

imwald
Silberengel 4 months ago
parent
commit
ae00e04ea5
  1. 144
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  2. 205
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  3. 7
      src/components/Note/MarkdownArticle/preprocessMarkup.ts

144
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -1,7 +1,6 @@ @@ -1,7 +1,6 @@
import { useSecondaryPage, useSmartHashtagNavigation, useSmartRelayNavigation } from '@/PageManager'
import Image from '@/components/Image'
import MediaPlayer from '@/components/MediaPlayer'
import WebPreview from '@/components/WebPreview'
import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link'
@ -225,6 +224,28 @@ function convertMarkdownToAsciidoc(content: string): string { @@ -225,6 +224,28 @@ function convertMarkdownToAsciidoc(content: string): string {
// Ordered lists: 1., 2., etc.
asciidoc = asciidoc.replace(/^(\s*)\d+\.\s+(.+)$/gm, '$1. $2')
// Protect existing AsciiDoc links (both url[text] and link:url[text] formats)
// Do this FIRST before any other processing to avoid double-processing
const asciidocLinkPlaceholders: string[] = []
// Match AsciiDoc link format: url[text] or link:url[text]
// Pattern matches: http(s)://url[text] or link:url[text]
// URL can contain dots, slashes, hyphens, etc., but stops at whitespace or [
// Then we match [text] where text can contain anything except ]
// Use a more permissive pattern - match URL until [ then match [text]
// The URL part can contain most characters except whitespace and [
asciidoc = asciidoc.replace(/(https?:\/\/[^\s\[\]]+\[[^\]]+\])/g, (match, link) => {
// This is an AsciiDoc link format (url[text]), protect it
const placeholder = `__ASCIIDOC_LINK_${asciidocLinkPlaceholders.length}__`
asciidocLinkPlaceholders.push(link)
return placeholder
})
// Also protect link:url[text] format
asciidoc = asciidoc.replace(/(link:[^\s\[\]]+\[[^\]]+\])/g, (match, link) => {
const placeholder = `__ASCIIDOC_LINK_${asciidocLinkPlaceholders.length}__`
asciidocLinkPlaceholders.push(link)
return placeholder
})
// Convert images: ![alt](url) -> image:url[alt] (single colon for inline, but AsciiDoc will render as block)
// For block images in AsciiDoc, we can use image:: or just ensure it's on its own line
asciidoc = asciidoc.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => {
@ -244,6 +265,11 @@ function convertMarkdownToAsciidoc(content: string): string { @@ -244,6 +265,11 @@ function convertMarkdownToAsciidoc(content: string): string {
return `link:${url}[${escapedText}]`
})
// Restore AsciiDoc links
asciidocLinkPlaceholders.forEach((link, index) => {
asciidoc = asciidoc.replace(`__ASCIIDOC_LINK_${index}__`, link)
})
// Nostr addresses are already converted to link: format above, no need to restore
// Convert strikethrough: ~~text~~ -> [line-through]#text#
@ -714,8 +740,8 @@ export default function AsciidocArticle({ @@ -714,8 +740,8 @@ export default function AsciidocArticle({
})
// Handle YouTube URLs and relay URLs in links
// Process all link matches first to determine which are standalone
const linkMatches: Array<{ match: string; href: string; linkText: string; index: number; isStandalone: boolean }> = []
// Only replace links that need special handling - leave AsciiDoc-generated links alone
const linkMatches: Array<{ match: string; href: string; linkText: string; index: number }> = []
const linkRegex = /<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/g
let linkMatch
while ((linkMatch = linkRegex.exec(htmlString)) !== null) {
@ -724,67 +750,16 @@ export default function AsciidocArticle({ @@ -724,67 +750,16 @@ export default function AsciidocArticle({
const linkText = linkMatch[2]
const index = linkMatch.index
// Check if link is standalone (on its own line, not part of a sentence/list/quote)
let isStandalone = false
if (href.startsWith('http://') || href.startsWith('https://')) {
// Get context around the link
const beforeMatch = htmlString.substring(Math.max(0, index - 500), index)
const afterMatch = htmlString.substring(index + match.length, Math.min(htmlString.length, index + match.length + 500))
// Extract the parent paragraph/div content
const paragraphMatch = beforeMatch.match(/<p[^>]*>([^<]*)$/)
const divMatch = beforeMatch.match(/<div[^>]*>([^<]*)$/)
// If link is in a paragraph, check if paragraph contains only the link
if (paragraphMatch) {
const paragraphEnd = afterMatch.match(/^([^<]*)<\/p>/)
const paragraphContent = paragraphMatch[1] + linkText + (paragraphEnd?.[1] || '')
const trimmedContent = paragraphContent.trim()
// If paragraph contains only the link (possibly with whitespace), it's standalone
if (trimmedContent === linkText.trim() || trimmedContent === '') {
// Check if it's in a list or blockquote by looking further back
const contextBefore = htmlString.substring(Math.max(0, index - 1000), index)
if (!contextBefore.match(/<[uo]l[^>]*>/) && !contextBefore.match(/<blockquote[^>]*>/)) {
isStandalone = true
}
}
}
// If link is in a div and the div contains only the link, it's standalone
if (!isStandalone && divMatch) {
const divEnd = afterMatch.match(/^([^<]*)<\/div>/)
const divContent = divMatch[1] + linkText + (divEnd?.[1] || '')
const trimmedContent = divContent.trim()
if (trimmedContent === linkText.trim() || trimmedContent === '') {
const contextBefore = htmlString.substring(Math.max(0, index - 1000), index)
if (!contextBefore.match(/<[uo]l[^>]*>/) && !contextBefore.match(/<blockquote[^>]*>/)) {
isStandalone = true
}
}
}
// If link appears to be on its own line (surrounded by block-level tags or whitespace)
if (!isStandalone) {
const beforeTrimmed = beforeMatch.replace(/\s*$/, '')
const afterTrimmed = afterMatch.replace(/^\s*/, '')
if (
(beforeTrimmed.endsWith('</p>') || beforeTrimmed.endsWith('</div>') || beforeTrimmed.endsWith('<br') || beforeTrimmed === '') &&
(afterTrimmed.startsWith('</p>') || afterTrimmed.startsWith('</div>') || afterTrimmed.startsWith('<p') || afterTrimmed.startsWith('<div') || afterTrimmed === '')
) {
const contextBefore = htmlString.substring(Math.max(0, index - 1000), index)
if (!contextBefore.match(/<[uo]l[^>]*>/) && !contextBefore.match(/<blockquote[^>]*>/)) {
isStandalone = true
}
}
}
// Only process links that need special handling (YouTube, relay URLs)
// Leave regular HTTP/HTTPS links as-is since AsciiDoc already formatted them correctly
if (isYouTubeUrl(href) || isWebsocketUrl(href)) {
linkMatches.push({ match, href, linkText, index })
}
linkMatches.push({ match, href, linkText, index, isStandalone })
}
// Replace links in reverse order to preserve indices
// Replace only special links in reverse order to preserve indices
for (let i = linkMatches.length - 1; i >= 0; i--) {
const { match, href, linkText, isStandalone } = linkMatches[i]
const { match, href, linkText, index } = linkMatches[i]
let replacement = match
// Check if the href is a YouTube URL
@ -797,23 +772,6 @@ export default function AsciidocArticle({ @@ -797,23 +772,6 @@ export default function AsciidocArticle({
const relayPath = `/relays/${encodeURIComponent(href)}`
replacement = `<a href="${relayPath}" class="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer" data-relay-url="${href}" data-original-text="${linkText.replace(/"/g, '&quot;')}">${linkText}</a>`
}
// For regular HTTP/HTTPS links, check if standalone
else if (href.startsWith('http://') || href.startsWith('https://')) {
if (isStandalone) {
// Standalone link - render as WebPreview
const cleanedUrl = cleanUrl(href)
replacement = `<div data-webpreview-url="${cleanedUrl.replace(/"/g, '&quot;')}" class="webpreview-placeholder my-2"></div>`
} else {
// Inline link - keep as regular link
const escapedLinkText = linkText.replace(/"/g, '&quot;')
replacement = `<a href="${href}" class="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" target="_blank" rel="noopener noreferrer" data-original-text="${escapedLinkText}">${linkText}</a>`
}
}
// For other links (like relative links), keep as-is but add data attribute
else {
const escapedLinkText = linkText.replace(/"/g, '&quot;')
replacement = match.replace(/<a/, `<a data-original-text="${escapedLinkText}"`)
}
htmlString = htmlString.substring(0, linkMatches[i].index) + replacement + htmlString.substring(linkMatches[i].index + match.length)
}
@ -840,7 +798,8 @@ export default function AsciidocArticle({ @@ -840,7 +798,8 @@ export default function AsciidocArticle({
return match
})
// Handle plain HTTP/HTTPS URLs in text (not in <a> tags, not YouTube, not relay) - convert to WebPreview placeholders
// Handle plain HTTP/HTTPS URLs in text (not in <a> tags, not YouTube, not relay) - convert to regular links
// NO WebPreview conversion for AsciiDoc articles
const httpUrlRegex = /https?:\/\/[^\s<>"']+/g
htmlString = htmlString.replace(httpUrlRegex, (match) => {
// Only replace if not already in a tag (basic check)
@ -853,8 +812,9 @@ export default function AsciidocArticle({ @@ -853,8 +812,9 @@ export default function AsciidocArticle({
if (isImage(match) || isVideo(match) || isAudio(match)) {
return match
}
// Convert to regular link - NO WebPreview
const cleanedUrl = cleanUrl(match)
return `<div data-webpreview-url="${cleanedUrl.replace(/"/g, '&quot;')}" class="webpreview-placeholder my-2"></div>`
return `<a href="${cleanedUrl}" class="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" target="_blank" rel="noopener noreferrer">${match}</a>`
}
return match
})
@ -1059,23 +1019,6 @@ export default function AsciidocArticle({ @@ -1059,23 +1019,6 @@ export default function AsciidocArticle({
reactRootsRef.current.set(container, root)
})
// Process WebPreview placeholders - replace with React components
const webpreviewPlaceholders = contentRef.current.querySelectorAll('.webpreview-placeholder[data-webpreview-url]')
webpreviewPlaceholders.forEach((element) => {
const url = element.getAttribute('data-webpreview-url')
if (!url) return
// Create a container for React component
const container = document.createElement('div')
container.className = 'my-2'
element.parentNode?.replaceChild(container, element)
// Use React to render the component
const root = createRoot(container)
root.render(<WebPreview url={url} className="w-full" />)
reactRootsRef.current.set(container, root)
})
// Process hashtags in text nodes - convert #tag to links
const walker = document.createTreeWalker(
contentRef.current,
@ -1431,15 +1374,6 @@ export default function AsciidocArticle({ @@ -1431,15 +1374,6 @@ export default function AsciidocArticle({
</div>
)}
{/* WebPreview cards for links from tags (only if not already in content) */}
{/* Note: Links in content are already rendered as links in the AsciiDoc HTML above, so we don't show WebPreview for them */}
{leftoverTagLinks.length > 0 && (
<div className="space-y-3 mt-6">
{leftoverTagLinks.map((url, index) => (
<WebPreview key={`tag-${index}-${url}`} url={url} className="w-full" />
))}
</div>
)}
</div>
{/* Image gallery lightbox */}

205
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -106,207 +106,42 @@ function isYouTubeUrl(url: string): boolean { @@ -106,207 +106,42 @@ function isYouTubeUrl(url: string): boolean {
return regex.test(url)
}
/**
* Parse inline markdown formatting while preserving newlines (for code blocks)
*/
function parseInlineMarkdownPreserveNewlines(text: string, keyPrefix: string): React.ReactNode[] {
const parts: React.ReactNode[] = []
let lastIndex = 0
const inlinePatterns: Array<{ index: number; end: number; type: string; data: any }> = []
// Bold: **text** (double asterisk) - allow newlines within
const doubleBoldAsteriskRegex = /\*\*([\s\S]+?)\*\*/g
const doubleBoldAsteriskMatches = Array.from(text.matchAll(doubleBoldAsteriskRegex))
doubleBoldAsteriskMatches.forEach(match => {
if (match.index !== undefined) {
inlinePatterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'bold',
data: match[1]
})
}
})
// Double underscore bold - allow newlines within
const doubleBoldUnderscoreRegex = /__([\s\S]+?)__/g
const doubleBoldUnderscoreMatches = Array.from(text.matchAll(doubleBoldUnderscoreRegex))
doubleBoldUnderscoreMatches.forEach(match => {
if (match.index !== undefined) {
const isInOther = inlinePatterns.some(p =>
(p.type === 'bold') &&
match.index! >= p.index &&
match.index! < p.end
)
if (!isInOther) {
inlinePatterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'bold',
data: match[1]
})
}
}
})
// Italic: _text_ (single underscore, not part of __bold__) - allow newlines within
const singleItalicUnderscoreRegex = /(?<!_)_([\s\S]+?)_(?!_)/g
const singleItalicUnderscoreMatches = Array.from(text.matchAll(singleItalicUnderscoreRegex))
singleItalicUnderscoreMatches.forEach(match => {
if (match.index !== undefined) {
const isInOther = inlinePatterns.some(p =>
(p.type === 'bold') &&
match.index! >= p.index &&
match.index! < p.end
)
if (!isInOther) {
inlinePatterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'italic',
data: match[1]
})
}
}
})
// Sort by index
inlinePatterns.sort((a, b) => a.index - b.index)
// Remove overlaps (keep first)
const filtered: typeof inlinePatterns = []
let lastEnd = 0
inlinePatterns.forEach(pattern => {
if (pattern.index >= lastEnd) {
filtered.push(pattern)
lastEnd = pattern.end
}
})
// Build React nodes, preserving newlines
filtered.forEach((pattern, i) => {
// Add text before pattern (preserving newlines)
if (pattern.index > lastIndex) {
const textBefore = text.substring(lastIndex, pattern.index)
if (textBefore) {
// Split by newlines and render each part
const lines = textBefore.split('\n')
lines.forEach((line, lineIdx) => {
if (lineIdx > 0) {
parts.push(<br key={`${keyPrefix}-br-${i}-${lineIdx}`} />)
}
if (line) {
parts.push(<span key={`${keyPrefix}-text-${i}-${lineIdx}`}>{line}</span>)
}
})
}
}
// Render pattern (preserving newlines within the pattern)
if (pattern.type === 'bold') {
const boldLines = pattern.data.split('\n')
boldLines.forEach((line: string, lineIdx: number) => {
if (lineIdx > 0) {
parts.push(<br key={`${keyPrefix}-bold-br-${i}-${lineIdx}`} />)
}
if (line) {
parts.push(<strong key={`${keyPrefix}-bold-${i}-${lineIdx}`}>{line}</strong>)
}
})
} else if (pattern.type === 'italic') {
const italicLines = pattern.data.split('\n')
italicLines.forEach((line: string, lineIdx: number) => {
if (lineIdx > 0) {
parts.push(<br key={`${keyPrefix}-italic-br-${i}-${lineIdx}`} />)
}
if (line) {
parts.push(<em key={`${keyPrefix}-italic-${i}-${lineIdx}`}>{line}</em>)
}
})
}
lastIndex = pattern.end
})
// Add remaining text (preserving newlines)
if (lastIndex < text.length) {
const remaining = text.substring(lastIndex)
const lines = remaining.split('\n')
lines.forEach((line, lineIdx) => {
if (lineIdx > 0) {
parts.push(<br key={`${keyPrefix}-br-final-${lineIdx}`} />)
}
if (line) {
parts.push(<span key={`${keyPrefix}-text-final-${lineIdx}`}>{line}</span>)
}
})
}
return parts
}
/**
* CodeBlock component that renders code with syntax highlighting using highlight.js
* Also processes inline markdown formatting (bold, italic) within the code
*/
function CodeBlock({ id, code, language }: { id: string; code: string; language: string }) {
const codeRef = useRef<HTMLDivElement>(null)
// Check if code contains markdown formatting
const hasMarkdownFormatting = /\*\*.*?\*\*|__.*?__|_.*?_|\*.*?\*/.test(code)
// Process inline markdown formatting (bold, italic) in code blocks while preserving newlines
const processedCode = useMemo(() => {
if (hasMarkdownFormatting) {
// Parse inline markdown while preserving newlines
return parseInlineMarkdownPreserveNewlines(code, `code-${id}`)
}
return code
}, [code, id, hasMarkdownFormatting])
useEffect(() => {
// Only apply syntax highlighting if there's no markdown formatting
// (highlight.js would interfere with HTML formatting)
if (!hasMarkdownFormatting) {
const initHighlight = async () => {
if (typeof window !== 'undefined' && codeRef.current) {
try {
const hljs = await import('highlight.js')
const codeElement = codeRef.current.querySelector('code')
if (codeElement) {
hljs.default.highlightElement(codeElement)
}
} catch (error) {
logger.error('Error loading highlight.js:', error)
const initHighlight = async () => {
if (typeof window !== 'undefined' && codeRef.current) {
try {
const hljs = await import('highlight.js')
const codeElement = codeRef.current.querySelector('code')
if (codeElement) {
hljs.default.highlightElement(codeElement)
}
} catch (error) {
logger.error('Error loading highlight.js:', error)
}
}
// Small delay to ensure DOM is ready
const timeoutId = setTimeout(initHighlight, 0)
return () => clearTimeout(timeoutId)
}
}, [code, language, hasMarkdownFormatting])
// Small delay to ensure DOM is ready
const timeoutId = setTimeout(initHighlight, 0)
return () => clearTimeout(timeoutId)
}, [code, language])
return (
<div className="my-4 overflow-x-auto">
<pre className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg border border-gray-200 dark:border-gray-700 whitespace-pre-wrap">
<div ref={codeRef}>
{hasMarkdownFormatting ? (
<code
id={id}
className="text-gray-900 dark:text-gray-100 font-mono text-sm"
>
{processedCode}
</code>
) : (
<code
id={id}
className={`hljs language-${language || 'plaintext'} text-gray-900 dark:text-gray-100`}
>
{code}
</code>
)}
<code
id={id}
className={`hljs language-${language || 'plaintext'} text-gray-900 dark:text-gray-100`}
>
{code}
</code>
</div>
</pre>
</div>

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

@ -149,6 +149,13 @@ export function preprocessAsciidocMediaLinks(content: string): string { @@ -149,6 +149,13 @@ export function preprocessAsciidocMediaLinks(content: string): string {
continue
}
// Check if this URL is part of an AsciiDoc link format url[text]
// If URL is immediately followed by [text], it's already an AsciiDoc link - skip it
const contextAfter = content.substring(urlEnd, Math.min(content.length, urlEnd + 50))
if (contextAfter.match(/^\s*\[[^\]]+\]/)) {
continue
}
const before = content.substring(Math.max(0, index - 30), index)
// Check if this URL is already part of AsciiDoc syntax

Loading…
Cancel
Save