538 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
			
		
		
	
	
			538 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
/**
 | 
						|
 * Generate a detailed accessibility report for WCAG 2.1 AAA compliance
 | 
						|
 * 
 | 
						|
 * This script analyzes all pages and generates a comprehensive report
 | 
						|
 * of accessibility issues that need to be fixed for AAA compliance
 | 
						|
 */
 | 
						|
 | 
						|
const { chromium } = require('playwright');
 | 
						|
const fs = require('fs');
 | 
						|
const path = require('path');
 | 
						|
const axeCore = require('axe-core');
 | 
						|
 | 
						|
// URLs to test - expand this list to cover all pages
 | 
						|
const BASE_URL = process.argv[2] || 'http://localhost:8080'\;
 | 
						|
const PAGES = [
 | 
						|
  '/',
 | 
						|
  '/stories/',
 | 
						|
  '/stories/open-source-success.html',
 | 
						|
  '/stories/viperwire.html',
 | 
						|
  '/stories/airport-dns.html',
 | 
						|
  '/stories/wordpress-security.html',
 | 
						|
  '/stories/nitric-leadership.html',
 | 
						|
  '/stories/healthcare-platform.html',
 | 
						|
  '/stories/web-design-java.html',
 | 
						|
  '/stories/motherboard-repair.html',
 | 
						|
  '/stories/fawe-plotsquared.html',
 | 
						|
  '/stories/showerloop.html',
 | 
						|
  '/stories/athion-turnaround.html',
 | 
						|
  '/stories/youtube-game-dev.html',
 | 
						|
  '/stories/app-development.html',
 | 
						|
  '/one-pager-tools/csv-tool.html'
 | 
						|
];
 | 
						|
 | 
						|
// Create reports directory if it doesn't exist
 | 
						|
const reportsDir = path.join(__dirname, '../reports');
 | 
						|
if (!fs.existsSync(reportsDir)) {
 | 
						|
  fs.mkdirSync(reportsDir, { recursive: true });
 | 
						|
}
 | 
						|
 | 
						|
// Issues storage
 | 
						|
const allIssues = {
 | 
						|
  byPage: {},
 | 
						|
  byRule: {},
 | 
						|
  summary: {
 | 
						|
    totalPages: 0,
 | 
						|
    pagesWithIssues: 0,
 | 
						|
    totalIssues: 0,
 | 
						|
    issuesByImpact: {
 | 
						|
      critical: 0,
 | 
						|
      serious: 0,
 | 
						|
      moderate: 0,
 | 
						|
      minor: 0
 | 
						|
    }
 | 
						|
  }
 | 
						|
};
 | 
						|
 | 
						|
