Browse Source

fix booklinks

imwald
Silberengel 3 months ago
parent
commit
e755b9ed75
  1. 235
      src/components/Bookstr/BookstrContent.tsx
  2. 96
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  3. 47
      src/components/Note/MarkdownArticle/preprocessMarkup.ts

235
src/components/Bookstr/BookstrContent.tsx

@ -65,9 +65,12 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
const [selectedVersions, setSelectedVersions] = useState<Map<number, string>>(new Map()) const [selectedVersions, setSelectedVersions] = useState<Map<number, string>>(new Map())
const [collapsedCards, setCollapsedCards] = useState<Set<number>>(new Set()) const [collapsedCards, setCollapsedCards] = useState<Set<number>>(new Set())
const [cardHeights, setCardHeights] = useState<Map<number, number>>(new Map()) const [cardHeights, setCardHeights] = useState<Map<number, number>>(new Map())
// Track which sections are still loading (by reference key)
const [loadingSections, setLoadingSections] = useState<Set<string>>(new Set())
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map()) const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
// Parse the wikilink // Parse the wikilink - use a ref to store the last parsed result for comparison
const parsedRef = useRef<ReturnType<typeof parseBookWikilink> & { bookType: string } | null>(null)
const parsed = useMemo(() => { const parsed = useMemo(() => {
try { try {
// NKBIP-08 format: book::... (must have double colon) // NKBIP-08 format: book::... (must have double colon)
@ -82,12 +85,17 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
} }
} else { } else {
// Invalid format - must start with book:: // Invalid format - must start with book::
parsedRef.current = null
return null return null
} }
const result = parseBookWikilink(wikilinkToParse) const result = parseBookWikilink(wikilinkToParse)
if (result) { if (result) {
const inferredBookType = result.bookType || 'bible' const inferredBookType = result.bookType || 'bible'
const parsedResult = { ...result, bookType: inferredBookType }
// Only log if this is a new parse (not a re-render with same wikilink)
if (parsedRef.current === null || JSON.stringify(parsedRef.current.references) !== JSON.stringify(parsedResult.references)) {
logger.debug('BookstrContent: Parsed wikilink', { logger.debug('BookstrContent: Parsed wikilink', {
wikilink, wikilink,
wikilinkToParse, wikilinkToParse,
@ -101,11 +109,16 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
})), })),
versions: result.versions versions: result.versions
}) })
return { ...result, bookType: inferredBookType }
} }
parsedRef.current = parsedResult
return parsedResult
}
parsedRef.current = null
return null return null
} catch (err) { } catch (err) {
logger.error('Error parsing bookstr wikilink', { error: err, wikilink }) logger.error('Error parsing bookstr wikilink', { error: err, wikilink })
parsedRef.current = null
return null return null
} }
}, [wikilink]) }, [wikilink])
@ -113,9 +126,14 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
// Track if we've already fetched to prevent infinite loops // Track if we've already fetched to prevent infinite loops
const hasFetchedRef = useRef<string | null>(null) const hasFetchedRef = useRef<string | null>(null)
const isFetchingRef = useRef<boolean>(false) const isFetchingRef = useRef<boolean>(false)
const lastWikilinkRef = useRef<string | null>(null)
const effectRunCountRef = useRef<number>(0)
// Fetch events for each reference // Fetch events for each reference
useEffect(() => { useEffect(() => {
effectRunCountRef.current += 1
const runCount = effectRunCountRef.current
// Early return if parsed is not ready // Early return if parsed is not ready
if (!parsed) { if (!parsed) {
setIsLoading(false) setIsLoading(false)
@ -137,19 +155,42 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
version: r.version version: r.version
}))) })))
// Prevent re-fetching if we've already fetched for this exact set of references // Reset fetch state if wikilink changed
if (lastWikilinkRef.current !== wikilink) {
hasFetchedRef.current = null
lastWikilinkRef.current = wikilink
isFetchingRef.current = false
effectRunCountRef.current = 1
}
// AGGRESSIVE: If we've already fetched for this exact key, STOP IMMEDIATELY
if (hasFetchedRef.current === fetchKey) { 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 return
} }
// If we're currently fetching, don't start another fetch
// But ensure we have placeholder sections to show // AGGRESSIVE: If we're already fetching, STOP IMMEDIATELY
if (isFetchingRef.current) { if (isFetchingRef.current) {
// If we don't have sections yet, create placeholders return
if (sections.length === 0) { }
// AGGRESSIVE: If effect has run more than once for the same wikilink, something is wrong
if (runCount > 2 && lastWikilinkRef.current === wikilink) {
logger.warn('BookstrContent: Effect running too many times, blocking', {
wikilink,
runCount,
fetchKey,
hasFetched: hasFetchedRef.current
})
return
}
// Mark that we're starting a fetch for this wikilink
logger.debug('BookstrContent: Starting fetch', { wikilink, fetchKey, runCount })
hasFetchedRef.current = fetchKey
isFetchingRef.current = true
// Create placeholder sections IMMEDIATELY - before any checks or async operations
// This ensures something is always displayed
const placeholderSections: BookSection[] = parsed.references.map(ref => ({ const placeholderSections: BookSection[] = parsed.references.map(ref => ({
reference: ref, reference: ref,
events: [], events: [],
@ -159,11 +200,15 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
})) }))
setSections(placeholderSections) setSections(placeholderSections)
setIsLoading(false) setIsLoading(false)
}
return let isCancelled = false
} let loadingTimeout: NodeJS.Timeout | null = null
// If we've fetched before but have no sections (component was re-mounted),
// create placeholders and don't fetch again const fetchEvents = async () => {
setError(null)
// Create placeholder sections IMMEDIATELY before any async operations
// This ensures something is always displayed, even if the fetch fails or is slow
const placeholderSections: BookSection[] = parsed.references.map(ref => ({ const placeholderSections: BookSection[] = parsed.references.map(ref => ({
reference: ref, reference: ref,
events: [], events: [],
@ -172,18 +217,21 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
originalChapter: ref.chapter originalChapter: ref.chapter
})) }))
setSections(placeholderSections) setSections(placeholderSections)
setIsLoading(false) setIsLoading(false) // Ensure loading is false - we have placeholders to show
return
}
hasFetchedRef.current = fetchKey // Mark all sections as loading initially (will be removed when fetch completes)
isFetchingRef.current = true const initialLoadingKeys = new Set(parsed.references.map(ref =>
`${ref.book}-${ref.chapter}-${ref.verse}`
))
setLoadingSections(initialLoadingKeys)
let isCancelled = false // Set a timeout to clear loading state if fetch takes too long (30 seconds)
loadingTimeout = setTimeout(() => {
const fetchEvents = async () => { if (!isCancelled) {
setIsLoading(true) logger.warn('BookstrContent: Fetch timeout - clearing loading state', { wikilink })
setError(null) setLoadingSections(new Set())
}
}, 30000)
try { try {
logger.debug('BookstrContent: Processing references', { logger.debug('BookstrContent: Processing references', {
@ -195,18 +243,6 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
})) }))
}) })
// Step 0: Create placeholder sections immediately so links don't disappear
const placeholderSections: BookSection[] = parsed.references.map(ref => ({
reference: ref,
events: [],
versions: [],
originalVerses: ref.verse,
originalChapter: ref.chapter
}))
setSections(placeholderSections)
// Show placeholders immediately - set loading to false BEFORE async operations
setIsLoading(false)
const newSections: BookSection[] = [] const newSections: BookSection[] = []
// Step 1: Check cache for ALL references first (in parallel) // Step 1: Check cache for ALL references first (in parallel)
@ -245,7 +281,16 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
// Step 2: Display cached results IMMEDIATELY // Step 2: Display cached results IMMEDIATELY
for (const { ref, cachedEvents } of cacheResults) { for (const { ref, cachedEvents } of cacheResults) {
const refKey = `${ref.book}-${ref.chapter}-${ref.verse}`
if (cachedEvents.length > 0) { if (cachedEvents.length > 0) {
// Mark this section as loaded (has cached data)
setLoadingSections(prev => {
const updated = new Set(prev)
updated.delete(refKey)
return updated
})
const allVersions = new Set<string>() const allVersions = new Set<string>()
cachedEvents.forEach(event => { cachedEvents.forEach(event => {
const metadata = extractBookMetadata(event) const metadata = extractBookMetadata(event)
@ -349,6 +394,8 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
for (const { ref, cachedEvents, versionsToFetch } of cacheResults) { for (const { ref, cachedEvents, versionsToFetch } of cacheResults) {
if (isCancelled) break if (isCancelled) break
const refKey = `${ref.book}-${ref.chapter}-${ref.verse}`
// If we already have cached events for this reference, skip or do background refresh // If we already have cached events for this reference, skip or do background refresh
if (cachedEvents.length > 0) { if (cachedEvents.length > 0) {
// Still fetch in background to get updates // Still fetch in background to get updates
@ -375,6 +422,13 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
Promise.all(fetchPromises).then(fetchedResults => { Promise.all(fetchPromises).then(fetchedResults => {
if (isCancelled) return if (isCancelled) return
// Mark this section as loaded (background fetch complete)
setLoadingSections(prev => {
const updated = new Set(prev)
updated.delete(refKey)
return updated
})
const allFetchedEvents = fetchedResults.flat() const allFetchedEvents = fetchedResults.flat()
if (allFetchedEvents.length > 0) { if (allFetchedEvents.length > 0) {
// Update the section with fresh data // Update the section with fresh data
@ -401,11 +455,23 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
} }
}).catch(err => { }).catch(err => {
logger.warn('BookstrContent: Background fetch failed', { error: err, ref }) logger.warn('BookstrContent: Background fetch failed', { error: err, ref })
// Mark as loaded even on error to stop spinner
setLoadingSections(prev => {
const updated = new Set(prev)
updated.delete(refKey)
return updated
})
}) })
continue continue
} }
// No cached events, fetch from network // No cached events, mark as loading and fetch from network
setLoadingSections(prev => {
const updated = new Set(prev)
updated.add(refKey)
return updated
})
const normalizedBook = ref.book.toLowerCase().replace(/\s+/g, '-') const normalizedBook = ref.book.toLowerCase().replace(/\s+/g, '-')
// Determine which versions to fetch // Determine which versions to fetch
@ -441,6 +507,13 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
} }
}) })
// Mark this section as loaded (found events)
setLoadingSections(prev => {
const updated = new Set(prev)
updated.delete(refKey)
return updated
})
newSections.push({ newSections.push({
reference: ref, reference: ref,
events: allEvents, events: allEvents,
@ -449,6 +522,13 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
originalChapter: ref.chapter originalChapter: ref.chapter
}) })
continue continue
} else {
// No events found, mark as loaded to stop spinner
setLoadingSections(prev => {
const updated = new Set(prev)
updated.delete(refKey)
return updated
})
} }
} }
} }
@ -528,6 +608,13 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
return aVerse - bVerse return aVerse - bVerse
}) })
// Mark this section as loaded (network fetch complete)
setLoadingSections(prev => {
const updated = new Set(prev)
updated.delete(refKey)
return updated
})
newSections.push({ newSections.push({
reference: ref, reference: ref,
events: filteredEvents, events: filteredEvents,
@ -590,11 +677,16 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
if (isCancelled) return if (isCancelled) return
logger.error('Error fetching bookstr events', { error: err, wikilink }) logger.error('Error fetching bookstr events', { error: err, wikilink })
setError(err instanceof Error ? err.message : 'Failed to fetch book content') setError(err instanceof Error ? err.message : 'Failed to fetch book content')
// Mark all sections as loaded on error to stop spinners
setLoadingSections(new Set())
} finally { } finally {
if (!isCancelled) { if (!isCancelled) {
setIsLoading(false) setIsLoading(false)
} }
isFetchingRef.current = false isFetchingRef.current = false
if (loadingTimeout) {
clearTimeout(loadingTimeout)
}
} }
} }
@ -603,8 +695,11 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
return () => { return () => {
isCancelled = true isCancelled = true
isFetchingRef.current = false isFetchingRef.current = false
if (loadingTimeout) {
clearTimeout(loadingTimeout)
}
} }
}, [parsed]) // Depend on parsed directly - it's memoized and won't change unless wikilink meaningfully changes }, [wikilink]) // Depend on wikilink directly - it's a stable string, parsed is derived from it
// Measure card heights - measure BEFORE applying collapse // Measure card heights - measure BEFORE applying collapse
useEffect(() => { useEffect(() => {
@ -731,6 +826,10 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
// Only show button if card is actually tall (needs collapse) or is currently collapsed // Only show button if card is actually tall (needs collapse) or is currently collapsed
const shouldShowButton = filteredEvents.length > 0 && (needsCollapse || isCardCollapsed) const shouldShowButton = filteredEvents.length > 0 && (needsCollapse || isCardCollapsed)
// Check if this section is still loading
const refKey = `${section.reference.book}-${section.reference.chapter}-${section.reference.verse}`
const isSectionLoading = loadingSections.has(refKey)
// Debug logging // Debug logging
if (filteredEvents.length > 0) { if (filteredEvents.length > 0) {
logger.debug('BookstrContent: Card collapse check', { logger.debug('BookstrContent: Card collapse check', {
@ -772,7 +871,8 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
{section.reference.verse && `:${section.reference.verse}`} {section.reference.verse && `:${section.reference.verse}`}
{selectedVersion && ` (${selectedVersion})`} {selectedVersion && ` (${selectedVersion})`}
</h4> </h4>
{filteredEvents.length === 0 && ( {/* Only show spinner if section is still loading AND has no events */}
{isSectionLoading && filteredEvents.length === 0 && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" /> <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)} )}
<VersionSelector <VersionSelector
@ -1064,14 +1164,48 @@ interface VersionSelectorProps {
} }
function VersionSelector({ section, selectedVersion, onVersionChange }: VersionSelectorProps) { function VersionSelector({ section, selectedVersion, onVersionChange }: VersionSelectorProps) {
// Sync availableVersions with section.versions when section updates
const [availableVersions, setAvailableVersions] = useState<string[]>(section.versions) const [availableVersions, setAvailableVersions] = useState<string[]>(section.versions)
const [isLoadingVersions, setIsLoadingVersions] = useState(false)
// When component mounts or section changes, try to fetch more versions if needed // Update availableVersions when section.versions changes (from parent fetches)
// Use a ref to track the last versions to avoid unnecessary updates
const lastVersionsRef = useRef<string>('')
useEffect(() => { useEffect(() => {
const fetchAvailableVersions = async () => { const versionsKey = JSON.stringify([...section.versions].sort())
if (availableVersions.length > 1) return // Already have multiple versions if (versionsKey !== lastVersionsRef.current && section.versions.length > availableVersions.length) {
lastVersionsRef.current = versionsKey
setAvailableVersions(section.versions)
}
}, [section.versions, availableVersions.length])
// DISABLED: Version fetching is causing loops. Use versions from parent only.
// Just sync with parent versions
useEffect(() => {
// COMPLETELY DISABLE VERSION FETCHING TO PREVENT LOOPS
// Just use the versions we already have from the parent
if (availableVersions.length === 0 && section.versions.length > 0) {
setAvailableVersions(section.versions)
}
/* DISABLED CODE - was causing infinite loops
// Reset fetch state if section reference changed
if (lastFetchKeyRef.current !== fetchKey) {
hasFetchedRef.current = false
}
// Skip if we've already fetched for this exact section
if (hasFetchedRef.current && lastFetchKeyRef.current === fetchKey) {
return
}
// Skip if we already have multiple versions
if (availableVersions.length > 1) {
hasFetchedRef.current = true
lastFetchKeyRef.current = fetchKey
return
}
const fetchAvailableVersions = async () => {
setIsLoadingVersions(true) setIsLoadingVersions(true)
try { try {
// Query for all versions of this book/chapter/verse // Query for all versions of this book/chapter/verse
@ -1094,15 +1228,23 @@ function VersionSelector({ section, selectedVersion, onVersionChange }: VersionS
if (versions.size > availableVersions.length) { if (versions.size > availableVersions.length) {
setAvailableVersions(Array.from(versions).sort()) setAvailableVersions(Array.from(versions).sort())
} }
// Mark as fetched for this section
hasFetchedRef.current = true
lastFetchKeyRef.current = fetchKey
} catch (err) { } catch (err) {
logger.warn('Error fetching available versions', { error: err }) logger.warn('Error fetching available versions', { error: err })
// Mark as fetched even on error to prevent retry loops
hasFetchedRef.current = true
lastFetchKeyRef.current = fetchKey
} finally { } finally {
setIsLoadingVersions(false) setIsLoadingVersions(false)
} }
} }
fetchAvailableVersions() fetchAvailableVersions()
}, [section.reference.book, section.reference.chapter, section.reference.verse, availableVersions.length]) */
}, [section.reference.book, section.reference.chapter, section.reference.verse, section.versions, availableVersions.length])
// Don't show selector if only one version available // Don't show selector if only one version available
if (availableVersions.length <= 1) { if (availableVersions.length <= 1) {
@ -1113,7 +1255,6 @@ function VersionSelector({ section, selectedVersion, onVersionChange }: VersionS
<Select <Select
value={selectedVersion} value={selectedVersion}
onValueChange={onVersionChange} onValueChange={onVersionChange}
disabled={isLoadingVersions}
> >
<SelectTrigger className="h-6 w-auto px-2 text-xs"> <SelectTrigger className="h-6 w-auto px-2 text-xs">
<SelectValue /> <SelectValue />

96
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -344,6 +344,28 @@ export default function AsciidocArticle({
// Normalize excessive newlines (reduce 3+ to 2) // Normalize excessive newlines (reduce 3+ to 2)
content = content.replace(/\n\s*\n\s*\n+/g, '\n\n') content = content.replace(/\n\s*\n\s*\n+/g, '\n\n')
// PROTECT WIKILINKS FIRST before any other processing
// This prevents AsciiDoc or other processors from converting them to regular links
// First, protect bookstr wikilinks by converting them to passthrough format
// Don't use [[...]] inside passthrough as AsciiDoc processes it - use a plain marker instead
content = content.replace(/\[\[book::([^\]]+)\]\]/g, (_match, bookContent) => {
const cleanContent = bookContent.trim()
// Use AsciiDoc passthrough without brackets - AsciiDoc processes [[...]] even in passthrough
// Use a unique marker format that won't conflict with other content
return `+++BOOKSTR_MARKER:${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
content = content.replace(/\[\[([^\]]+)\]\]/g, (_match, linkContent) => {
// Skip if this was already processed as a bookstr wikilink (shouldn't happen, but safety check)
if (linkContent.startsWith('book::')) {
return _match
}
// Convert to AsciiDoc passthrough format so it's preserved
return `+++WIKILINK:${linkContent}+++`
})
// Convert all markdown syntax to AsciiDoc syntax // Convert all markdown syntax to AsciiDoc syntax
content = convertMarkdownToAsciidoc(content) content = convertMarkdownToAsciidoc(content)
@ -601,6 +623,17 @@ export default function AsciidocArticle({
let htmlString = typeof html === 'string' ? html : html.toString() let htmlString = typeof html === 'string' ? html : html.toString()
// Debug: log HTML to check if passthrough markers are preserved
if (process.env.NODE_ENV === 'development') {
const hasBookstrMarker = htmlString.includes('BOOKSTR_START') || htmlString.includes('BOOKSTR')
const hasWikilinkMarker = htmlString.includes('WIKILINK')
logger.debug('AsciidocArticle: HTML contains markers', {
hasBookstrMarker,
hasWikilinkMarker,
htmlPreview: htmlString.substring(0, 2000)
})
}
// Note: Markdown is now converted to AsciiDoc in preprocessing, // Note: Markdown is now converted to AsciiDoc in preprocessing,
// so post-processing markdown should not be necessary // so post-processing markdown should not be necessary
@ -691,27 +724,44 @@ export default function AsciidocArticle({
}) })
// Handle bookstr markers - convert passthrough markers to placeholders // Handle bookstr markers - convert passthrough markers to placeholders
// AsciiDoc passthrough +++BOOKSTR_START:...:BOOKSTR_END+++ outputs BOOKSTR_START:...:BOOKSTR_END in HTML // AsciiDoc passthrough +++BOOKSTR_MARKER:...:BOOKSTR_END+++ outputs BOOKSTR_MARKER:...:BOOKSTR_END in HTML
// Match the delimited format to extract the exact content (non-greedy to stop at :BOOKSTR_END) // Match the delimited format to extract the exact content
htmlString = htmlString.replace(/BOOKSTR_START:(.+?):BOOKSTR_END/g, (_match, bookContent) => { // IMPORTANT: Process this BEFORE any other pattern matching
htmlString = htmlString.replace(/BOOKSTR_MARKER:\s*(.+?)\s*:BOOKSTR_END/g, (_match, bookContent) => {
// Trim whitespace and escape special characters for HTML attributes // Trim whitespace and escape special characters for HTML attributes
const cleanContent = bookContent.trim() const cleanContent = bookContent.trim()
const escaped = cleanContent.replace(/"/g, '&quot;').replace(/'/g, '&#39;') const escaped = cleanContent.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
logger.debug('BookstrContent: Found bookstr marker in HTML', { cleanContent, escaped })
return `<span data-bookstr="${escaped}" class="bookstr-placeholder"></span>`
})
// Also handle if AsciiDoc converted it to WIKILINK: format (fallback)
htmlString = htmlString.replace(/WIKILINK:bookstr::([^<>\s]+)/g, (_match, bookContent) => {
const cleanContent = bookContent.trim()
const escaped = cleanContent.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
logger.debug('BookstrContent: Found bookstr in WIKILINK format', { cleanContent, escaped })
return `<span data-bookstr="${escaped}" class="bookstr-placeholder"></span>` return `<span data-bookstr="${escaped}" class="bookstr-placeholder"></span>`
}) })
// Handle wikilinks - convert passthrough markers to placeholders // Handle wikilinks - convert passthrough markers to placeholders
// AsciiDoc passthrough +++WIKILINK:link|display+++ outputs just WIKILINK:link|display in HTML // 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 // Match WIKILINK: followed by any characters (including |) until end of text or HTML tag
// IMPORTANT: Skip any [[bookstr::...]] patterns that might have been missed
htmlString = htmlString.replace(/WIKILINK:([^<>\s]+)/g, (_match, linkContent) => { htmlString = htmlString.replace(/WIKILINK:([^<>\s]+)/g, (_match, linkContent) => {
// Skip if this is a bookstr wikilink
if (linkContent.includes('bookstr::')) {
return _match
}
// Escape special characters for HTML attributes // Escape special characters for HTML attributes
const escaped = linkContent.replace(/"/g, '&quot;').replace(/'/g, '&#39;') const escaped = linkContent.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
return `<span data-wikilink="${escaped}" class="wikilink-placeholder"></span>` return `<span data-wikilink="${escaped}" class="wikilink-placeholder"></span>`
}) })
// Handle YouTube URLs and relay URLs in links // Handle YouTube URLs and relay URLs in links
// Also check for bookstr content that might have been converted to links
// Only replace links that need special handling - leave AsciiDoc-generated links alone // Only replace links that need special handling - leave AsciiDoc-generated links alone
const linkMatches: Array<{ match: string; href: string; linkText: string; index: number }> = [] const linkMatches: Array<{ match: string; href: string; linkText: string; index: number }> = []
const bookstrLinkMatches: Array<{ match: string; bookContent: string; index: number }> = []
const linkRegex = /<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/g const linkRegex = /<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/g
let linkMatch let linkMatch
while ((linkMatch = linkRegex.exec(htmlString)) !== null) { while ((linkMatch = linkRegex.exec(htmlString)) !== null) {
@ -720,6 +770,17 @@ export default function AsciidocArticle({
const linkText = linkMatch[2] const linkText = linkMatch[2]
const index = linkMatch.index const index = linkMatch.index
// Check if this link contains bookstr content (might have been converted by AsciiDoc)
if (linkText.includes('bookstr::') || href.includes('bookstr::')) {
// Extract bookstr content from link text or href
const bookstrMatch = linkText.match(/bookstr::([^\]]+)/) || href.match(/bookstr::([^\]]+)/)
if (bookstrMatch) {
const bookContent = bookstrMatch[1].trim()
bookstrLinkMatches.push({ match, bookContent, index })
continue
}
}
// Only process links that need special handling (YouTube, relay URLs) // Only process links that need special handling (YouTube, relay URLs)
// Leave regular HTTP/HTTPS links as-is since AsciiDoc already formatted them correctly // Leave regular HTTP/HTTPS links as-is since AsciiDoc already formatted them correctly
if (isYouTubeUrl(href) || isWebsocketUrl(href)) { if (isYouTubeUrl(href) || isWebsocketUrl(href)) {
@ -727,6 +788,16 @@ export default function AsciidocArticle({
} }
} }
// Replace bookstr links in reverse order to preserve indices
for (let i = bookstrLinkMatches.length - 1; i >= 0; i--) {
const { match, bookContent, index } = bookstrLinkMatches[i]
const escaped = bookContent.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
logger.debug('BookstrContent: Found bookstr in converted link', { bookContent, escaped })
htmlString = htmlString.substring(0, index) +
`<span data-bookstr="${escaped}" class="bookstr-placeholder"></span>` +
htmlString.substring(index + match.length)
}
// Replace only special links in reverse order to preserve indices // Replace only special links in reverse order to preserve indices
for (let i = linkMatches.length - 1; i >= 0; i--) { for (let i = linkMatches.length - 1; i >= 0; i--) {
const { match, href, linkText } = linkMatches[i] const { match, href, linkText } = linkMatches[i]
@ -993,15 +1064,23 @@ export default function AsciidocArticle({
const parent = element.parentElement const parent = element.parentElement
if (parent) { if (parent) {
const existingContainer = parent.querySelector(`.bookstr-container[data-bookstr-key="${placeholderKey}"]`) const existingContainer = parent.querySelector(`.bookstr-container[data-bookstr-key="${placeholderKey}"]`)
if (existingContainer && reactRootsRef.current.has(existingContainer)) { if (existingContainer) {
// Container already exists with a React root, just remove this duplicate placeholder // Container already exists - check if it has a React root
if (reactRootsRef.current.has(existingContainer)) {
// Already has a React root, just remove this duplicate placeholder
element.remove() element.remove()
return return
} else {
// Container exists but no root - this shouldn't happen, but clean it up
existingContainer.remove()
}
} }
} }
// Skip if already processed (to avoid duplicate processing) // Skip if already processed (to avoid duplicate processing)
if (processedPlaceholdersRef.current.has(placeholderKey)) { if (processedPlaceholdersRef.current.has(placeholderKey)) {
// If we've processed this but the element still exists, remove it
element.remove()
return return
} }
@ -1011,16 +1090,21 @@ export default function AsciidocArticle({
// Prepend book:: prefix since BookstrContent expects it // Prepend book:: prefix since BookstrContent expects it
const wikilink = `book::${bookstrContent}` const wikilink = `book::${bookstrContent}`
logger.debug('BookstrContent: Rendering component', { bookstrContent, wikilink })
// Create a container for React component // Create a container for React component
const container = document.createElement('div') const container = document.createElement('div')
container.className = 'bookstr-container' container.className = 'bookstr-container'
container.setAttribute('data-bookstr-key', placeholderKey) container.setAttribute('data-bookstr-key', placeholderKey)
element.parentNode?.replaceChild(container, element) element.parentNode?.replaceChild(container, element)
// Use React to render the component // Use React to render the component - only render once per container
// Check if this container already has a root to avoid re-rendering
if (!reactRootsRef.current.has(container)) {
const root = createRoot(container) const root = createRoot(container)
root.render(<BookstrContent wikilink={wikilink} />) root.render(<BookstrContent wikilink={wikilink} />)
reactRootsRef.current.set(container, root) reactRootsRef.current.set(container, root)
}
}) })
// Process wikilinks - replace placeholders with React components // Process wikilinks - replace placeholders with React components

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

@ -112,33 +112,29 @@ export function preprocessMarkdownMediaLinks(content: string): string {
export function preprocessAsciidocMediaLinks(content: string): string { export function preprocessAsciidocMediaLinks(content: string): string {
let processed = content let processed = content
// First, protect bookstr wikilinks by converting them to passthrough format // Note: Wikilinks are now processed in AsciidocArticle.tsx BEFORE this function is called
// Process bookstr wikilinks BEFORE regular wikilinks to avoid conflicts // to prevent AsciiDoc from converting them to regular links. We skip wikilink processing here.
// Skip any remaining wikilinks (they should already be processed, but safety check)
// Check for passthrough markers to avoid double-processing
if (processed.includes('BOOKSTR_START:') || processed.includes('WIKILINK:')) {
// Wikilinks already processed, skip
} else {
// Fallback: protect bookstr wikilinks if they weren't processed yet
processed = processed.replace(/\[\[book::([^\]]+)\]\]/g, (_match, bookContent) => { processed = processed.replace(/\[\[book::([^\]]+)\]\]/g, (_match, bookContent) => {
const cleanContent = bookContent.trim() const cleanContent = bookContent.trim()
// Use AsciiDoc passthrough to preserve the marker through AsciiDoc processing return `+++BOOKSTR_MARKER:${cleanContent}:BOOKSTR_END+++`
// 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 // Fallback: protect regular wikilinks if they weren't processed yet
// This prevents AsciiDoc from processing them and prevents URLs inside from being processed processed = processed.replace(/\[\[([^\]]+)\]\]/g, (_match, linkContent) => {
const wikilinkRegex = /\[\[([^\]]+)\]\]/g // Skip if this was already processed as a bookstr wikilink
const wikilinkRanges: Array<{ start: number; end: number }> = [] if (linkContent.startsWith('book::')) {
const wikilinkMatches = Array.from(processed.matchAll(wikilinkRegex)) return _match
wikilinkMatches.forEach(match => {
if (match.index !== undefined) {
wikilinkRanges.push({
start: match.index,
end: match.index + match[0].length
})
} }
})
processed = processed.replace(wikilinkRegex, (_match, linkContent) => {
// Convert to AsciiDoc passthrough format so it's preserved
return `+++WIKILINK:${linkContent}+++` return `+++WIKILINK:${linkContent}+++`
}) })
}
// Find all URLs but process them in reverse order to preserve indices // Find all URLs but process them in reverse order to preserve indices
const allMatches: Array<{ url: string; index: number }> = [] const allMatches: Array<{ url: string; index: number }> = []
@ -150,11 +146,12 @@ export function preprocessAsciidocMediaLinks(content: string): string {
const url = match[0] const url = match[0]
const urlEnd = index + url.length const urlEnd = index + url.length
// Skip URLs that are inside wikilinks // Skip URLs that are inside wikilinks (already processed as passthrough markers)
const isInWikilink = wikilinkRanges.some(range => // Check if URL is inside a passthrough marker
index >= range.start && urlEnd <= range.end const beforeUrl = content.substring(Math.max(0, index - 100), index)
) const afterUrl = content.substring(urlEnd, Math.min(content.length, urlEnd + 100))
if (isInWikilink) { if (beforeUrl.includes('BOOKSTR_START:') || beforeUrl.includes('WIKILINK:') ||
afterUrl.includes(':BOOKSTR_END') || afterUrl.includes('+++')) {
continue continue
} }

Loading…
Cancel
Save