UNPKG

@casoon/auditmysite

Version:

Professional website analysis suite with robust accessibility testing, Core Web Vitals performance monitoring, SEO analysis, and content optimization insights. Features isolated browser contexts, retry mechanisms, and comprehensive API endpoints for profe

1,159 lines (1,083 loc) 163 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.HTMLGenerator = void 0; const fs = __importStar(require("fs/promises")); const path = __importStar(require("path")); const base_types_1 = require("../types/base-types"); /** * HTML Report Generator with Certificate Badges and Comprehensive Reporting * - Includes certificate SVG badges based on overall score * - Sticky navigation with anchor links * - Comprehensive sections for all analysis types * - Modern, interactive design */ class HTMLGenerator { constructor() { this.certificateSVGs = { 'PLATINUM': '', 'GOLD': '', 'SILVER': '', 'BRONZE': '', 'NEEDS_IMPROVEMENT': '' }; this.loadCertificateSVGs(); } async loadCertificateSVGs() { try { const certificatesPath = path.join(__dirname, '..', 'assets', 'certificates'); for (const level of Object.keys(this.certificateSVGs)) { const svgPath = path.join(certificatesPath, `${level}.svg`); try { this.certificateSVGs[level] = await fs.readFile(svgPath, 'utf-8'); } catch (error) { console.warn(`Could not load certificate SVG for ${level}:`, error); // Fallback SVG this.certificateSVGs[level] = this.createFallbackSVG(level); } } } catch (error) { console.warn('Error loading certificate SVGs:', error); } } createFallbackSVG(level) { const colors = { PLATINUM: '#e5e7eb', GOLD: '#fbbf24', SILVER: '#d1d5db', BRONZE: '#cd7c2f', NEEDS_IMPROVEMENT: '#ef4444' }; return `<svg width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg"> <circle cx="60" cy="60" r="55" fill="${colors[level]}" stroke="#374151" stroke-width="2"/> <text x="60" y="50" text-anchor="middle" fill="#374151" font-family="Arial" font-size="12" font-weight="bold">${level.replace('_', ' ')}</text> <text x="60" y="70" text-anchor="middle" fill="#374151" font-family="Arial" font-size="10">CERTIFICATE</text> </svg>`; } async generateFromJSON(jsonPath) { const json = await fs.readFile(jsonPath, 'utf8'); const data = JSON.parse(json); return this.generate(data); } async generate(data) { // Calculate overall metrics if not present if (!data.summary.overallScore) { data.summary.overallScore = this.calculateOverallScore(data); } if (!data.summary.overallGrade) { data.summary.overallGrade = (0, base_types_1.calculateGrade)(data.summary.overallScore); } if (!data.summary.certificateLevel) { data.summary.certificateLevel = (0, base_types_1.calculateCertificateLevel)(data.summary.overallScore); } const domain = this.extractDomain(data.metadata.sitemapUrl); const certificateLevel = data.summary.certificateLevel; const certificateSVG = this.certificateSVGs[certificateLevel] || this.createFallbackSVG(certificateLevel); const css = this.generateCSS(); const header = this.renderHeader(data, domain, certificateSVG); const navigation = this.renderNavigation(); const summary = this.renderSummary(data); const accessibility = this.renderAccessibilitySection(data); const performance = this.renderPerformanceSection(data); const seo = this.renderSEOSection(data); const contentWeight = this.renderContentWeightSection(data); const mobileFriendliness = this.renderMobileFriendlinessSection(data); const geoAudit = this.renderGeoAuditSection(data); const pages = this.renderPagesSection(data); const footer = this.renderFooter(data); return `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>AuditMySite Report - ${this.escape(domain)}</title> <style>${css}</style> </head> <body> ${header} ${navigation} <div class="main-content"> <div class="container"> ${summary} ${accessibility} ${performance} ${seo} ${contentWeight} ${mobileFriendliness} ${geoAudit} ${pages} ${footer} </div> </div> <script>${this.generateJavaScript()}</script> </body> </html>`; } generateCSS() { return ` :root { --color-bg: #f8fafc; --color-card: #ffffff; --color-text: #1f2937; --color-subtle: #6b7280; --primary: #2563eb; --success: #10b981; --warning: #f59e0b; --error: #ef4444; --radius: 12px; --shadow: 0 4px 16px rgba(0,0,0,0.08); --shadow-lg: 0 8px 32px rgba(0,0,0,0.12); } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: var(--color-bg); color: var(--color-text); line-height: 1.6; } .header { background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); color: white; padding: 2rem 0; box-shadow: var(--shadow-lg); position: relative; z-index: 50; } .header-content { max-width: 1200px; margin: 0 auto; padding: 0 1rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 2rem; } .header-info h1 { font-size: 2rem; font-weight: 700; margin-bottom: 0.5rem; } .header-meta { color: rgba(255, 255, 255, 0.8); font-size: 0.9rem; } .certificate-badge { text-align: center; } .certificate-badge svg { width: 120px; height: 120px; filter: drop-shadow(0 4px 8px rgba(0,0,0,0.3)); } .certificate-grade { margin-top: 0.5rem; font-size: 1.25rem; font-weight: 600; color: rgba(255, 255, 255, 0.9); } .sticky-nav { position: sticky; top: 0; background: white; border-bottom: 1px solid #e5e7eb; box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 40; } .nav-container { max-width: 1200px; margin: 0 auto; padding: 0 1rem; display: flex; align-items: center; overflow-x: auto; white-space: nowrap; } .nav-link { display: inline-block; padding: 1rem 1.5rem; text-decoration: none; color: var(--color-subtle); font-weight: 500; border-bottom: 3px solid transparent; transition: all 0.2s ease; } .nav-link:hover, .nav-link.active { color: var(--primary); border-bottom-color: var(--primary); } .main-content { padding: 2rem 0; } .container { max-width: 1200px; margin: 0 auto; padding: 0 1rem; } .section { background: var(--color-card); border-radius: var(--radius); box-shadow: var(--shadow); margin-bottom: 2rem; overflow: hidden; scroll-margin-top: 120px; } .section-header { background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); border-bottom: 1px solid #e5e7eb; padding: 1.5rem; display: flex; align-items: center; gap: 0.75rem; } .section-header h2 { font-size: 1.5rem; font-weight: 600; color: var(--color-text); } .section-content { padding: 1.5rem; } .metrics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem; } .metric-card { background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%); border: 1px solid #e5e7eb; border-radius: var(--radius); padding: 1.5rem; text-align: center; transition: transform 0.2s ease, box-shadow 0.2s ease; } .metric-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); } .metric-value { font-size: 2.5rem; font-weight: 700; display: block; margin-bottom: 0.5rem; } .metric-label { color: var(--color-subtle); font-size: 0.875rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; } .grade-badge { display: inline-block; padding: 0.5rem 1rem; border-radius: 50px; font-weight: 700; font-size: 1.25rem; color: white; text-shadow: 0 1px 2px rgba(0,0,0,0.3); } .grade-A { background: linear-gradient(135deg, #10b981 0%, #059669 100%); } .grade-B { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); } .grade-C { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); } .grade-D { background: linear-gradient(135deg, #f97316 0%, #ea580c 100%); } .grade-F { background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); } .success { color: var(--success); } .warning { color: var(--warning); } .error { color: var(--error); } .info { color: var(--primary); } .data-table { width: 100%; border-collapse: collapse; margin-top: 1rem; border-radius: var(--radius); overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .data-table th, .data-table td { padding: 1rem; text-align: left; border-bottom: 1px solid #f3f4f6; } .data-table th { background: #f8fafc; font-weight: 600; color: var(--color-text); border-bottom: 2px solid #e5e7eb; } .data-table tr:hover { background: #f9fafb; } .data-table tr:last-child td { border-bottom: none; } .page-status { font-weight: 600; padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.75rem; text-transform: uppercase; } .status-passed { background: #ecfdf5; color: #065f46; } .status-failed { background: #fef2f2; color: #991b1b; } .status-crashed { background: #fffbeb; color: #92400e; } .no-data { text-align: center; color: var(--color-subtle); font-style: italic; padding: 2rem; } .issue-list { list-style: none; padding: 0; } .issue-item { padding: 1rem; border-left: 4px solid #e5e7eb; margin-bottom: 1rem; background: #f9fafb; border-radius: 0 var(--radius) var(--radius) 0; } .issue-item.critical { border-left-color: #dc2626; } .issue-item.error { border-left-color: #ef4444; } .issue-item.warning { border-left-color: #f59e0b; } .issue-item.notice { border-left-color: #3b82f6; } .issue-item.info { border-left-color: #6b7280; } .scrollable-issues-container { max-height: 720px; overflow-y: auto; border: 1px solid #e5e7eb; border-radius: var(--radius); background: #ffffff; padding: 0.5rem; } .scrollable-issues-container::-webkit-scrollbar { width: 8px; } .scrollable-issues-container::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 4px; } .scrollable-issues-container::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; } .scrollable-issues-container::-webkit-scrollbar-thumb:hover { background: #94a3b8; } /* Scrollable table container */ .scrollable-table-container { max-height: 720px; overflow-y: auto; overflow-x: auto; border: 1px solid #e5e7eb; border-radius: var(--radius); box-shadow: var(--shadow); background: white; } .scrollable-table-container::-webkit-scrollbar { width: 8px; } .scrollable-table-container::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 4px; } .scrollable-table-container::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; } .scrollable-table-container::-webkit-scrollbar-thumb:hover { background: #94a3b8; } /* Value color coding */ .value-excellent { color: #10b981; font-weight: 600; } .value-good { color: #059669; font-weight: 600; } .value-warning { color: #f59e0b; font-weight: 600; } .value-poor { color: #ef4444; font-weight: 600; } .value-critical { color: #dc2626; font-weight: 700; background: #fef2f2; padding: 2px 6px; border-radius: 4px; } /* Score badges */ .score-badge { display: inline-block; padding: 4px 8px; border-radius: 6px; font-weight: 600; font-size: 0.8rem; min-width: 45px; text-align: center; } .score-a { background: #dcfce7; color: #166534; } .score-b { background: #fef3c7; color: #92400e; } .score-c { background: #fed7aa; color: #9a3412; } .score-d { background: #fecaca; color: #991b1b; } .score-f { background: #fef2f2; color: #dc2626; } /* Performance value specific coloring */ .perf-excellent { color: #10b981; font-weight: 600; } /* < 1000ms LCP */ .perf-good { color: #059669; font-weight: 600; } /* < 2500ms LCP */ .perf-poor { color: #f59e0b; font-weight: 600; } /* < 4000ms LCP */ .perf-critical { color: #ef4444; font-weight: 600; } /* > 4000ms LCP */ /* Content weight specific coloring */ .size-small { color: #10b981; font-weight: 600; } /* < 500KB */ .size-medium { color: #059669; font-weight: 600; } /* < 1MB */ .size-large { color: #f59e0b; font-weight: 600; } /* < 2MB */ .size-huge { color: #ef4444; font-weight: 600; } /* > 2MB */ /* N/A values styling */ .perf-na { color: #9ca3af; font-style: italic; font-weight: 500; } .footer { text-align: center; color: var(--color-subtle); font-size: 0.875rem; padding: 2rem 0; border-top: 1px solid #e5e7eb; margin-top: 2rem; } .detailed-issues-container { max-height: 720px; overflow-y: auto; border: 1px solid #e5e7eb; border-radius: var(--radius); background: #f9fafb; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.85rem; line-height: 1.5; } .copy-button { background: var(--primary); color: white; border: none; padding: 0.5rem 1rem; border-radius: var(--radius); font-size: 0.875rem; cursor: pointer; margin-bottom: 1rem; transition: background-color 0.2s ease; } .copy-button:hover { background: #1d4ed8; } .copy-button.copied { background: var(--success); } .scrollable-issues-container { max-height: 720px; overflow: auto; border: 1px solid #e5e7eb; background: var(--color-card); border-radius: 8px; padding: 0.5rem; } @media (max-width: 768px) { .header-content { flex-direction: column; text-align: center; } .metrics-grid { grid-template-columns: 1fr; } .nav-container { justify-content: flex-start; } } `; } renderHeader(data, domain, certificateSVG) { const timestamp = new Date(data.metadata.timestamp).toLocaleString(); const pages = data.pages || []; const analyzedCount = pages.filter(p => p.status !== 'skipped').length; const skippedCount = pages.filter(p => p.status === 'skipped').length; const skipInfo = skippedCount > 0 ? ` (skipped: ${skippedCount})` : ''; return ` <div class="header"> <div class="header-content"> <div class="header-info"> <h1>Website Audit Report</h1> <div class="header-meta"> <div><strong>Domain:</strong> ${this.escape(domain)}</div> <div><strong>Generated:</strong> ${timestamp}</div> <div><strong>Pages Analyzed:</strong> ${analyzedCount} of ${data.summary.totalPages}${skipInfo}</div> </div> </div> </div> </div> `; } renderNavigation() { return ` <nav class="sticky-nav"> <div class="nav-container"> <a href="#summary" class="nav-link">Summary</a> <a href="#accessibility" class="nav-link">Accessibility</a> <a href="#performance" class="nav-link">Performance</a> <a href="#seo" class="nav-link">SEO</a> <a href="#contentweight" class="nav-link">Content Weight</a> <a href="#mobile" class="nav-link">Mobile</a> <a href="#geo" class="nav-link">GEO</a> </div> </nav> `; } renderSummary(data) { const s = data.summary; const successRate = s.successRate || 0; const duration = Math.round(data.metadata.duration / 1000); const pagesList = data.pages || []; const analyzedCount = pagesList.filter(p => p.status !== 'skipped').length; const skippedCount = pagesList.filter(p => p.status === 'skipped').length; return ` <section id="summary" class="section"> <div class="section-header"> <h2>Executive Summary</h2> </div> <div class="section-content"> <!-- Overview --> <div class="metrics-grid"> <div class="metric-card" style="border: 2px solid var(--primary); background: linear-gradient(135deg, #f8fafc 0%, #e0f2fe 100%);"> <div class="metric-value info" style="font-size: 3rem; font-weight: 800;">${s.overallScore || 0}/100</div> <div class="metric-label" style="font-weight: 600; color: var(--primary);">Overall Score</div> <small style="color: #6b7280; font-size: 0.75rem; margin-top: 0.5rem; display: block;">Weighted average of all analyses</small> </div> <div class="metric-card"> <div class="metric-value success">${successRate}%</div> <div class="metric-label">Success Rate</div> </div> <div class="metric-card"> <div class="metric-value info">${analyzedCount}/${s.totalPages}</div> <div class="metric-label">Pages Analyzed</div> ${skippedCount > 0 ? `<small style="color: #6b7280;">Skipped: ${skippedCount}</small>` : ''} </div> <div class="metric-card"> <div class="metric-value info">${duration}s</div> <div class="metric-label">Analysis Duration</div> </div> </div> <!-- Outcomes --> <div class="metrics-grid" style="margin-top: 1rem;"> <div class="metric-card"> <div class="metric-value success">${s.passedPages}</div> <div class="metric-label">Pages Passed</div> </div> <div class="metric-card"> <div class="metric-value error">${s.failedPages}</div> <div class="metric-label">Pages Failed</div> </div> <div class="metric-card"> <div class="metric-value error">${s.totalErrors}</div> <div class="metric-label">Total Errors</div> </div> <div class="metric-card"> <div class="metric-value warning">${s.totalWarnings}</div> <div class="metric-label">Total Warnings</div> </div> </div> <!-- Key KPIs --> ${(() => { const cwPages = (data.pages || []).filter((p) => p.contentWeight); const perfPages = (data.pages || []).filter((p) => p.performance); const avg = (arr) => arr.length ? Math.round(arr.reduce((a, b) => a + b, 0) / arr.length) : 0; const anyPositive = (arr) => arr.some(v => typeof v === 'number' && v > 0); // Content sizes const jsSizes = cwPages.map((p) => p.contentWeight?.resources?.javascript?.size || p.contentWeight?.resources?.js?.size || p.contentWeight?.resources?.javascript || p.contentWeight?.resources?.js || p.contentWeight?.javascript || 0); const imgSizes = cwPages.map((p) => p.contentWeight?.resources?.images?.size || p.contentWeight?.resources?.images || p.contentWeight?.images || 0); const avgJs = avg(jsSizes); const avgImg = avg(imgSizes); const jsHeavy = jsSizes.filter((s) => s > 500000).length; // > 500KB JS const imgHeavy = imgSizes.filter((s) => s > 1000000).length; // > 1MB images // Performance KPIs const reqs = perfPages.map((p) => p.performance?.metrics?.requestCount || p.performance?.requestCount || 0); const xfer = perfPages.map((p) => p.performance?.metrics?.transferSize || p.performance?.transferSize || 0); const avgReq = avg(reqs); const avgXfer = avg(xfer); if (cwPages.length === 0 && perfPages.length === 0) return ''; const reqClass = avgReq <= 60 ? 'success' : avgReq <= 100 ? 'warning' : 'error'; const xferClass = avgXfer <= 1200000 ? 'success' : avgXfer <= 2500000 ? 'warning' : 'error'; const fmtBytes = (val, hasData) => hasData ? this.formatBytes(val) : '<span class="perf-na">N/A</span>'; const fmtCount = (val, hasData) => hasData ? String(val) : '<span class="perf-na">N/A</span>'; const hasJsData = cwPages.length > 0; const hasImgData = cwPages.length > 0; const hasReqData = perfPages.length > 0; const hasXferData = perfPages.length > 0; return ` <div style="margin-top: 1rem;"> <h3 style="margin-bottom: 0.75rem;">Key KPIs</h3> <div class="metrics-grid" style="grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));"> <div class="metric-card"> <div class="metric-value ${hasJsData ? (avgJs <= 300000 ? 'success' : avgJs <= 600000 ? 'warning' : 'error') : 'info'}">${fmtBytes(avgJs, hasJsData)}</div> <div class="metric-label">Avg JS Size/Page</div> </div> <div class="metric-card"> <div class="metric-value ${hasImgData ? (avgImg <= 800000 ? 'success' : avgImg <= 1600000 ? 'warning' : 'error') : 'info'}">${fmtBytes(avgImg, hasImgData)}</div> <div class="metric-label">Avg Images Size/Page</div> </div> <div class="metric-card"> <div class="metric-value ${hasReqData ? reqClass : 'info'}">${fmtCount(avgReq, hasReqData)}</div> <div class="metric-label">Avg Requests/Page</div> </div> <div class="metric-card"> <div class="metric-value ${hasXferData ? xferClass : 'info'}">${fmtBytes(avgXfer, hasXferData)}</div> <div class="metric-label">Avg Transfer/Page</div> </div> ${cwPages.length > 0 ? ` <div class="metric-card"> <div class="metric-value ${jsHeavy > 0 ? 'warning' : 'success'}">${jsHeavy}</div> <div class="metric-label">JS-heavy Pages (>500KB)</div> </div> <div class="metric-card"> <div class="metric-value ${imgHeavy > 0 ? 'warning' : 'success'}">${imgHeavy}</div> <div class="metric-label">Image-heavy Pages (>1MB)</div> </div>` : ''} </div> </div> `; })()} <!-- Overall Score Breakdown --> <div style="margin-top: 2rem;"> <h3 style="margin-bottom: 1rem;">Overall Score Breakdown</h3> <p style="color: #6b7280; margin-bottom: 1rem; font-size: 0.9rem;">The Overall Score is calculated as a weighted average of all analyses. Each category contributes based on its importance for web quality:</p> ${this.renderScoreBreakdown(data)} </div> </div> </section> `; } renderAccessibilitySection(data) { // Include pages with accessibility data, excluding skipped (redirected) pages from details const accessibilityPages = data.pages.filter(p => p.accessibility && p.status !== 'skipped' && (p.accessibility.score > 0 || (p.accessibility.errors && p.accessibility.errors.length > 0) || (p.accessibility.warnings && p.accessibility.warnings.length > 0))); const failedPages = data.pages.filter(p => p.status === 'skipped' && p.accessibility); if (accessibilityPages.length === 0 && failedPages.length === 0) { return ` <section id="accessibility" class="section"> <div class="section-header"> <h2>Accessibility Analysis</h2> </div> <div class="section-content"> <div class="no-data">No accessibility data available</div> </div> </section> `; } // Calculate comprehensive accessibility metrics const totalErrors = accessibilityPages.reduce((sum, p) => sum + (p.accessibility.errors?.length || 0), 0); const totalWarnings = accessibilityPages.reduce((sum, p) => sum + (p.accessibility.warnings?.length || 0), 0); const totalNotices = accessibilityPages.reduce((sum, p) => sum + (p.accessibility.notices?.length || 0), 0); const avgScore = accessibilityPages.length > 0 ? accessibilityPages.reduce((sum, p) => sum + (p.accessibility.score || 0), 0) / accessibilityPages.length : 0; // Additional comprehensive metrics const totalImagesWithoutAlt = accessibilityPages.reduce((sum, p) => { return sum + (this.extractMetricFromIssues(p.accessibility, 'alt') || p.imagesWithoutAlt || 0); }, 0); const totalButtonsWithoutLabel = accessibilityPages.reduce((sum, p) => { return sum + (this.extractMetricFromIssues(p.accessibility, 'button') || p.buttonsWithoutLabel || 0); }, 0); const totalContrastIssues = accessibilityPages.reduce((sum, p) => { return sum + (this.extractMetricFromIssues(p.accessibility, 'contrast') || p.colorContrastIssues?.length || 0); }, 0); // Analyze error/warning distribution const excellentPages = accessibilityPages.filter(p => (p.accessibility.score || 0) >= 90).length; const goodPages = accessibilityPages.filter(p => { const score = p.accessibility.score || 0; return score >= 75 && score < 90; }).length; const needsImprovementPages = accessibilityPages.filter(p => (p.accessibility.score || 0) < 75).length; const criticalPages = accessibilityPages.filter(p => (p.accessibility.score || 0) < 40).length; // Most common accessibility issues analysis const issuesByType = {}; accessibilityPages.forEach(page => { const allIssues = [ ...(page.accessibility.errors || []), ...(page.accessibility.warnings || []) ]; allIssues.forEach((issue) => { let issueType = 'Other'; if (typeof issue === 'string') { // Try to categorize string-based issues if (issue.includes('alt') || issue.includes('image')) issueType = 'Images & Alt Text'; else if (issue.includes('heading') || issue.includes('h1') || issue.includes('h2')) issueType = 'Headings'; else if (issue.includes('contrast') || issue.includes('color')) issueType = 'Color Contrast'; else if (issue.includes('form') || issue.includes('label')) issueType = 'Forms'; else if (issue.includes('link') || issue.includes('anchor')) issueType = 'Links'; else if (issue.includes('keyboard') || issue.includes('focus')) issueType = 'Keyboard Navigation'; } else if (issue.code) { // Categorize based on pa11y/axe codes if (issue.code.includes('image') || issue.code.includes('alt')) issueType = 'Images & Alt Text'; else if (issue.code.includes('heading')) issueType = 'Headings'; else if (issue.code.includes('contrast') || issue.code.includes('color')) issueType = 'Color Contrast'; else if (issue.code.includes('form') || issue.code.includes('label')) issueType = 'Forms'; else if (issue.code.includes('link')) issueType = 'Links'; else if (issue.code.includes('keyboard') || issue.code.includes('focus')) issueType = 'Keyboard Navigation'; } if (!issuesByType[issueType]) { issuesByType[issueType] = { count: 0, severity: (typeof issue === 'string' || issue.type === 'error') ? 'error' : 'warning' }; } issuesByType[issueType].count++; }); }); const topIssueTypes = Object.entries(issuesByType) .sort(([, a], [, b]) => b.count - a.count) .slice(0, 4); return ` <section id="accessibility" class="section"> <div class="section-header"> <h2>Accessibility Analysis <a href="https://github.com/casoon/AuditMySite/blob/main/docs/accessibility.html" target="_blank" style="margin-left: 0.5rem; color: #6b7280; text-decoration: none; font-size: 0.875rem;" title="What do these accessibility metrics mean?"> <i style="font-style: normal; border: 1px solid; border-radius: 50%; padding: 2px 6px; font-size: 0.75rem;">i</i> </a> </h2> </div> <div class="section-content"> <!-- Core Accessibility Metrics --> <div class="metrics-grid"> <div class="metric-card"> <div class="metric-value ${avgScore >= 90 ? 'success' : avgScore >= 75 ? 'warning' : 'error'}">${Math.round(avgScore)}/100</div> <div class="metric-label">Average A11y Score</div> </div> <div class="metric-card"> <div class="metric-value ${totalErrors === 0 ? 'success' : totalErrors < 5 ? 'warning' : 'error'}">${totalErrors}</div> <div class="metric-label">Critical Issues</div> </div> <div class="metric-card"> <div class="metric-value ${totalWarnings === 0 ? 'success' : totalWarnings < 10 ? 'warning' : 'error'}">${totalWarnings}</div> <div class="metric-label">Warnings</div> </div> <div class="metric-card"> <div class="metric-value ${totalImagesWithoutAlt === 0 ? 'success' : totalImagesWithoutAlt < 5 ? 'warning' : 'error'}">${totalImagesWithoutAlt}</div> <div class="metric-label">Images Missing Alt</div> </div> <div class="metric-card"> <div class="metric-value ${totalButtonsWithoutLabel === 0 ? 'success' : totalButtonsWithoutLabel < 3 ? 'warning' : 'error'}">${totalButtonsWithoutLabel}</div> <div class="metric-label">Unlabeled Buttons</div> </div> <div class="metric-card"> <div class="metric-value ${totalContrastIssues === 0 ? 'success' : totalContrastIssues < 5 ? 'warning' : 'error'}">${totalContrastIssues}</div> <div class="metric-label">Contrast Issues</div> </div> <div class="metric-card"> <div class="metric-value info">${totalNotices}</div> <div class="metric-label">Notices</div> </div> <div class="metric-card"> <div class="metric-value info">${accessibilityPages.length}</div> <div class="metric-label">Pages Analyzed</div> </div> </div> <!-- Page Quality Distribution --> <div style="margin-top: 2rem;"> <h3 style="margin-bottom: 1rem;">Accessibility Quality Distribution</h3> <div class="metrics-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem;"> <div class="metric-card"> <div class="metric-value ${excellentPages === accessibilityPages.length ? 'success' : excellentPages > accessibilityPages.length * 0.5 ? 'warning' : 'error'}">${excellentPages}</div> <div class="metric-label">Excellent (90+)</div> </div> <div class="metric-card"> <div class="metric-value ${goodPages === 0 && excellentPages === accessibilityPages.length ? 'success' : 'info'}">${goodPages}</div> <div class="metric-label">Good (75-89)</div> </div> <div class="metric-card"> <div class="metric-value ${needsImprovementPages === 0 ? 'success' : needsImprovementPages < accessibilityPages.length * 0.2 ? 'warning' : 'error'}">${needsImprovementPages}</div> <div class="metric-label">Needs Work (40-74)</div> </div> <div class="metric-card"> <div class="metric-value ${criticalPages === 0 ? 'success' : 'error'}">${criticalPages}</div> <div class="metric-label">Critical (<40)</div> </div> ${failedPages.length > 0 ? ` <div class="metric-card"> <div class="metric-value warning">${failedPages.length}</div> <div class="metric-label">Skipped (Redirects)</div> </div>` : ''} </div> </div> ${topIssueTypes.length > 0 ? ` <div style="margin-top: 2rem;"> <h3 style="margin-bottom: 1rem;">Most Common Accessibility Issues</h3> <div class="metrics-grid" style="grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem;"> ${topIssueTypes.map(([issueType, data]) => { const severityClass = data.severity === 'error' ? 'error' : 'warning'; return ` <div class="metric-card"> <div class="metric-value ${severityClass}">${data.count}</div> <div class="metric-label">${issueType}</div> <small style="color: #6b7280; font-size: 0.8rem; margin-top: 0.5rem; display: block;"> ${data.severity === 'error' ? 'Critical' : 'Needs attention'} </small> </div> `; }).join('')} </div> </div> ` : ''} <div style="margin-top: 2rem;"> <h3 style="margin-bottom: 1rem;">Per-Page Accessibility Details</h3> <div class="scrollable-table-container" style="max-height: 720px;"> <table class="data-table"> <thead> <tr> <th style="position: sticky; left: 0; background: #f8fafc; z-index: 11;">Page</th> <th>A11y Score</th> <th>Errors</th> <th>Warnings</th> <th>Notices</th> <th>Images w/o Alt</th> <th>Buttons w/o Label</th> <th>Contrast Issues</th> <th>WCAG Level</th> <th>ARIA Issues</th> </tr> </thead> <tbody> ${accessibilityPages.map(p => { const score = p.accessibility?.score || 0; const errors = p.accessibility?.errors?.length || 0; const warnings = p.accessibility?.warnings?.length || 0; const notices = p.accessibility?.notices?.length || 0; // Extract common accessibility metrics from issues or separate fields const imagesWithoutAlt = this.extractMetricFromIssues(p.accessibility, 'alt') || p.imagesWithoutAlt || 0; const buttonsWithoutLabel = this.extractMetricFromIssues(p.accessibility, 'button') || p.buttonsWithoutLabel || 0; const contrastIssues = this.extractMetricFromIssues(p.accessibility, 'contrast') || p.colorContrastIssues?.length || 0; // Professional WCAG analysis const wcagLevel = this.calculateWCAGLevel(score, errors, warnings); const ariaIssues = this.calculateARIAIssues(p.accessibility, buttonsWithoutLabel, imagesWithoutAlt); const getScoreClass = (score) => score >= 90 ? 'value-excellent' : score >= 75 ? 'value-good' : score >= 60 ? 'value-warning' : score >= 40 ? 'value-poor' : 'value-critical'; const getIssueClass = (count) => count === 0 ? 'value-excellent' : count < 3 ? 'value-warning' : 'value-critical'; const getWCAGClass = (level) => level === 'AAA' ? 'value-excellent' : level === 'AA' ? 'value-good' : level === 'A' ? 'value-warning' : 'value-critical'; return ` <tr> <td style="max-width: 200px;"> <strong>${this.escape(p.title || 'Untitled')}</strong><br/> <small style="color: #6b7280; font-size: 0.7rem;">${this.escape(p.url.length > 40 ? '...' + p.url.slice(-37) : p.url)}</small> </td> <td class="${getScoreClass(score)}">${score}/100</td> <td class="${getIssueClass(errors)}">${errors}</td> <td class="${getIssueClass(warnings)}">${warnings}</td> <td class="${notices === 0 ? 'value-excellent' : 'value-good'}">${notices}</td> <td class="${getIssueClass(imagesWithoutAlt)}">${imagesWithoutAlt}</td> <td class="${getIssueClass(buttonsWithoutLabel)}">${buttonsWithoutLabel}</td> <td class="${getIssueClass(contrastIssues)}">${contrastIssues}</td> <td class="${getWCAGClass(wcagLevel)}">${wcagLevel}</td> <td class="${getIssueClass(ariaIssues)}">${ariaIssues}</td> </tr> `; }).join('')} </tbody> </table> </div> </div> <!-- Professional WCAG 2.1 Compliance Analysis --> <div style="margin-top: 2rem;"> <h3 style="margin-bottom: 1rem;">WCAG 2.1 Compliance Analysis</h3> <div class="metrics-grid" style="grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;"> ${this.renderWCAGComplianceMetrics(accessibilityPages)} </div> </div> <!-- ARIA Implementation Analysis --> <div style="margin-top: 2rem;"> <h3 style="margin-bottom: 1rem;">ARIA Implementation Analysis</h3> <div class="metrics-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem;"> ${this.renderARIAAnalysisMetrics(accessibilityPages)} </div> </div> <!-- Detailed Accessibility Issues --> <div style="margin-top: 2rem;"> <h3 style="margin-bottom: 1rem;">Detailed Accessibility Issues & Recommendations</h3> ${this.renderAccessibilityDetailedIssues(data.pages)} </div> ${failedPages.length > 0 ? this.renderSkippedPagesInfo(failedPages) : ''} </div> </section> `; } renderPerformanceSection(data) { // Include pages with any performance data, even if the overall test failed const performancePages = data.pages.filter(p => p.performance || p.enhancedPerformance || (p.status === 'failed' && p.performance)); if (performancePages.length === 0) { return ` <section id="performance" class="section"> <div class="section-header"> <h2>Performance Analysis</h2> </div> <div class="section-content"> <div class="no-data">No performance data available</div> </div> </section> `; } // Helper function to safely get numeric value const getNumericValue = (value) => { if (value === null || value === undefined || isNaN(value)) return 0; return Number(value) || 0; }; // Helper function to safely calculate average const safeAvg = (values) => { const validValues = values.filter(v => !isNaN(v) && v !== null && v !== undefined); return validValues.length > 0 ? validValues.reduce((sum, v) => sum + v, 0) / validValues.length : 0; }; const avgScore = safeAvg(performancePages.map(p => getNumericValue(p.performance.score))); const avgLCP = safeAvg(performancePages.map(p => getNumericValue(p.performance.coreWebVitals?.largestContentfulPaint))); const avgFCP = safeAvg(performancePages.map(p => getNumericValue(p.performance.coreWebVitals?.firstContentfulPaint))); const avgCLS = safeAvg(performancePages.map(p => getNumericValue(p.performance.coreWebVitals?.cumulativeLayoutShift))); const avgTTFB = safeAvg(performancePages.map(p => getNumericValue(p.performance.coreWebVitals?.timeToFirstByte))); const avgDomContentLoaded = safeAvg(performancePages.map(p => getNumericValue(p.performance.metrics?.domContentLoaded))); const avgLoadComplete = safeAvg(performancePages.map(p => getNumericValue(p.performance.metrics?.loadComplete))); // Synthesize performance issues based on heuristics if none provided performancePages.forEach(p => { if (!p.performance) return; if (!Array.isArray(p.performance.issues)) p.performance.issues = []; const issues = p.performance.issues; const LCP = getNumericValue(p.performance.coreWebVitals?.largestContentfulPaint); const FCP = getNumericValue(p.performance.coreWebVitals?.firstContentfulPaint); const CLS = getNumericValue(p.performance.coreWebVitals?.cumulativeLayoutShift); const TTFB = getNumericValue(p.performance.coreWebVitals?.timeToFirstByte); if (LCP > 4000) issues.push({ type: 'error', message: `High LCP: ${Math.round(LCP)}ms (target <= 2500ms)` }); else if (LCP > 2500) issues.push({ type: 'warning', message: `LCP could be improved: ${Math.round(LCP)}ms (target <= 2500ms)` }); if (CLS > 0.25) issues.push({ type: 'error', message: `High CLS: ${CLS.toFixed(3)} (target <= 0.10)` }); else if (CLS > 0.10) issues.push({ type: 'warning', message: `CLS could be improved: ${CLS.toFixed(3)} (target <= 0.10)` }); if (TTFB > 1000) issues.push({ type: 'error', message: `High TTFB: ${Math.round(TTFB)}ms (target <= 500ms)` }); else if (TTFB > 500) issues.push({ type: 'warning', message: `TTFB could be improved: ${Math.round(TTFB)}ms (target <= 500ms)` }); if (FCP > 3000) issues.push({ type: 'warning', message: `Slow FCP: ${Math.round(FCP)}ms (target <= 1800ms)` }); }); const totalPerformanceIssues = performancePages.reduce((sum, p) => sum + (p.performance.issues?.length || 0), 0); return ` <section id="performance" class="section"> <div class="section-header"> <h2>Performance Analysis <a href="https://github.com/casoon/AuditMySite/blob/main/docs/performance.html" target="_blank" style="margin-left: 0.5rem; color: #6b7280; text-decoration: none; font-size: 0.875rem;" title="Understanding Core Web Vitals and performance metrics"> <i style="font-style: normal; border: 1px solid; border-radius: 50%; padding: 2px 6px; font-size: 0.75rem;">i</i> </a> </h2> </div> <div class="section-content"> ${(() => { const ms = data.systemPerformance?.measurementSettings; if (!ms) return ''; const onOff = ms.psiProfile ? 'Enabled' : 'Disabled'; const cpu = ms.cpuThrottlingRate ? `CPU×${ms.cpuThrottlingRate}` : 'CPU×1'; const net = ms.network ? `${ms.network.latencyMs}ms, ${ms.network.downloadKbps}kbps down, ${ms.network.uploadKbps}kbps up` : 'Default network'; return ` <div style="margin-bottom: 1rem; color: #6b7280; font-size: 0.9rem;"> <strong>Measurement Settings:</strong> PSI-like Profile ${onOff} · ${cpu} · ${net} </div> `; })()} <div class="metrics-grid"> <div class="metric-card"> <div class="metric-value info">${Math.round(avgScore)}/100</div> <div class="metric-label">Average Score</div> </div> <div class="metric-card"> <div class="metric-value ${avgLCP < 2500 ? 'success' : avgLCP < 4000 ? 'warning' : 'error'}">${Math.round(avgLCP)}ms</div> <div class="metric-label">Avg LCP</div> </div> <div class="metric-card"> <div class="metric-value ${avgFCP < 1800 ? 'success' : avgFCP < 3000 ? 'warning' : 'error'}">${Math.round(avgFCP)}ms</div> <div class="metric-label">Avg FCP</div> </div> <div class="metric-card"> <div class="metric-value ${avgCLS < 0.1 ? 'success' : avgCLS < 0.25 ? 'warning' : 'error'}">${isNaN(avgCLS) || avgCLS === 0 ? '0.000' : avgCLS.toFixed(3)}</div> <div class="metric-label">Avg CLS</div> </div> <div class="metric-card"> <div class="metric-value info">${Math.round(avgTTFB)}ms</div> <div class="metric-label">Avg TTFB</div> </div> <div class="metric-card"> <div class="metric-value info">${Math.round(avgDomContentLoaded)}ms</div> <div class="metric-label">Avg DOM Loaded</div> </div> <div class="metric-card"> <div class="metric-value info">${Math.round(avgLoadComplete)}ms</div> <div class="metric-label">Avg Load Complete</div> </div> <div class="metric-card"> <div class="metric-value warning">${totalPerformanceIssues}</div> <div class="metric-label">Total Issues</div> </div> <div class="metric-card"> <div class="metric-value info">${performancePages.length}</div> <div class="metric-label">Pages Analyzed</div> </div> </div> <div style="margin-top: 2rem;"> <h3 style="margin-bottom: 1rem;">Per-Page Performance Details (Desktop & Mobile)</h3> <div class="scrollable-table-container" style="max-height: 720px;"> <table class="data-table"> <thead> <tr>