/** * 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
([\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 = ``;
}
/**
* Utility: Format date for display
*/
function formatDate(date) {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}