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 { @@ -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({ @@ -660,30 +663,36 @@ export default function AsciidocArticle({
// Also handle nostr: addresses in plain text nodes (not already in <a> 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]+)[^<]*)</g, (_match, textContent) => {
// 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,})(\[\])?[^<]*)</g, (_match, textContent) => {
// 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, '&quot;').replace(/'/g, '&#39;')
if (bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')) {
replacements.push({
start,
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')) {
replacements.push({
start,
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({ @@ -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({ @@ -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({ @@ -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(<EmbeddedMention userId={bech32Id} />)
reactRootsRef.current.set(container, root)
// Use React to render the component, with error handling
try {
const root = createRoot(container)
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
@ -940,6 +968,9 @@ export default function AsciidocArticle({ @@ -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({ @@ -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(<EmbeddedNote noteId={bech32Id} />)
reactRootsRef.current.set(container, root)
// Use React to render the component, with error handling
try {
const root = createRoot(container)
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
@ -1063,7 +1103,20 @@ export default function AsciidocArticle({ @@ -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)) {

2
src/components/Note/LiveEvent.tsx

@ -26,7 +26,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam @@ -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 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 && (

2
src/components/Note/LongFormArticlePreview.tsx

@ -44,7 +44,7 @@ export default function LongFormArticlePreview({ @@ -44,7 +44,7 @@ export default function LongFormArticlePreview({
)
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) {

2
src/components/Note/PublicationCard.tsx

@ -65,7 +65,7 @@ export default function PublicationCard({ @@ -65,7 +65,7 @@ export default function PublicationCard({
)
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) {

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

@ -14,6 +14,7 @@ import indexedDb from '@/services/indexed-db.service' @@ -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({ @@ -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

2
src/components/Note/WikiCard.tsx

@ -44,7 +44,7 @@ export default function WikiCard({ @@ -44,7 +44,7 @@ export default function WikiCard({
)
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) {

84
src/components/WebPreview/index.tsx

@ -2,7 +2,7 @@ import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata' @@ -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 { @@ -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? @@ -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? @@ -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? @@ -494,7 +566,7 @@ export default function WebPreview({ url, className }: { url: string; className?
</div>
)}
{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 && (
<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? @@ -576,7 +648,7 @@ export default function WebPreview({ url, className }: { url: string; className?
</a>
</div>
{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" />
<a

20
src/lib/event-metadata.ts

@ -221,6 +221,14 @@ export function getZapInfoFromEvent(receiptEvent: Event) { @@ -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) {
let title: string | undefined
let summary: string | undefined
@ -240,7 +248,10 @@ export function getLongFormArticleMetadataFromEvent(event: Event) { @@ -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) { @@ -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) }

Loading…
Cancel
Save