agda-web-docs-lib
Version:
Library for enhancing Agda-generated HTML documentation
775 lines (639 loc) • 29.1 kB
JavaScript
/**
* Type Preview on Hover
* Shows a preview of Agda type/function definitions when hovering over links
*/
(function() {
let previewContainer;
let activeTimeout;
let activeLink = null;
const codeCache = new Map();
const previewDelay = 300; // ms delay before showing preview
document.addEventListener('DOMContentLoaded', function() {
init();
});
/**
* Initialize the preview functionality
*/
function init() {
// Get or create the preview container
previewContainer = document.getElementById('type-preview-container');
if (!previewContainer) {
previewContainer = document.createElement('div');
previewContainer.id = 'type-preview-container';
previewContainer.className = 'type-preview-container';
previewContainer.style.display = 'none';
document.body.appendChild(previewContainer);
}
// Add event listeners to hoverable links
function setupHoverableLinks() {
const hoverableLinks = document.querySelectorAll('.type-hoverable');
hoverableLinks.forEach(link => {
link.addEventListener('mouseenter', handleMouseEnter);
link.addEventListener('mouseleave', handleMouseLeave);
link.addEventListener('focus', handleMouseEnter);
link.addEventListener('blur', handleMouseLeave);
});
}
// Initial setup
setupHoverableLinks();
// Hide preview when clicking outside
document.addEventListener('click', function(event) {
if (previewContainer.style.display === 'block' &&
!previewContainer.contains(event.target) &&
!activeLink?.contains(event.target)) {
hidePreview();
}
});
}
/**
* Handle mouse enter event on hoverable links
*/
function handleMouseEnter(event) {
// Clear any existing timeout
if (activeTimeout) {
clearTimeout(activeTimeout);
}
// Set the active link
activeLink = event.target;
// Get the href attribute or data-original-href if available
const originalHref = activeLink.getAttribute('data-original-href');
const href = activeLink.getAttribute('href');
if (!href) return;
// Use timeout to avoid showing preview for quick mouse movements
activeTimeout = setTimeout(() => {
showPreview(activeLink, href);
}, previewDelay);
}
/**
* Handle mouse leave event
*/
function handleMouseLeave() {
// Clear timeout if mouse leaves quickly
if (activeTimeout) {
clearTimeout(activeTimeout);
activeTimeout = null;
}
}
/**
* Determine which context lines to include in the preview
*/
function determineContextLines(allLines, targetIndex) {
// Default to showing a few lines before and after the target line
const defaultContextLines = 3;
// Helper function to check if a line is empty or just whitespace
const isEmptyLine = (line) => {
const content = line.textContent || '';
return content.trim() === '';
};
// Helper function to check if a line contains a comment
const isComment = (line) => {
const content = line.innerHTML || '';
return content.includes('class="Comment"');
};
// Helper function to extract comment text from a line
const extractCommentText = (line) => {
const commentElements = line.querySelectorAll('.Comment');
if (commentElements.length === 0) return '';
// Join all comment text and clean it up
return Array.from(commentElements)
.map(el => el.textContent || '')
.join(' ')
.trim();
};
// Start with basic context
let startIndex = Math.max(0, targetIndex - defaultContextLines);
let endIndex = Math.min(allLines.length - 1, targetIndex + defaultContextLines);
// Look for comments that precede the definition
let commentLines = [];
let definitionStartIndex = targetIndex;
// Look backward from the target to find comments and the start of the definition
let idx = targetIndex;
while (idx > 0) {
idx--;
// If we find an empty line, this means any comments before it should NOT be included
if (isEmptyLine(allLines[idx])) {
// The definition starts after the empty line
definitionStartIndex = idx + 1;
// Don't include any comments that are separated by an empty line
commentLines = [];
break;
}
// If we find a comment line directly before the definition (no empty line in between)
if (isComment(allLines[idx])) {
// Collect all consecutive comment lines that are directly adjacent
let commentIdx = idx;
let foundComments = [];
while (commentIdx >= 0 && isComment(allLines[commentIdx])) {
foundComments.unshift({
line: allLines[commentIdx],
text: extractCommentText(allLines[commentIdx])
});
commentIdx--;
}
if (foundComments.length > 0) {
commentLines = foundComments;
}
// The definition starts AFTER the comment lines
// Find the first non-comment line after the comments
let nextIdx = idx;
while (nextIdx < allLines.length && isComment(allLines[nextIdx])) {
nextIdx++;
}
definitionStartIndex = nextIdx;
break;
}
definitionStartIndex = idx;
}
// Set the start index to the beginning of the definition (excluding comments)
startIndex = definitionStartIndex;
// Look forward to capture the entire declaration
idx = targetIndex;
let bracketCount = 0;
let foundEndOfDeclaration = false;
// Count open and close brackets/parens to find the complete expression
while (idx < allLines.length - 1 && !foundEndOfDeclaration) {
const content = allLines[idx].textContent || '';
// Count brackets crudely (this is a simple heuristic)
for (const char of content) {
if (char === '(' || char === '{' || char === '[') bracketCount++;
if (char === ')' || char === '}' || char === ']') bracketCount--;
}
idx++;
// Check if the next line is empty and brackets are balanced
if (idx < allLines.length && bracketCount <= 0) {
if (isEmptyLine(allLines[idx]) || isComment(allLines[idx])) {
// Found the end, but don't include this empty line
foundEndOfDeclaration = true;
// Keep endIndex at the last non-empty line
endIndex = idx - 1;
break;
}
}
// If we reached here, include this line
endIndex = idx;
}
// Ensure we don't exceed array bounds
startIndex = Math.max(0, startIndex);
endIndex = Math.min(allLines.length - 1, endIndex);
return { startIndex, endIndex, commentLines };
}
/**
* Show the preview for the given link and target
*/
async function showPreview(link, href) {
// Parse the href to get the file and line number
const [file, lineFragment] = href.split('#');
if (!lineFragment) return;
// Extract the line number and block ID from the fragment
let lineNumber, blockId;
// Check if using the new block-specific format (BX-LY)
const blockLineMatch = lineFragment.match(/^(B\d+)-L(\d+)$/);
if (blockLineMatch) {
blockId = blockLineMatch[1];
lineNumber = parseInt(blockLineMatch[2], 10);
} else if (lineFragment.startsWith('L')) {
// Legacy format (LY)
blockId = 'B1'; // Assume first block for legacy format
lineNumber = parseInt(lineFragment.substring(1), 10);
} else {
return; // Unknown format
}
if (isNaN(lineNumber)) return;
// Construct cache key
const cacheKey = `${file || window.location.pathname}#${blockId}-${lineNumber}`;
// Check if we already have this code block in cache
let codeBlock;
if (codeCache.has(cacheKey)) {
codeBlock = codeCache.get(cacheKey);
} else {
// Find the code block content - either in the current page or fetch from another page
if (!file || file === window.location.pathname.split('/').pop()) {
// Same page - look for the target line
const lineContentId = `${blockId}-LC${lineNumber}`;
const targetLine = document.getElementById(lineContentId);
if (!targetLine) return;
// Get the containing code block
const codeContainer = targetLine.closest('.code-content');
if (!codeContainer) return;
// Get all lines from the code container
const allLines = Array.from(codeContainer.querySelectorAll('.code-line'));
const targetIndex = allLines.indexOf(targetLine);
if (targetIndex === -1) return;
// Determine context-aware start and end indexes
const { startIndex, endIndex, commentLines } = determineContextLines(allLines, targetIndex);
// Create a new container with the context lines
const contextLines = allLines.slice(startIndex, endIndex + 1);
// Get current module name from document title or path
const moduleName = document.title || window.location.pathname.split('/').pop().replace('.html', '');
// Build HTML for the code preview
codeBlock = buildCodePreview(contextLines, lineNumber, startIndex, moduleName, blockId, commentLines);
// Cache the result
codeCache.set(cacheKey, codeBlock);
} else {
// Different page - we'll need to fetch it
try {
// Extract module name from file path
const moduleName = file.replace('.html', '');
codeBlock = await fetchCodeFromFile(file, lineNumber, moduleName, blockId);
if (codeBlock) {
codeCache.set(cacheKey, codeBlock);
}
} catch (error) {
console.error('Error fetching code preview:', error);
return;
}
}
}
if (!codeBlock) return;
// Add content to preview container
previewContainer.innerHTML = '';
previewContainer.appendChild(codeBlock);
// Position the preview near the link
positionPreview(link);
// Show the preview
previewContainer.style.display = 'block';
}
/**
* Build a code preview element with the given context lines
*/
function buildCodePreview(contextLines, highlightLineNumber, startLineIndex, moduleName = '', blockId = 'B1', commentLines = []) {
const container = document.createElement('div');
container.className = 'code-preview-container';
// Add heading with link to full definition
const heading = document.createElement('div');
heading.className = 'preview-heading';
// Create module name text
const moduleText = document.createElement('span');
moduleText.className = 'module-name';
// If module name is provided, include it
if (moduleName) {
moduleText.textContent = `Definition in ${moduleName}`;
} else {
moduleText.textContent = 'Definition';
}
// Create link to full definition
const linkToDefinition = document.createElement('a');
linkToDefinition.className = 'link-to-definition';
linkToDefinition.textContent = `Line ${highlightLineNumber}`;
// Determine the link href using block-specific format
// If this is from another file, include the file name
if (moduleName && moduleName !== (document.title || '')) {
linkToDefinition.href = `${moduleName}.html#${blockId}-L${highlightLineNumber}`;
} else {
linkToDefinition.href = `#${blockId}-L${highlightLineNumber}`;
}
// Add both elements to heading
heading.appendChild(moduleText);
heading.appendChild(linkToDefinition);
container.appendChild(heading);
// Add comment section if we have comments
if (commentLines.length > 0) {
const commentContainer = document.createElement('div');
commentContainer.className = 'preview-comment-container';
// Combine all comment text into one block for proper list processing
let allCommentText = '';
commentLines.forEach((comment, index) => {
const commentText = comment.text;
// Clean up the comment text - remove comment delimiters and extra whitespace
let cleanText = commentText
.replace(/^\{-\s*/, '') // Remove opening {-
.replace(/\s*-\}$/, '') // Remove closing -}
.replace(/^--\s*/, '') // Remove -- prefix for line comments
.replace(/^\s*\*\s*/, '') // Remove leading asterisks
.trim();
// Add to combined text with proper spacing
if (allCommentText && cleanText) {
allCommentText += '\n' + cleanText;
} else if (cleanText) {
allCommentText = cleanText;
}
});
if (allCommentText) {
// Split into lines and clean each line
const lines = allCommentText.split('\n').map(line => {
// Remove common comment prefixes and clean up, but preserve list markers
return line
.replace(/^\s*--\s*/, '') // Remove -- prefix for line comments
.replace(/^\s*\*?\s*/, '') // Remove leading asterisks and whitespace
.trim();
}).filter(line => line.length > 0);
if (lines.length > 0) {
const commentBlock = document.createElement('div');
commentBlock.className = 'preview-comment-block';
// Process lines to detect lists and format them properly
let currentList = null;
let currentListType = null;
let currentListItem = null;
let inParametersSection = false;
let parametersTable = null;
lines.forEach(line => {
// Check if this line indicates a parameters section
if (line.toLowerCase().match(/^\s*parameters?\s*:?\s*$/i)) {
// Close any existing list first
if (currentList) {
commentBlock.appendChild(currentList);
currentList = null;
currentListType = null;
currentListItem = null;
}
// Add some spacing before parameters section
const spacer = document.createElement('div');
spacer.className = 'preview-comment-spacer';
commentBlock.appendChild(spacer);
// Create parameters heading
const parametersHeading = document.createElement('div');
parametersHeading.className = 'preview-comment-line preview-parameters-heading';
parametersHeading.innerHTML = '<strong>Parameters:</strong>';
commentBlock.appendChild(parametersHeading);
// Create parameters table
parametersTable = document.createElement('table');
parametersTable.className = 'preview-parameters-table';
inParametersSection = true;
return;
}
// Check if this line is a list item (be more specific about list detection)
const bulletMatch = line.match(/^[-*+]\s+(.+)$/);
const numberedMatch = line.match(/^\d+\.\s+(.+)$/);
if (bulletMatch && inParametersSection) {
// This is a parameter item - parse it as "name: description"
const paramText = bulletMatch[1];
const colonIndex = paramText.indexOf(':');
if (colonIndex > 0) {
const paramName = paramText.substring(0, colonIndex).trim();
const paramDesc = paramText.substring(colonIndex + 1).trim();
const row = document.createElement('tr');
row.className = 'preview-parameter-row';
const nameCell = document.createElement('td');
nameCell.className = 'preview-parameter-name';
nameCell.textContent = paramName;
const descCell = document.createElement('td');
descCell.className = 'preview-parameter-desc';
descCell.textContent = paramDesc;
row.appendChild(nameCell);
row.appendChild(descCell);
parametersTable.appendChild(row);
} else {
// No colon found, treat as regular parameter with no description
const row = document.createElement('tr');
row.className = 'preview-parameter-row';
const nameCell = document.createElement('td');
nameCell.className = 'preview-parameter-name';
nameCell.textContent = paramText;
const descCell = document.createElement('td');
descCell.className = 'preview-parameter-desc';
descCell.textContent = '';
row.appendChild(nameCell);
row.appendChild(descCell);
parametersTable.appendChild(row);
}
} else if (bulletMatch) {
// Regular bullet list item (not in parameters section)
// End parameters section if we were in one
if (inParametersSection && parametersTable) {
commentBlock.appendChild(parametersTable);
parametersTable = null;
inParametersSection = false;
}
if (currentListType !== 'ul') {
// Close any existing list and start a new unordered list
if (currentList) {
commentBlock.appendChild(currentList);
}
currentList = document.createElement('ul');
currentList.className = 'preview-comment-list';
currentListType = 'ul';
}
currentListItem = document.createElement('li');
currentListItem.className = 'preview-comment-list-item';
currentListItem.textContent = bulletMatch[1];
currentList.appendChild(currentListItem);
} else if (numberedMatch) {
// Numbered list item
// End parameters section if we were in one
if (inParametersSection && parametersTable) {
commentBlock.appendChild(parametersTable);
parametersTable = null;
inParametersSection = false;
}
if (currentListType !== 'ol') {
// Close any existing list and start a new ordered list
if (currentList) {
commentBlock.appendChild(currentList);
}
currentList = document.createElement('ol');
currentList.className = 'preview-comment-list';
currentListType = 'ol';
}
currentListItem = document.createElement('li');
currentListItem.className = 'preview-comment-list-item';
currentListItem.textContent = numberedMatch[1]; // Use the first capture group for the text
currentList.appendChild(currentListItem);
} else if (currentListItem && line.trim() !== '' && !inParametersSection) {
// This is a continuation line for the current list item (but not in parameters section)
// Add it to the current list item with a space
currentListItem.textContent += ' ' + line;
} else if (inParametersSection && parametersTable && line.trim() !== '') {
// This is a continuation line for the last parameter description
const lastRow = parametersTable.lastElementChild;
if (lastRow) {
const descCell = lastRow.querySelector('.preview-parameter-desc');
if (descCell) {
descCell.textContent += ' ' + line;
}
}
} else {
// This is a regular line or empty line
// End parameters section if we were in one
if (inParametersSection && parametersTable) {
commentBlock.appendChild(parametersTable);
parametersTable = null;
inParametersSection = false;
}
// Close any existing list first
if (currentList) {
commentBlock.appendChild(currentList);
currentList = null;
currentListType = null;
currentListItem = null;
}
// Only add non-empty lines as regular comment lines
if (line.trim() !== '') {
const commentLine = document.createElement('div');
commentLine.className = 'preview-comment-line';
commentLine.textContent = line;
commentBlock.appendChild(commentLine);
}
}
});
// Don't forget to append any remaining list or parameters table
if (currentList) {
commentBlock.appendChild(currentList);
}
if (inParametersSection && parametersTable) {
commentBlock.appendChild(parametersTable);
}
commentContainer.appendChild(commentBlock);
}
}
container.appendChild(commentContainer);
}
// Create line numbers container
const lineNumbers = document.createElement('div');
lineNumbers.className = 'preview-line-numbers';
// Create code content container
const codeContent = document.createElement('div');
codeContent.className = 'preview-code-content';
// Add each line with its number
contextLines.forEach((line, index) => {
// The actual line number in the document (startLineIndex is 0-based, but lines are 1-based)
const actualLineNumber = startLineIndex + index + 1;
// Add line number
const lineNumberSpan = document.createElement('span');
lineNumberSpan.className = 'preview-line-number';
lineNumberSpan.textContent = actualLineNumber;
if (actualLineNumber === highlightLineNumber) {
lineNumberSpan.classList.add('highlight');
}
lineNumbers.appendChild(lineNumberSpan);
// Add code line
const codeLine = document.createElement('div');
codeLine.className = 'preview-code-line';
if (actualLineNumber === highlightLineNumber) {
codeLine.classList.add('highlight');
}
// Clone the line content more carefully to preserve whitespace and syntax highlighting
const lineContentWrapper = document.createElement('div');
lineContentWrapper.style.whiteSpace = 'pre';
lineContentWrapper.innerHTML = line.innerHTML;
codeLine.appendChild(lineContentWrapper);
codeContent.appendChild(codeLine);
});
// Create preview code structure - use the same exact class structure as in the main content
const previewCode = document.createElement('div');
// Use the same class structure as the main Agda code blocks to inherit styles
previewCode.className = 'Agda';
// Create a code container to match the main content structure
const codeContainer = document.createElement('div');
codeContainer.className = 'code-container';
codeContainer.appendChild(lineNumbers);
codeContainer.appendChild(codeContent);
previewCode.appendChild(codeContainer);
container.appendChild(previewCode);
return container;
}
/**
* Fetch code from another file
*/
async function fetchCodeFromFile(file, lineNumber, moduleName = '', blockId = 'B1') {
try {
const response = await fetch(file);
if (!response.ok) {
throw new Error(`Failed to fetch ${file}: ${response.status}`);
}
const html = await response.text();
// Create a temporary document to parse the HTML
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Find the target line using the block-specific ID
const lineContentId = `${blockId}-LC${lineNumber}`;
const targetLine = doc.getElementById(lineContentId);
// If the specific block isn't found, try to find any line with that number
// (fallback for files that might not have the updated format)
if (!targetLine) {
// Look for a line with the legacy format or in any block
const legacyTarget = doc.getElementById(`LC${lineNumber}`);
if (legacyTarget) {
// Use the legacy target's code block
const codeContainer = legacyTarget.closest('.code-content');
if (codeContainer) {
const allLines = Array.from(codeContainer.querySelectorAll('.code-line'));
const targetIndex = allLines.indexOf(legacyTarget);
if (targetIndex !== -1) {
const { startIndex, endIndex, commentLines } = determineContextLines(allLines, targetIndex);
const contextLines = allLines.slice(startIndex, endIndex + 1);
return buildCodePreview(contextLines, lineNumber, startIndex, moduleName, 'B1', commentLines);
}
}
}
return null;
}
// Get the containing code block
const codeContainer = targetLine.closest('.code-content');
if (!codeContainer) return null;
// Get all lines from the code container
const allLines = Array.from(codeContainer.querySelectorAll('.code-line'));
const targetIndex = allLines.indexOf(targetLine);
if (targetIndex === -1) return null;
// Determine context-aware start and end indexes
const { startIndex, endIndex, commentLines } = determineContextLines(allLines, targetIndex);
// Create a new container with the context lines
const contextLines = allLines.slice(startIndex, endIndex + 1);
// Build HTML for the code preview
return buildCodePreview(contextLines, lineNumber, startIndex, moduleName, blockId, commentLines);
} catch (error) {
console.error('Error fetching preview:', error);
return null;
}
}
/**
* Position the preview container near the link
*/
function positionPreview(link) {
if (!link || !previewContainer) return;
const linkRect = link.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Reset any previous positioning
previewContainer.style.maxHeight = '';
previewContainer.style.maxWidth = '';
// Allow the container to take its natural size first
previewContainer.style.visibility = 'hidden';
previewContainer.style.display = 'block';
// Get container dimensions
const containerWidth = previewContainer.offsetWidth;
const containerHeight = previewContainer.offsetHeight;
// Default position is below the link
let top = linkRect.bottom + window.scrollY + 5;
let left = linkRect.left + window.scrollX;
// Check if the preview would go off the bottom of the viewport
if (linkRect.bottom + containerHeight > viewportHeight) {
// Position above the link instead
top = linkRect.top + window.scrollY - containerHeight - 5;
// If it would go off the top too, position it at the top of the viewport
if (top < window.scrollY) {
top = window.scrollY + 5;
// Constrain height to fit in viewport
const maxHeight = viewportHeight - 10;
previewContainer.style.maxHeight = `${maxHeight}px`;
}
}
// Check if the preview would go off the right of the viewport
if (left + containerWidth > viewportWidth) {
// Align right edge with viewport edge
left = viewportWidth - containerWidth + window.scrollX - 5;
// Don't let it go off the left either
if (left < window.scrollX) {
left = window.scrollX + 5;
previewContainer.style.maxWidth = `${viewportWidth - 10}px`;
}
}
// Apply the calculated position
previewContainer.style.top = `${top}px`;
previewContainer.style.left = `${left}px`;
// Show the preview
previewContainer.style.visibility = 'visible';
}
/**
* Hide the preview
*/
function hidePreview() {
if (previewContainer) {
previewContainer.style.display = 'none';
}
if (activeTimeout) {
clearTimeout(activeTimeout);
activeTimeout = null;
}
activeLink = null;
}
})();