UNPKG

agda-web-docs-lib

Version:

Library for enhancing Agda-generated HTML documentation

623 lines (534 loc) 22.2 kB
// Client-side search functionality for Agda docs (function() { // Variable declarations let searchActive = false; let searchResults = []; let searchIndex = {}; // Create DOM elements for search function createSearchUI() { // Create overlay container const searchOverlay = document.createElement('div'); searchOverlay.className = 'search-overlay'; searchOverlay.style.display = 'none'; // Create search container const searchContainer = document.createElement('div'); searchContainer.className = 'search-container'; // Create search input const searchInput = document.createElement('input'); searchInput.type = 'text'; searchInput.className = 'search-input'; searchInput.placeholder = 'Search documentation... (Ctrl+K / Cmd+K)'; searchInput.setAttribute('autocomplete', 'off'); searchInput.setAttribute('autocorrect', 'off'); searchInput.setAttribute('autocapitalize', 'off'); searchInput.setAttribute('spellcheck', 'false'); // Create search results container const resultsContainer = document.createElement('div'); resultsContainer.className = 'search-results'; resultsContainer.style.display = 'none'; // Initially hidden // Assemble the search UI searchContainer.appendChild(searchInput); searchContainer.appendChild(resultsContainer); searchOverlay.appendChild(searchContainer); document.body.appendChild(searchOverlay); return { overlay: searchOverlay, container: searchContainer, input: searchInput, results: resultsContainer }; } // Load search index asynchronously function loadSearchIndex() { // Check for chunked index first // Use current directory as base path instead of hardcoded paths const basePath = window.location.pathname.endsWith('/') ? window.location.pathname : window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/') + 1); const metadataPath = basePath + 'search-index-metadata.json'; fetch(metadataPath) .then(response => { if (response.ok) { return response.json(); } else { // Metadata not found, try regular index throw new Error('Metadata not found'); } }) .then(metadata => { if (metadata.chunked) { console.log(`Loading chunked search index with ${metadata.chunks.length} chunks...`); return loadChunkedSearchIndex(basePath, metadata); } else { throw new Error('Non-chunked metadata found'); } }) .catch(() => { // Fall back to regular search index const indexPath = basePath + 'search-index.json'; fetch(indexPath) .then(response => { if (!response.ok) { throw new Error('Failed to load search index'); } return response.json(); }) .then(data => { searchIndex = data; console.log('Search index loaded successfully'); }) .catch(error => { console.error('Error loading search index:', error); }); }); } // Load chunked search index function loadChunkedSearchIndex(basePath, metadata) { const chunkPromises = metadata.chunks.map(chunkName => { const chunkPath = basePath + `search-index-${chunkName}.json`; return fetch(chunkPath) .then(response => { if (!response.ok) { throw new Error(`Failed to load chunk: ${chunkName}`); } return response.json(); }); }); return Promise.all(chunkPromises) .then(chunks => { // Merge all chunks into a single index searchIndex = {}; chunks.forEach(chunk => { Object.assign(searchIndex, chunk); }); console.log(`Chunked search index loaded successfully with ${metadata.totalEntries} entries from ${metadata.totalFiles} files`); }) .catch(error => { console.error('Error loading chunked search index:', error); }); } // Fuzzy search implementation function fuzzySearch(query, text) { if (!query || !text) return { matched: false, score: 0, highlights: [] }; query = query.toLowerCase(); text = text.toLowerCase(); // For code searches, check for substring matches first if (text.includes(query)) { // Direct substring match - higher score const startIndex = text.indexOf(query); const highlights = Array.from({ length: query.length }, (_, i) => startIndex + i); return { matched: true, score: 10 + (query.length / text.length), // Boost direct matches highlights }; } // Fall back to character-by-character matching let queryIndex = 0; let score = 0; const highlights = []; let lastMatchIndex = -1; let consecutiveMatches = 0; for (let i = 0; i < text.length; i++) { if (queryIndex >= query.length) break; if (text[i] === query[queryIndex]) { // If this is a consecutive match, increase the score if (lastMatchIndex === i - 1) { consecutiveMatches++; score += (consecutiveMatches * 2); // Consecutive matches are more valuable } else { consecutiveMatches = 1; score += 1; } lastMatchIndex = i; highlights.push(i); queryIndex++; } } const matched = queryIndex === query.length; // Normalize score based on text length (shorter matches score higher) const normalizedScore = matched ? score / (text.length * 0.6) : 0; return { matched, score: normalizedScore, highlights }; } // Perform search with current query function performSearch(query) { if (!query || query.length < 2 || !searchIndex || Object.keys(searchIndex).length === 0) { return []; } const results = []; // First pass: check for exact module name matches for (const [moduleFile, entries] of Object.entries(searchIndex)) { const moduleName = moduleFile.replace('.html', ''); // Search in module name const moduleMatch = fuzzySearch(query, moduleName); if (moduleMatch.matched) { results.push({ type: 'module', moduleName: moduleName, url: moduleFile, content: moduleName, score: moduleMatch.score * 2, // Give more weight to module matches highlights: moduleMatch.highlights }); } // Search in entries entries.forEach(entry => { const contentMatch = fuzzySearch(query, entry.content); if (contentMatch.matched) { results.push({ type: entry.type, moduleName: moduleName, url: entry.lineNumber ? `${moduleFile}#L${entry.lineNumber}` : moduleFile, content: entry.content, context: entry.context || '', score: contentMatch.score, highlights: contentMatch.highlights }); } }); } // Sort by score (highest first) results.sort((a, b) => b.score - a.score); // Limit results to avoid overwhelming the UI return results.slice(0, 20); } // Render search results function renderSearchResults(searchUI, results, toggleSearchFn) { searchUI.results.innerHTML = ''; if (results.length === 0) { const noResults = document.createElement('div'); noResults.className = 'no-results'; noResults.textContent = 'No results found'; searchUI.results.appendChild(noResults); // Still show the container with "No results found" message searchUI.results.style.display = 'block'; return; } // Show the results container searchUI.results.style.display = 'block'; results.forEach((result, index) => { const resultEl = document.createElement('a'); resultEl.className = 'search-result'; resultEl.setAttribute('data-index', index); resultEl.href = result.url; // Handle different result types differently if (result.type === 'code') { // For code type, don't show separate title, just the context with highlights const preview = document.createElement('div'); preview.className = 'result-preview code-preview'; // Format and highlight code context if (result.context) { const lines = result.context.split('\n'); let formattedContext = ''; lines.forEach((line, i) => { // Find if this is the line containing our match const isMatchLine = line.toLowerCase().includes(result.content.toLowerCase()); if (isMatchLine) { // This is the matched line, highlight the match formattedContext += `<span class="code-highlight">${highlightMatches(line, result.highlights)}</span>\n`; } else { // Context line formattedContext += `<span class="code-context">${line}</span>\n`; } }); preview.innerHTML = formattedContext; } else { preview.textContent = result.content; } resultEl.appendChild(preview); } else { // For non-code types, show title and preview separately // Add result title const title = document.createElement('div'); title.className = 'result-title'; // Highlight matches in the title let titleContent = result.content; if (result.highlights) { titleContent = highlightMatches(result.content, result.highlights); } title.innerHTML = titleContent; resultEl.appendChild(title); // Add result preview if it exists and is different from the title if (result.context && result.context !== result.content) { const preview = document.createElement('div'); preview.className = 'result-preview'; if (result.highlights) { preview.innerHTML = highlightMatches(result.context, result.highlights); } else { preview.textContent = result.context; } resultEl.appendChild(preview); } } // Add source path and type indicator in a container const pathContainer = document.createElement('div'); pathContainer.className = 'result-footer'; // Add module path const path = document.createElement('span'); path.className = 'result-path'; path.textContent = result.moduleName; pathContainer.appendChild(path); // Add result type indicator const typeIndicator = document.createElement('span'); typeIndicator.className = `result-type result-type-${result.type}`; typeIndicator.textContent = result.type; pathContainer.appendChild(typeIndicator); resultEl.appendChild(pathContainer); // Add click handler for search results - natural anchor click behavior resultEl.addEventListener('click', (e) => { e.preventDefault(); // Prevent default to handle navigation ourselves // Update selection highlighting visually const allResults = searchUI.results.querySelectorAll('.search-result'); allResults.forEach(r => r.classList.remove('selected')); resultEl.classList.add('selected'); // Close the search overlay toggleSearchFn(); // Handle navigation directly without delay const targetUrl = result.url; // Handle same-page navigation specially const currentPath = window.location.pathname; const currentPathWithoutHash = currentPath.split('#')[0]; const isInternalLink = targetUrl.startsWith('#') || (targetUrl.includes('#') && targetUrl.split('#')[0] === currentPathWithoutHash); if (isInternalLink) { // For same-page navigation, just set the hash const hash = targetUrl.includes('#') ? targetUrl.split('#')[1] : targetUrl.substring(1); window.location.hash = hash; } else { // For different page navigation window.location.href = targetUrl; } }); // Add hover effect manually (highlight on hover) resultEl.addEventListener('mouseover', () => { const allResults = searchUI.results.querySelectorAll('.search-result'); allResults.forEach(r => r.classList.remove('selected')); resultEl.classList.add('selected'); }); searchUI.results.appendChild(resultEl); }); } // Highlight matched characters in text - with consecutive matches merged function highlightMatches(text, highlights) { if (!highlights || highlights.length === 0) return text; // Sort the highlights to ensure they're in order highlights = [...highlights].sort((a, b) => a - b); // Identify consecutive sequences const sequences = []; let currentSequence = [highlights[0]]; for (let i = 1; i < highlights.length; i++) { // If this highlight is consecutive with the previous one if (highlights[i] === highlights[i-1] + 1) { currentSequence.push(highlights[i]); } else { // End of a sequence sequences.push([...currentSequence]); currentSequence = [highlights[i]]; } } // Add the last sequence if (currentSequence.length > 0) { sequences.push(currentSequence); } // Now apply the highlights in reverse order to avoid index shifting issues let result = text; for (let i = sequences.length - 1; i >= 0; i--) { const sequence = sequences[i]; const startIdx = sequence[0]; const endIdx = sequence[sequence.length - 1] + 1; // +1 to include the last character // Extract the substring and wrap it const before = result.substring(0, startIdx); const highlighted = result.substring(startIdx, endIdx); const after = result.substring(endIdx); result = before + `<span class="result-match">${highlighted}</span>` + after; } return result; } // Initialize search UI once DOM is loaded document.addEventListener('DOMContentLoaded', () => { // Load search index as soon as possible loadSearchIndex(); // Create search UI elements const searchUI = createSearchUI(); // Add search button to header const headerRight = document.querySelector('.header-right'); if (headerRight) { const searchButton = document.createElement('button'); searchButton.className = 'search-button'; searchButton.setAttribute('title', 'Search documentation (Ctrl+K / Cmd+K)'); searchButton.setAttribute('aria-label', 'Search documentation'); searchButton.innerHTML = ` <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <circle cx="11" cy="11" r="8"></circle> <line x1="21" y1="21" x2="16.65" y2="16.65"></line> </svg> `; // Insert before theme toggle const themeToggle = headerRight.querySelector('.theme-toggle'); if (themeToggle) { headerRight.insertBefore(searchButton, themeToggle); } else { headerRight.appendChild(searchButton); } // Apply consistent styling setTimeout(() => { // Match classes with other header buttons for consistent styling const allHeaderButtons = headerRight.querySelectorAll('button'); allHeaderButtons.forEach(button => { if (!button.classList.contains('search-button')) { const classes = Array.from(button.classList).filter(cls => cls !== 'theme-toggle'); searchButton.classList.add(...classes); } }); }, 100); // Add click handler to search button searchButton.addEventListener('click', toggleSearch); } // Function to toggle search overlay function toggleSearch() { searchActive = !searchActive; if (searchActive) { searchUI.overlay.style.display = 'flex'; searchUI.input.focus(); searchUI.results.style.display = 'none'; // Hide results when opening document.body.classList.add('search-active'); } else { searchUI.overlay.style.display = 'none'; document.body.classList.remove('search-active'); searchUI.input.value = ''; searchUI.results.innerHTML = ''; searchUI.results.style.display = 'none'; // Hide results when closing } } // Handle keyboard shortcuts document.addEventListener('keydown', (e) => { // Ctrl+K or Cmd+K to open search if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); toggleSearch(); } // Escape to close search if (e.key === 'Escape' && searchActive) { e.preventDefault(); toggleSearch(); } // Handle arrow keys in results if (searchActive && searchResults.length > 0) { const resultElements = searchUI.results.querySelectorAll('.search-result'); const selectedElement = searchUI.results.querySelector('.search-result.selected'); if (e.key === 'ArrowDown') { e.preventDefault(); let nextIndex = 0; // Default to first if none selected if (selectedElement) { // Find current index for (let i = 0; i < resultElements.length; i++) { if (resultElements[i] === selectedElement) { // Move to next (or stay at end) nextIndex = Math.min(i + 1, resultElements.length - 1); break; } } } // Remove selection from all resultElements.forEach(el => el.classList.remove('selected')); // Add to the next one resultElements[nextIndex].classList.add('selected'); // Ensure visible resultElements[nextIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } else if (e.key === 'ArrowUp') { e.preventDefault(); let prevIndex = resultElements.length - 1; // Default to last if none selected if (selectedElement) { // Find current index for (let i = 0; i < resultElements.length; i++) { if (resultElements[i] === selectedElement) { // Move to previous (or stay at start) prevIndex = Math.max(i - 1, 0); break; } } } // Remove selection from all resultElements.forEach(el => el.classList.remove('selected')); // Add to the previous one resultElements[prevIndex].classList.add('selected'); // Ensure visible resultElements[prevIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } else if (e.key === 'Enter') { e.preventDefault(); // If an item is selected, navigate to it if (selectedElement) { const index = parseInt(selectedElement.getAttribute('data-index')); if (!isNaN(index) && index >= 0 && index < searchResults.length) { // Get the result from the searchResults array const result = searchResults[index]; // Close search and navigate toggleSearch(); // Navigate directly without delay const targetUrl = result.url; // Handle same-page navigation specially const currentPath = window.location.pathname; const currentPathWithoutHash = currentPath.split('#')[0]; const isInternalLink = targetUrl.startsWith('#') || (targetUrl.includes('#') && targetUrl.split('#')[0] === currentPathWithoutHash); if (isInternalLink) { // For same-page navigation, just set the hash const hash = targetUrl.includes('#') ? targetUrl.split('#')[1] : targetUrl.substring(1); window.location.hash = hash; } else { // For different page navigation window.location.href = targetUrl; } } } } } }); // Close search when clicking outside searchUI.overlay.addEventListener('click', (e) => { if (e.target === searchUI.overlay) { toggleSearch(); } }); // Prevent clicks on the container from closing the overlay searchUI.container.addEventListener('click', (e) => { e.stopPropagation(); }); // Implement search functionality with debounce let searchTimeout = null; searchUI.input.addEventListener('input', (e) => { const query = e.target.value.trim(); // Clear previous timeout if (searchTimeout) { clearTimeout(searchTimeout); } // Hide results container if query is empty if (!query || query.length < 2) { searchUI.results.innerHTML = ''; searchUI.results.style.display = 'none'; searchResults = []; return; } // Debounce search to avoid too many searches while typing searchTimeout = setTimeout(() => { // Show loading indicator searchUI.results.innerHTML = '<div class="search-loading"><div class="search-loading-spinner"></div></div>'; searchUI.results.style.display = 'block'; // Perform search searchResults = performSearch(query); // Render results renderSearchResults(searchUI, searchResults, toggleSearch); }, 200); }); }); })();