Browse Source

fix attribute selector bug by escaping relevant chars

don't render content, anymore
ensure summary and about fields always appear and make their text larger
imwald
Silberengel 3 months ago
parent
commit
93b15240ce
  1. 87
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  2. 2
      src/components/Note/LiveEvent.tsx
  3. 2
      src/components/Note/LongFormArticlePreview.tsx
  4. 2
      src/components/Note/PublicationCard.tsx
  5. 8
      src/components/Note/PublicationIndex/PublicationIndex.tsx
  6. 2
      src/components/Note/WikiCard.tsx
  7. 84
      src/components/WebPreview/index.tsx
  8. 20
      src/lib/event-metadata.ts

87
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -60,10 +60,13 @@ function convertMarkdownToAsciidoc(content: string): string {
// Convert nostr addresses directly to AsciiDoc link format // Convert nostr addresses directly to AsciiDoc link format
// Do this early so they're protected from other markdown conversions // Do this early so they're protected from other markdown conversions
// naddr addresses can be 200+ characters, so we use + instead of specific length // 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 // Convert directly to AsciiDoc link format
// This will be processed later in HTML post-processing to render as React components // 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 // 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 <a> tags) // Also handle nostr: addresses in plain text nodes (not already in <a> tags)
// Process text nodes by replacing content between > and < // Process text nodes by replacing content between > and <
// Use more flexible regex that matches any valid bech32 address // Use more flexible regex that matches any valid bech32 address (naddr can be 200+ chars)
htmlString = htmlString.replace(/>([^<]*nostr:((?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+)[^<]*)</g, (_match, textContent) => { // Match addresses with optional [] suffix
// Extract nostr addresses from the text content - use the same flexible pattern htmlString = htmlString.replace(/>([^<]*nostr:((?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]{20,})(\[\])?[^<]*)</g, (_match, textContent) => {
const nostrRegex = /nostr:((?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+)/g // 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 let processedText = textContent
const replacements: Array<{ start: number; end: number; replacement: string }> = [] const replacements: Array<{ start: number; end: number; replacement: string }> = []
let m let m
while ((m = nostrRegex.exec(textContent)) !== null) { while ((m = nostrRegex.exec(textContent)) !== null) {
const bech32Id = m[1] const bech32Id = m[1]
const emptyBrackets = m[2] // [] suffix if present
const start = m.index const start = m.index
const end = m.index + m[0].length const end = m.index + m[0].length
// Escape bech32Id for HTML attributes
const escapedId = bech32Id.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
if (bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')) { if (bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')) {
replacements.push({ replacements.push({
start, start,
end, end,
replacement: `<span data-nostr-mention="${bech32Id}" class="nostr-mention-placeholder"></span>` replacement: `<span data-nostr-mention="${escapedId}" class="nostr-mention-placeholder"></span>`
}) })
} else if (bech32Id.startsWith('note') || bech32Id.startsWith('nevent') || bech32Id.startsWith('naddr')) { } else if (bech32Id.startsWith('note') || bech32Id.startsWith('nevent') || bech32Id.startsWith('naddr')) {
replacements.push({ replacements.push({
start, start,
end, end,
replacement: `<div data-nostr-note="${bech32Id}" class="nostr-note-placeholder"></div>` replacement: `<div data-nostr-note="${escapedId}" class="nostr-note-placeholder"></div>`
}) })
} }
} }
@ -697,6 +706,13 @@ export default function AsciidocArticle({
return `>${processedText}<` 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 // Handle LaTeX math expressions from AsciiDoc stem processor
// AsciiDoc with stem: latexmath outputs \(...\) for inline and \[...\] for block math // AsciiDoc with stem: latexmath outputs \(...\) for inline and \[...\] for block math
// In HTML, these appear as literal \( and \) characters (backslash + parenthesis) // 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') const bech32Id = element.getAttribute('data-nostr-mention')
if (!bech32Id) { if (!bech32Id) {
logger.warn('Nostr mention placeholder found but no bech32Id attribute') 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 return
} }
@ -924,14 +943,23 @@ export default function AsciidocArticle({
const parent = element.parentNode const parent = element.parentNode
if (!parent) { if (!parent) {
logger.warn('Nostr mention placeholder has no parent node') logger.warn('Nostr mention placeholder has no parent node')
// Fallback: show as plain text
const textNode = document.createTextNode(`nostr:${bech32Id}`)
return return
} }
parent.replaceChild(container, element) parent.replaceChild(container, element)
// Use React to render the component // Use React to render the component, with error handling
const root = createRoot(container) try {
root.render(<EmbeddedMention userId={bech32Id} />) const root = createRoot(container)
reactRootsRef.current.set(container, root) root.render(<EmbeddedMention userId={bech32Id} />)
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 // Process nostr: notes - replace placeholders with React components
@ -940,6 +968,9 @@ export default function AsciidocArticle({
const bech32Id = element.getAttribute('data-nostr-note') const bech32Id = element.getAttribute('data-nostr-note')
if (!bech32Id) { if (!bech32Id) {
logger.warn('Nostr note placeholder found but no bech32Id attribute') 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 return
} }
@ -949,14 +980,23 @@ export default function AsciidocArticle({
const parent = element.parentNode const parent = element.parentNode
if (!parent) { if (!parent) {
logger.warn('Nostr note placeholder has no parent node') logger.warn('Nostr note placeholder has no parent node')
// Fallback: show as plain text
const textNode = document.createTextNode(`nostr:${bech32Id}`)
return return
} }
parent.replaceChild(container, element) parent.replaceChild(container, element)
// Use React to render the component // Use React to render the component, with error handling
const root = createRoot(container) try {
root.render(<EmbeddedNote noteId={bech32Id} />) const root = createRoot(container)
reactRootsRef.current.set(container, root) root.render(<EmbeddedNote noteId={bech32Id} />)
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 // 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 // Look for a sibling or nearby container with the same key
const parent = element.parentElement const parent = element.parentElement
if (parent) { 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) { if (existingContainer) {
// Container already exists - check if it has a React root // Container already exists - check if it has a React root
if (reactRootsRef.current.has(existingContainer)) { if (reactRootsRef.current.has(existingContainer)) {

2
src/components/Note/LiveEvent.tsx

@ -26,7 +26,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
const titleComponent = <div className="text-xl font-semibold line-clamp-1">{metadata.title}</div> const titleComponent = <div className="text-xl font-semibold line-clamp-1">{metadata.title}</div>
const summaryComponent = metadata.summary && ( const summaryComponent = metadata.summary && (
<div className="text-sm text-muted-foreground line-clamp-4">{metadata.summary}</div> <div className="text-base text-muted-foreground line-clamp-4">{metadata.summary}</div>
) )
const tagsComponent = metadata.tags.length > 0 && ( const tagsComponent = metadata.tags.length > 0 && (

2
src/components/Note/LongFormArticlePreview.tsx

@ -44,7 +44,7 @@ export default function LongFormArticlePreview({
) )
const summaryComponent = metadata.summary && ( const summaryComponent = metadata.summary && (
<div className="text-sm text-muted-foreground line-clamp-4">{metadata.summary}</div> <div className="text-base text-muted-foreground line-clamp-4">{metadata.summary}</div>
) )
if (isSmallScreen) { if (isSmallScreen) {

2
src/components/Note/PublicationCard.tsx

@ -65,7 +65,7 @@ export default function PublicationCard({
) )
const summaryComponent = metadata.summary && ( const summaryComponent = metadata.summary && (
<div className="text-sm text-muted-foreground line-clamp-4">{metadata.summary}</div> <div className="text-base text-muted-foreground line-clamp-4">{metadata.summary}</div>
) )
if (isSmallScreen) { if (isSmallScreen) {

8
src/components/Note/PublicationIndex/PublicationIndex.tsx

@ -14,6 +14,7 @@ import indexedDb from '@/services/indexed-db.service'
import { isReplaceableEvent } from '@/lib/event' import { isReplaceableEvent } from '@/lib/event'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { extractBookMetadata } from '@/lib/bookstr-parser' import { extractBookMetadata } from '@/lib/bookstr-parser'
import { dTagToTitleCase } from '@/lib/event-metadata'
interface PublicationReference { interface PublicationReference {
coordinate?: string 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) { 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 return meta

2
src/components/Note/WikiCard.tsx

@ -44,7 +44,7 @@ export default function WikiCard({
) )
const summaryComponent = metadata.summary && ( const summaryComponent = metadata.summary && (
<div className="text-sm text-muted-foreground line-clamp-4">{metadata.summary}</div> <div className="text-base text-muted-foreground line-clamp-4">{metadata.summary}</div>
) )
if (isSmallScreen) { if (isSmallScreen) {

84
src/components/WebPreview/index.tsx

@ -2,7 +2,7 @@ import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata'
import { useFetchEvent } from '@/hooks/useFetchEvent' import { useFetchEvent } from '@/hooks/useFetchEvent'
import { useFetchProfile } from '@/hooks/useFetchProfile' import { useFetchProfile } from '@/hooks/useFetchProfile'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent, dTagToTitleCase } from '@/lib/event-metadata'
import { extractBookMetadata } from '@/lib/bookstr-parser' import { extractBookMetadata } from '@/lib/bookstr-parser'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' 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 }) { export default function WebPreview({ url, className }: { url: string; className?: string }) {
const { autoLoadMedia } = useContentPolicy() const { autoLoadMedia } = useContentPolicy()
const { isSmallScreen } = useScreenSize() 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) // Enhanced card for event URLs (always show if nostr identifier detected, even while loading)
if (nostrType === 'naddr' || nostrType === 'nevent' || nostrType === 'note') { if (nostrType === 'naddr' || nostrType === 'nevent' || nostrType === 'note') {
const eventTypeName = fetchedEvent ? getEventTypeName(fetchedEvent.kind) : null const eventTypeName = fetchedEvent ? getEventTypeName(fetchedEvent.kind) : null
const eventTitle = eventMetadata?.title || eventTypeName
const eventSummary = eventMetadata?.summary || description const eventSummary = eventMetadata?.summary || description
// Fallback to OG image from website if event doesn't have an image // 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 // Determine which article component to use based on event kind
const isAsciidocEvent = fetchedEvent && (fetchedEvent.kind === ExtendedKind.WIKI_ARTICLE || fetchedEvent.kind === ExtendedKind.PUBLICATION_CONTENT) 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 isMarkdownEvent = fetchedEvent && (fetchedEvent.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN)
const showContentPreview = previewEvent && previewEvent.content && (isAsciidocEvent || isMarkdownEvent) // 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 // Render all images on left side, crop wider ones
return ( return (
@ -494,7 +566,7 @@ export default function WebPreview({ url, className }: { url: string; className?
</div> </div>
)} )}
{eventSummary && !showContentPreview && ( {eventSummary && !showContentPreview && (
<div className="text-xs text-muted-foreground line-clamp-2 mb-1">{eventSummary}</div> <div className="text-base text-muted-foreground line-clamp-2 mb-1">{eventSummary}</div>
)} )}
{showContentPreview && ( {showContentPreview && (
<div className="my-2 text-sm line-clamp-6 overflow-hidden [&_img]:hidden [&_h1]:hidden [&_h2]:hidden"> <div className="my-2 text-sm line-clamp-6 overflow-hidden [&_img]:hidden [&_h1]:hidden [&_h2]:hidden">
@ -576,7 +648,7 @@ export default function WebPreview({ url, className }: { url: string; className?
</a> </a>
</div> </div>
{fetchedProfile?.about && ( {fetchedProfile?.about && (
<div className="text-xs text-muted-foreground line-clamp-2 mb-1 mt-1">{fetchedProfile.about}</div> <div className="text-base text-muted-foreground line-clamp-2 mb-1 mt-1">{fetchedProfile.about}</div>
)} )}
<hr className="mt-4 mb-2 border-t border-border" /> <hr className="mt-4 mb-2 border-t border-border" />
<a <a

20
src/lib/event-metadata.ts

@ -221,6 +221,14 @@ export function getZapInfoFromEvent(receiptEvent: Event) {
} }
} }
// Helper function to convert d-tag to title case
export function dTagToTitleCase(dTag: string): string {
return dTag
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
export function getLongFormArticleMetadataFromEvent(event: Event) { export function getLongFormArticleMetadataFromEvent(event: Event) {
let title: string | undefined let title: string | undefined
let summary: string | undefined let summary: string | undefined
@ -240,7 +248,10 @@ export function getLongFormArticleMetadataFromEvent(event: Event) {
}) })
if (!title) { 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) } return { title, summary, image, tags: Array.from(tags) }
@ -268,7 +279,12 @@ export function getLiveEventMetadataFromEvent(event: Event) {
}) })
if (!title) { 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) } return { title, summary, image, status, tags: Array.from(tags) }

Loading…
Cancel
Save