Browse Source

render book wikilinks

imwald
Silberengel 3 months ago
parent
commit
d883017eb9
  1. 80
      src/components/Bookstr/BookstrContent.tsx
  2. 85
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  3. 13
      src/components/Note/MarkdownArticle/preprocessMarkup.ts

80
src/components/Bookstr/BookstrContent.tsx

@ -59,7 +59,7 @@ function buildBibleGatewayUrl(reference: BookReference, version?: string): strin @@ -59,7 +59,7 @@ function buildBibleGatewayUrl(reference: BookReference, version?: string): strin
export function BookstrContent({ wikilink, className }: BookstrContentProps) {
const [sections, setSections] = useState<BookSection[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isLoading, setIsLoading] = useState(false) // Start as false, only set to true when actually fetching
const [error, setError] = useState<string | null>(null)
const [expandedSections, setExpandedSections] = useState<Set<number>>(new Set())
const [selectedVersions, setSelectedVersions] = useState<Map<number, string>>(new Map())
@ -110,10 +110,16 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -110,10 +110,16 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
}
}, [wikilink])
// Track if we've already fetched to prevent infinite loops
const hasFetchedRef = useRef<string | null>(null)
const isFetchingRef = useRef<boolean>(false)
// Fetch events for each reference
useEffect(() => {
// Early return if parsed is not ready
if (!parsed) {
setIsLoading(false)
setError('Failed to parse bookstr wikilink')
return
}
@ -123,6 +129,56 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -123,6 +129,56 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
return
}
// Create a unique key for this fetch based on the parsed references
const fetchKey = JSON.stringify(parsed.references.map(r => ({
book: r.book,
chapter: r.chapter,
verse: r.verse,
version: r.version
})))
// Prevent re-fetching if we've already fetched for this exact set of references
if (hasFetchedRef.current === fetchKey) {
// If we already have sections, don't fetch again
if (sections.length > 0) {
// Ensure loading is false if we have sections
setIsLoading(false)
return
}
// If we're currently fetching, don't start another fetch
// But ensure we have placeholder sections to show
if (isFetchingRef.current) {
// If we don't have sections yet, create placeholders
if (sections.length === 0) {
const placeholderSections: BookSection[] = parsed.references.map(ref => ({
reference: ref,
events: [],
versions: [],
originalVerses: ref.verse,
originalChapter: ref.chapter
}))
setSections(placeholderSections)
setIsLoading(false)
}
return
}
// If we've fetched before but have no sections (component was re-mounted),
// create placeholders and don't fetch again
const placeholderSections: BookSection[] = parsed.references.map(ref => ({
reference: ref,
events: [],
versions: [],
originalVerses: ref.verse,
originalChapter: ref.chapter
}))
setSections(placeholderSections)
setIsLoading(false)
return
}
hasFetchedRef.current = fetchKey
isFetchingRef.current = true
let isCancelled = false
const fetchEvents = async () => {
@ -148,7 +204,8 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -148,7 +204,8 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
originalChapter: ref.chapter
}))
setSections(placeholderSections)
setIsLoading(false) // Show placeholders immediately
// Show placeholders immediately - set loading to false BEFORE async operations
setIsLoading(false)
const newSections: BookSection[] = []
@ -537,6 +594,7 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -537,6 +594,7 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
if (!isCancelled) {
setIsLoading(false)
}
isFetchingRef.current = false
}
}
@ -544,9 +602,9 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -544,9 +602,9 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
return () => {
isCancelled = true
isFetchingRef.current = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wikilink]) // Only depend on wikilink - parsed is derived from it via useMemo
}, [parsed]) // Depend on parsed directly - it's memoized and won't change unless wikilink meaningfully changes
// Measure card heights - measure BEFORE applying collapse
useEffect(() => {
@ -611,7 +669,9 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -611,7 +669,9 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
return () => clearTimeout(timeoutId)
}, [sections, collapsedCards])
if (isLoading) {
// Show loading spinner only if we're actively loading AND have no sections
// Once we have sections (even empty placeholders), show them instead
if (isLoading && sections.length === 0) {
return (
<span className={cn('inline-flex items-center gap-1', className)}>
<span>{wikilink}</span>
@ -620,6 +680,16 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -620,6 +680,16 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
)
}
// If we have no sections and no error, show the wikilink as plain text
// This handles the case where parsing failed or no data is available
if (sections.length === 0 && !error && !isLoading) {
return (
<span className={cn('inline-flex items-center gap-1', className)}>
<span>{wikilink}</span>
</span>
)
}
if (error) {
return (
<span className={cn('inline-flex items-center gap-1', className)} title={error}>

85
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -690,6 +690,16 @@ export default function AsciidocArticle({ @@ -690,6 +690,16 @@ export default function AsciidocArticle({
return `<div data-citation="${escapedId}" data-citation-type="${citationType}" class="citation-placeholder"></div>`
})
// Handle bookstr markers - convert passthrough markers to placeholders
// AsciiDoc passthrough +++BOOKSTR_START:...:BOOKSTR_END+++ outputs BOOKSTR_START:...:BOOKSTR_END in HTML
// Match the delimited format to extract the exact content (non-greedy to stop at :BOOKSTR_END)
htmlString = htmlString.replace(/BOOKSTR_START:(.+?):BOOKSTR_END/g, (_match, bookContent) => {
// Trim whitespace and escape special characters for HTML attributes
const cleanContent = bookContent.trim()
const escaped = cleanContent.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
return `<span data-bookstr="${escaped}" class="bookstr-placeholder"></span>`
})
// Handle wikilinks - convert passthrough markers to placeholders
// AsciiDoc passthrough +++WIKILINK:link|display+++ outputs just WIKILINK:link|display in HTML
// Match WIKILINK: followed by any characters (including |) until end of text or HTML tag
@ -799,17 +809,35 @@ export default function AsciidocArticle({ @@ -799,17 +809,35 @@ export default function AsciidocArticle({
// Store React roots for cleanup
const reactRootsRef = useRef<Map<Element, Root>>(new Map())
// Track which placeholders have been processed to avoid re-processing
const processedPlaceholdersRef = useRef<Set<string>>(new Set())
// Post-process rendered HTML to inject React components for nostr: links and handle hashtags
useEffect(() => {
if (!contentRef.current || !parsedHtml || isLoading) return
// Clean up previous roots
// Only clean up roots that are no longer in the DOM
const rootsToCleanup: Array<[Element, Root]> = []
reactRootsRef.current.forEach((root, element) => {
root.unmount()
if (!element.isConnected) {
rootsToCleanup.push([element, root])
reactRootsRef.current.delete(element)
}
})
// Unmount disconnected roots asynchronously to avoid race conditions
if (rootsToCleanup.length > 0) {
setTimeout(() => {
rootsToCleanup.forEach(([, root]) => {
try {
root.unmount()
} catch (err) {
// Ignore errors during cleanup
}
})
}, 0)
}
// Process nostr: mentions - replace placeholders with React components (inline)
const nostrMentions = contentRef.current.querySelectorAll('.nostr-mention-placeholder[data-nostr-mention]')
nostrMentions.forEach((element) => {
@ -951,19 +979,47 @@ export default function AsciidocArticle({ @@ -951,19 +979,47 @@ export default function AsciidocArticle({
})
// Process bookstr wikilinks - replace placeholders with React components
// Only process elements that are still placeholders (not already converted to containers)
const bookstrPlaceholders = contentRef.current.querySelectorAll('.bookstr-placeholder[data-bookstr]')
bookstrPlaceholders.forEach((element) => {
const bookstrContent = element.getAttribute('data-bookstr')
if (!bookstrContent) return
// Create a unique key for this placeholder
const placeholderKey = `bookstr-${bookstrContent}`
// Check if this placeholder has already been converted to a container
// 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}"]`)
if (existingContainer && reactRootsRef.current.has(existingContainer)) {
// Container already exists with a React root, just remove this duplicate placeholder
element.remove()
return
}
}
// Skip if already processed (to avoid duplicate processing)
if (processedPlaceholdersRef.current.has(placeholderKey)) {
return
}
// Mark as processed
processedPlaceholdersRef.current.add(placeholderKey)
// Prepend book:: prefix since BookstrContent expects it
const wikilink = `book::${bookstrContent}`
// Create a container for React component
const container = document.createElement('div')
container.className = 'bookstr-container'
container.setAttribute('data-bookstr-key', placeholderKey)
element.parentNode?.replaceChild(container, element)
// Use React to render the component
const root = createRoot(container)
root.render(<BookstrContent wikilink={bookstrContent} />)
root.render(<BookstrContent wikilink={wikilink} />)
reactRootsRef.current.set(container, root)
})
@ -1094,14 +1150,29 @@ export default function AsciidocArticle({ @@ -1094,14 +1150,29 @@ export default function AsciidocArticle({
}
})
// Cleanup function
// No cleanup needed here - we only clean up disconnected roots above
// Full cleanup happens on component unmount
}, [parsedHtml, isLoading, navigateToHashtag, navigateToRelay])
// Cleanup on component unmount
useEffect(() => {
return () => {
reactRootsRef.current.forEach((root) => {
const rootsToCleanup = Array.from(reactRootsRef.current.values())
reactRootsRef.current.clear()
processedPlaceholdersRef.current.clear()
// Unmount asynchronously
setTimeout(() => {
rootsToCleanup.forEach((root) => {
try {
root.unmount()
} catch (err) {
// Ignore errors during cleanup
}
})
reactRootsRef.current.clear()
}, 0)
}
}, [parsedHtml, isLoading, navigateToHashtag, navigateToRelay])
}, [])
// Initialize syntax highlighting
useEffect(() => {

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

@ -112,11 +112,20 @@ export function preprocessMarkdownMediaLinks(content: string): string { @@ -112,11 +112,20 @@ export function preprocessMarkdownMediaLinks(content: string): string {
export function preprocessAsciidocMediaLinks(content: string): string {
let processed = content
// First, protect wikilinks by converting them to passthrough format
// First, protect bookstr wikilinks by converting them to passthrough format
// Process bookstr wikilinks BEFORE regular wikilinks to avoid conflicts
processed = processed.replace(/\[\[book::([^\]]+)\]\]/g, (_match, bookContent) => {
const cleanContent = bookContent.trim()
// Use AsciiDoc passthrough to preserve the marker through AsciiDoc processing
// Add a unique delimiter to make it easier to match in HTML
return `+++BOOKSTR_START:${cleanContent}:BOOKSTR_END+++`
})
// Then protect regular wikilinks by converting them to passthrough format
// This prevents AsciiDoc from processing them and prevents URLs inside from being processed
const wikilinkRegex = /\[\[([^\]]+)\]\]/g
const wikilinkRanges: Array<{ start: number; end: number }> = []
const wikilinkMatches = Array.from(content.matchAll(wikilinkRegex))
const wikilinkMatches = Array.from(processed.matchAll(wikilinkRegex))
wikilinkMatches.forEach(match => {
if (match.index !== undefined) {
wikilinkRanges.push({

Loading…
Cancel
Save