{
+ if (!currentViewAddress) {
+ // No current view specified - viewing publication/blog index
+ // For blogs, don't show highlights on the index - the text is in the articles, not the index
+ if (publicationType === "blog") {
+ return [];
+ }
+ // For regular publications, show all highlights
+ return highlights;
+ }
+
+ // Check if currentViewAddress is a blog entry (30041) or section
+ const isBlogEntry = currentViewAddress.startsWith('30041:');
+
+ // Filter highlights to only those scoped to the current view
+ // OR to the root publication if viewing a blog entry
+ return highlights.filter((highlight) => {
+ const aTag = highlight.tags.find((tag) => tag[0] === "a");
+ if (!aTag) return false;
+
+ const highlightAddress = aTag[1];
+
+ // Match if highlight is scoped to current view
+ if (highlightAddress === currentViewAddress) {
+ return true;
+ }
+
+ // If viewing a blog entry (30041), also show highlights scoped to the parent publication (30040)
+ if (isBlogEntry && rootAddress && highlightAddress === rootAddress) {
+ return true;
+ }
+
+ return false;
+ });
+ });
+
+ // Derived state for grouped highlights (using filtered highlights)
let groupedHighlights = $derived.by(() => {
- return groupHighlightsByAuthor(highlights);
+ return groupHighlightsByAuthor(filteredHighlights);
});
/**
@@ -327,46 +375,256 @@
let sectionElement: HTMLElement | null = null;
if (targetAddress) {
- // Search in entire document, not just containerRef
- sectionElement = document.getElementById(targetAddress);
- if (sectionElement) {
- // AI-NOTE: The actual content is in a nested
- // created by contentParagraph snippet. Look for that nested section first.
- const contentSection = sectionElement.querySelector('section.publication-leather');
- if (contentSection) {
- searchRoot = contentSection as HTMLElement;
- console.debug(`[HighlightLayer] Found nested content section for ${targetAddress}, searching for text: "${text.substring(0, 50)}"`);
+ // Check if this is a publication address (30040) or section address (30041)
+ const isPublicationAddress = targetAddress.startsWith('30040:');
+
+ if (isPublicationAddress) {
+ // For publication-scoped highlights, search in all sections of that publication
+ // Find all section elements that belong to this publication
+ const allSections = document.querySelectorAll('section[id^="30041:"], section[id^="30040:"]');
+ const matchingSections: HTMLElement[] = [];
+
+ // Extract publication identifier from target address (pubkey:d-tag)
+ const pubParts = targetAddress.split(':');
+ if (pubParts.length >= 3) {
+ const pubKey = pubParts[1];
+ const pubDtag = pubParts[2];
+
+ // Find sections that belong to this publication
+ for (const section of allSections) {
+ const sectionId = section.id;
+ const sectionParts = sectionId.split(':');
+ if (sectionParts.length >= 3 && sectionParts[1] === pubKey) {
+ // This section belongs to the same publication
+ matchingSections.push(section as HTMLElement);
+ }
+ }
+ }
+
+ if (matchingSections.length > 0) {
+ // Create a container to search across all matching sections
+ // We'll search each section individually
+ console.debug(`[HighlightLayer] Found ${matchingSections.length} sections for publication ${targetAddress}, searching for text: "${text.substring(0, 50)}"`);
+
+ // Search in each matching section
+ for (const section of matchingSections) {
+ const contentSection = section.querySelector('section.publication-leather');
+ const searchTarget = (contentSection || section.querySelector('.section-content') || section) as HTMLElement;
+
+ if (searchTarget) {
+ // Use TreeWalker to find text in this section
+ const walker = document.createTreeWalker(
+ searchTarget,
+ NodeFilter.SHOW_TEXT,
+ null,
+ );
+
+ const textNodes: Node[] = [];
+ let node: Node | null;
+ while ((node = walker.nextNode())) {
+ textNodes.push(node);
+ }
+
+ // Search for the text in this section's text nodes
+ const normalizedSearchText = text.trim().replace(/\s+/g, ' ').toLowerCase();
+
+ for (const textNode of textNodes) {
+ const nodeText = textNode.textContent || "";
+ if (!nodeText || nodeText.trim().length === 0) continue;
+
+ const normalizedNodeText = nodeText.replace(/\s+/g, ' ').toLowerCase();
+
+ if (normalizedNodeText.includes(normalizedSearchText)) {
+ // Found match - highlight it
+ let index = normalizedNodeText.indexOf(normalizedSearchText);
+ if (index !== -1) {
+ const searchPattern = text.trim();
+ let actualIndex = nodeText.toLowerCase().indexOf(searchPattern.toLowerCase());
+
+ if (actualIndex === -1) {
+ // Try flexible whitespace matching
+ const escapedText = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const flexiblePattern = escapedText.split(/\s+/).join('\\s+');
+ const regex = new RegExp(flexiblePattern, 'i');
+ const regexMatch = nodeText.match(regex);
+ if (regexMatch && regexMatch.index !== undefined) {
+ actualIndex = regexMatch.index;
+ const actualMatchText = regexMatch[0];
+
+ const parent = textNode.parentNode;
+ if (!parent) continue;
+
+ if (
+ parent.nodeName === "MARK" ||
+ (parent instanceof Element && parent.classList?.contains("highlight"))
+ ) {
+ continue;
+ }
+
+ const before = nodeText.substring(0, actualIndex);
+ const after = nodeText.substring(actualIndex + actualMatchText.length);
+
+ const highlightSpan = document.createElement("mark");
+ highlightSpan.className = "highlight";
+ highlightSpan.style.backgroundColor = color;
+ highlightSpan.style.borderRadius = "2px";
+ highlightSpan.style.padding = "2px 0";
+ highlightSpan.style.color = "inherit";
+ highlightSpan.style.fontWeight = "inherit";
+ highlightSpan.textContent = actualMatchText;
+
+ const fragment = document.createDocumentFragment();
+ if (before) fragment.appendChild(document.createTextNode(before));
+ fragment.appendChild(highlightSpan);
+ if (after) fragment.appendChild(document.createTextNode(after));
+
+ parent.replaceChild(fragment, textNode);
+
+ console.debug(`[HighlightLayer] Successfully highlighted text in publication-scoped highlight`);
+
+ if (targetAddress) {
+ const retryKey = `${targetAddress}:${text}`;
+ textHighlightRetries.delete(retryKey);
+ }
+
+ return; // Found and highlighted, done
+ }
+ } else {
+ // Found exact match
+ const parent = textNode.parentNode;
+ if (!parent) continue;
+
+ if (
+ parent.nodeName === "MARK" ||
+ (parent instanceof Element && parent.classList?.contains("highlight"))
+ ) {
+ continue;
+ }
+
+ const matchLength = text.length;
+ const before = nodeText.substring(0, actualIndex);
+ const match = nodeText.substring(actualIndex, actualIndex + matchLength);
+ const after = nodeText.substring(actualIndex + matchLength);
+
+ const highlightSpan = document.createElement("mark");
+ highlightSpan.className = "highlight";
+ highlightSpan.style.backgroundColor = color;
+ highlightSpan.style.borderRadius = "2px";
+ highlightSpan.style.padding = "2px 0";
+ highlightSpan.style.color = "inherit";
+ highlightSpan.style.fontWeight = "inherit";
+ highlightSpan.textContent = match;
+
+ const fragment = document.createDocumentFragment();
+ if (before) fragment.appendChild(document.createTextNode(before));
+ fragment.appendChild(highlightSpan);
+ if (after) fragment.appendChild(document.createTextNode(after));
+
+ parent.replaceChild(fragment, textNode);
+
+ console.debug(`[HighlightLayer] Successfully highlighted text in publication-scoped highlight`);
+
+ if (targetAddress) {
+ const retryKey = `${targetAddress}:${text}`;
+ textHighlightRetries.delete(retryKey);
+ }
+
+ return; // Found and highlighted, done
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // If we get here, we searched all sections but didn't find the text
+ // This might mean the content isn't loaded yet, so we'll retry
+ const retryKey = `${targetAddress}:${text}`;
+ const currentRetries = textHighlightRetries.get(retryKey) || 0;
+ if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
+ textHighlightRetries.set(retryKey, currentRetries + 1);
+ const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
+ console.debug(`[HighlightLayer] Text not found in publication sections yet, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES})`);
+ setTimeout(() => {
+ findAndHighlightText(text, color, targetAddress, retryCount + 1);
+ }, delay);
+ return;
+ } else {
+ // Only warn if we truly couldn't find it after all retries
+ // Check if highlight was actually rendered before warning
+ const queryRoot = containerRef || document;
+ const existingHighlights = queryRoot.querySelectorAll("mark.highlight");
+ const highlightFound = Array.from(existingHighlights).some(mark =>
+ mark.textContent?.toLowerCase().includes(text.toLowerCase())
+ );
+ if (!highlightFound) {
+ console.debug(`[HighlightLayer] Text "${text}" not found in publication sections after ${MAX_TEXT_HIGHLIGHT_RETRIES} retries (content may not be loaded yet)`);
+ }
+ return;
+ }
} else {
- // Fallback to .section-content div if nested section not found
- const contentDiv = sectionElement.querySelector(".section-content");
- if (contentDiv) {
- searchRoot = contentDiv as HTMLElement;
- console.debug(`[HighlightLayer] Found section-content div for ${targetAddress}, searching for text: "${text.substring(0, 50)}"`);
+ // No sections found for this publication - might not be loaded yet
+ const retryKey = `${targetAddress}:${text}`;
+ const currentRetries = textHighlightRetries.get(retryKey) || 0;
+ if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
+ textHighlightRetries.set(retryKey, currentRetries + 1);
+ const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
+ console.debug(`[HighlightLayer] No sections found for publication ${targetAddress}, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES})`);
+ setTimeout(() => {
+ findAndHighlightText(text, color, targetAddress, retryCount + 1);
+ }, delay);
+ return;
} else {
- // Last resort: search entire section
- searchRoot = sectionElement;
- console.debug(`[HighlightLayer] Found section element for ${targetAddress} (no nested content found), searching for text: "${text.substring(0, 50)}"`);
+ // Only warn if we truly couldn't find sections after all retries
+ console.debug(`[HighlightLayer] No sections found for publication ${targetAddress} after ${MAX_TEXT_HIGHLIGHT_RETRIES} retries (sections may not be loaded yet)`);
+ return;
}
}
} else {
- // Section not found - might not be loaded yet, retry if we haven't exceeded max retries
- const retryKey = `${targetAddress}:${text}`;
- const currentRetries = textHighlightRetries.get(retryKey) || 0;
- if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
- textHighlightRetries.set(retryKey, currentRetries + 1);
- const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
- console.debug(`[HighlightLayer] Section element not found for ${targetAddress}, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES})`);
- setTimeout(() => {
- findAndHighlightText(text, color, targetAddress, retryCount + 1);
- }, delay);
- return;
+ // Section-scoped highlight - search in specific section element
+ sectionElement = document.getElementById(targetAddress);
+ if (sectionElement) {
+ // AI-NOTE: The actual content is in a nested
+ // created by contentParagraph snippet. Look for that nested section first.
+ const contentSection = sectionElement.querySelector('section.publication-leather');
+ if (contentSection) {
+ searchRoot = contentSection as HTMLElement;
+ console.debug(`[HighlightLayer] Found nested content section for ${targetAddress}, searching for text: "${text.substring(0, 50)}"`);
+ } else {
+ // Fallback to .section-content div if nested section not found
+ const contentDiv = sectionElement.querySelector(".section-content");
+ if (contentDiv) {
+ searchRoot = contentDiv as HTMLElement;
+ console.debug(`[HighlightLayer] Found section-content div for ${targetAddress}, searching for text: "${text.substring(0, 50)}"`);
+ } else {
+ // Last resort: search entire section
+ searchRoot = sectionElement;
+ console.debug(`[HighlightLayer] Found section element for ${targetAddress} (no nested content found), searching for text: "${text.substring(0, 50)}"`);
+ }
+ }
} else {
- console.warn(`[HighlightLayer] Section element not found for ${targetAddress} after ${MAX_TEXT_HIGHLIGHT_RETRIES} retries, giving up.`);
- return;
+ // Section not found - might not be loaded yet, retry if we haven't exceeded max retries
+ const retryKey = `${targetAddress}:${text}`;
+ const currentRetries = textHighlightRetries.get(retryKey) || 0;
+ if (currentRetries < MAX_TEXT_HIGHLIGHT_RETRIES) {
+ textHighlightRetries.set(retryKey, currentRetries + 1);
+ const delay = TEXT_HIGHLIGHT_RETRY_DELAYS[Math.min(currentRetries, TEXT_HIGHLIGHT_RETRY_DELAYS.length - 1)];
+ console.debug(`[HighlightLayer] Section element not found for ${targetAddress}, retrying in ${delay}ms (attempt ${currentRetries + 1}/${MAX_TEXT_HIGHLIGHT_RETRIES})`);
+ setTimeout(() => {
+ findAndHighlightText(text, color, targetAddress, retryCount + 1);
+ }, delay);
+ return;
+ } else {
+ // Only warn if we truly couldn't find the section after all retries
+ console.debug(`[HighlightLayer] Section element not found for ${targetAddress} after ${MAX_TEXT_HIGHLIGHT_RETRIES} retries (section may not be loaded yet)`);
+ return;
+ }
}
}
- } else {
- // No target address - use containerRef if available, otherwise document
+ }
+
+ // If no target address, use containerRef if available, otherwise document
+ if (!targetAddress) {
if (containerRef) {
searchRoot = containerRef;
console.debug(`[HighlightLayer] No target address, searching in containerRef for text: "${text.substring(0, 50)}"`);
@@ -389,11 +647,12 @@
// AI-NOTE: First, check if the text exists in the full content
// This helps us know if we should continue searching
+ // Normalize whitespace for matching - highlights may have different whitespace than DOM
const fullText = searchRoot instanceof HTMLElement
? searchRoot.textContent || searchRoot.innerText || ""
: "";
- const normalizedSearchText = text.trim().toLowerCase();
- const normalizedFullText = fullText.toLowerCase();
+ const normalizedSearchText = text.trim().replace(/\s+/g, ' ').toLowerCase();
+ const normalizedFullText = fullText.replace(/\s+/g, ' ').toLowerCase();
// AI-NOTE: If content is empty or very short, the section might still be loading
// Check if we have meaningful content (more than just whitespace/formatting)
@@ -429,7 +688,9 @@
return;
}
}
- console.warn(
+ // Only warn if we truly couldn't find the text after checking
+ // This might be a false negative if content is still loading
+ console.debug(
`[HighlightLayer] Text "${text}" not found in full content. Full text (first 200 chars): "${fullText.substring(0, 200)}"`,
);
return;
@@ -453,7 +714,7 @@
console.log(`[HighlightLayer] Sample text nodes (first 20 non-empty):`, sampleNodes.map(n => `"${n.textContent?.substring(0, 50)}${(n.textContent?.length || 0) > 50 ? '...' : ''}"`));
// Search for the highlight text in text nodes
- // AI-NOTE: Use simple indexOf for exact matching - the text should match exactly
+ // normalizedSearchText is already defined above with whitespace normalization
for (let i = 0; i < textNodes.length; i++) {
const textNode = textNodes[i];
const nodeText = textNode.textContent || "";
@@ -461,19 +722,105 @@
continue; // Skip empty text nodes
}
+ // Normalize node text for comparison (collapse whitespace)
+ const normalizedNodeText = nodeText.replace(/\s+/g, ' ').toLowerCase();
+
// Log every text node that contains the search text (for debugging)
- if (nodeText.toLowerCase().includes(text.toLowerCase())) {
- console.log(`[HighlightLayer] Text node ${i} contains search text: "${nodeText}"`);
+ if (normalizedNodeText.includes(normalizedSearchText)) {
+ console.log(`[HighlightLayer] Text node ${i} contains search text: "${nodeText.substring(0, 100)}${nodeText.length > 100 ? '...' : ''}"`);
+ }
+
+ // Try normalized match first (case-insensitive, whitespace-normalized)
+ let index = normalizedNodeText.indexOf(normalizedSearchText);
+
+ // If normalized match found, find the actual position in the original text
+ // by searching for the normalized pattern in the original text
+ if (index !== -1) {
+ // Find the actual start position in the original text
+ // We need to account for whitespace differences
+ const searchPattern = text.trim();
+ let actualIndex = nodeText.toLowerCase().indexOf(searchPattern.toLowerCase());
+
+ // If that doesn't work, try finding by character position accounting for whitespace
+ if (actualIndex === -1) {
+ // Build a regex that matches the text with flexible whitespace
+ const escapedText = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const flexiblePattern = escapedText.split(/\s+/).join('\\s+');
+ const regex = new RegExp(flexiblePattern, 'i');
+ const regexMatch = nodeText.match(regex);
+ if (regexMatch && regexMatch.index !== undefined) {
+ actualIndex = regexMatch.index;
+ // Update the search text to the actual matched text for highlighting
+ const actualMatchText = regexMatch[0];
+
+ // Use the actual matched text length for highlighting
+ const before = nodeText.substring(0, actualIndex);
+ const matchedText = actualMatchText;
+ const after = nodeText.substring(actualIndex + actualMatchText.length);
+
+ const parent = textNode.parentNode;
+ if (!parent) {
+ console.warn(`[HighlightLayer] Text node has no parent, skipping`);
+ continue;
+ }
+
+ // Skip if already highlighted
+ if (
+ parent.nodeName === "MARK" ||
+ (parent instanceof Element && parent.classList?.contains("highlight"))
+ ) {
+ console.debug(`[HighlightLayer] Text node already highlighted, skipping`);
+ continue;
+ }
+
+ console.debug(`[HighlightLayer] Found match at index ${actualIndex}: "${matchedText}" in node: "${nodeText.substring(0, 100)}${nodeText.length > 100 ? '...' : ''}"`);
+
+ // Create highlight span with visible styling
+ const highlightSpan = document.createElement("mark");
+ highlightSpan.className = "highlight";
+ highlightSpan.style.backgroundColor = color;
+ highlightSpan.style.borderRadius = "2px";
+ highlightSpan.style.padding = "2px 0";
+ highlightSpan.style.color = "inherit";
+ highlightSpan.style.fontWeight = "inherit";
+ highlightSpan.textContent = matchedText;
+
+ // Replace the text node with the highlighted version
+ const fragment = document.createDocumentFragment();
+ if (before) fragment.appendChild(document.createTextNode(before));
+ fragment.appendChild(highlightSpan);
+ if (after) fragment.appendChild(document.createTextNode(after));
+
+ parent.replaceChild(fragment, textNode);
+
+ console.debug(
+ `[HighlightLayer] Successfully highlighted text "${matchedText}" at index ${actualIndex} in node with text: "${nodeText.substring(0, 50)}${nodeText.length > 50 ? "..." : ""}"`,
+ );
+
+ // Clear retry count on success
+ if (targetAddress) {
+ const retryKey = `${targetAddress}:${text}`;
+ textHighlightRetries.delete(retryKey);
+ }
+
+ return; // Only highlight first occurrence to avoid multiple highlights
+ }
+ } else {
+ // Found exact match (case-insensitive) - use it directly
+ index = actualIndex;
+ }
}
- // Try exact match first (case-sensitive)
- let index = nodeText.indexOf(text);
+ // Fallback: try exact match (case-sensitive) if normalized match failed
+ if (index === -1) {
+ index = nodeText.indexOf(text);
+ }
- // If exact match fails, try case-insensitive
+ // Fallback: try case-insensitive exact match
if (index === -1) {
- const normalizedNodeText = nodeText.toLowerCase();
- const normalizedSearchText = text.toLowerCase();
- index = normalizedNodeText.indexOf(normalizedSearchText);
+ const lowerNodeText = nodeText.toLowerCase();
+ const lowerSearchText = text.toLowerCase();
+ index = lowerNodeText.indexOf(lowerSearchText);
}
if (index !== -1) {
@@ -569,7 +916,7 @@
return;
}
- if (highlights.length === 0) {
+ if (filteredHighlights.length === 0) {
return;
}
@@ -591,8 +938,8 @@
});
}
- // Apply each highlight
- for (const highlight of highlights) {
+ // Apply each highlight (only filtered highlights for current view)
+ for (const highlight of filteredHighlights) {
const content = highlight.content;
const color = colorMap.get(highlight.pubkey) || "hsla(60, 70%, 60%, 0.5)";
@@ -634,10 +981,10 @@
);
// AI-NOTE: Debug logging to help diagnose highlight visibility issues
- if (renderedHighlights.length === 0 && highlights.length > 0) {
- console.warn(`[HighlightLayer] No highlights rendered despite ${highlights.length} highlights available. Container:`, containerRef, "Visible:", visible);
+ if (renderedHighlights.length === 0 && filteredHighlights.length > 0) {
+ console.warn(`[HighlightLayer] No highlights rendered despite ${filteredHighlights.length} filtered highlights available. Container:`, containerRef, "Visible:", visible, "CurrentView:", currentViewAddress);
// Log highlight details for debugging
- highlights.forEach((h, i) => {
+ filteredHighlights.forEach((h, i) => {
const aTag = h.tags.find((tag) => tag[0] === "a");
const offsetTag = h.tags.find((tag) => tag[0] === "offset");
console.debug(`[HighlightLayer] Highlight ${i}: content="${h.content.substring(0, 50)}", targetAddress="${aTag?.[1]}", hasOffset=${!!offsetTag}`);
@@ -647,11 +994,11 @@
/**
* Clear all highlights from the page
+ * AI-NOTE: If containerRef is not set (e.g., blog entries), clear from document
*/
function clearHighlights() {
- if (!containerRef) return;
-
- const highlightElements = containerRef.querySelectorAll("mark.highlight");
+ const queryRoot = containerRef || document;
+ const highlightElements = queryRoot.querySelectorAll("mark.highlight");
highlightElements.forEach((el) => {
const parent = el.parentNode;
if (parent) {
@@ -701,11 +1048,12 @@
// Watch for visibility AND highlights changes - render when both are ready
// AI-NOTE: Also watch containerRef to ensure it's set before rendering
+ // AI-NOTE: For blog entries viewed directly, containerRef might not be set, so we render without it
$effect(() => {
// This effect runs when either visible, highlights.length, or containerRef changes
- const highlightCount = highlights.length;
+ const highlightCount = filteredHighlights.length;
- if (visible && highlightCount > 0 && containerRef) {
+ if (visible && highlightCount > 0) {
// AI-NOTE: Retry rendering with increasing delays to handle async content loading
// This is especially important when viewing sections directly
let retryCount = 0;
@@ -878,7 +1226,7 @@
{/if}
@@ -1621,12 +1670,16 @@
{/if}
+
diff --git a/src/lib/components/publications/PublicationSection.svelte b/src/lib/components/publications/PublicationSection.svelte
index 50d02a9..1f50fe1 100644
--- a/src/lib/components/publications/PublicationSection.svelte
+++ b/src/lib/components/publications/PublicationSection.svelte
@@ -303,6 +303,27 @@
ref(sectionRef);
});
+
+ // Initialize ABC notation blocks after content is rendered
+ $effect(() => {
+ if (typeof window === "undefined") return;
+
+ // Watch for content changes
+ leafContent.then(() => {
+ // Wait for content to be rendered in DOM
+ const initABC = () => {
+ if (typeof (window as any).initializeABCBlocks === "function") {
+ (window as any).initializeABCBlocks();
+ } else {
+ // If function not available yet, wait a bit and try again
+ setTimeout(initABC, 100);
+ }
+ };
+
+ // Initialize after a short delay to ensure DOM is ready
+ setTimeout(initABC, 200);
+ });
+ });
diff --git a/src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts b/src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
index 14b2344..b83fcd4 100644
--- a/src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
+++ b/src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
@@ -8,6 +8,7 @@ import plantumlEncoder from "plantuml-encoder";
* - PlantUML diagrams
* - BPMN diagrams
* - TikZ diagrams
+ * - ABC notation (music)
*/
export async function postProcessAdvancedAsciidoctorHtml(
html: string,
@@ -25,6 +26,8 @@ export async function postProcessAdvancedAsciidoctorHtml(
processedHtml = processBPMNBlocks(processedHtml);
// Process TikZ blocks
processedHtml = processTikZBlocks(processedHtml);
+ // Process ABC notation blocks
+ processedHtml = processABCBlocks(processedHtml);
// After all processing, apply highlight.js if available
if (
typeof globalThis !== "undefined" &&
@@ -366,6 +369,147 @@ function processTikZBlocks(html: string): string {
return html;
}
+/**
+ * Processes ABC notation blocks in HTML content
+ * Uses data attributes to mark blocks for rendering, which will be processed by a global function
+ */
+function processABCBlocks(html: string): string {
+ // Match code blocks with class 'language-abc' or 'abc'
+ html = html.replace(
+ /
`;
+ } catch (error) {
+ console.warn("Failed to process ABC fallback block:", error);
+ return match;
+ }
+ }
+ return match;
+ },
+ );
+
+ return html;
+}
+
+/**
+ * Initializes ABC notation rendering for all blocks marked with data-abc-content
+ * This function is called after HTML is inserted into the DOM
+ */
+function initializeABCBlocks(): void {
+ if (typeof window === "undefined") return;
+
+ const abcBlocks = document.querySelectorAll('[data-abc-content]');
+ if (abcBlocks.length === 0) return;
+
+ // Load abcjs from CDN if not already loaded
+ if (typeof (window as any).ABCJS === "undefined") {
+ const script = document.createElement("script");
+ script.src = "https://cdn.jsdelivr.net/npm/abcjs@6.2.0/dist/abcjs-basic.min.js";
+ script.onload = () => {
+ renderAllABCBlocks();
+ };
+ script.onerror = () => {
+ console.warn("Failed to load abcjs library");
+ };
+ document.head.appendChild(script);
+ } else {
+ renderAllABCBlocks();
+ }
+
+ function renderAllABCBlocks(): void {
+ const abcjs = (window as any).ABCJS;
+ if (!abcjs) return;
+
+ abcBlocks.forEach((block) => {
+ const container = block as HTMLElement;
+ const abcContent = container.getAttribute("data-abc-content");
+ if (!abcContent) return;
+
+ // Decode HTML entities
+ const textarea = document.createElement("textarea");
+ textarea.innerHTML = abcContent;
+ const decodedContent = textarea.value;
+
+ try {
+ abcjs.renderAbc(container.id || container, decodedContent, {
+ responsive: "resize",
+ staffwidth: 740,
+ scale: 1.0,
+ paddingleft: 20,
+ paddingright: 20,
+ paddingtop: 20,
+ paddingbottom: 20,
+ });
+ // Remove data attribute after rendering to avoid re-rendering
+ container.removeAttribute("data-abc-content");
+ } catch (error) {
+ console.warn("Failed to render ABC notation:", error);
+ container.innerHTML = '
Error rendering ABC notation. Please check the source.
';
+ }
+ });
+ }
+}
+
+// Make initializeABCBlocks available globally so it can be called from Svelte components
+if (typeof window !== "undefined") {
+ (window as any).initializeABCBlocks = initializeABCBlocks;
+}
+
/**
* Escapes HTML characters for safe display
*/
diff --git a/src/lib/utils/markup/asciidoctorExtensions.ts b/src/lib/utils/markup/asciidoctorExtensions.ts
index 0a7b646..e4651d1 100644
--- a/src/lib/utils/markup/asciidoctorExtensions.ts
+++ b/src/lib/utils/markup/asciidoctorExtensions.ts
@@ -76,6 +76,7 @@ export function createAdvancedExtensions(): any {
registerDiagramBlock("plantuml");
registerDiagramBlock("tikz");
registerDiagramBlock("bpmn");
+ registerDiagramBlock("abc");
// --- END NEW ---
return extensions;