diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 493aba8..56b8c72 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -60,10 +60,13 @@ function convertMarkdownToAsciidoc(content: string): string { // Convert nostr addresses directly to AsciiDoc link format // Do this early so they're protected from other markdown conversions // naddr addresses can be 200+ characters, so we use + instead of specific length - asciidoc = asciidoc.replace(/nostr:(npub1[a-z0-9]{58,}|nprofile1[a-z0-9]+|note1[a-z0-9]{58,}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g, (_match, bech32Id) => { + // Also handle optional [] suffix (empty link text in AsciiDoc) + asciidoc = asciidoc.replace(/nostr:(npub1[a-z0-9]{58,}|nprofile1[a-z0-9]+|note1[a-z0-9]{58,}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)(\[\])?/g, (_match, bech32Id, emptyBrackets) => { // Convert directly to AsciiDoc link format // This will be processed later in HTML post-processing to render as React components - return `link:nostr:${bech32Id}[${bech32Id}]` + // If [] suffix is present, use empty link text, otherwise use the bech32Id + const linkText = emptyBrackets ? '' : bech32Id + return `link:nostr:${bech32Id}[${linkText}]` }) // Protect code blocks - we'll process them separately @@ -660,30 +663,36 @@ export default function AsciidocArticle({ // Also handle nostr: addresses in plain text nodes (not already in tags) // Process text nodes by replacing content between > and < - // Use more flexible regex that matches any valid bech32 address - htmlString = htmlString.replace(/>([^<]*nostr:((?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+)[^<]*) { - // Extract nostr addresses from the text content - use the same flexible pattern - const nostrRegex = /nostr:((?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+)/g + // Use more flexible regex that matches any valid bech32 address (naddr can be 200+ chars) + // Match addresses with optional [] suffix + htmlString = htmlString.replace(/>([^<]*nostr:((?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]{20,})(\[\])?[^<]*) { + // Extract nostr addresses from the text content - use flexible pattern that handles long addresses + // npub and note are typically 58 chars, but naddr can be 200+ chars + 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 let processedText = textContent const replacements: Array<{ start: number; end: number; replacement: string }> = [] let m while ((m = nostrRegex.exec(textContent)) !== null) { const bech32Id = m[1] + const emptyBrackets = m[2] // [] suffix if present const start = m.index const end = m.index + m[0].length + // Escape bech32Id for HTML attributes + const escapedId = bech32Id.replace(/"/g, '"').replace(/'/g, ''') + if (bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')) { replacements.push({ start, end, - replacement: `` + replacement: `` }) } else if (bech32Id.startsWith('note') || bech32Id.startsWith('nevent') || bech32Id.startsWith('naddr')) { replacements.push({ start, end, - replacement: `
` + replacement: `
` }) } } @@ -697,6 +706,13 @@ export default function AsciidocArticle({ return `>${processedText}<` }) + // Fallback: ensure any remaining nostr: addresses are shown as plain text + // This catches any that weren't converted to placeholders + htmlString = htmlString.replace(/([^>])nostr:((?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]{20,})(\[\])?/g, (_match, prefix, bech32Id, emptyBrackets) => { + // Show as plain text if not already in a tag or placeholder + return `${prefix}nostr:${bech32Id}${emptyBrackets || ''}` + }) + // Handle LaTeX math expressions from AsciiDoc stem processor // AsciiDoc with stem: latexmath outputs \(...\) for inline and \[...\] for block math // In HTML, these appear as literal \( and \) characters (backslash + parenthesis) @@ -915,6 +931,9 @@ export default function AsciidocArticle({ const bech32Id = element.getAttribute('data-nostr-mention') if (!bech32Id) { logger.warn('Nostr mention placeholder found but no bech32Id attribute') + // Fallback: show as plain text + const textNode = document.createTextNode(`nostr:${element.textContent || ''}`) + element.parentNode?.replaceChild(textNode, element) return } @@ -924,14 +943,23 @@ export default function AsciidocArticle({ const parent = element.parentNode if (!parent) { logger.warn('Nostr mention placeholder has no parent node') + // Fallback: show as plain text + const textNode = document.createTextNode(`nostr:${bech32Id}`) return } parent.replaceChild(container, element) - // Use React to render the component - const root = createRoot(container) - root.render() - reactRootsRef.current.set(container, root) + // Use React to render the component, with error handling + try { + const root = createRoot(container) + root.render() + reactRootsRef.current.set(container, root) + } catch (error) { + logger.error('Failed to render nostr mention', { bech32Id, error }) + // Fallback: show as plain text + const textNode = document.createTextNode(`nostr:${bech32Id}`) + parent.replaceChild(textNode, container) + } }) // Process nostr: notes - replace placeholders with React components @@ -940,6 +968,9 @@ export default function AsciidocArticle({ const bech32Id = element.getAttribute('data-nostr-note') if (!bech32Id) { logger.warn('Nostr note placeholder found but no bech32Id attribute') + // Fallback: show as plain text + const textNode = document.createTextNode(`nostr:${element.textContent || ''}`) + element.parentNode?.replaceChild(textNode, element) return } @@ -949,14 +980,23 @@ export default function AsciidocArticle({ const parent = element.parentNode if (!parent) { logger.warn('Nostr note placeholder has no parent node') + // Fallback: show as plain text + const textNode = document.createTextNode(`nostr:${bech32Id}`) return } parent.replaceChild(container, element) - // Use React to render the component - const root = createRoot(container) - root.render() - reactRootsRef.current.set(container, root) + // Use React to render the component, with error handling + try { + const root = createRoot(container) + root.render() + reactRootsRef.current.set(container, root) + } catch (error) { + logger.error('Failed to render nostr note', { bech32Id, error }) + // Fallback: show as plain text + const textNode = document.createTextNode(`nostr:${bech32Id}`) + parent.replaceChild(textNode, container) + } }) // Process citations - replace placeholders with React components @@ -1063,7 +1103,20 @@ export default function AsciidocArticle({ // Look for a sibling or nearby container with the same key const parent = element.parentElement if (parent) { - const existingContainer = parent.querySelector(`.bookstr-container[data-bookstr-key="${placeholderKey}"]`) + // Escape the attribute value for use in CSS selector + // If the value contains double quotes, use single quotes for the selector + // Otherwise escape double quotes and backslashes + let selector: string + if (placeholderKey.includes('"')) { + // Use single quotes and escape any single quotes in the value + const escapedValue = placeholderKey.replace(/'/g, "\\'") + selector = `.bookstr-container[data-bookstr-key='${escapedValue}']` + } else { + // Use double quotes and escape any double quotes and backslashes + const escapedValue = placeholderKey.replace(/["\\]/g, '\\$&') + selector = `.bookstr-container[data-bookstr-key="${escapedValue}"]` + } + const existingContainer = parent.querySelector(selector) if (existingContainer) { // Container already exists - check if it has a React root if (reactRootsRef.current.has(existingContainer)) { diff --git a/src/components/Note/LiveEvent.tsx b/src/components/Note/LiveEvent.tsx index ca9fb89..e67e907 100644 --- a/src/components/Note/LiveEvent.tsx +++ b/src/components/Note/LiveEvent.tsx @@ -26,7 +26,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam const titleComponent =
{metadata.title}
const summaryComponent = metadata.summary && ( -
{metadata.summary}
+
{metadata.summary}
) const tagsComponent = metadata.tags.length > 0 && ( diff --git a/src/components/Note/LongFormArticlePreview.tsx b/src/components/Note/LongFormArticlePreview.tsx index 6bc997b..861b54f 100644 --- a/src/components/Note/LongFormArticlePreview.tsx +++ b/src/components/Note/LongFormArticlePreview.tsx @@ -44,7 +44,7 @@ export default function LongFormArticlePreview({ ) const summaryComponent = metadata.summary && ( -
{metadata.summary}
+
{metadata.summary}
) if (isSmallScreen) { diff --git a/src/components/Note/PublicationCard.tsx b/src/components/Note/PublicationCard.tsx index 3178b0b..7e51898 100644 --- a/src/components/Note/PublicationCard.tsx +++ b/src/components/Note/PublicationCard.tsx @@ -65,7 +65,7 @@ export default function PublicationCard({ ) const summaryComponent = metadata.summary && ( -
{metadata.summary}
+
{metadata.summary}
) if (isSmallScreen) { diff --git a/src/components/Note/PublicationIndex/PublicationIndex.tsx b/src/components/Note/PublicationIndex/PublicationIndex.tsx index 7e9e72e..f4ee3ff 100644 --- a/src/components/Note/PublicationIndex/PublicationIndex.tsx +++ b/src/components/Note/PublicationIndex/PublicationIndex.tsx @@ -14,6 +14,7 @@ import indexedDb from '@/services/indexed-db.service' import { isReplaceableEvent } from '@/lib/event' import { useSecondaryPage } from '@/PageManager' import { extractBookMetadata } from '@/lib/bookstr-parser' +import { dTagToTitleCase } from '@/lib/event-metadata' interface PublicationReference { coordinate?: string @@ -77,9 +78,12 @@ export default function PublicationIndex({ } } - // Fallback title from d-tag if no title + // Fallback title from d-tag if no title (convert to title case) if (!meta.title) { - meta.title = event.tags.find(tag => tag[0] === 'd')?.[1] + const dTag = event.tags.find(tag => tag[0] === 'd')?.[1] + if (dTag) { + meta.title = dTagToTitleCase(dTag) + } } return meta diff --git a/src/components/Note/WikiCard.tsx b/src/components/Note/WikiCard.tsx index 1f6fd96..9956a6e 100644 --- a/src/components/Note/WikiCard.tsx +++ b/src/components/Note/WikiCard.tsx @@ -44,7 +44,7 @@ export default function WikiCard({ ) const summaryComponent = metadata.summary && ( -
{metadata.summary}
+
{metadata.summary}
) if (isSmallScreen) { diff --git a/src/components/WebPreview/index.tsx b/src/components/WebPreview/index.tsx index b798dec..c7c254f 100644 --- a/src/components/WebPreview/index.tsx +++ b/src/components/WebPreview/index.tsx @@ -2,7 +2,7 @@ import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata' import { useFetchEvent } from '@/hooks/useFetchEvent' import { useFetchProfile } from '@/hooks/useFetchProfile' import { ExtendedKind } from '@/constants' -import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' +import { getLongFormArticleMetadataFromEvent, dTagToTitleCase } from '@/lib/event-metadata' import { extractBookMetadata } from '@/lib/bookstr-parser' import { cn } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' @@ -59,6 +59,75 @@ function getEventTypeName(kind: number): string { } } +// Helper function to extract first header from content +function extractFirstHeader(content: string): string | null { + if (!content) return null + + // Try AsciiDoc header (= or ==) + const asciidocHeaderMatch = content.match(/^=+\s+(.+)$/m) + if (asciidocHeaderMatch) { + return asciidocHeaderMatch[1].trim() + } + + // Try Markdown header (#) + const markdownHeaderMatch = content.match(/^#+\s+(.+)$/m) + if (markdownHeaderMatch) { + return markdownHeaderMatch[1].trim() + } + + // Try setext header (underlined with === or ---) + const setextMatch = content.match(/^(.+)\n[=]+$/m) || content.match(/^(.+)\n[-]+$/m) + if (setextMatch) { + return setextMatch[1].trim() + } + + return null +} + +// Helper function to extract first line of content +function extractFirstLine(content: string): string | null { + if (!content) return null + + const firstLine = content.split('\n')[0]?.trim() + return firstLine || null +} + +// Helper function to get title with fallbacks +function getTitleWithFallbacks(event: Event | null, eventMetadata: { title?: string; summary?: string } | null): string | null { + if (!event) return null + + // Get d-tag for comparison + const dTag = event.tags.find(tag => tag[0] === 'd')?.[1] + + // 1. Title tag - but if it matches the d-tag, convert to title case + if (eventMetadata?.title) { + // If title exactly matches d-tag (case-insensitive), convert to title case + if (dTag && eventMetadata.title.toLowerCase() === dTag.toLowerCase()) { + return dTagToTitleCase(dTag) + } + return eventMetadata.title + } + + // 2. d-tag in title case + if (dTag) { + return dTagToTitleCase(dTag) + } + + // 3. First header from content + const firstHeader = extractFirstHeader(event.content) + if (firstHeader) { + return firstHeader + } + + // 4. First line of content + const firstLine = extractFirstLine(event.content) + if (firstLine) { + return firstLine + } + + return null +} + export default function WebPreview({ url, className }: { url: string; className?: string }) { const { autoLoadMedia } = useContentPolicy() const { isSmallScreen } = useScreenSize() @@ -402,7 +471,6 @@ export default function WebPreview({ url, className }: { url: string; className? // Enhanced card for event URLs (always show if nostr identifier detected, even while loading) if (nostrType === 'naddr' || nostrType === 'nevent' || nostrType === 'note') { const eventTypeName = fetchedEvent ? getEventTypeName(fetchedEvent.kind) : null - const eventTitle = eventMetadata?.title || eventTypeName const eventSummary = eventMetadata?.summary || description // Fallback to OG image from website if event doesn't have an image @@ -425,8 +493,12 @@ export default function WebPreview({ url, className }: { url: string; className? // Determine which article component to use based on event kind const isAsciidocEvent = fetchedEvent && (fetchedEvent.kind === ExtendedKind.WIKI_ARTICLE || fetchedEvent.kind === ExtendedKind.PUBLICATION_CONTENT) - const isMarkdownEvent = fetchedEvent && (fetchedEvent.kind === kinds.LongFormArticle || fetchedEvent.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) - const showContentPreview = previewEvent && previewEvent.content && (isAsciidocEvent || isMarkdownEvent) + const isMarkdownEvent = fetchedEvent && (fetchedEvent.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) + // Only show content preview if summary exists (exclude LongFormArticle - they should show summary instead) + const showContentPreview = eventSummary && previewEvent && previewEvent.content && (isAsciidocEvent || isMarkdownEvent) + + // Get title with fallbacks + const eventTitle = getTitleWithFallbacks(fetchedEvent || null, eventMetadata) || eventTypeName // Render all images on left side, crop wider ones return ( @@ -494,7 +566,7 @@ export default function WebPreview({ url, className }: { url: string; className? )} {eventSummary && !showContentPreview && ( -
{eventSummary}
+
{eventSummary}
)} {showContentPreview && (
@@ -576,7 +648,7 @@ export default function WebPreview({ url, className }: { url: string; className?
{fetchedProfile?.about && ( -
{fetchedProfile.about}
+
{fetchedProfile.about}
)}
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' ') +} + export function getLongFormArticleMetadataFromEvent(event: Event) { let title: string | undefined let summary: string | undefined @@ -240,7 +248,10 @@ export function getLongFormArticleMetadataFromEvent(event: Event) { }) if (!title) { - title = event.tags.find(tagNameEquals('d'))?.[1] + const dTag = event.tags.find(tagNameEquals('d'))?.[1] + if (dTag) { + title = dTagToTitleCase(dTag) + } } return { title, summary, image, tags: Array.from(tags) } @@ -268,7 +279,12 @@ export function getLiveEventMetadataFromEvent(event: Event) { }) if (!title) { - title = event.tags.find(tagNameEquals('d'))?.[1] ?? 'no title' + const dTag = event.tags.find(tagNameEquals('d'))?.[1] + if (dTag) { + title = dTagToTitleCase(dTag) + } else { + title = 'no title' + } } return { title, summary, image, status, tags: Array.from(tags) }