/** * Grimoire - Application JavaScript * ================================= * Part of the Grimoire static site generator * https://github.com/TristanInSec/Grimoire * * AJAX-powered navigation for seamless page transitions without full reloads. * Uses the History API to maintain browser navigation while fetching content dynamically. * * Features: * - AJAX page loading with fade transitions * - Browser history integration (back/forward support) * - Dynamic breadcrumb updates * - Persistent bookmarks and recent items via localStorage * - Collapsible category navigation with state persistence * - Real-time search with result highlighting * - Dark/light theme switching * - Code block copy-to-clipboard functionality * - Mobile-responsive sidebar * * Storage Keys (localStorage): * - doc-platform-bookmarks: Array of bookmarked page IDs * - doc-platform-recent: Array of recently viewed page IDs * - doc-platform-category-states: Object mapping category IDs to expanded state * - doc-platform-theme: Current theme ('dark' or 'light') * - doc-platform-recent-collapsed: Boolean for Recent section collapse state * - doc-platform-bookmarks-collapsed: Boolean for Bookmarks section collapse state */ (function() { 'use strict'; // State management let bookmarks = JSON.parse(localStorage.getItem('doc-platform-bookmarks') || '[]'); let recentItems = JSON.parse(localStorage.getItem('doc-platform-recent') || '[]'); let categoryStates = JSON.parse(localStorage.getItem('doc-platform-category-states') || '{}'); // Store INITIAL base path (calculated once at page load) // This is the directory containing index.html const INITIAL_BASE_PATH = window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/') + 1); // Initialize on DOM ready document.addEventListener('DOMContentLoaded', function() { initializeApp(); }); function initializeApp() { // 1. Setup AJAX navigation setupAjaxNavigation(); // 2. Category collapse/expand setupCategoryNavigation(); // 3. Subcategory collapse/expand setupSubcategoryNavigation(); // 4. Deep level (3+) collapse/expand setupDeepNavigation(); // 5. Current page highlighting highlightCurrentPage(); // 6. Bookmark functionality setupBookmarks(); // 7. Recent items addToRecent(window.currentPageId); updateRecentItems(); // 8. Clear buttons setupClearButtons(); // 9. Collapse buttons for sections setupCollapseSections(); // 10. Mobile menu setupMobileMenu(); // 11. Theme toggle setupTheme(); // 12. Search functionality setupSearch(); // 13. Export button setupExportButton(); // 14. Copy buttons for code blocks addCopyButtons(); // 15. Restore states restoreStates(); // 16. Syntax highlighting if (typeof Prism !== 'undefined') { Prism.highlightAll(); } // 17. Scroll to top button setupScrollToTop(); // 18. Table of Contents with Scroll Spy setupTableOfContents(); } // ============================================ // AJAX Navigation for Smooth Transitions // ============================================ function setupAjaxNavigation() { // Intercept all nav-item clicks document.addEventListener('click', function(e) { const navItem = e.target.closest('.nav-item'); if (navItem && navItem.href) { e.preventDefault(); // Get the href attribute directly (relative path) const href = navItem.getAttribute('href'); // Resolve relative to INITIAL BASE PATH (never changes) // This prevents path stacking issues const absoluteUrl = new URL(href, window.location.origin + INITIAL_BASE_PATH).href; loadPage(absoluteUrl, navItem.dataset.page); } }); // Handle browser back/forward window.addEventListener('popstate', function(e) { if (e.state && e.state.pageId) { loadPage(e.state.url, e.state.pageId, false); } }); // Logo click - navigate to index using the href (updated after each AJAX nav) document.addEventListener('click', function(e) { const logo = e.target.closest('.logo'); if (logo) { e.preventDefault(); window.location.href = logo.getAttribute('href'); } }); // Save initial state if (window.currentPageId) { history.replaceState({ pageId: window.currentPageId, url: window.location.href }, '', window.location.href); } } function loadPage(url, pageId, pushState = true) { // Show loading indicator const mainContent = document.querySelector('.main-content'); if (!mainContent) { console.error('[AJAX] Main content container not found'); return; } mainContent.style.opacity = '0.5'; mainContent.style.transition = 'opacity 0.2s'; // Fetch new page fetch(url) .then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.text(); }) .then(html => { // Parse HTML const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // Extract new content const newMain = doc.querySelector('.main-content'); const newTitle = doc.querySelector('title'); const newLogo = doc.querySelector('.logo'); if (!newMain) { console.error('[AJAX] No .main-content found in fetched page'); throw new Error('Invalid page structure'); } // Replace ENTIRE main content mainContent.innerHTML = newMain.innerHTML; // Update page title if (newTitle) { document.title = newTitle.textContent; } // Update logo href to match new page's depth if (newLogo) { const currentLogo = document.querySelector('.logo'); if (currentLogo) { currentLogo.setAttribute('href', newLogo.getAttribute('href')); } } // Update current page ID window.currentPageId = pageId; // Update URL in browser if (pushState) { history.pushState({ pageId: pageId, url: url }, '', url); } // Update UI highlightCurrentPage(); updateBreadcrumb(pageId); addToRecent(pageId); updateRecentItems(); updateBookmarkButtons(); // Re-apply syntax highlighting if (typeof Prism !== 'undefined') { Prism.highlightAll(); } // Add copy buttons to code blocks addCopyButtons(); // Regenerate Table of Contents setupTableOfContents(); // Update footer sibling links for new page depth const newAbout = doc.querySelector('.footer-column a[href*="about.html"]'); if (newAbout) { const base = newAbout.getAttribute('href').replace('about.html', ''); document.querySelectorAll('.footer-sibling').forEach(function(a) { a.href = base + a.dataset.page; }); } // Scroll to top window.scrollTo(0, 0); // Fade in mainContent.style.opacity = '1'; }) .catch(error => { console.error('[AJAX] Failed to load page:', error); mainContent.style.opacity = '1'; // Fallback to standard navigation on fetch failure window.location.href = url; }); } // ============================================ // Breadcrumb Updates // ============================================ function updateBreadcrumb(pageId) { const breadcrumbCategory = document.getElementById('breadcrumbCategory'); const breadcrumbPage = document.getElementById('breadcrumbPage'); const breadcrumbSeparator2 = document.getElementById('breadcrumbSeparator2'); const readingTimeDisplay = document.getElementById('readingTimeDisplay'); if (!breadcrumbCategory) return; // Handle welcome page if (pageId === 'welcome' || !pageId) { breadcrumbCategory.textContent = 'Welcome'; if (breadcrumbPage) breadcrumbPage.style.display = 'none'; if (breadcrumbSeparator2) breadcrumbSeparator2.style.display = 'none'; if (readingTimeDisplay) readingTimeDisplay.textContent = '5 min read'; return; } // Get page data const pageData = window.pageData ? window.pageData[pageId] : null; if (pageData) { if (pageData.level3) { // Three-level breadcrumb: Category > Subcategory > Sub-subcategory breadcrumbCategory.textContent = pageData.level1; if (breadcrumbPage) { breadcrumbPage.textContent = `${pageData.level2} › ${pageData.level3}`; breadcrumbPage.style.display = 'inline'; } if (breadcrumbSeparator2) breadcrumbSeparator2.style.display = 'inline'; } else if (pageData.level2) { // Two-level breadcrumb: Category > Subcategory breadcrumbCategory.textContent = pageData.level1; if (breadcrumbPage) { breadcrumbPage.textContent = pageData.level2; breadcrumbPage.style.display = 'inline'; } if (breadcrumbSeparator2) breadcrumbSeparator2.style.display = 'inline'; } else { // Single-level breadcrumb: Just category breadcrumbCategory.textContent = pageData.level1 || pageData.category || 'Article'; if (breadcrumbPage) breadcrumbPage.style.display = 'none'; if (breadcrumbSeparator2) breadcrumbSeparator2.style.display = 'none'; } // Update reading time if (readingTimeDisplay && pageData.reading_time) { readingTimeDisplay.textContent = `${pageData.reading_time} min read`; } } } // ============================================ // Category & Subcategory Navigation // ============================================ function setupCategoryNavigation() { document.querySelectorAll('.category-header').forEach(button => { button.addEventListener('click', function() { const category = this.dataset.category; const items = document.getElementById(category); const chevron = this.querySelector('.category-chevron'); if (items) { const isExpanded = items.classList.contains('expanded'); if (isExpanded) { items.classList.remove('expanded'); chevron.style.transform = 'rotate(0deg)'; categoryStates[category] = false; } else { items.classList.add('expanded'); chevron.style.transform = 'rotate(90deg)'; categoryStates[category] = true; } // Save state localStorage.setItem('doc-platform-category-states', JSON.stringify(categoryStates)); } }); }); } function setupSubcategoryNavigation() { document.querySelectorAll('.subcategory-header').forEach(button => { button.addEventListener('click', function() { const category = this.dataset.category; const items = document.getElementById(category); const chevron = this.querySelector('.subcategory-chevron'); if (items) { const isExpanded = items.classList.contains('expanded'); if (isExpanded) { items.classList.remove('expanded'); chevron.style.transform = 'rotate(0deg)'; categoryStates[category] = false; } else { items.classList.add('expanded'); chevron.style.transform = 'rotate(90deg)'; categoryStates[category] = true; } localStorage.setItem('doc-platform-category-states', JSON.stringify(categoryStates)); } }); }); } function setupDeepNavigation() { // Handle deep level (3+) folder expand/collapse document.querySelectorAll('.deep-header').forEach(button => { button.addEventListener('click', function() { const category = this.dataset.category; const items = document.getElementById(category); const chevron = this.querySelector('.deep-chevron'); if (items) { const isExpanded = items.classList.contains('expanded'); if (isExpanded) { items.classList.remove('expanded'); this.classList.remove('active'); categoryStates[category] = false; } else { items.classList.add('expanded'); this.classList.add('active'); categoryStates[category] = true; } localStorage.setItem('doc-platform-category-states', JSON.stringify(categoryStates)); } }); }); } function highlightCurrentPage() { // Remove all active states from nav items only document.querySelectorAll('.nav-item').forEach(item => { item.classList.remove('active'); }); // Highlight current page if (window.currentPageId) { // Find the nav-item link with this page ID const currentItem = document.querySelector(`a.nav-item[data-page="${window.currentPageId}"]`); if (currentItem) { currentItem.classList.add('active'); // Expand all parent containers up the tree let element = currentItem; while (element) { // Check for deep-items let deepParent = element.closest('.deep-items'); if (deepParent) { deepParent.classList.add('expanded'); const header = deepParent.previousElementSibling; if (header && header.classList.contains('deep-header')) { header.classList.add('active'); } element = deepParent.parentElement; continue; } // Check for subcategory-items let subParent = element.closest('.subcategory-items'); if (subParent) { subParent.classList.add('expanded'); const header = subParent.previousElementSibling; if (header) { const chevron = header.querySelector('.subcategory-chevron'); if (chevron) chevron.style.transform = 'rotate(90deg)'; } element = subParent.parentElement; continue; } // Check for category-items let catParent = element.closest('.category-items'); if (catParent) { catParent.classList.add('expanded'); const header = catParent.previousElementSibling; if (header) { const chevron = header.querySelector('.category-chevron'); if (chevron) chevron.style.transform = 'rotate(90deg)'; } element = catParent.parentElement; continue; } break; } } } } // ============================================ // Bookmarks // ============================================ function setupBookmarks() { updateBookmarkButtons(); updateBookmarks(); // Add click handlers to bookmark buttons document.addEventListener('click', function(e) { const bookmarkBtn = e.target.closest('.bookmark-btn'); if (bookmarkBtn) { e.preventDefault(); e.stopPropagation(); const pageId = bookmarkBtn.dataset.page; if (pageId) { toggleBookmark(pageId); } } }); } function toggleBookmark(pageId) { const index = bookmarks.indexOf(pageId); if (index > -1) { bookmarks.splice(index, 1); } else { bookmarks.push(pageId); } localStorage.setItem('doc-platform-bookmarks', JSON.stringify(bookmarks)); updateBookmarkButtons(); updateBookmarks(); } function updateBookmarkButtons() { document.querySelectorAll('.bookmark-btn').forEach(btn => { const pageId = btn.dataset.page; const icon = btn.querySelector('i'); if (icon) { if (bookmarks.includes(pageId)) { icon.className = 'fas fa-bookmark'; } else { icon.className = 'far fa-bookmark'; } } }); } function updateBookmarks() { const container = document.getElementById('bookmarkedItems'); if (!container) return; if (bookmarks.length === 0) { container.innerHTML = '
No bookmarks yet
'; return; } container.innerHTML = bookmarks.map(pageId => { const pageData = window.pageData ? window.pageData[pageId] : null; if (!pageData) return ''; return `
${pageData.page}
${pageData.category}
`; }).join(''); // Add click handlers for navigation container.querySelectorAll('.quick-access-item').forEach(item => { item.addEventListener('click', function(e) { if (e.target.closest('.remove-item')) { e.stopPropagation(); const removeBtn = e.target.closest('.remove-item'); const pageId = removeBtn.dataset.page; toggleBookmark(pageId); return; } const pageId = this.dataset.page; const link = document.querySelector(`.nav-item[data-page="${pageId}"]`); if (link && link.href) { // Use INITIAL base path to prevent path stacking const href = link.getAttribute('href'); const absoluteUrl = new URL(href, window.location.origin + INITIAL_BASE_PATH).href; loadPage(absoluteUrl, pageId); } }); }); } // ============================================ // Recent Items // ============================================ function addToRecent(pageId) { if (!pageId || pageId === 'welcome') return; const index = recentItems.indexOf(pageId); if (index > -1) { recentItems.splice(index, 1); } recentItems.unshift(pageId); recentItems = recentItems.slice(0, 10); localStorage.setItem('doc-platform-recent', JSON.stringify(recentItems)); } function updateRecentItems() { const container = document.getElementById('recentItems'); if (!container) return; if (recentItems.length === 0) { container.innerHTML = '
No recent pages
'; return; } container.innerHTML = recentItems.map(pageId => { const pageData = window.pageData ? window.pageData[pageId] : null; if (!pageData) return ''; return `
${pageData.page}
${pageData.category}
`; }).join(''); // Add click handlers for navigation container.querySelectorAll('.quick-access-item').forEach(item => { item.addEventListener('click', function(e) { if (e.target.closest('.remove-item')) { e.stopPropagation(); const removeBtn = e.target.closest('.remove-item'); const pageId = removeBtn.dataset.page; removeFromRecent(pageId); return; } const pageId = this.dataset.page; const link = document.querySelector(`.nav-item[data-page="${pageId}"]`); if (link && link.href) { // Use INITIAL base path to prevent path stacking const href = link.getAttribute('href'); const absoluteUrl = new URL(href, window.location.origin + INITIAL_BASE_PATH).href; loadPage(absoluteUrl, pageId); } }); }); } function removeFromRecent(pageId) { const index = recentItems.indexOf(pageId); if (index > -1) { recentItems.splice(index, 1); localStorage.setItem('doc-platform-recent', JSON.stringify(recentItems)); updateRecentItems(); } } // ============================================ // Clear Buttons & Collapse // ============================================ function setupClearButtons() { const clearRecent = document.getElementById('clearRecent'); const clearBookmarks = document.getElementById('clearBookmarks'); if (clearRecent) { clearRecent.addEventListener('click', function(e) { e.stopPropagation(); recentItems = []; localStorage.setItem('doc-platform-recent', JSON.stringify(recentItems)); updateRecentItems(); }); } if (clearBookmarks) { clearBookmarks.addEventListener('click', function(e) { e.stopPropagation(); bookmarks = []; localStorage.setItem('doc-platform-bookmarks', JSON.stringify(bookmarks)); updateBookmarkButtons(); updateBookmarks(); }); } } function setupCollapseSections() { const recentTitle = document.getElementById('recentTitle'); const bookmarksTitle = document.getElementById('bookmarksTitle'); if (recentTitle) { recentTitle.addEventListener('click', function(e) { if (!e.target.closest('.clear-btn')) { toggleCollapse('recent'); } }); } if (bookmarksTitle) { bookmarksTitle.addEventListener('click', function(e) { if (!e.target.closest('.clear-btn')) { toggleCollapse('bookmarks'); } }); } } function toggleCollapse(section) { const button = document.getElementById(`collapse${section.charAt(0).toUpperCase() + section.slice(1)}`); const content = document.getElementById(`${section}Content`); const icon = button?.querySelector('i'); if (!content || !icon) return; const isCollapsed = content.classList.contains('collapsed'); if (isCollapsed) { content.classList.remove('collapsed'); button.classList.remove('collapsed'); icon.className = 'fas fa-chevron-down'; localStorage.setItem(`doc-platform-${section}-collapsed`, 'false'); } else { content.classList.add('collapsed'); button.classList.add('collapsed'); icon.className = 'fas fa-chevron-right'; localStorage.setItem(`doc-platform-${section}-collapsed`, 'true'); } } // ============================================ // Mobile Menu // ============================================ function setupMobileMenu() { const mobileMenuBtn = document.getElementById('mobileMenuBtn'); const sidebar = document.getElementById('sidebar'); const overlay = document.querySelector('.sidebar-overlay'); if (mobileMenuBtn && sidebar) { mobileMenuBtn.addEventListener('click', function() { sidebar.classList.toggle('visible'); if (overlay) overlay.classList.toggle('visible'); }); } if (overlay) { overlay.addEventListener('click', function() { sidebar.classList.remove('visible'); overlay.classList.remove('visible'); }); } } // ============================================ // Theme // ============================================ function setupTheme() { const themeToggle = document.getElementById('themeToggle'); if (!themeToggle) return; const savedTheme = localStorage.getItem('doc-platform-theme') || 'dark'; document.body.setAttribute('data-theme', savedTheme); updateThemeIcon(savedTheme); themeToggle.addEventListener('click', function() { const current = document.body.getAttribute('data-theme'); const newTheme = current === 'dark' ? 'light' : 'dark'; document.body.setAttribute('data-theme', newTheme); localStorage.setItem('doc-platform-theme', newTheme); updateThemeIcon(newTheme); }); } function updateThemeIcon(theme) { const themeToggle = document.getElementById('themeToggle'); if (!themeToggle) return; const icon = themeToggle.querySelector('i'); if (icon) { icon.className = theme === 'dark' ? 'fas fa-sun' : 'fas fa-moon'; } } // ============================================ // Search // ============================================ function setupSearch() { const searchInput = document.getElementById('searchInput'); if (!searchInput) return; let debounceTimer; searchInput.addEventListener('input', function(e) { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { filterNavigation(e.target.value.toLowerCase().trim()); }, 150); }); // Keyboard shortcuts document.addEventListener('keydown', function(e) { // Ctrl/Cmd + K to focus search if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); searchInput.focus(); } // Escape to clear search if (e.key === 'Escape' && document.activeElement === searchInput) { searchInput.value = ''; filterNavigation(''); searchInput.blur(); } }); } function filterNavigation(query) { const navItems = document.querySelectorAll('.nav-item'); const categories = document.querySelectorAll('.nav-category'); const subcategories = document.querySelectorAll('.nav-subcategory'); if (!query) { // Show all items navItems.forEach(item => { item.style.display = ''; item.classList.remove('search-highlight'); }); categories.forEach(cat => cat.style.display = ''); subcategories.forEach(subcat => subcat.style.display = ''); return; } // Filter items navItems.forEach(item => { const text = item.textContent.toLowerCase(); const matches = text.includes(query); item.style.display = matches ? '' : 'none'; item.classList.toggle('search-highlight', matches); }); // Hide empty subcategories subcategories.forEach(subcat => { const items = subcat.querySelectorAll('.nav-item'); const visibleItems = Array.from(items).filter(item => item.style.display !== 'none'); subcat.style.display = visibleItems.length === 0 ? 'none' : ''; // Expand visible subcategories if (visibleItems.length > 0) { const content = subcat.querySelector('.subcategory-items'); if (content) content.classList.add('expanded'); } }); // Hide empty categories categories.forEach(cat => { const items = cat.querySelectorAll('.nav-item'); const visibleItems = Array.from(items).filter(item => item.style.display !== 'none'); cat.style.display = visibleItems.length === 0 ? 'none' : ''; // Expand visible categories if (visibleItems.length > 0) { const content = cat.querySelector('.category-items'); if (content) content.classList.add('expanded'); } }); } // ============================================ // Restore States // ============================================ function restoreStates() { // Restore category states Object.entries(categoryStates).forEach(([category, isExpanded]) => { const items = document.getElementById(category); const header = items?.previousElementSibling; const chevron = header?.querySelector('.category-chevron, .subcategory-chevron'); if (items && isExpanded) { items.classList.add('expanded'); if (chevron) chevron.style.transform = 'rotate(90deg)'; } }); // Restore collapse states ['recent', 'bookmarks'].forEach(section => { const isCollapsed = localStorage.getItem(`doc-platform-${section}-collapsed`) === 'true'; if (isCollapsed) { const content = document.getElementById(`${section}Content`); const button = document.getElementById(`collapse${section.charAt(0).toUpperCase() + section.slice(1)}`); const icon = button?.querySelector('i'); if (content) { content.classList.add('collapsed'); if (button) button.classList.add('collapsed'); if (icon) icon.className = 'fas fa-chevron-right'; } } }); } // ============================================ // Export Button - HTML Export // ============================================ function setupExportButton() { const exportBtn = document.getElementById('exportBtn'); const footerExportBtn = document.getElementById('footerExportBtn'); if (exportBtn) { exportBtn.addEventListener('click', function() { exportCurrentPage(); }); } if (footerExportBtn) { footerExportBtn.addEventListener('click', function(e) { e.preventDefault(); exportCurrentPage(); }); } // Discord link obfuscation - prevents bot scraping const discordLink = document.getElementById('discordLink'); if (discordLink) { discordLink.addEventListener('click', function(e) { e.preventDefault(); const d = this.getAttribute('data-d'); if (d) { const base = ['dis','cord','.gg/'].join(''); window.open('https://' + base + d, '_blank', 'noopener'); } }); } } function exportCurrentPage() { try { // Get current page content const contentElement = document.querySelector('.article-content'); if (!contentElement) { alert('No content found to export'); return; } // Get page title from multiple sources let pageTitle = 'Page'; // Try window.pageData first if (window.currentPageId && window.pageData?.[window.currentPageId]) { pageTitle = window.pageData[window.currentPageId].page || pageTitle; } else { // Fallback: get title from h1 or document title const h1 = contentElement.querySelector('h1'); if (h1) { pageTitle = h1.textContent.trim(); } else if (document.title) { pageTitle = document.title.split('|')[0].split('-')[0].trim(); } } const pageData = { page: pageTitle }; // Create export HTML const exportContent = createExportHTML(pageData, contentElement); // Create download const blob = new Blob([exportContent], { type: 'text/html;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${pageTitle.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.html`; a.style.display = 'none'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (error) { console.error('[Export] Failed:', error); alert('Export failed: ' + error.message); } } function createExportHTML(pageData, contentElement) { // Print-friendly neutral styles (no accent colors) const printStyles = ` body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 2rem; color: #1a1a1a; background: #fff; } h1, h2, h3, h4, h5, h6 { color: #1a1a1a; margin-top: 2rem; margin-bottom: 1rem; } h1 { font-size: 2rem; border-bottom: 2px solid #333; padding-bottom: 0.5rem; } h2 { font-size: 1.5rem; border-bottom: 1px solid #ccc; padding-bottom: 0.3rem; } h3 { font-size: 1.25rem; } pre { background: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; padding: 1rem; overflow-x: auto; font-family: 'Consolas', 'Monaco', monospace; font-size: 0.9rem; } code { background: #f5f5f5; padding: 0.15rem 0.3rem; border-radius: 3px; font-family: 'Consolas', 'Monaco', monospace; font-size: 0.9rem; } pre code { background: none; padding: 0; } blockquote { border-left: 3px solid #666; margin: 1rem 0; padding: 0.5rem 1rem; background: #f9f9f9; color: #555; } table { border-collapse: collapse; width: 100%; margin: 1rem 0; } th, td { border: 1px solid #ccc; padding: 0.5rem 0.75rem; text-align: left; } th { background: #f5f5f5; font-weight: 600; } img { max-width: 100%; height: auto; } a { color: #333; text-decoration: underline; } .copy-btn, .bookmark-btn { display: none; } @media print { body { padding: 0; } pre { white-space: pre-wrap; word-wrap: break-word; } } `; return ` ${pageData.page || 'Page'} ${contentElement.innerHTML} `; } // ============================================ // Copy Code Buttons // ============================================ function addCopyButtons() { // Find all code blocks that don't already have copy buttons const codeBlocks = document.querySelectorAll('pre:not([data-copy-added])'); codeBlocks.forEach(pre => { // Mark as processed pre.setAttribute('data-copy-added', 'true'); // Get the code content const code = pre.querySelector('code'); const codeText = code ? code.textContent : pre.textContent; // Create copy button const copyBtn = document.createElement('button'); copyBtn.className = 'copy-btn'; copyBtn.setAttribute('data-copy', codeText); copyBtn.innerHTML = ' Copy'; copyBtn.title = 'Copy code to clipboard'; // Add click handler copyBtn.addEventListener('click', function(e) { e.preventDefault(); copyToClipboard(codeText, copyBtn); }); // Create wrapper if pre doesn't have one if (!pre.parentElement.classList.contains('code-wrapper')) { const wrapper = document.createElement('div'); wrapper.className = 'code-wrapper'; pre.parentNode.insertBefore(wrapper, pre); wrapper.appendChild(pre); wrapper.appendChild(copyBtn); } else { pre.parentElement.appendChild(copyBtn); } }); } function copyToClipboard(text, button) { navigator.clipboard.writeText(text).then(() => { const originalText = button.innerHTML; button.innerHTML = ' Copied'; button.classList.add('copied'); setTimeout(() => { button.innerHTML = originalText; button.classList.remove('copied'); }, 2000); }).catch(() => { console.error('Failed to copy to clipboard'); button.innerHTML = ' Failed'; setTimeout(() => { button.innerHTML = ' Copy'; }, 2000); }); } // ============================================ // Scroll to Top Button // ============================================ function setupScrollToTop() { const scrollBtn = document.getElementById('scrollToTop'); if (!scrollBtn) return; // Show/hide button based on scroll position window.addEventListener('scroll', function() { if (window.scrollY > 300) { scrollBtn.classList.add('visible'); } else { scrollBtn.classList.remove('visible'); } }); // Scroll to top on click scrollBtn.addEventListener('click', function() { window.scrollTo({ top: 0, behavior: 'smooth' }); }); } // ============================================ // Table of Contents with Scroll Spy // ============================================ function setupTableOfContents() { const tocList = document.getElementById('tocList'); const tocContainer = document.getElementById('tocContainer'); // Always remove has-toc class first (will re-add if TOC is visible) document.body.classList.remove('has-toc'); if (!tocList || !tocContainer) return; // Only show TOC if page has h2 headings (articles have h2, welcome page doesn't) const h2Headings = document.querySelectorAll('.article-content > h2'); if (h2Headings.length === 0) { tocContainer.style.display = 'none'; return; } // Get all h2 and h3 headings for TOC (direct children only to avoid nested elements) const headings = document.querySelectorAll('.article-content > h2, .article-content > h3'); if (headings.length === 0) { tocContainer.style.display = 'none'; return; } // Add class to body to enable margin on main content document.body.classList.add('has-toc'); // Configuration const CONFIG = { rootMargin: '-80px 0px -80% 0px', scrollOffset: 100 }; let activeId = null; let tocObserver = null; // Generate TOC from headings (h2 and h3) function generateTOC() { tocList.innerHTML = ''; let currentH2Item = null; let currentSublist = null; headings.forEach(heading => { // Ensure heading has an ID if (!heading.id) { heading.id = heading.textContent .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, ''); } const id = heading.id; const text = heading.textContent; const level = heading.tagName.toLowerCase(); if (level === 'h2') { const li = document.createElement('li'); li.className = 'toc-item'; li.innerHTML = `${text}`; currentSublist = document.createElement('ul'); currentSublist.className = 'toc-sublist'; // collapsed by default li.appendChild(currentSublist); tocList.appendChild(li); currentH2Item = li; } else if (level === 'h3' && currentSublist) { const li = document.createElement('li'); li.className = 'toc-item'; li.innerHTML = `${text}`; currentSublist.appendChild(li); } }); } // Set active link and expand/collapse sublists function setActiveLink(id) { if (activeId === id) return; activeId = id; // Remove all active states tocList.querySelectorAll('.toc-link').forEach(link => { link.classList.remove('active'); }); // Collapse all sublists tocList.querySelectorAll('.toc-sublist').forEach(sublist => { sublist.classList.remove('expanded'); }); // Find and activate the current link const activeLink = tocList.querySelector(`.toc-link[data-id="${id}"]`); if (!activeLink) return; activeLink.classList.add('active'); // If this is an h3, expand its parent sublist (but don't highlight parent h2) const parentSublist = activeLink.closest('.toc-sublist'); if (parentSublist) { parentSublist.classList.add('expanded'); } // If this is an h2, expand its sublist const siblingSublist = activeLink.nextElementSibling; if (siblingSublist?.classList.contains('toc-sublist')) { siblingSublist.classList.add('expanded'); } } // Setup Intersection Observer for scroll spy function setupScrollSpy() { if (tocObserver) { tocObserver.disconnect(); } tocObserver = new IntersectionObserver( (entries) => { entries.forEach(entry => { if (entry.isIntersecting) { setActiveLink(entry.target.id); } }); }, { rootMargin: CONFIG.rootMargin, threshold: 0 } ); headings.forEach(heading => { if (heading.id) { tocObserver.observe(heading); } }); } // Smooth scroll on TOC click function setupSmoothScroll() { tocList.addEventListener('click', (e) => { const link = e.target.closest('.toc-link'); if (!link) return; e.preventDefault(); const targetId = link.getAttribute('data-id'); const target = document.getElementById(targetId); if (target) { const top = target.offsetTop - CONFIG.scrollOffset; window.scrollTo({ top: top, behavior: 'smooth' }); history.pushState(null, '', `#${targetId}`); setActiveLink(targetId); } }); } // Initialize generateTOC(); // Show TOC container if we have content (CSS media query will still control visibility on small screens) if (tocList.children.length > 0) { tocContainer.style.display = ''; // Remove inline style, let CSS take over setupScrollSpy(); setupSmoothScroll(); // Set initial active state const hash = window.location.hash.slice(1); if (hash && document.getElementById(hash)) { setActiveLink(hash); } else if (headings[0]?.id) { setActiveLink(headings[0].id); } } else { tocContainer.style.display = 'none'; } } // ============================================ // Global Functions // ============================================ window.toggleBookmark = toggleBookmark; window.removeFromRecent = removeFromRecent; })();