599 lines
22 KiB
JavaScript
599 lines
22 KiB
JavaScript
/**
|
|
* 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 <div class="mermaid">
|
|
let diagramCount = 0;
|
|
const result = html.replace(/<pre><code class="language-mermaid">([\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 `<div class="mermaid"${nameAttr}>${code}</div>`;
|
|
});
|
|
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 = `
|
|
<button class="zoom-btn" data-action="zoom-in" title="Zoom In (Ctrl++)">🔍+</button>
|
|
<button class="zoom-btn" data-action="zoom-out" title="Zoom Out (Ctrl+-)">🔍−</button>
|
|
<button class="zoom-btn" data-action="zoom-reset" title="Reset Zoom">Reset</button>
|
|
<button class="zoom-btn" data-action="zoom-fit" title="Fit to Window">Fit</button>
|
|
<div class="toolbar-separator"></div>
|
|
<button class="zoom-btn" data-action="save-svg" title="Save as SVG">📥 SVG</button>
|
|
<button class="zoom-btn" data-action="save-png" title="Save as PNG">📥 PNG</button>
|
|
<span class="zoom-hint" style="margin-left: auto; font-size: 0.85rem; color: #999;">💡 Drag to pan</span>
|
|
`;
|
|
|
|
// 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 = '<div class="loading">Loading documentation...</div>';
|
|
|
|
// 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 = `<article class="markdown-body">${cleanHtml}</article>`;
|
|
|
|
// 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 = `<div class="error-message"><p>❌ ${message}</p></div>`;
|
|
}
|
|
|
|
/**
|
|
* Utility: Format date for display
|
|
*/
|
|
function formatDate(date) {
|
|
return new Date(date).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric'
|
|
});
|
|
}
|