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}
)}