/** * SmartCane Documentation Viewer * Dynamically loads and renders markdown files from docs/ folder */ // Configuration const DOCS_PATH = '../docs/'; const STORAGE_KEY = 'smartcane-last-doc'; // Configure marked options marked.setOptions({ breaks: true, gfm: true, headerIds: true, mangle: false, }); // Configure Mermaid if (typeof mermaid !== 'undefined') { mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'loose' }); } /** * Convert mermaid code blocks to proper divs * This must happen BEFORE DOMPurify sanitization */ function convertMermaidCodeBlocks(html) { // Replace ```mermaid code blocks with
let diagramCount = 0; const result = html.replace(/
([\s\S]*?)<\/code><\/pre>/g, (match, code) => {
        // Extract diagram name from code BEFORE sanitization
        const diagramName = extractMermaidDiagramName(code);
        diagramCount++;
        console.log(`[Mermaid ${diagramCount}] Extracted name: "${diagramName}"`);
        const nameAttr = diagramName ? ` data-diagram-name="${diagramName.replace(/"/g, '"')}"` : '';
        return `
${code}
`; }); console.log(`Converted ${diagramCount} mermaid diagrams`); return result; } /** * Extract diagram name from mermaid code * Looks for comment lines like: %% Diagram Name */ function extractMermaidDiagramName(code) { const lines = code.split('\n'); for (const line of lines) { const trimmed = line.trim(); // Look for %% comment lines at the start if (trimmed.startsWith('%%')) { let title = trimmed.substring(2).trim(); // Remove common prefixes if they exist if (title.toLowerCase().startsWith('title:')) { title = title.substring(6).trim(); } else if (title.toLowerCase().startsWith('name:')) { title = title.substring(5).trim(); } else if (title.toLowerCase().startsWith('diagram:')) { title = title.substring(8).trim(); } // Return if non-empty and not just decorative comments if (title && title.length > 2 && !title.match(/^={3,}|^-{3,}/)) { return title; } } } return null; } /** * Add zoom controls to mermaid diagrams */ function addMermaidZoomControls(container) { const mermaidDivs = container.querySelectorAll('.mermaid'); mermaidDivs.forEach((div, index) => { // Create wrapper container const wrapper = document.createElement('div'); wrapper.className = 'mermaid-wrapper'; wrapper.id = `mermaid-wrapper-${index}`; // Create zoom controls toolbar const toolbar = document.createElement('div'); toolbar.className = 'mermaid-toolbar'; toolbar.innerHTML = `
💡 Drag to pan `; // Create zoom container for the diagram const zoomContainer = document.createElement('div'); zoomContainer.className = 'mermaid-zoom-container'; zoomContainer.style.overflow = 'auto'; // Move diagram into zoom container zoomContainer.appendChild(div.cloneNode(true)); // Assemble the wrapper wrapper.appendChild(toolbar); wrapper.appendChild(zoomContainer); // Replace original div with wrapper div.parentNode.replaceChild(wrapper, div); // Extract diagram name from the mermaid div data attribute let diagramName = div.getAttribute('data-diagram-name'); console.log(`[Diagram ${index}] data-diagram-name attribute: "${diagramName}"`); // Store zoom state and diagram name const zoomState = { level: 1, isDragging: false, startX: 0, startY: 0, scrollLeftStart: 0, scrollTopStart: 0, fitZoom: 1, diagramName: diagramName }; wrapper.dataset.zoomLevel = zoomState.level; // Wait for SVG to render, then auto-fit and size container setTimeout(() => { const svg = zoomContainer.querySelector('svg'); if (svg) { // Get natural SVG dimensions const svgWidth = svg.width.baseVal.value || svg.getBoundingClientRect().width; const svgHeight = svg.height.baseVal.value || svg.getBoundingClientRect().height; const containerWidth = zoomContainer.clientWidth - 20; // Account for padding // Calculate proportional container height based on aspect ratio if (svgWidth > 0 && svgHeight > 0) { const aspectRatio = svgHeight / svgWidth; const proportionalHeight = Math.max(300, Math.min(containerWidth * aspectRatio, window.innerHeight * 0.8)); // Set container height to fit diagram proportions zoomContainer.style.minHeight = proportionalHeight + 'px'; // Calculate fit zoom to fill container const containerHeight = proportionalHeight - 20; // Account for padding const scaleWidth = containerWidth / svgWidth; const scaleHeight = containerHeight / svgHeight; const fitScale = Math.min(scaleWidth, scaleHeight); if (fitScale !== 1) { zoomState.level = fitScale; zoomState.fitZoom = fitScale; svg.style.transform = `scale(${zoomState.level})`; svg.style.transformOrigin = 'top left'; svg.style.display = 'inline-block'; wrapper.dataset.zoomLevel = zoomState.level; } } } }, 200); // Add event listeners to buttons const buttons = toolbar.querySelectorAll('.zoom-btn'); buttons.forEach(btn => { btn.addEventListener('click', () => { const action = btn.dataset.action; const svg = zoomContainer.querySelector('svg'); if (!svg) return; switch (action) { case 'zoom-in': zoomState.level = Math.min(zoomState.level + 0.2, 5); break; case 'zoom-out': zoomState.level = Math.max(zoomState.level - 0.2, 0.5); break; case 'zoom-reset': zoomState.level = 1; zoomContainer.scrollLeft = 0; zoomContainer.scrollTop = 0; break; case 'zoom-fit': zoomState.level = zoomState.fitZoom; zoomContainer.scrollLeft = 0; zoomContainer.scrollTop = 0; break; case 'save-svg': saveMermaidAsSVG(svg, index, zoomState.diagramName); return; case 'save-png': saveMermaidAsPNG(zoomContainer, index, zoomState.diagramName); return; } // Apply zoom svg.style.transform = `scale(${zoomState.level})`; svg.style.transformOrigin = 'top left'; svg.style.display = 'inline-block'; wrapper.dataset.zoomLevel = zoomState.level; }); }); // Add mouse drag to pan functionality const svg = zoomContainer.querySelector('svg'); if (svg) { zoomContainer.addEventListener('mousedown', (e) => { if (e.button === 0 && zoomState.level > 1) { // Left mouse button zoomState.isDragging = true; zoomState.startX = e.pageX; zoomState.startY = e.pageY; zoomState.scrollLeftStart = zoomContainer.scrollLeft; zoomState.scrollTopStart = zoomContainer.scrollTop; zoomContainer.style.cursor = 'grabbing'; e.preventDefault(); } }); document.addEventListener('mousemove', (e) => { if (zoomState.isDragging && wrapper.contains(document.elementFromPoint(e.clientX, e.clientY))) { const deltaX = e.pageX - zoomState.startX; const deltaY = e.pageY - zoomState.startY; zoomContainer.scrollLeft = zoomState.scrollLeftStart - deltaX; zoomContainer.scrollTop = zoomState.scrollTopStart - deltaY; } }); document.addEventListener('mouseup', () => { if (zoomState.isDragging) { zoomState.isDragging = false; zoomContainer.style.cursor = 'grab'; } }); // Change cursor to grab when hovering zoomContainer.addEventListener('mouseenter', () => { if (zoomState.level > 1) { zoomContainer.style.cursor = 'grab'; } }); zoomContainer.addEventListener('mouseleave', () => { zoomContainer.style.cursor = 'auto'; zoomState.isDragging = false; }); } }); } /** * Initialize the documentation viewer */ document.addEventListener('DOMContentLoaded', async () => { console.log('Initializing SmartCane Documentation Viewer...'); // Load and display list of documentation files await populateDocumentList(); // Handle URL hash on page load (for bookmarking) if (window.location.hash) { const docName = window.location.hash.substring(1); loadDocument(docName); } // Setup search functionality setupSearch(); }); /** * Fetch and populate the list of markdown files from docs folder */ async function populateDocumentList() { try { // This is a simple fallback list in case directory listing isn't available // On Netlify, you can replace this with a _data.json file if needed const docFiles = [ 'system_architecture.md', 'ARCHITECTURE_DATA_FLOW.md', 'ARCHITECTURE_INTEGRATION_GUIDE.md', 'CLIENT_TYPE_ARCHITECTURE.md', 'DEV_LAPTOP_EXECUTION.md', 'PIPELINE_OVERVIEW.md', 'QUALITY_CHECK_REPORT.md', 'REVIEW_SUMMARY.md', 'SOBIT_DEPLOYMENT.md' ]; // Sort alphabetically with main architecture file first docFiles.sort((a, b) => { // Ensure system_architecture.md comes first if (a === 'system_architecture.md') return -1; if (b === 'system_architecture.md') return 1; return a.localeCompare(b); }); displayDocumentList(docFiles); } catch (error) { console.error('Error loading document list:', error); showError('Could not load documentation list.'); } } /** * Display the list of documents in the sidebar */ function displayDocumentList(docFiles) { const docList = document.getElementById('doc-list'); docList.innerHTML = ''; // Clear loading message docFiles.forEach(file => { // Create friendly display name from filename const displayName = formatDocName(file); const docId = file.replace('.md', ''); const li = document.createElement('li'); const a = document.createElement('a'); a.href = `#${docId}`; a.textContent = displayName; a.className = 'doc-link'; // Add click handler a.addEventListener('click', (e) => { e.preventDefault(); loadDocument(docId); setActiveLink(a); }); li.appendChild(a); docList.appendChild(li); }); } /** * Format document name for display (e.g., "ARCHITECTURE_DATA_FLOW.md" → "📊 Data Flow") */ function formatDocName(filename) { const nameMap = { 'system_architecture.md': '🏗️ System Architecture', 'ARCHITECTURE_DATA_FLOW.md': '📊 Data Flow', 'ARCHITECTURE_INTEGRATION_GUIDE.md': '🔗 Integration Guide', 'CLIENT_TYPE_ARCHITECTURE.md': '🎯 Client Types', 'DEV_LAPTOP_EXECUTION.md': '💻 Dev Laptop Setup', 'PIPELINE_OVERVIEW.md': '📋 Pipeline Overview', 'QUALITY_CHECK_REPORT.md': '✅ Quality Report', 'REVIEW_SUMMARY.md': '📝 Summary', 'SOBIT_DEPLOYMENT.md': '🚀 Production Deployment' }; return nameMap[filename] || filename.replace('.md', ''); } /** * Load and display a markdown document */ async function loadDocument(docId) { const filename = docId.endsWith('.md') ? docId : `${docId}.md`; const filepath = DOCS_PATH + filename; try { console.log(`Loading document: ${filepath}`); // Show loading state const contentDiv = document.getElementById('content'); contentDiv.innerHTML = '
Loading documentation...
'; // Fetch the markdown file const response = await fetch(filepath); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const markdown = await response.text(); // Parse markdown to HTML let html = marked.parse(markdown); // Convert mermaid code blocks BEFORE sanitization html = convertMermaidCodeBlocks(html); // Sanitize HTML with mermaid divs allowed const cleanHtml = DOMPurify.sanitize(html, { ALLOWED_TAGS: ['div', 'p', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'u', 'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'img', 'hr', 'br', 'span', 'svg', 'g', 'path', 'text', 'line', 'circle', 'rect', 'tspan'], ALLOWED_ATTR: ['class', 'id', 'href', 'title', 'alt', 'src', 'width', 'height', 'viewBox', 'd', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'fill', 'stroke', 'stroke-width', 'data-diagram-name'], KEEP_CONTENT: true }); // Add to content div with sanitized HTML contentDiv.innerHTML = `
${cleanHtml}
`; // Scroll to top contentDiv.scrollTop = 0; // Update URL hash window.location.hash = docId; // Save to session storage for quick reload sessionStorage.setItem(STORAGE_KEY, docId); // Add syntax highlighting to code blocks (if highlight.js available) highlightCodeBlocks(contentDiv); // Render Mermaid diagrams if (typeof mermaid !== 'undefined') { try { console.log('Running Mermaid...'); await mermaid.run(); // Add zoom controls to diagrams addMermaidZoomControls(contentDiv); } catch (e) { console.warn('Mermaid render warning:', e); } } } catch (error) { console.error(`Error loading document ${filepath}:`, error); showError(`Could not load document: ${filename}`); } } /** * Save Mermaid diagram as SVG */ function saveMermaidAsSVG(svgElement, index, diagramName = null) { try { console.log(`[Export SVG] Diagram ${index}, name: "${diagramName}"`); // Clone the SVG to avoid modifying the original const svgClone = svgElement.cloneNode(true); // Reset any transforms for clean export svgClone.style.transform = 'none'; // Serialize SVG to string const svgString = new XMLSerializer().serializeToString(svgClone); // Create blob const blob = new Blob([svgString], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); // Generate filename: use diagram name if available, else use timestamp const filename = diagramName ? `${diagramName}.svg` : `mermaid-diagram-${new Date().toISOString().slice(0, 10)}-${index}.svg`; console.log(`[Export SVG] Generated filename: "${filename}"`); // Download const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); console.log(`Downloaded: ${filename}`); } catch (error) { console.error('Error saving SVG:', error); alert('Failed to save SVG. Check console for details.'); } } /** * Save Mermaid diagram as PNG */ async function saveMermaidAsPNG(container, index, diagramName = null) { try { console.log(`[Export PNG] Diagram ${index}, name: "${diagramName}"`); if (typeof html2canvas === 'undefined') { alert('PNG export library not available. Please try SVG export instead.'); return; } // Show loading state const originalCursor = document.body.style.cursor; document.body.style.cursor = 'wait'; // Get the SVG element const svg = container.querySelector('svg'); if (!svg) { alert('No diagram found to export.'); return; } // Reset transform for cleaner capture const originalTransform = svg.style.transform; svg.style.transform = 'scale(1)'; // Capture the diagram const canvas = await html2canvas(container, { backgroundColor: '#ffffff', scale: 2, // Higher quality logging: false, useCORS: true }); // Restore original transform svg.style.transform = originalTransform; // Convert canvas to blob and download canvas.toBlob((blob) => { const url = URL.createObjectURL(blob); const filename = diagramName ? `${diagramName}.png` : `mermaid-diagram-${new Date().toISOString().slice(0, 10)}-${index}.png`; console.log(`[Export PNG] Generated filename: "${filename}"`); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); document.body.style.cursor = originalCursor; console.log(`Downloaded: ${filename}`); }); } catch (error) { console.error('Error saving PNG:', error); document.body.style.cursor = originalCursor; alert('Failed to save PNG. Check console for details.'); } } function highlightCodeBlocks(container) { // Only apply if highlight.js is loaded if (typeof hljs !== 'undefined') { container.querySelectorAll('pre code').forEach(block => { hljs.highlightElement(block); }); } else { // Fallback: add basic syntax highlighting classes container.querySelectorAll('pre code').forEach(block => { block.classList.add('code-block'); }); } } /** * Set the active link in the sidebar */ function setActiveLink(activeElement) { // Remove active class from all links document.querySelectorAll('.doc-link').forEach(link => { link.classList.remove('active'); }); // Add active class to clicked link activeElement.classList.add('active'); } /** * Setup search functionality */ function setupSearch() { const searchInput = document.getElementById('search-input'); searchInput.addEventListener('input', (e) => { const searchTerm = e.target.value.toLowerCase(); const docLinks = document.querySelectorAll('.doc-link'); docLinks.forEach(link => { const text = link.textContent.toLowerCase(); const isVisible = text.includes(searchTerm); link.parentElement.style.display = isVisible ? 'block' : 'none'; }); }); } /** * Display error message in content area */ function showError(message) { const contentDiv = document.getElementById('content'); contentDiv.innerHTML = `

❌ ${message}

`; } /** * Utility: Format date for display */ function formatDate(date) { return new Date(date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); }