async function runAxe(page, pageUrl) {
 | 
						|
  try {
 | 
						|
    // Use a file URL to load axe-core from a local file to avoid CSP issues
 | 
						|
    const axeScriptPath = path.join(__dirname, 'axe-core.js');
 | 
						|
    fs.writeFileSync(axeScriptPath, axeCore.source);
 | 
						|
    
 | 
						|
    // Add the script as a file
 | 
						|
    await page.addScriptTag({ path: axeScriptPath });
 | 
						|
    
 | 
						|
    // Run axe with WCAG 2.1 AAA rules
 | 
						|
    const results = await page.evaluate(() => {
 | 
						|
      return new Promise(resolve => {
 | 
						|
        axe.run(document, { 
 | 
						|
          runOnly: { 
 | 
						|
            type: 'tag', 
 | 
						|
            values: ['wcag2aaa'] 
 | 
						|
          },
 | 
						|
          resultTypes: ['violations', 'incomplete'],
 | 
						|
          rules: {
 | 
						|
            'color-contrast': { enabled: true, options: { noScroll: true } }
 | 
						|
          }
 | 
						|
        })
 | 
						|
        .then(results => resolve(results))
 | 
						|
        .catch(err => resolve({ error: err.toString() }));
 | 
						|
      });
 | 
						|
    });
 | 
						|
    
 | 
						|
    // Clean up temporary file
 | 
						|
    fs.unlinkSync(axeScriptPath);
 | 
						|
    
 | 
						|
    return results;
 | 
						|
  } catch (error) {
 | 
						|
    console.error('Error running axe:', error);
 | 
						|
    return { error: error.toString() };
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
async function testPage(browser, pageUrl) {
 | 
						|
  const context = await browser.newContext({
 | 
						|
    bypassCSP: true
 | 
						|
  });
 | 
						|
  const page = await context.newPage();
 | 
						|
  console.log(`Testing ${pageUrl}...`);
 | 
						|
  
 | 
						|
  try {
 | 
						|
    // Navigate to the page
 | 
						|
    await page.goto(pageUrl, { waitUntil: 'networkidle' });
 | 
						|
    
 | 
						|
    // Run axe-core tests
 | 
						|
    const results = await runAxe(page, pageUrl);
 | 
						|
    
 | 
						|
    // Save results to file
 | 
						|
    const fileName = pageUrl === BASE_URL ? 'index' : pageUrl.replace(BASE_URL, '').replace(/\//g, '-').replace(/^-/, '');
 | 
						|
    const reportPath = path.join(reportsDir, `axe-${fileName || 'index'}.json`);
 | 
						|
    fs.writeFileSync(reportPath, JSON.stringify(results, null, 2));
 | 
						|
    
 | 
						|
    // Log results summary
 | 
						|
    if (results.error) {
 | 
						|
      console.error(`Error running axe-core on ${pageUrl}:`, results.error);
 | 
						|
      return { url: pageUrl, success: false, error: results.error };
 | 
						|
    }
 | 
						|
    
 | 
						|
    const { violations, incomplete } = results;
 | 
						|
    console.log(`Results for ${pageUrl}:`);
 | 
						|
    console.log(`- Violations: ${violations.length}`);
 | 
						|
    console.log(`- Incomplete: ${incomplete.length}`);
 | 
						|
    
 | 
						|
    // Track issues for detailed report
 | 
						|
    const pageIssues = [];
 | 
						|
    
 | 
						|
    // Process violations
 | 
						|
    if (violations.length > 0) {
 | 
						|
      console.log('\nViolations:');
 | 
						|
      violations.forEach((violation, i) => {
 | 
						|
        console.log(`${i + 1}. ${violation.id} - ${violation.help} (Impact: ${violation.impact})`);
 | 
						|
        console.log(`   ${violation.description}`);
 | 
						|
        console.log(`   WCAG: ${violation.tags.filter(t => t.startsWith('wcag')).join(', ')}`);
 | 
						|
        console.log(`   Elements: ${violation.nodes.length}`);
 | 
						|
        
 | 
						|
        // Add to issues tracking
 | 
						|
        violation.nodes.forEach(node => {
 | 
						|
          const issue = {
 | 
						|
            rule: violation.id,
 | 
						|
            impact: violation.impact,
 | 
						|
            description: violation.description,
 | 
						|
            help: violation.help,
 | 
						|
            helpUrl: violation.helpUrl,
 | 
						|
            wcagTags: violation.tags.filter(t => t.startsWith('wcag')),
 | 
						|
            element: node.html,
 | 
						|
            target: node.target,
 | 
						|
            failureSummary: node.failureSummary,
 | 
						|
            elementData: node.any.map(d => d.data || {})
 | 
						|
          };
 | 
						|
          pageIssues.push(issue);
 | 
						|
          
 | 
						|
          // Update rule-based tracking
 | 
						|
          if (!allIssues.byRule[violation.id]) {
 | 
						|
            allIssues.byRule[violation.id] = {
 | 
						|
              id: violation.id,
 | 
						|
              description: violation.description,
 | 
						|
              help: violation.help,
 | 
						|
              helpUrl: violation.helpUrl,
 | 
						|
              impact: violation.impact,
 | 
						|
              wcagTags: violation.tags.filter(t => t.startsWith('wcag')),
 | 
						|
              occurrences: []
 | 
						|
            };
 | 
						|
          }
 | 
						|
          
 | 
						|
          allIssues.byRule[violation.id].occurrences.push({
 | 
						|
            page: pageUrl,
 | 
						|
            element: node.html,
 | 
						|
            target: node.target,
 | 
						|
            failureSummary: node.failureSummary,
 | 
						|
            elementData: node.any.map(d => d.data || {})
 | 
						|
          });
 | 
						|
          
 | 
						|
          // Update summary stats
 | 
						|
          allIssues.summary.totalIssues++;
 | 
						|
          if (violation.impact) {
 | 
						|
            allIssues.summary.issuesByImpact[violation.impact]++;
 | 
						|
          }
 | 
						|
        });
 | 
						|
      });
 | 
						|
    }
 | 
						|
    
 | 
						|
    // Store page issues
 | 
						|
    allIssues.byPage[pageUrl] = {
 | 
						|
      url: pageUrl,
 | 
						|
      issueCount: pageIssues.length,
 | 
						|
      issues: pageIssues
 | 
						|
    };
 | 
						|
    
 | 
						|
    // Update summary
 | 
						|
    allIssues.summary.totalPages++;
 | 
						|
    if (pageIssues.length > 0) {
 | 
						|
      allIssues.summary.pagesWithIssues++;
 | 
						|
    }
 | 
						|
    
 | 
						|
    return { 
 | 
						|
      url: pageUrl, 
 | 
						|
      success: violations.length === 0, 
 | 
						|
      violations: violations.length,
 | 
						|
      incomplete: incomplete.length
 | 
						|
    };
 | 
						|
  } catch (error) {
 | 
						|
    console.error(`Error testing ${pageUrl}:`, error);
 | 
						|
    return { url: pageUrl, success: false, error: error.toString() };
 | 
						|
  } finally {
 | 
						|
    await page.close();
 | 
						|
    await context.close();
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
function generateFixRecommendations() {
 | 
						|
  const recommendations = {};
 | 
						|
  
 | 
						|
  // Process color contrast issues
 | 
						|
  if (allIssues.byRule['color-contrast-enhanced']) {
 | 
						|
    const colorIssues = allIssues.byRule['color-contrast-enhanced'];
 | 
						|
    recommendations.colorContrast = {
 | 
						|
      title: 'Color Contrast Issues',
 | 
						|
      description: 'The following color combinations need to be updated to meet WCAG 2.1 AAA requirements (7:1 contrast ratio):',
 | 
						|
      colorPairs: []
 | 
						|
    };
 | 
						|
    
 | 
						|
    // Group by color pairs
 | 
						|
    const colorPairMap = {};
 | 
						|
    colorIssues.occurrences.forEach(occurrence => {
 | 
						|
      occurrence.elementData.forEach(data => {
 | 
						|
        if (data.fgColor && data.bgColor) {
 | 
						|
          const key = `${data.fgColor}/${data.bgColor}`;
 | 
						|
          if (!colorPairMap[key]) {
 | 
						|
            colorPairMap[key] = {
 | 
						|
              fgColor: data.fgColor,
 | 
						|
              bgColor: data.bgColor,
 | 
						|
              contrastRatio: data.contrastRatio,
 | 
						|
              requiredRatio: data.expectedContrastRatio,
 | 
						|
              pages: new Set(),
 | 
						|
              elements: []
 | 
						|
            };
 | 
						|
          }
 | 
						|
          colorPairMap[key].pages.add(occurrence.page);
 | 
						|
          colorPairMap[key].elements.push({
 | 
						|
            html: occurrence.element,
 | 
						|
            page: occurrence.page
 | 
						|
          });
 | 
						|
        }
 | 
						|
      });
 | 
						|
    });
 | 
						|
    
 | 
						|
    // Convert to array and add suggestions
 | 
						|
    Object.values(colorPairMap).forEach(pair => {
 | 
						|
      const suggestion = {
 | 
						|
        foreground: pair.fgColor,
 | 
						|
        background: pair.bgColor,
 | 
						|
        currentRatio: pair.contrastRatio,
 | 
						|
        requiredRatio: pair.requiredRatio,
 | 
						|
        pagesAffected: Array.from(pair.pages),
 | 
						|
        elementCount: pair.elements.length,
 | 
						|
        suggestedFixes: []
 | 
						|
      };
 | 
						|
      
 | 
						|
      // Generate suggested fixes
 | 
						|
      // Make foreground darker
 | 
						|
      const darkerFg = darkenColor(pair.fgColor, 0.2);
 | 
						|
      suggestion.suggestedFixes.push({
 | 
						|
        type: 'Darken foreground',
 | 
						|
        color: darkerFg,
 | 
						|
        description: `Change foreground color from ${pair.fgColor} to ${darkerFg}`
 | 
						|
      });
 | 
						|
      
 | 
						|
      // Make background lighter
 | 
						|
      const lighterBg = lightenColor(pair.bgColor, 0.2);
 | 
						|
      suggestion.suggestedFixes.push({
 | 
						|
        type: 'Lighten background',
 | 
						|
        color: lighterBg,
 | 
						|
        description: `Change background color from ${pair.bgColor} to ${lighterBg}`
 | 
						|
      });
 | 
						|
      
 | 
						|
      recommendations.colorContrast.colorPairs.push(suggestion);
 | 
						|
    });
 | 
						|
  }
 | 
						|
  
 | 
						|
  return recommendations;
 | 
						|
}
 | 
						|
 | 
						|
// Helper function to darken a hex color
 | 
						|
function darkenColor(hex, amount) {
 | 
						|
  return adjustColor(hex, -amount);
 | 
						|
}
 | 
						|
 | 
						|
// Helper function to lighten a hex color
 | 
						|
function lightenColor(hex, amount) {
 | 
						|
  return adjustColor(hex, amount);
 | 
						|
}
 | 
						|
 | 
						|
// Helper function to adjust a hex color
 | 
						|
function adjustColor(hex, amount) {
 | 
						|
  let r = parseInt(hex.substring(1, 3), 16);
 | 
						|
  let g = parseInt(hex.substring(3, 5), 16);
 | 
						|
  let b = parseInt(hex.substring(5, 7), 16);
 | 
						|
  
 | 
						|
  if (amount > 0) {
 | 
						|
    // Lighten
 | 
						|
    r = Math.min(255, Math.round(r + (255 - r) * amount));
 | 
						|
    g = Math.min(255, Math.round(g + (255 - g) * amount));
 | 
						|
    b = Math.min(255, Math.round(b + (255 - b) * amount));
 | 
						|
  } else {
 | 
						|
    // Darken
 | 
						|
    amount = -amount;
 | 
						|
    r = Math.max(0, Math.round(r * (1 - amount)));
 | 
						|
    g = Math.max(0, Math.round(g * (1 - amount)));
 | 
						|
    b = Math.max(0, Math.round(b * (1 - amount)));
 | 
						|
  }
 | 
						|
  
 | 
						|
  return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
 | 
						|
}
 | 
						|
 | 
						|
async function runTests() {
 | 
						|
  const browser = await chromium.launch();
 | 
						|
  const results = [];
 | 
						|
  
 | 
						|
  try {
 | 
						|
    // Test each page
 | 
						|
    for (const pagePath of PAGES) {
 | 
						|
      const pageUrl = `${BASE_URL}${pagePath}`;
 | 
						|
      const result = await testPage(browser, pageUrl);
 | 
						|
      results.push(result);
 | 
						|
    }
 | 
						|
    
 | 
						|
    // Generate detailed report
 | 
						|
    const recommendations = generateFixRecommendations();
 | 
						|
    
 | 
						|
    const detailedReport = {
 | 
						|
      timestamp: new Date().toISOString(),
 | 
						|
      baseUrl: BASE_URL,
 | 
						|
      summary: allIssues.summary,
 | 
						|
      recommendations: recommendations,
 | 
						|
      issuesByRule: allIssues.byRule
 | 
						|
    };
 | 
						|
    
 | 
						|
    // Save detailed report
 | 
						|
    fs.writeFileSync(
 | 
						|
      path.join(reportsDir, 'accessibility-detailed-report.json'), 
 | 
						|
      JSON.stringify(detailedReport, null, 2)
 | 
						|
    );
 | 
						|
    
 | 
						|
    // Generate HTML report
 | 
						|
    const htmlReport = generateHtmlReport(detailedReport);
 | 
						|
    fs.writeFileSync(
 | 
						|
      path.join(reportsDir, 'accessibility-report.html'), 
 | 
						|
      htmlReport
 | 
						|
    );
 | 
						|
    
 | 
						|
    console.log('\n=== Detailed Report Generated ===');
 | 
						|
    console.log(`Total pages tested: ${detailedReport.summary.totalPages}`);
 | 
						|
    console.log(`Pages with issues: ${detailedReport.summary.pagesWithIssues}`);
 | 
						|
    console.log(`Total issues found: ${detailedReport.summary.totalIssues}`);
 | 
						|
    console.log(`Issues by impact:`);
 | 
						|
    Object.entries(detailedReport.summary.issuesByImpact).forEach(([impact, count]) => {
 | 
						|
      if (count > 0) {
 | 
						|
        console.log(`  - ${impact}: ${count}`);
 | 
						|
      }
 | 
						|
    });
 | 
						|
    console.log(`\nDetailed report saved to: ${path.join(reportsDir, 'accessibility-detailed-report.json')}`);
 | 
						|
    console.log(`HTML report saved to: ${path.join(reportsDir, 'accessibility-report.html')}`);
 | 
						|
    
 | 
						|
    // Exit with appropriate code
 | 
						|
    process.exit(detailedReport.summary.totalIssues > 0 ? 1 : 0);
 | 
						|
  } catch (error) {
 | 
						|
    console.error('Error running tests:', error);
 | 
						|
    process.exit(1);
 | 
						|
  } finally {
 | 
						|
    await browser.close();
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
function generateHtmlReport(report) {
 | 
						|
  const colorPairs = report.recommendations.colorContrast ? report.recommendations.colorContrast.colorPairs : [];
 | 
						|
  
 | 
						|
  return `<!DOCTYPE html>
 | 
						|
<html lang="en">
 | 
						|
<head>
 | 
						|
  <meta charset="UTF-8">
 | 
						|
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
						|
  <title>Accessibility Report - WCAG 2.1 AAA Compliance</title>
 | 
						|
  <style>
 | 
						|
    body {
 | 
						|
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
 | 
						|
      line-height: 1.6;
 | 
						|
      color: #333;
 | 
						|
      max-width: 1200px;
 | 
						|
      margin: 0 auto;
 | 
						|
      padding: 20px;
 | 
						|
    }
 | 
						|
    h1, h2, h3, h4 {
 | 
						|
      color: #2c3e50;
 | 
						|
    }
 | 
						|
    .summary {
 | 
						|
      background-color: #f8f9fa;
 | 
						|
      border-radius: 5px;
 | 
						|
      padding: 20px;
 | 
						|
      margin-bottom: 30px;
 | 
						|
    }
 | 
						|
    .issue-count {
 | 
						|
      font-size: 24px;
 | 
						|
      font-weight: bold;
 | 
						|
    }
 | 
						|
    .color-pair {
 | 
						|
      margin-bottom: 30px;
 | 
						|
      border: 1px solid #ddd;
 | 
						|
      padding: 15px;
 | 
						|
      border-radius: 5px;
 | 
						|
    }
 | 
						|
    .color-sample {
 | 
						|
      display: inline-block;
 | 
						|
      width: 100px;
 | 
						|
      height: 50px;
 | 
						|
      margin-right: 10px;
 | 
						|
      border: 1px solid #ddd;
 | 
						|
    }
 | 
						|
    .color-info {
 | 
						|
      display: inline-block;
 | 
						|
      vertical-align: top;
 | 
						|
    }
 | 
						|
    .suggestion {
 | 
						|
      margin-top: 10px;
 | 
						|
      padding: 10px;
 | 
						|
      background-color: #f0f7ff;
 | 
						|
      border-radius: 5px;
 | 
						|
    }
 | 
						|
    .pages-list {
 | 
						|
      margin-top: 10px;
 | 
						|
      font-size: 14px;
 | 
						|
    }
 | 
						|
    .impact-critical { color: #d9534f; }
 | 
						|
    .impact-serious { color: #f0ad4e; }
 | 
						|
    .impact-moderate { color: #5bc0de; }
 | 
						|
    .impact-minor { color: #5cb85c; }
 | 
						|
    table {
 | 
						|
      width: 100%;
 | 
						|
      border-collapse: collapse;
 | 
						|
      margin-bottom: 20px;
 | 
						|
    }
 | 
						|
    th, td {
 | 
						|
      border: 1px solid #ddd;
 | 
						|
      padding: 8px;
 | 
						|
      text-align: left;
 | 
						|
    }
 | 
						|
    th {
 | 
						|
      background-color: #f2f2f2;
 | 
						|
    }
 | 
						|
    tr:nth-child(even) {
 | 
						|
      background-color: #f9f9f9;
 | 
						|
    }
 | 
						|
  </style>
 | 
						|
</head>
 | 
						|
<body>
 | 
						|
  <h1>Accessibility Report - WCAG 2.1 AAA Compliance</h1>
 | 
						|
  <p>Generated on: ${new Date(report.timestamp).toLocaleString()}</p>
 | 
						|
  
 | 
						|
  <div class="summary">
 | 
						|
    <h2>Summary</h2>
 | 
						|
    <p><span class="issue-count">${report.summary.totalIssues}</span> issues found across ${report.summary.pagesWithIssues} pages (${report.summary.totalPages} pages tested)</p>
 | 
						|
    <p>Issues by impact:</p>
 | 
						|
    <ul>
 | 
						|
      ${report.summary.issuesByImpact.critical > 0 ? `<li><span class="impact-critical">Critical: ${report.summary.issuesByImpact.critical}</span></li>` : ''}
 | 
						|
      ${report.summary.issuesByImpact.serious > 0 ? `<li><span class="impact-serious">Serious: ${report.summary.issuesByImpact.serious}</span></li>` : ''}
 | 
						|
      ${report.summary.issuesByImpact.moderate > 0 ? `<li><span class="impact-moderate">Moderate: ${report.summary.issuesByImpact.moderate}</span></li>` : ''}
 | 
						|
      ${report.summary.issuesByImpact.minor > 0 ? `<li><span class="impact-minor">Minor: ${report.summary.issuesByImpact.minor}</span></li>` : ''}
 | 
						|
    </ul>
 | 
						|
  </div>
 | 
						|
  
 | 
						|
  <h2>Color Contrast Issues</h2>
 | 
						|
  ${colorPairs.length === 0 ? '<p>No color contrast issues found.</p>' : ''}
 | 
						|
  ${colorPairs.map(pair => `
 | 
						|
    <div class="color-pair">
 | 
						|
      <h3>Color Combination</h3>
 | 
						|
      <div>
 | 
						|
        <div class="color-sample" style="background-color: ${pair.foreground}"></div>
 | 
						|
        <div class="color-info">
 | 
						|
          <p>Foreground: ${pair.foreground}</p>
 | 
						|
        </div>
 | 
						|
      </div>
 | 
						|
      <div>
 | 
						|
        <div class="color-sample" style="background-color: ${pair.background}"></div>
 | 
						|
        <div class="color-info">
 | 
						|
          <p>Background: ${pair.background}</p>
 | 
						|
        </div>
 | 
						|
      </div>
 | 
						|
      <p>Current contrast ratio: ${pair.currentRatio} (Required: ${pair.requiredRatio})</p>
 | 
						|
      <p>Found on ${pair.elementCount} element${pair.elementCount !== 1 ? 's' : ''} across ${pair.pagesAffected.length} page${pair.pagesAffected.length !== 1 ? 's' : ''}</p>
 | 
						|
      
 | 
						|
      <div class="pages-list">
 | 
						|
        <p>Pages affected:</p>
 | 
						|
        <ul>
 | 
						|
          ${pair.pagesAffected.map(page => `<li>${page}</li>`).join('')}
 | 
						|
        </ul>
 | 
						|
      </div>
 | 
						|
      
 | 
						|
      <h4>Suggested Fixes</h4>
 | 
						|
      ${pair.suggestedFixes.map(fix => `
 | 
						|
        <div class="suggestion">
 | 
						|
          <h5>${fix.type}</h5>
 | 
						|
          <div class="color-sample" style="background-color: ${fix.color}"></div>
 | 
						|
          <div class="color-info">
 | 
						|
            <p>${fix.description}</p>
 | 
						|
          </div>
 | 
						|
        </div>
 | 
						|
      `).join('')}
 | 
						|
    </div>
 | 
						|
  `).join('')}
 | 
						|
  
 | 
						|
  <h2>Issues by Rule</h2>
 | 
						|
  <table>
 | 
						|
    <thead>
 | 
						|
      <tr>
 | 
						|
        <th>Rule</th>
 | 
						|
        <th>Impact</th>
 | 
						|
        <th>Description</th>
 | 
						|
        <th>Occurrences</th>
 | 
						|
        <th>WCAG Criteria</th>
 | 
						|
      </tr>
 | 
						|
    </thead>
 | 
						|
    <tbody>
 | 
						|
      ${Object.values(report.issuesByRule).map(rule => `
 | 
						|
        <tr>
 | 
						|
          <td><a href="${rule.helpUrl}" target="_blank">${rule.id}</a></td>
 | 
						|
          <td class="impact-${rule.impact}">${rule.impact}</td>
 | 
						|
          <td>${rule.description}</td>
 | 
						|
          <td>${rule.occurrences.length}</td>
 | 
						|
          <td>${rule.wcagTags.join(', ')}</td>
 | 
						|
        </tr>
 | 
						|
      `).join('')}
 | 
						|
    </tbody>
 | 
						|
  </table>
 | 
						|
</body>
 | 
						|
</html>`;
 | 
						|
}
 | 
						|
 | 
						|
runTests();
 |