From d883017eb9a30af26b29cf321adf83d57e6b55ce Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 1 Dec 2025 21:47:17 +0100 Subject: [PATCH] render book wikilinks --- src/components/Bookstr/BookstrContent.tsx | 80 +++++++++++++++-- .../Note/AsciidocArticle/AsciidocArticle.tsx | 89 +++++++++++++++++-- .../Note/MarkdownArticle/preprocessMarkup.ts | 13 ++- 3 files changed, 166 insertions(+), 16 deletions(-) diff --git a/src/components/Bookstr/BookstrContent.tsx b/src/components/Bookstr/BookstrContent.tsx index c1ca61a..1fbab4c 100644 --- a/src/components/Bookstr/BookstrContent.tsx +++ b/src/components/Bookstr/BookstrContent.tsx @@ -59,7 +59,7 @@ function buildBibleGatewayUrl(reference: BookReference, version?: string): strin export function BookstrContent({ wikilink, className }: BookstrContentProps) { const [sections, setSections] = useState([]) - const [isLoading, setIsLoading] = useState(true) + const [isLoading, setIsLoading] = useState(false) // Start as false, only set to true when actually fetching const [error, setError] = useState(null) const [expandedSections, setExpandedSections] = useState>(new Set()) const [selectedVersions, setSelectedVersions] = useState>(new Map()) @@ -110,10 +110,16 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { } }, [wikilink]) + // Track if we've already fetched to prevent infinite loops + const hasFetchedRef = useRef(null) + const isFetchingRef = useRef(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) { 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) { 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) { if (!isCancelled) { setIsLoading(false) } + isFetchingRef.current = false } } @@ -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) { 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 ( {wikilink} @@ -619,6 +679,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 ( + + {wikilink} + + ) + } if (error) { return ( diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index e768303..4c3d634 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -690,6 +690,16 @@ export default function AsciidocArticle({ return `
` }) + // 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, '"').replace(/'/g, ''') + return `` + }) + // 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({ // Store React roots for cleanup const reactRootsRef = useRef>(new Map()) + // Track which placeholders have been processed to avoid re-processing + const processedPlaceholdersRef = useRef>(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() - reactRootsRef.current.delete(element) + 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({ }) // 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() + root.render() reactRootsRef.current.set(container, root) }) @@ -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) => { - root.unmount() - }) + 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 + } + }) + }, 0) } - }, [parsedHtml, isLoading, navigateToHashtag, navigateToRelay]) + }, []) // Initialize syntax highlighting useEffect(() => { diff --git a/src/components/Note/MarkdownArticle/preprocessMarkup.ts b/src/components/Note/MarkdownArticle/preprocessMarkup.ts index 29e521e..0584d45 100644 --- a/src/components/Note/MarkdownArticle/preprocessMarkup.ts +++ b/src/components/Note/MarkdownArticle/preprocessMarkup.ts @@ -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({