/* assets/extra.js * * Shows #source-pane only when we actually have a source to display. * Opens source externally when there's not enough space for the iframe. * Makes source pane state stateful via URL hash. * Preloads and caches all source pages for instant loading. */ /* ---------- cache system -------------------------------------------- */ class SourceCache { constructor() { this.cache = new Map(); this.sourceRegistry = new Map(); // Maps original URLs to local paths this.preloadComplete = false; this.cacheTimeout = 24 * 60 * 60 * 1000; // 24 hours in milliseconds } get(url) { const cached = this.cache.get(url); if (cached) { // Check if cache entry has expired if (Date.now() - cached.timestamp > this.cacheTimeout) { console.log(`โฐ Cache expired for: ${url}`); this.cache.delete(url); return null; } // Move to end (LRU) and update access time this.cache.delete(url); cached.lastAccessed = Date.now(); this.cache.set(url, cached); return cached.content; } return null; } set(url, content) { const now = Date.now(); this.cache.set(url, { content: content, timestamp: now, lastAccessed: now }); } has(url) { const cached = this.cache.get(url); if (cached) { // Check if expired if (Date.now() - cached.timestamp > this.cacheTimeout) { this.cache.delete(url); return false; } return true; } return false; } getLocalPath(originalUrl) { return this.sourceRegistry.get(originalUrl); } registerSource(originalUrl, localPath) { this.sourceRegistry.set(originalUrl, localPath); } isAvailableLocally(originalUrl) { return this.sourceRegistry.has(originalUrl); } clear() { this.cache.clear(); this.sourceRegistry.clear(); } size() { return this.cache.size; } setCacheTimeout(hours) { this.cacheTimeout = hours * 60 * 60 * 1000; console.log(`๐ Cache timeout set to ${hours} hours`); } cleanExpired() { const now = Date.now(); let cleaned = 0; for (const [url, cached] of this.cache.entries()) { if (now - cached.timestamp > this.cacheTimeout) { this.cache.delete(url); cleaned++; } } if (cleaned > 0) { console.log(`๐งน Cleaned ${cleaned} expired cache entries`); } return cleaned; } getCacheStats() { const now = Date.now(); const stats = { total: this.cache.size, fresh: 0, stale: 0, avgAge: 0 }; let totalAge = 0; for (const cached of this.cache.values()) { const age = now - cached.timestamp; totalAge += age; if (age > this.cacheTimeout) { stats.stale++; } else { stats.fresh++; } } stats.avgAge = stats.total > 0 ? Math.round(totalAge / stats.total / 1000 / 60) : 0; // minutes return stats; } } const sourceCache = new SourceCache(); let currentSourceUrl = null; // Track current source for header buttons /* ---------- helpers -------------------------------------------------- */ async function sha1hex(str) { const buf = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(str)); return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); } async function slug(url) { const u = new URL(url); const host = u.host.toLowerCase().replace(/\W+/g, '-').replace(/^-+|-+$/g, ''); const path = (u.pathname.replace(/^\/|\/$/g, '').toLowerCase().replace(/\W+/g, '-').slice(0, 60)) || 'root'; const hash = (await sha1hex(url)).slice(0, 10); return `${host}__${path}__${hash}.html`; } function hasSpaceForSourcePane() { // Check if screen is wide enough for main content (600px) + source pane (400px) + gap return window.innerWidth >= 1024; // 64rem โ 1024px } function getSourceUrlFromHash() { // Extract source URL from hash like #source=https://example.com const hash = window.location.hash; const match = hash.match(/[#&]source=([^&]+)/); return match ? decodeURIComponent(match[1]) : null; } function setSourceUrlInHash(url) { // Update URL hash with source parameter const currentHash = window.location.hash; const newHash = currentHash.includes('source=') ? currentHash.replace(/([#&])source=[^&]*/, `$1source=${encodeURIComponent(url)}`) : (currentHash ? `${currentHash}&source=${encodeURIComponent(url)}` : `#source=${encodeURIComponent(url)}`); history.pushState(null, '', newHash); } function removeSourceFromHash() { // Remove source parameter from hash const currentHash = window.location.hash; const newHash = currentHash .replace(/[#&]source=[^&]*/, '') .replace(/^&/, '#') .replace(/&$/, ''); if (newHash !== currentHash) { history.pushState(null, '', newHash || '#'); } } async function discoverAndPreloadSources() { console.log('๐ Discovering available source pages...'); // Find all source-link elements on the page const sourceLinks = document.querySelectorAll('a.source-link'); const discoveryPromises = []; for (const link of sourceLinks) { const originalUrl = link.href; const localPath = `/sources/${await slug(originalUrl)}`; // Check if local version exists (do this once during preload) discoveryPromises.push( fetch(localPath, { method: 'HEAD' }) .then(response => { if (response.ok) { sourceCache.registerSource(originalUrl, localPath); console.log(`๐ Registered: ${originalUrl} -> ${localPath}`); } }) .catch(() => { // Local version doesn't exist, that's fine }) ); } await Promise.all(discoveryPromises); console.log(`โ Discovery complete. Found ${sourceCache.sourceRegistry.size} local sources.`); } async function preloadSource(url) { try { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const content = await response.text(); sourceCache.set(url, content); return content; } catch (error) { console.warn(`Failed to preload source: ${url}`, error); return null; } } async function preloadAllSources() { if (sourceCache.preloadComplete) return; await discoverAndPreloadSources(); // Preload a few of the most recently discovered sources const localSources = Array.from(sourceCache.sourceRegistry.values()).slice(0, 10); // Limit to first 10 if (localSources.length > 0) { console.log(`๐ฆ Preloading ${localSources.length} source pages...`); const preloadPromises = localSources.map(async (localPath) => { if (!sourceCache.has(localPath)) { await preloadSource(localPath); } }); await Promise.all(preloadPromises); console.log(`๐ Preloaded ${sourceCache.size()} source pages`); } sourceCache.preloadComplete = true; } function createDataUrl(htmlContent) { // Create a data URL for the HTML content const blob = new Blob([htmlContent], { type: 'text/html' }); return URL.createObjectURL(blob); } async function updateSourceControls() { const originalBtn = document.querySelector('.source-control-original'); const archiveBtn = document.querySelector('.source-control-archive'); if (!currentSourceUrl || !originalBtn || !archiveBtn) return; // Update original button originalBtn.href = currentSourceUrl; // Check if archive is available (including checking if it exists) let localPath = null; if (sourceCache.isAvailableLocally(currentSourceUrl)) { localPath = sourceCache.getLocalPath(currentSourceUrl); } else { // Try to find the archive if not in registry yet const expectedLocalPath = `/sources/${await slug(currentSourceUrl)}`; try { const response = await fetch(expectedLocalPath, { method: 'HEAD' }); if (response.ok) { localPath = expectedLocalPath; sourceCache.registerSource(currentSourceUrl, expectedLocalPath); } } catch { // No archive available } } // Update archive button if (localPath) { archiveBtn.href = localPath; archiveBtn.style.display = 'inline-flex'; } else { archiveBtn.style.display = 'none'; } } async function showSourcePane(sourceUrl) { const pane = document.getElementById('wiki-source-pane'); const frame = document.getElementById('source-frame'); if (!hasSpaceForSourcePane()) { window.open(sourceUrl, '_blank'); return; } // Update current source URL for header controls currentSourceUrl = sourceUrl; // Show pane first for immediate feedback if (pane) pane.style.display = 'block'; // Update header controls (async to check for archives) updateSourceControls().catch(console.error); // Always prefer archived version if available, never load original in iframe let target = sourceUrl; if (sourceCache.isAvailableLocally(sourceUrl)) { target = sourceCache.getLocalPath(sourceUrl); } else { // If no local archive exists, try to generate the expected path and check if it exists const expectedLocalPath = `/sources/${await slug(sourceUrl)}`; try { const response = await fetch(expectedLocalPath, { method: 'HEAD' }); if (response.ok) { target = expectedLocalPath; sourceCache.registerSource(sourceUrl, expectedLocalPath); } } catch { // No local archive available - show message instead of loading original if (frame) { const noArchiveHtml = `
`; const dataUrl = createDataUrl(noArchiveHtml); frame.src = dataUrl; } setSourceUrlInHash(sourceUrl); return; } } // Load archived content if (sourceCache.has(target)) { const cachedContent = sourceCache.get(target); const dataUrl = createDataUrl(cachedContent); if (frame) frame.src = dataUrl; console.log(`โก Instant load from cache: ${target}`); } else { // Load and cache the archived content console.log(`๐ฅ Loading and caching: ${target}`); const content = await preloadSource(target); if (content && frame) { const dataUrl = createDataUrl(content); frame.src = dataUrl; } else if (frame) { // Show error message if archive fails to load const errorHtml = `Failed to load the archived content.
Use the "original" button to open in a new tab.
`; const dataUrl = createDataUrl(errorHtml); frame.src = dataUrl; } } // Update URL setSourceUrlInHash(sourceUrl); } function hideSourcePane() { const pane = document.getElementById('wiki-source-pane'); if (pane) { pane.style.display = 'none'; currentSourceUrl = null; } removeSourceFromHash(); } function createSourcePaneHeader() { const header = document.createElement('div'); header.className = 'source-pane-header'; // Left side - links const links = document.createElement('div'); links.className = 'source-pane-links'; // Archive button const archiveBtn = document.createElement('a'); archiveBtn.className = 'source-control-btn source-control-archive'; archiveBtn.target = '_blank'; archiveBtn.innerHTML = 'โOpen this archive copy'; // Original button const originalBtn = document.createElement('a'); originalBtn.className = 'source-control-btn source-control-original'; originalBtn.target = '_blank'; originalBtn.innerHTML = 'โView original'; links.appendChild(archiveBtn); links.appendChild(originalBtn); // Right side - close button only const controls = document.createElement('div'); controls.className = 'source-pane-controls'; // Close button const closeBtn = document.createElement('button'); closeBtn.className = 'close-btn'; closeBtn.innerHTML = 'ร'; closeBtn.addEventListener('click', hideSourcePane); controls.appendChild(closeBtn); header.appendChild(links); header.appendChild(controls); return header; } /* ---------- main ----------------------------------------------------- */ document.addEventListener('DOMContentLoaded', async () => { const pane = document.getElementById('wiki-source-pane'); // wrapper