UNPKG

@knowcode/doc-builder

Version:

Reusable documentation builder for markdown-based sites with Vercel deployment support

1,356 lines (1,151 loc) 72.5 kB
const fs = require('fs-extra'); const path = require('path'); const marked = require('marked'); const chalk = require('chalk'); const matter = require('gray-matter'); const SupabaseAuth = require('./supabase-auth'); const { generateMetaTags, generateJSONLD, generateDescription, extractKeywords, generateBreadcrumbs, generateSitemap, generateRobotsTxt } = require('./seo'); const { replaceEmojisWithIcons } = require('./emoji-mapper'); // Configure marked options marked.setOptions({ highlight: function(code, lang) { return `<code class="language-${lang}">${escapeHtml(code)}</code>`; }, breaks: true, gfm: true }); // Helper function to normalize titles function normalizeTitle(title) { if (!title) return title; // Check if title is all caps (more than 50% uppercase letters) const upperCount = (title.match(/[A-Z]/g) || []).length; const letterCount = (title.match(/[a-zA-Z]/g) || []).length; const isAllCaps = letterCount > 0 && (upperCount / letterCount) > 0.5; if (isAllCaps) { // Convert to title case return title.toLowerCase() .split(' ') .map(word => { // Keep small words lowercase unless they're first const smallWords = ['a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'from', 'in', 'into', 'of', 'on', 'or', 'the', 'to', 'with']; if (smallWords.includes(word.toLowerCase()) && word !== title.split(' ')[0]) { return word.toLowerCase(); } return word.charAt(0).toUpperCase() + word.slice(1); }) .join(' '); } return title; } // Helper function to escape HTML function escapeHtml(text) { const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }; return text.replace(/[&<>"']/g, m => map[m]); } // Helper function to check if filename contains non-printable characters function hasNonPrintableChars(filename) { // Check for non-printable ASCII characters (0x00-0x1F, 0x7F-0x9F) // Exclude common allowed control chars like tab (0x09), newline (0x0A), carriage return (0x0D) return /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/.test(filename); } // Helper function to sanitize filename for safe processing function sanitizeFilename(filename) { // Remove non-printable characters return filename.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, ''); } // Helper function to smartly capitalize text while preserving existing capitalization function smartCapitalize(text) { if (!text) return text; return text .replace(/[-_]/g, ' ') // Replace hyphens and underscores with spaces .split(' ') .map(word => { if (!word) return word; // If word has any uppercase letters (like "AI", "API", "iPhone"), preserve it if (/[A-Z]/.test(word)) { return word; } // If word is all lowercase, capitalize first letter return word.charAt(0).toUpperCase() + word.slice(1); }) .join(' '); } // Detect document status from content and front matter function detectDocumentStatus(content, frontMatter = {}) { // Check front matter first if (frontMatter.status) { return frontMatter.status.toLowerCase(); } // Check content for status indicators (case insensitive) const contentLower = content.toLowerCase(); const statusIndicators = { 'draft': ['🚧', 'draft', 'work in progress', 'wip', 'under construction'], 'complete': ['✅', 'completed', 'verified', 'ready', '✓'], 'warning': ['⚠️', '❗', 'warning', 'caution', 'attention'], 'error': ['❌', '🚨', 'error', 'failed', 'broken'], 'planning': ['📋', 'planning', 'todo', 'roadmap', 'brainstorm'], 'deprecated': ['🗑️', 'deprecated', 'obsolete', 'archived'] }; for (const [status, indicators] of Object.entries(statusIndicators)) { for (const indicator of indicators) { if (contentLower.includes(indicator)) { return status; } } } // Special cases based on file name patterns if (frontMatter.title || content.includes('# ')) { const title = (frontMatter.title || content.match(/^#\s+(.+)$/m)?.[1] || '').toLowerCase(); if (title.includes('readme') || title.includes('overview')) return 'readme'; if (title.includes('guide') || title.includes('tutorial')) return 'guide'; if (title.includes('troubleshoot') || title.includes('problem')) return 'troubleshoot'; if (title.includes('api') || title.includes('reference')) return 'reference'; } return 'default'; } // Get icon for document status with subtle styling function getIconForStatus(status, isFolder = false, config = {}) { if (isFolder) { return '<i class="ph ph-folder"></i>'; } // Check if dynamic icons are enabled (default: true) const dynamicIcons = config.features?.dynamicNavIcons !== false; const subtleColors = config.features?.subtleColors !== false && dynamicIcons; if (!dynamicIcons) { return '<i class="ph ph-file-text"></i>'; } const statusIcons = { 'draft': { icon: 'ph ph-pencil-simple', color: subtleColors ? '#d97706' : undefined // More subtle amber }, 'complete': { icon: 'ph ph-check-circle', color: subtleColors ? (config.isStaticOutput ? '#2563eb' : '#059669') : undefined // Blue for static, green for normal }, 'warning': { icon: 'ph ph-warning', color: subtleColors ? '#ea580c' : undefined // More subtle orange }, 'error': { icon: 'ph ph-x-circle', color: subtleColors ? '#dc2626' : undefined // More subtle red }, 'planning': { icon: 'ph ph-list-checks', color: subtleColors ? '#7c3aed' : undefined // More subtle violet }, 'deprecated': { icon: 'ph ph-archive', color: subtleColors ? '#6b7280' : undefined // Subtle gray }, 'guide': { icon: 'ph ph-book-open', color: subtleColors ? '#2563eb' : undefined // More subtle blue }, 'readme': { icon: 'ph ph-house', color: subtleColors ? '#059669' : undefined // More subtle emerald }, 'troubleshoot': { icon: 'ph ph-wrench', color: subtleColors ? '#dc2626' : undefined // More subtle red }, 'reference': { icon: 'ph ph-book', color: subtleColors ? '#7c3aed' : undefined // More subtle violet }, 'default': { icon: 'ph ph-file-text', color: undefined } }; const statusConfig = statusIcons[status] || statusIcons.default; const colorStyle = statusConfig.color ? ` style="color: ${statusConfig.color};"` : ''; return `<i class="${statusConfig.icon}"${colorStyle}></i>`; } // Extract summary from markdown content for tooltips function extractSummary(content, maxLength = 150) { // Remove front matter content = content.replace(/^---[\s\S]*?---\n/, ''); // First, try to find Overview or Summary sections const overviewMatch = content.match(/##\s+(Overview|Summary)\s*\n+([\s\S]*?)(?=\n##|\n#[^#]|$)/i); if (overviewMatch) { // Extract the content under Overview/Summary section let summaryContent = overviewMatch[2]; // Clean up the extracted content summaryContent = summaryContent.replace(/```[\s\S]*?```/g, ''); // Remove code blocks summaryContent = summaryContent.replace(/`[^`]+`/g, ''); // Remove inline code summaryContent = summaryContent.replace(/!\[[^\]]*\]\([^)]*\)/g, ''); // Remove images summaryContent = summaryContent.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1'); // Convert links to text summaryContent = summaryContent.replace(/<[^>]+>/g, ''); // Remove HTML summaryContent = summaryContent.replace(/\*\*([^*]+)\*\*/g, '$1'); // Remove bold summaryContent = summaryContent.replace(/\*([^*]+)\*/g, '$1'); // Remove italic summaryContent = summaryContent.replace(/__([^_]+)__/g, '$1'); // Remove bold alt summaryContent = summaryContent.replace(/_([^_]+)_/g, '$1'); // Remove italic alt summaryContent = summaryContent.replace(/~~([^~]+)~~/g, '$1'); // Remove strikethrough summaryContent = summaryContent.replace(/^>\s*/gm, ''); // Remove blockquotes summaryContent = summaryContent.replace(/^[-*+]\s+/gm, ''); // Remove list markers summaryContent = summaryContent.replace(/^\d+\.\s+/gm, ''); // Remove numbered lists summaryContent = summaryContent.replace(/^---+$/gm, ''); // Remove horizontal rules summaryContent = summaryContent.replace(/^\*\*\*+$/gm, ''); // Remove horizontal rules alt // Normalize whitespace summaryContent = summaryContent.trim().replace(/\s+/g, ' '); // Get the first sentence or paragraph const sentences = summaryContent.split(/\.\s+/); if (sentences.length > 0 && sentences[0].trim().length > 10) { summaryContent = sentences[0].trim(); if (!summaryContent.endsWith('.')) { summaryContent += '.'; } // Truncate if needed if (summaryContent.length > maxLength) { const cutIndex = summaryContent.lastIndexOf(' ', maxLength); summaryContent = summaryContent.substring(0, cutIndex > 0 ? cutIndex : maxLength).trim() + '...'; } return summaryContent; } } // Fallback to original logic if no Overview/Summary section found // Remove headers but keep their text content = content.replace(/^#+\s+(.+)$/gm, '$1. '); // Remove code blocks content = content.replace(/```[\s\S]*?```/g, ''); content = content.replace(/`[^`]+`/g, ''); // Remove images content = content.replace(/!\[[^\]]*\]\([^)]*\)/g, ''); // Convert links to just their text content = content.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1'); // Remove HTML tags content = content.replace(/<[^>]+>/g, ''); // Remove markdown formatting content = content.replace(/\*\*([^*]+)\*\*/g, '$1'); // Bold content = content.replace(/\*([^*]+)\*/g, '$1'); // Italic content = content.replace(/__([^_]+)__/g, '$1'); // Bold content = content.replace(/_([^_]+)_/g, '$1'); // Italic content = content.replace(/~~([^~]+)~~/g, '$1'); // Strikethrough // Remove blockquotes content = content.replace(/^>\s*/gm, ''); // Remove list markers content = content.replace(/^[-*+]\s+/gm, ''); content = content.replace(/^\d+\.\s+/gm, ''); // Remove horizontal rules content = content.replace(/^---+$/gm, ''); content = content.replace(/^\*\*\*+$/gm, ''); // Remove extra whitespace and normalize content = content.trim().replace(/\s+/g, ' '); // Skip common metadata patterns at the start content = content.replace(/^(Generated|Status|Verified|Date|Author|Version|Updated|Created):\s*[^\n]+\s*/gi, ''); // Find the first meaningful content paragraph const paragraphs = content.split(/\.\s+/).filter(p => p.trim().length > 20); if (paragraphs.length > 0) { content = paragraphs[0].trim(); if (!content.endsWith('.')) { content += '.'; } } // Truncate if needed if (content.length > maxLength) { // Try to cut at a word boundary const cutIndex = content.lastIndexOf(' ', maxLength); content = content.substring(0, cutIndex > 0 ? cutIndex : maxLength).trim() + '...'; } return content || 'No description available'; } // Process markdown content function processMarkdownContent(content, config = {}) { // Convert mermaid code blocks to mermaid divs with titles // Process before markdown parsing to prevent breaking up the content const mermaidBlocks = []; let processedContent = content.replace(/```mermaid\n([\s\S]*?)```/g, (match, mermaidContent) => { // Try to extract title from mermaid content let title = 'Diagram'; // Look for title in various mermaid formats const titlePatterns = [ /title\s+([^\n]+)/i, // gantt charts: title My Title /graph\s+\w+\[["']([^"']+)["']\]/, // graph TD["My Title"] /flowchart\s+\w+\[["']([^"']+)["']\]/, // flowchart TD["My Title"] /---\s*title:\s*([^\n]+)\s*---/, // frontmatter style ]; for (const pattern of titlePatterns) { const match = mermaidContent.match(pattern); if (match) { title = match[1].trim(); break; } } // Store the mermaid block and return a placeholder const placeholder = `MERMAID_BLOCK_${mermaidBlocks.length}`; mermaidBlocks.push(`<div class="mermaid-wrapper"> <div class="mermaid">${mermaidContent.trim()}</div> </div>`); return placeholder; }); // Parse the markdown let html = marked.parse(processedContent); // Replace placeholders with actual mermaid blocks mermaidBlocks.forEach((block, index) => { html = html.replace(`<p>MERMAID_BLOCK_${index}</p>`, block); html = html.replace(`MERMAID_BLOCK_${index}`, block); }); // Convert internal .md links to .html links // Matches href="...md" or href='...md' and converts to .html // Handles: file.md, path/file.md, file.md#anchor, file.md?query html = html.replace(/href=(["'])([^"']*?)\.md((?:#[^"']*)?(?:\?[^"']*)?)\1/gi, 'href=$1$2.html$3$1'); // Replace emojis with Phosphor icons if enabled html = replaceEmojisWithIcons(html, config); return html; } /** * Generate favicon tag based on config */ function generateFaviconTag(favicon) { // If it's a single character (emoji), use SVG data URI if (favicon && favicon.length <= 2) { return `<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>${favicon}</text></svg>">`; } // Otherwise assume it's a path to an image file return `<link rel="icon" href="${favicon}">`; } // Generate static HTML (without authentication and with relative paths) function generateStaticHTML(title, content, navigation, currentPath = '', config = {}, originalContent = '', frontMatter = {}) { // Use regular generateHTML but force disable auth features and use relative paths const staticConfig = { ...config, features: { ...config.features, authentication: false, privateDirectoryAuth: false }, // Special flag to indicate this is static output (for relative paths) isStaticOutput: true }; return generateHTML(title, content, navigation, currentPath, staticConfig, originalContent, frontMatter); } // Generate HTML from template function generateHTML(title, content, navigation, currentPath = '', config = {}, originalContent = '', frontMatter = {}) { // For normal output, use standard depth calculation const pathParts = currentPath.split('/').filter(p => p); let depth = pathParts.length; let relativePath = depth > 0 ? '../'.repeat(depth) : ''; // For static output, calculate depth differently (exclude the filename) if (config.isStaticOutput) { depth = pathParts.length > 1 ? pathParts.length - 1 : 0; relativePath = depth > 0 ? '../'.repeat(depth) : ''; } // For static output, use relative paths; for normal output, use absolute paths const resourcePath = config.isStaticOutput ? relativePath : '/'; const siteName = config.siteName || 'Documentation'; const siteDescription = config.siteDescription || 'Documentation site'; // Get doc-builder version from package.json const packageJson = require('../package.json'); const docBuilderVersion = packageJson.version; // SEO preparation let seoTags = ''; let jsonLd = ''; let finalSeoTitle = `${title} - ${siteName}`; let pageDescription = frontMatter.description || generateDescription(originalContent || content, config.seo?.descriptionFallback) || siteDescription; if (config.seo?.enabled && config.seo?.siteUrl) { // Generate page URL const pageUrl = `${config.seo.siteUrl}/${currentPath}`; // Extract keywords - priority: front matter > content extraction + global const contentKeywords = config.seo?.autoKeywords !== false ? extractKeywords(originalContent || content, config.seo?.keywordLimit || 7) : []; const pageKeywords = frontMatter.keywords || []; const keywords = [...new Set([...(config.seo.keywords || []), ...pageKeywords, ...contentKeywords])] .slice(0, config.seo?.keywordLimit || 7); // Generate breadcrumbs const breadcrumbs = generateBreadcrumbs(currentPath, config.seo.siteUrl, siteName); // Generate SEO-optimized title const titleTemplate = config.seo?.titleTemplate || '{pageTitle} | {siteName}'; const seoTitle = titleTemplate .replace('{pageTitle}', title) .replace('{siteName}', siteName); // Ensure title is within optimal SEO length (50-60 characters) if (seoTitle.length > 60) { // Try different approaches to fit within limits if (title.length <= 50) { // If page title fits, just use that finalSeoTitle = title; } else if (title.length > 50) { // Truncate page title but keep meaningful part const truncatedTitle = title.substring(0, 47) + '...'; finalSeoTitle = truncatedTitle; } else { finalSeoTitle = seoTitle.substring(0, 57) + '...'; } } else { finalSeoTitle = seoTitle; } // Generate meta tags seoTags = generateMetaTags({ title: finalSeoTitle, description: pageDescription, url: pageUrl, author: config.seo.author, keywords: keywords, twitterHandle: config.seo.twitterHandle, ogImage: config.seo.ogImage, siteName: siteName, language: config.seo.language, type: 'article', customMetaTags: config.seo.customMetaTags || [] }); // Generate JSON-LD const now = new Date().toISOString(); jsonLd = generateJSONLD({ title: title, description: pageDescription, url: pageUrl, author: config.seo.author, siteName: siteName, datePublished: now, dateModified: now, breadcrumbs: breadcrumbs, organization: config.seo.organization, type: 'TechArticle' }); } return `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="description" content="${escapeHtml(pageDescription || generateDescription(originalContent || content) || siteDescription)}"> <title>${escapeHtml(finalSeoTitle || `${title} - ${siteName}`)}</title> ${seoTags} <!-- Fonts --> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <!-- Icons --> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> ${config.features?.phosphorIcons ? `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@phosphor-icons/web@2.1.1/src/${config.features?.phosphorWeight || 'regular'}/style.css">` : ''} <!-- Mermaid --> <script src="https://cdn.jsdelivr.net/npm/mermaid@10.6.1/dist/mermaid.min.js"></script> <!-- Styles --> <link rel="stylesheet" href="${resourcePath}css/notion-style.css"> ${config.isStaticOutput ? ` <!-- Blue theme and white background overrides for static output --> <style> /* Override green colors with blue */ :root { --primary: #2563eb; --primary-dark: #1d4ed8; --primary-light: #dbeafe; --accent: #3b82f6; --color-accent-green: #2563eb; --color-accent-emerald: #3b82f6; /* White backgrounds for cleaner appearance */ --color-bg-secondary: #FFFFFF; --color-bg-tertiary: #FAFAFA; --color-bg-hover: #F8F9FA; } /* Override green status colors with blue variants */ .nav-item.active { background-color: #dbeafe !important; color: #1d4ed8 !important; } .nav-item:hover { background-color: #eff6ff !important; } /* Override complete/success icons to blue */ .ph-check-circle[style*="color: #059669"] { color: #2563eb !important; } /* Override README/home icon color */ .ph-house[style*="color: #059669"] { color: #2563eb !important; } /* Override links */ a { color: #2563eb; } a:hover { color: #1d4ed8; } /* Override buttons and interactive elements */ .theme-toggle:hover, .menu-toggle:hover { background-color: #dbeafe; color: #1d4ed8; } /* Override nav title hover */ .nav-title:hover { background-color: #eff6ff; } /* White backgrounds for sidebar and navigation */ body:not(.dark-mode) .sidebar { background: #FFFFFF !important; background-color: #FFFFFF !important; border-right: 1px solid #E5E7EB !important; } body:not(.dark-mode) .nav-section { background: #FFFFFF !important; background-color: #FFFFFF !important; } body:not(.dark-mode) .nav-content { background: #FFFFFF !important; background-color: #FFFFFF !important; } body:not(.dark-mode) .navigation { background: #FFFFFF !important; background-color: #FFFFFF !important; } /* Subtle separation for nav sections */ body:not(.dark-mode) .nav-section + .nav-section { border-top: 1px solid #F3F4F6; } /* Adjust hover states for white backgrounds */ body:not(.dark-mode) .nav-item:hover { background-color: #F8F9FA !important; } body:not(.dark-mode) .nav-title.collapsible:hover { background-color: #F8F9FA !important; } /* Header stays white (already is) but ensure consistency */ body:not(.dark-mode) .header { background: #FFFFFF !important; background-color: #FFFFFF !important; border-bottom: 1px solid #E5E7EB !important; } /* Ensure search box looks good on white */ body:not(.dark-mode) .filter-input, body:not(.dark-mode) .sidebar-search { background: #F8F9FA !important; background-color: #F8F9FA !important; border: 1px solid #E5E7EB !important; } body:not(.dark-mode) .filter-input:focus, body:not(.dark-mode) .sidebar-search:focus { background: #FFFFFF !important; background-color: #FFFFFF !important; border-color: #2563eb !important; } /* Override breadcrumbs */ .breadcrumbs a { color: #2563eb; } .breadcrumbs a:hover { color: #1d4ed8; background-color: #dbeafe; } /* Override filter icon */ .filter-icon { color: #2563eb; } /* Override deployment info on hover */ .deployment-date:hover { color: #2563eb; } /* Dark mode adjustments */ body.dark-mode { --primary: #3b82f6; --primary-dark: #2563eb; --primary-light: #1e3a8a; --accent: #60a5fa; } body.dark-mode .nav-item.active { background-color: rgba(59, 130, 246, 0.1) !important; color: #60a5fa !important; } body.dark-mode .nav-item:hover { background-color: rgba(59, 130, 246, 0.05) !important; } body.dark-mode a { color: #60a5fa; } body.dark-mode a:hover { color: #93bbfc; } </style> ` : ''} ${(config.features?.authentication === 'supabase' || config.features?.privateDirectoryAuth === true) ? ` <!-- Hide content until auth check --> <style> body { visibility: hidden; opacity: 0; transition: opacity 0.3s ease; } body.authenticated { visibility: visible; opacity: 1; } /* Show login/logout pages immediately */ body.auth-page { visibility: visible; opacity: 1; } /* Style auth button consistently */ .auth-btn { background: none; border: none; color: var(--text-secondary); cursor: pointer; padding: 0.5rem; border-radius: 0.5rem; transition: all 0.2s; font-size: 1.1rem; } .auth-btn:hover { background: var(--bg-secondary); color: var(--text-primary); } </style> ` : ''} <!-- Favicon --> ${generateFaviconTag(config.favicon || '✨')} ${jsonLd} </head> <body> ${!config.isStaticOutput ? ` <!-- Header --> <header class="header"> <div class="header-content"> <a href="${config.isStaticOutput ? relativePath + 'index.html' : '/index.html'}" class="logo">${siteName}</a> <div class="header-actions"> <div class="deployment-info"> <span class="deployment-date" title="Built with doc-builder v${docBuilderVersion}">Last updated: ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })} UTC</span> </div> ${(config.features?.authentication === 'supabase' || config.features?.privateDirectoryAuth === true) ? ` <a href="${relativePath}login.html" class="auth-btn" title="Login/Logout"> <i class="fas fa-sign-in-alt"></i> </a> ` : ''} <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme"> <i class="fas fa-moon"></i> </button> <button id="menu-toggle" class="menu-toggle" aria-label="Toggle menu"> <i class="fas fa-bars"></i> </button> </div> </div> </header> ` : ''} ${config.banner?.enabled || config.features?.banner ? ` <!-- Preview Banner --> <div id="preview-banner" class="preview-banner banner-${config.banner?.type || 'warning'}"> <div class="banner-content"> <i class="${config.banner?.icon || 'fas fa-exclamation-triangle'} banner-icon"></i> <span class="banner-text">${config.banner?.text || 'This documentation is a preview version - some content may be incomplete'}</span> ${config.banner?.dismissible !== false ? ` <button id="dismiss-banner" class="banner-dismiss" aria-label="Dismiss banner"> <i class="fas fa-times"></i> </button> ` : ''} </div> </div> ` : ''} <!-- Breadcrumbs --> <nav class="breadcrumbs ${config.isStaticOutput ? 'breadcrumbs-static' : ''}" id="breadcrumbs" ${config.isStaticOutput ? `data-build-date="${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })} UTC" data-doc-builder-version="${docBuilderVersion}"` : ''}> ${config.isStaticOutput ? ` <!-- Pre-rendered breadcrumb content for static builds --> <div class="breadcrumbs-content breadcrumbs-homepage"> <div class="breadcrumbs-right"> <span class="breadcrumb-date" title="Built with doc-builder v${docBuilderVersion}">Last updated: ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })} UTC</span> <button class="breadcrumb-pdf-btn" onclick="if(typeof exportToPDF !== 'undefined') exportToPDF(); else window.print();" title="Export to PDF" aria-label="Export to PDF"> <i class="fas fa-file-pdf"></i> </button> </div> </div> ` : '<!-- Breadcrumbs will be generated by JavaScript -->'} </nav> <!-- Main Content --> <div class="main-wrapper"> <!-- Sidebar --> <aside class="sidebar"> <div class="sidebar-header"> <div class="filter-box"> <input type="text" placeholder="Search menu..." class="filter-input" id="nav-filter"> <i class="fas fa-search filter-icon"></i> </div> </div> <nav class="navigation"> ${navigation} </nav> <div class="resize-handle"></div> </aside> <!-- Content Area --> <main class="content"> <div class="content-inner"> ${content} </div> </main> </div> <!-- Scripts --> <script> // Pass configuration to frontend window.docBuilderConfig = { features: { showPdfDownload: ${config.features?.showPdfDownload !== false}, menuDefaultOpen: ${config.features?.menuDefaultOpen !== false}, mermaidEnhanced: ${config.features?.mermaidEnhanced !== false} } }; </script> <script src="${resourcePath}js/main.js"></script> ${(config.features?.authentication === 'supabase' || config.features?.privateDirectoryAuth === true) ? `<script src="https://unpkg.com/@supabase/supabase-js@2"></script> <script src="${resourcePath}js/auth.js"></script>` : ''} </body> </html>`; } // Define folder descriptions for tooltips const folderDescriptions = { 'product-roadmap': 'Strategic vision, timeline, and feature planning', 'product-requirements': 'Detailed product specifications, requirements documents, and feature definitions', 'architecture': 'System design, data flows, and technical infrastructure documentation', 'system-analysis': 'Comprehensive system analysis, functional requirements, and cross-component documentation', 'bubble': 'Core application platform - business logic, UI/UX, and user workflows', 'quickbase': 'Database schema, data management, and backend operations', 'activecampaign': 'Marketing automation integration and lead management system', 'doc-signer': 'Document signing service for digital signatures and PDF generation', 'api-deprecated': 'Legacy API documentation (deprecated, for reference only)', 'postman': 'API testing tools, collections, and test automation', 'mcp': 'Model Context Protocol integration and configuration', 'team': 'Team structure, roles, and responsibilities', 'thought-leadership': 'Strategic insights and industry perspectives', 'middleware': 'Integration layers and data transformation services', 'paths': 'User journey flows and process workflows', 'testing': 'Test strategies, scenarios, and quality assurance processes', 'api': 'API documentation and integration guides' }; // Build navigation structure with rich functionality function buildNavigationStructure(files, currentFile, config = {}) { const tree = { files: [], folders: {} }; // Check if authentication is enabled (either global or private directory) const isAuthEnabled = config.features?.authentication === 'supabase' || config.features?.privateDirectoryAuth === true; // Include all files in navigation - we'll use CSS to show/hide based on auth state files.forEach(file => { const parts = file.urlPath.split('/'); let current = tree; // Navigate/create folder structure for (let i = 0; i < parts.length - 1; i++) { const folder = parts[i]; if (!current.folders[folder]) { current.folders[folder] = { files: [], folders: {} }; } current = current.folders[folder]; } // Add file to current folder current.files.push(file); }); // Helper function to check if a node has active child const checkActiveChild = (node, currentFile) => { // Check files if (node.files.some(f => f.urlPath === currentFile)) return true; // Check folders recursively return Object.values(node.folders).some(folder => checkActiveChild(folder, currentFile)); }; // Helper function to generate file title const generateFileTitle = (file, parentDisplayName, level) => { // First check for front matter title if (file.frontMatter?.title) { return file.frontMatter.title; } let title = file.displayName; if (file.displayName === 'README') { return level === 0 ? 'Overview' : `${parentDisplayName} Overview`; } // Clean up title by removing common prefixes and improving formatting title = title .replace(/^(bubble|system|quickbase|middleware|product-roadmap)-?/, ''); title = smartCapitalize(title); return title; }; // OPTIMIZATION: Helper function to render a section using array joins instead of string concatenation const renderSection = (folderName, folderData, level = 0, parentPath = '') => { const icons = { 'root': 'ph ph-caret-down', 'product-roadmap': 'ph ph-road-horizon', 'product-requirements': 'ph ph-list-checks', 'architecture': 'ph ph-tree-structure', 'system-analysis': 'ph ph-chart-line-up', 'system': 'ph ph-gear-six', 'bubble': 'ph ph-circle', 'quickbase': 'ph ph-database', 'activecampaign': 'ph ph-envelope', 'doc-signer': 'ph ph-signature', 'api-deprecated': 'ph ph-archive', 'postman': 'ph ph-flask', 'mcp': 'ph ph-puzzle-piece', 'team': 'ph ph-users', 'thought-leadership': 'ph ph-lightbulb', 'middleware': 'ph ph-stack', 'paths': 'ph ph-path', 'testing': 'ph ph-test-tube', 'api': 'ph ph-plug', 'documentation-tool': 'ph ph-wrench', 'guides': 'ph ph-book', 'private': 'ph ph-lock', 'launch': 'ph ph-rocket-launch', 'prompts': 'ph ph-chat-circle-dots' }; const displayName = folderName === 'root' ? 'Documentation' : smartCapitalize(folderName); const icon = icons[folderName] || 'ph ph-folder'; if (!folderData.files.length && !Object.keys(folderData.folders).length) { return ''; } // Include parent path in section ID to make it unique const pathParts = parentPath ? [parentPath, folderName].join('-') : folderName; const sectionId = `nav-${pathParts}-${level}`; const isCollapsible = level > 0 || folderName !== 'root'; const collapseIcon = isCollapsible ? '<i class="ph ph-caret-right collapse-icon"></i>' : ''; // Check if this folder has a README.md file to link to const readmeFile = folderData.files.find(f => f.displayName === 'README'); const folderLink = readmeFile ? `href="${config.isStaticOutput ? readmeFile.urlPath : '/' + readmeFile.urlPath}"` : 'href="#"'; // Get folder description for tooltip const folderDescription = folderDescriptions[folderName] || ''; const tooltipAttr = folderDescription ? `data-tooltip="${escapeHtml(folderDescription)}"` : ''; // Check if this folder has active child or if we're on index page const hasActiveChild = checkActiveChild(folderData, currentFile); const shouldExpand = hasActiveChild || currentFile === 'index.html' || (currentFile === 'README.html' && level === 1); // Check if this is a private folder const isPrivateFolder = folderName === 'private' || parentPath.includes('private'); const privateClass = isPrivateFolder ? ' private-nav' : ''; // OPTIMIZATION: Use array instead of string concatenation const htmlParts = []; const isRoot = folderName === 'root' && level === 0; if (isRoot) { htmlParts.push(` <div class="nav-section${privateClass}" data-level="${level}"> <a class="nav-title toggle-all-nav expanded" href="#" id="nav-toggle-all" title="Collapse/Expand All"> <i class="ph ph-caret-down" id="toggle-all-icon"></i> ${displayName} </a> <div class="nav-content">`); } else { htmlParts.push(` <div class="nav-section${privateClass}" data-level="${level}"> <a class="nav-title${isCollapsible ? ' collapsible' : ''}${shouldExpand ? ' expanded' : ''}" ${folderLink} ${isCollapsible ? `data-target="${sectionId}"` : ''} ${tooltipAttr}> ${collapseIcon}<i class="${icon}"></i> ${displayName} </a> <div class="nav-content${isCollapsible ? (shouldExpand ? '' : ' collapsed') : ''}" ${isCollapsible ? `id="${sectionId}"` : ''}>`); } // Sort and render files const sortedFiles = [...folderData.files].sort((a, b) => { if (a.displayName === 'README') return -1; if (b.displayName === 'README') return 1; return a.displayName.localeCompare(b.displayName); }); sortedFiles.forEach(file => { const title = generateFileTitle(file, displayName, level); let isActive = ''; if (currentFile === file.urlPath) { isActive = ' active'; } else if (currentFile === 'index.html' && file.displayName === 'README' && folderName === 'root') { isActive = ' active'; } const linkPath = config.isStaticOutput ? file.urlPath : '/' + file.urlPath; const tooltip = file.summary ? ` data-tooltip="${escapeHtml(file.summary)}"` : ''; const icon = getIconForStatus(file.status || 'default', false, config); htmlParts.push(` <a href="${linkPath}" class="nav-item${isActive}"${tooltip}>${icon} ${title}</a>`); }); htmlParts.push(`</div></div>`); // Render subfolders AFTER closing the parent section Object.keys(folderData.folders) .sort() .forEach(subFolder => { const currentPath = parentPath ? `${parentPath}-${folderName}` : folderName; htmlParts.push(renderSection(subFolder, folderData.folders[subFolder], level + 1, currentPath)); }); return htmlParts.join(''); }; // Check if this is a flat structure const hasFolders = Object.keys(tree.folders).length > 0; if (!hasFolders) { // Generate simple flat navigation for all files in root return renderSection('root', { files: tree.files, folders: {} }, 0); } else { // OPTIMIZATION: Use array for hierarchical navigation building const navParts = []; // 1. First render the root Documentation section with Overview const readmeFile = tree.files.find(f => f.displayName === 'README'); if (readmeFile || tree.files.length > 0) { const rootFiles = readmeFile ? [readmeFile] : []; navParts.push(renderSection('root', { files: rootFiles, folders: {} }, 0)); } // 2. Then render all folders alphabetically Object.keys(tree.folders) .sort() .forEach(folderName => { navParts.push(renderSection(folderName, tree.folders[folderName], 1)); }); // 3. Finally, add remaining root files to the Documentation section const otherRootFiles = tree.files.filter(f => f.displayName !== 'README'); if (otherRootFiles.length > 0) { const nav = navParts.join(''); // Find the closing </div></div> of the first nav-section (Documentation) const navSections = nav.split('<div class="nav-section"'); if (navSections.length > 1) { const firstSection = navSections[1]; const contentDivEnd = firstSection.indexOf('</div></div>'); if (contentDivEnd !== -1) { const additionalFilesParts = []; otherRootFiles.forEach(file => { const title = smartCapitalize(file.displayName); let isActive = ''; if (currentFile === file.urlPath) { isActive = ' active'; } const linkPath = config.isStaticOutput ? file.urlPath : '/' + file.urlPath; const tooltip = file.summary ? ` data-tooltip="${escapeHtml(file.summary)}"` : ''; const icon = getIconForStatus(file.status || 'default', false, config); additionalFilesParts.push(` <a href="${linkPath}" class="nav-item${isActive}"${tooltip}>${icon} ${title}</a>`); }); // Reconstruct with additional files inserted navSections[1] = firstSection.slice(0, contentDivEnd) + additionalFilesParts.join('') + firstSection.slice(contentDivEnd); return navSections.join('<div class="nav-section"'); } } } return navParts.join(''); } } // Process single markdown file async function processMarkdownFile(filePath, outputPath, allFiles, config, useStaticHTML = false) { const rawContent = await fs.readFile(filePath, 'utf-8'); const fileName = path.basename(filePath, '.md'); const relativePath = path.relative(config.docsDir, filePath); // Encode special characters in URL but keep slashes const urlPath = relativePath .replace(/\.md$/, '.html') .replace(/\\/g, '/') .split('/') .map(segment => encodeURIComponent(segment)) .join('/'); // Parse front matter const { data: frontMatter, content } = matter(rawContent); // Extract title - priority: front matter > H1 > filename const h1Match = content.match(/^#\s+(.+)$/m); const h1Title = h1Match ? h1Match[1] : null; // Normalize title if needed (e.g., convert all-caps to title case) const rawTitle = frontMatter.title || h1Title || fileName; const title = config.features?.normalizeTitle !== false ? normalizeTitle(rawTitle) : rawTitle; // Extract summary for tooltip - priority: front matter > auto-extract const summary = frontMatter.description || extractSummary(content); // OPTIMIZATION: Update the file entry in allFiles with computed summary and status // This allows navigation tooltips to work even though we defer content loading const fileEntry = allFiles.find(f => f.path === filePath); if (fileEntry) { if (!fileEntry.summary) { fileEntry.summary = summary; } if (!fileEntry.status) { fileEntry.status = detectDocumentStatus(content, frontMatter); } } // Process content const htmlContent = processMarkdownContent(content, config); // Build navigation - pass config to handle private file filtering // For static HTML, we need to build navigation with relative paths const navConfig = useStaticHTML ? { ...config, isStaticOutput: true } : config; const navigation = buildNavigationStructure(allFiles, urlPath, navConfig); // Generate full HTML (pass original content and front matter for SEO) const html = useStaticHTML ? generateStaticHTML(title, htmlContent, navigation, urlPath, config, content, frontMatter) : generateHTML(title, htmlContent, navigation, urlPath, config, content, frontMatter); // Write file await fs.ensureDir(path.dirname(outputPath)); await fs.writeFile(outputPath, html); return { title, urlPath, summary, frontMatter }; } // OPTIMIZATION: Unified directory scanner - scans once for both markdown and attachments async function scanAllFiles(dir, baseDir = dir, options = {}) { const markdownFiles = []; const attachmentFiles = []; const attachmentTypes = options.attachmentTypes || []; const items = await fs.readdir(dir); for (const item of items) { // Skip files with non-printable characters in their names if (hasNonPrintableChars(item)) { console.log(chalk.yellow(`⚠️ Skipping file with non-printable characters: ${sanitizeFilename(item)}`)); continue; } const fullPath = path.join(dir, item); const stat = await fs.stat(fullPath); // Skip private directories if excludePrivate is true if (stat.isDirectory() && options.excludePrivate && item === 'private') { continue; } if (stat.isDirectory() && !item.startsWith('.') && !item.startsWith('_')) { const subResult = await scanAllFiles(fullPath, baseDir, options); markdownFiles.push(...subResult.markdownFiles); attachmentFiles.push(...subResult.attachmentFiles); } else if (item.endsWith('.md') && !item.startsWith('_')) { // Process markdown files const relativePath = path.relative(baseDir, fullPath); const urlPath = relativePath .replace(/\.md$/, '.html') .replace(/\\/g, '/') .split('/') .map(segment => encodeURIComponent(segment)) .join('/'); const displayName = smartCapitalize(path.basename(item, '.md')); // OPTIMIZATION: Only read frontmatter, not full content // Read file but only parse frontmatter, defer content processing const rawContent = await fs.readFile(fullPath, 'utf-8'); const { data: frontMatter } = matter(rawContent, { excerpt: false }); // Use frontmatter description if available, otherwise defer summary extraction const summary = frontMatter.description || ''; const status = frontMatter.status || null; const isPrivate = relativePath.split(path.sep)[0] === 'private' || relativePath.startsWith('private/') || relativePath.startsWith('private\\'); if (options.excludePrivate && isPrivate) { continue; } markdownFiles.push({ path: fullPath, relativePath, urlPath, displayName, summary, isPrivate, status, frontMatter, }); } else if (attachmentTypes.length > 0) { // Process attachment files const ext = path.extname(item).toLowerCase(); if (attachmentTypes.includes(ext) && !item.startsWith('.')) { const relativePath = path.relative(baseDir, fullPath); const isPrivate = relativePath.split(path.sep)[0] === 'private' || relativePath.startsWith('private/') || relativePath.startsWith('private\\'); if (options.excludePrivate && isPrivate) { continue; } attachmentFiles.push({ path: fullPath, relativePath, size: stat.size }); } } } return { markdownFiles, attachmentFiles }; } // Get all markdown files (backward compatibility wrapper) async function getAllMarkdownFiles(dir, baseDir = dir, options = {}) { const result = await scanAllFiles(dir, baseDir, options); return result.markdownFiles; } // Get all attachment files (backward compatibility wrapper) async function getAllAttachmentFiles(dir, baseDir = dir, attachmentTypes) { const result = await scanAllFiles(dir, baseDir, { attachmentTypes }); return result.attachmentFiles; } // Copy attachment files to output directory async function copyAttachmentFiles(attachmentFiles, docsDir, outputDir) { let totalSize = 0; let copiedCount = 0; for (const file of attachmentFiles) { try { const outputPath = path.join(outputDir, file.relativePath); const outputDirPath = path.dirname(outputPath); // Create directory if it doesn't exist await fs.ensureDir(outputDirPath); // Copy the file await fs.copy(file.path, outputPath, { overwrite: true }); totalSize += file.size; copiedCount++; } catch (error) { console.warn(chalk.yellow(`Warning: Could not copy ${file.relativePath}: ${error.message}`)); } } return { copiedCount, totalSize }; } // Main build function async function buildDocumentation(config) { const docsDir = path.join(process.cwd(), config.docsDir); const outputDir = path.join(process.cwd(), config.outputDir); // Log version for debugging const packageJson = require('../package.json'); console.log(chalk.blue(`📦 Using @knowcode/doc-builder v${packageJson.version}`)); // Ensure output directory exists await fs.ensureDir(outputDir); // Check and create placeholder README.md if missing console.log(chalk.blue('📋 Checking documentation structure...')); const readmeGenerated = await createPlaceholderReadme(docsDir, config); // OPTIMIZATION: Single unified scan for both markdown and attachments console.log(chalk.blue('📄 Scanning documentation directory...')); const attachmentTypes = config.attachmentTypes || [ '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.csv', '.ppt', '.pptx', '.txt', '.rtf', '.html', '.htm', '.zip', '.tar', '.gz', '.7z', '.rar', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.bmp', '.json', '.xml', '.yaml', '.yml', '.toml', '.mp4', '.mp3', '.wav', '.avi', '.mov' ]; const { markdownFiles: files, attachmentFiles } = await scanAllFiles(docsDir, docsDir, { attachmentTypes: config.features?.attachments !== false ? attachmentTypes : [] }); console.log(chalk.green(`✅ Found ${files.length} markdown files${readmeGenerated ? ' (including auto-generated README)' : ''}`)); if (config.features?.attachments !== false && attachmentFiles.length > 0) { console.log(chalk.gray(` Found ${attachmentFiles.length} attachments`)); } // Log the files found if (files.length > 0) { console.log(chalk.gray(' Found files:')); files.forEach(file => { console.log(chalk.gray(` - ${file.relativePath} → ${file.urlPath}`)); }); } console.log(chalk.blue('📝 Processing files...')); for (const file of files) { const outputPath = path.join(outputDir, file.urlPath); await processMarkdownFile(file.path, outputPath, files, config); console.log(chalk.green(`✅ Generated: ${outputPath}`)); } // Copy assets const assetsDir = path.join(__dirname, '../assets'); const cssSource = path.join(assetsDir, 'css'); const jsSource = path.join(assetsDir, 'js'); if (fs.existsSync(cssSource)) { await fs.copy(cssSource, path.join(outputDir, 'css'), { overwrite: true }); } if (fs.existsSync(jsSource)) { await fs.copy(jsSource, path.join(outputDir, 'js'), { overwrite: true }); // Gene