axe-playwright-report
Version:
Playwright + axe-core integration to run accessibility scans and build HTML dashboard reports.
1,014 lines (905 loc) • 62.8 kB
JavaScript
#!/usr/bin/env node
const fs = require('fs');
const path = require("path");
const URL = require('url').URL;
require('dotenv').config({ path: path.join(process.cwd(), '.env.a11y') });
const BASE_DIR = process.env.OUTPUT_DIR || './axe-playwright-report'
const PAGES_DIR = '/pages';
const MERGE_STRATEGY = process.env.MERGE_STRATEGY || 'best';
let allFiles = [];
let jsonFiles = [];
function getAxeVersion(version) {
const parts = version.split('.');
return parts.slice(0, 2).join('.');
}
function escapeHTML(html) {
return String(html ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function readJSONFile(path) {
return JSON.parse(fs.readFileSync(path, 'utf8'));
}
function generateBaseDashboard(template) {
return template.replace("{{CONTENT}}",
`<body>
<header>
<div class="container mx-auto flex justify-between">
<h1 class="header-text text-2xl font-bold text-gray-800">Accessibility Report Dashboard</h1>
<p class="text-sm text-gray-500">Powered by axe-core</p>
</div>
</header>
<nav>
<div id="breadcrumb-container" style="display:none;">
<!--<ul class="breadcrumb">
<li><a href="#" onclick="showDashboard()">Dashboard</a></li>
<li id="report-breadcrumb" style="display:none;">Report Details</li>
</ul>-->
</div>
</nav>
<main>
<h1 class="visually-hidden">Dashboard Overview</h1>
<div class="container mx-auto">
<!-- Dashboard View -->
<div class="dashboard active" id="dashboard">
{{summary_cards}}
<div class="chart-container grid grid-cols-2 gap-4">
{{impact_distribution_chart}}
{{disabilities_affected_chart}}
</div>
{{dashboard_search_bar}}
{{table_cards}}
</div>
</div>
</main><body>
`);
}
function generateBaseContent(template, report) {
const { userAgent, windowWidth, windowHeight } = report.testEnvironment || {};
return template.replace("{{CONTENT}}",
`<body>
<header>
<div class="container mx-auto flex justify-between">
<h1 class="text-2xl font-bold text-gray-800">Accessibility Report Dashboard</h1>
<p class="text-sm text-gray-500">Powered by axe-core</p>
</div>
</header>
<main>
<body class="text-gray-900">
<div class="container mx-auto">
<nav>
<div id="breadcrumb-container">
<ul class="breadcrumb">
<li><a href="../index.html" title="Dashboard">Dashboard</a></li>
<li id="report-breadcrumb">${report.id}</li>
</ul>
</div>
</nav>
<!-- Header and Breadcrumb -->
<div class="mb-2">
<div class="page-url flex justify-between items-center">
<h1 class="text-3xl font-bold">Page: ${report.pagePath !== undefined ? report.pagePath : report.url}</h1>
<div class="flex items-center">
<a href="${report.url}" target="_blank" class="text-blue-600 hover:text-blue-800">
<button class="border border-blue-600 text-blue-600 px-4 py-2 rounded hover:bg-blue-100">
View in Browser
</button>
</a>
<button id="generateBugReportBtn"
class="ml-2 text-white px-4 py-2 rounded bg-gray-300 cursor-not-allowed relative"
disabled=""
title="Generates a preformatted title and summary based on this issue, ready to paste into Jira or other tracking tools.">
Generate Bug Summary
</button>
</div>
</div>
<div class="text-sm text-gray-500">
<div>Browser: ${userAgent || 'N/A'}, Viewport: ${windowWidth || 0}×${windowHeight || 0}</div>
</div>
</div>
{{FILTERS}}
{{TABS}}
{{ISSUE_CARDS}}
</div>
</body>
<!-- Modal Backdrop -->
<div id="bugReportModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center">
<!-- Modal Content -->
<div class="bg-white rounded-lg p-6 max-w-2xl w-full shadow-lg">
<h2 class="text-xl font-bold mb-4">Bug Summary</h2>
<div class="flex items-center mb-2">
<strong>Title:</strong>
<button id="copyBugTitleBtn" type="button" title="Copy to clipboard" class="ml-2 text-gray-500 hover:text-blue-600" style="background: none; border: none; cursor: pointer;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<rect x="8" y="8" width="12" height="12" rx="2" ry="2"></rect>
<rect x="4" y="4" width="12" height="12" rx="2" ry="2"></rect>
</svg>
</button>
</div>
<input id="bugTitle" class="mt-1 text-gray-800 w-full border rounded p-2 text-sm" type="text" placeholder="Bug Title">
<div class="flex items-center mb-2">
<strong>Description:</strong>
<button id="copyBugSummaryBtn" type="button" title="Copy to clipboard" class="ml-2 text-gray-500 hover:text-blue-600" style="background: none; border: none; cursor: pointer;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<rect x="8" y="8" width="12" height="12" rx="2" ry="2"></rect>
<rect x="4" y="4" width="12" height="12" rx="2" ry="2"></rect>
</svg>
</button>
</div>
<textarea id="bugSummary" class="mt-1 text-gray-800 w-full border rounded p-2 text-sm" rows="20"></textarea>
<div class="text-right">
<button id="closeBugModal" class="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Close
</button>
</div>
</div>
</div>
</main></body>
`) + `
<script>
const bugBtn = document.getElementById('generateBugReportBtn');
const modal = document.getElementById('bugReportModal');
const closeBtn = document.getElementById('closeBugModal');
document.addEventListener('change', function () {
const anyChecked = document.querySelectorAll('input[type="checkbox"]:checked').length > 0;
bugBtn.disabled = !anyChecked;
bugBtn.classList.toggle('bg-blue-600', anyChecked);
bugBtn.classList.toggle('hover:bg-blue-700', anyChecked);
bugBtn.classList.toggle('cursor-pointer', anyChecked);
bugBtn.classList.toggle('bg-gray-300', !anyChecked);
bugBtn.classList.toggle('cursor-not-allowed', !anyChecked);
});
bugBtn.addEventListener('click', () => {
const bugTitle = document.getElementById('bugTitle');
const summaryEl = document.getElementById('bugSummary');
const pageElement = document.querySelector(".page-url")
const pageTitle = pageElement.querySelector("h1").innerText.trim().split(" ")[1]
const pageUrl = pageElement.querySelector("a").getAttribute("href")
const issueCardsList = Array.from(document.querySelectorAll('.issue-card')).filter(card => card.querySelector('input[type="checkbox"]:checked'));
if (issueCardsList.length === 1) {
const summary = issueCardsList[0].querySelector('.issue_description > p').innerText.trim();
const issueId = issueCardsList[0].querySelector('.issue-id').innerText.trim();
const impact = issueCardsList[0].querySelector(".issue-impact").innerText.trim();
const tags = Array.from(issueCardsList[0].querySelectorAll('.tag_list span')).map(el => el.innerText.trim()).join(', ');
const expectedResult = issueCardsList[0].querySelector(".help-section").innerText.trim();
const fixLink = issueCardsList[0].querySelector(".fix-link").getAttribute('href');
const issues = Array.from(issueCardsList[0].querySelectorAll('.issue-card .failure-list li')).map(el => el.innerText.trim())
const actualResult = "\\n- " + issues.join(" OR\\n- ");
const affectedElements = Array.from(issueCardsList[0].querySelectorAll('.issue-card .node-html div')).map(el => el.innerText.trim()).join('\\n');
bugTitle.value = "[A11y] " + summary + " at " + pageTitle + " page";
summaryEl.value =
"**Issue Id:** " + issueId + "\\n\\n" +
"**URL:** " + pageUrl + "\\n\\n" +
"**Impact:** " + impact + "\\n\\n" +
"**Tags:** " + tags + "\\n\\n" +
"**Steps:**\\n...\\n\\n" +
"**Expected Result:** " + expectedResult + "\\n\\n" +
"**Actual Result:** " + actualResult + "\\n\\n" +
"**Affected Elements:**\\n\`\`\`html" + affectedElements + "\\n\`\`\`\\n\\n" +
"**Help Link:** " + fixLink;
} else {
const templateContainer = []
for (let i = 0; i < issueCardsList.length; i++) {
const summary = issueCardsList[i].querySelector('.issue_description > p').innerText.trim();
const impact = issueCardsList[i].querySelector(".issue-impact").innerText.trim();
const impactColored = impact === 'critical' ? "🟥 " + impact : impact === 'serious' ? '🟧 ' + impact : impact === 'moderate' ? '🟩 ' + impact : impact;
const issueId = issueCardsList[i].querySelector('.issue-id').innerText.trim();
const issues = Array.from(issueCardsList[i].querySelectorAll('.issue-card .failure-list li')).map(el => el.innerText.trim())
const expectedResult = issueCardsList[i].querySelector(".help-section").innerText.trim();
const affectedElements = Array.from(issueCardsList[i].querySelectorAll('.issue-card .node-html div')).map(el => el.innerText.trim()).join('\\n');
const fixLink = issueCardsList[i].querySelector(".fix-link").getAttribute('href');
const tmp =
"** #" + (i + 1) + " " + impactColored + " – " + issueId + "**\\n\\n" +
"**Affected Elements:**\\n\\n" +
"\`\`\`html\\n" + affectedElements + "\\n\`\`\`\\n\\n" +
"**Issue:** " + summary + "\\n\\n" +
"**Fix:** " + expectedResult + "\\n\\n" +
"**Help Link:** " + fixLink;
templateContainer.push(tmp);
}
bugTitle.value = "[A11y] Accessibility Issues on Page: " + pageTitle;
summaryEl.value = "**URL: **" +pageUrl + "\\n\\n" + templateContainer.join("\\n\\n---\\n\\n");
}
// const issueCards = document.querySelectorAll('.issue-card');
// for (const issueCard of issueCards) {
// if (issueCard.querySelector('input[type="checkbox"]:checked')) {
// const summary = issueCard.querySelector('.issue_description > p').innerText.trim();
// const issueId = issueCard.querySelector('.issue-id').innerText.trim();
// const pageElement = document.querySelector(".page-url")
// const pageTitle = pageElement.querySelector("h1").innerText.trim().split(" ")[1]
// const pageUrl = pageElement.querySelector("a").getAttribute("href")
// const impact = issueCard.querySelector(".issue-impact").innerText.trim();
// const tags = Array.from(issueCard.querySelectorAll('.tag_list span')).map(el => el.innerText.trim()).join(', ');
// const expectedResult = issueCard.querySelector(".help-section").innerText.trim();
// const fixLink = issueCard.querySelector(".fix-link").getAttribute('href');
// const issues = Array.from(issueCard.querySelectorAll('.issue-card .failure-list li')).map(el => el.innerText.trim())
// const actualResult = "\\n- " + issues.join(" OR\\n- ");
// const affectedElements = Array.from(issueCard.querySelectorAll('.issue-card .node-html div')).map(el => el.innerText.trim()).join('\\n');
//
// bugTitle.value = "[A11y] " + summary + " at " + pageTitle + " page";
//
// summaryEl.value = "**Issue Id:** " + issueId + "\\n\\n" + "**URL:** " + pageUrl + "\\n\\n" + "**Impact:** " + impact + "\\n\\n" + "**Tags:** " + tags + "\\n\\n" + "**Steps:**\\n...\\n\\n" + "**Expected Result:** " + expectedResult + "\\n\\n" + "**Actual Result:** " + actualResult + "\\n\\n" + "**Affected Elements:**\\n<code>" + affectedElements + "</code>\\n\\n" + "**Help Link:** " + fixLink;
//
// }
// }
modal.classList.remove('hidden'); // 💡 this makes it visible
});
closeBtn.addEventListener('click', () => {
modal.classList.add('hidden');
});
document.getElementById('copyBugSummaryBtn').addEventListener('click', function() {
const summary = document.getElementById('bugSummary');
summary.select();
document.execCommand('copy');
});
document.getElementById('copyBugTitleBtn').addEventListener('click', function() {
const summary = document.getElementById('bugSummary');
summary.select();
document.execCommand('copy');
});
document.addEventListener('change', function () {
const anyChecked = document.querySelectorAll('input[type="checkbox"]:checked').length > 0;
if (anyChecked) {
bugBtn.disabled = false;
bugBtn.classList.remove('bg-gray-300', 'cursor-not-allowed');
bugBtn.classList.add('bg-blue-600', 'hover:bg-blue-700', 'cursor-pointer');
} else {
bugBtn.disabled = true;
bugBtn.classList.remove('bg-blue-600', 'hover:bg-blue-700', 'cursor-pointer');
bugBtn.classList.add('bg-gray-300', 'cursor-not-allowed');
}
});
window.addEventListener('DOMContentLoaded', () => {
const violationsTab = document.querySelector('[data-tab="violations"]');
if (violationsTab) violationsTab.click();
const impactFilter = document.getElementById("impact-filter");
const tagFilter = document.getElementById("tag-filter");
const disabilityFilter = document.getElementById("disability-filter");
if (impactFilter) impactFilter.addEventListener("change", () => applyFilters(impactFilter, tagFilter, disabilityFilter));
if (tagFilter) tagFilter.addEventListener("change", () => applyFilters(impactFilter, tagFilter, disabilityFilter));
if (disabilityFilter) disabilityFilter.addEventListener("change", () => applyFilters(impactFilter, tagFilter, disabilityFilter));
});
document.querySelectorAll('.toggle-details').forEach(button => {
button.addEventListener('click', () => {
const details = button.nextElementSibling;
details.classList.toggle('expanded');
button.classList.toggle('expanded');
});
})
document.querySelectorAll('.toggle-details').forEach(button => {
button.addEventListener('click', function (e) {
e.stopPropagation(); // Prevent event bubbling to parent elements
const nodeDetails = this.nextElementSibling;
nodeDetails.classList.toggle('hidden');
// Update button text based on state
if (nodeDetails.classList.contains('hidden')) {
this.innerHTML = '<i class="fas fa-chevron-down"></i> Show failure details';
} else {
this.innerHTML = '<i class="fas fa-chevron-up"></i> Hide failure details';
}
});
});
</script>`
}
function generateFilters(report, affected) {
// Extract unique impact values and tags (existing code)
const impactValues = new Set();
const tagValues = new Set();
const disabilityValues = new Set();
// Collect impacts and tags from all sections
[...report.violations, ...report.incomplete, ...report.passes].forEach(issue => {
if (issue.impact) {
impactValues.add(issue.impact.toLowerCase());
}
// Collect all unique tags
if (issue.tags && Array.isArray(issue.tags)) {
issue.tags.forEach(tag => tagValues.add(tag.toLowerCase()));
}
// Collect all unique disability types
if (affected && Array.isArray(affected)) {
const rule = affected.find(item => item.ruleId === issue.id);
if (rule && rule['disabilityTypesAffected']) {
rule['disabilityTypesAffected'].forEach(disability => {
if (disability.name) {
disabilityValues.add(disability.name);
}
});
}
}
});
// const sortedImpacts = ['Critical 🟥', 'Serious 🟧', 'Moderate 🟨', 'Minor 🟩'].filter(i => impactValues.has(i));
// Convert to arrays and sort
const sortedImpacts = ['critical', 'serious', 'moderate', 'minor'].filter(i => impactValues.has(i));
const sortedTags = [...tagValues];
const sortedDisabilities = [...disabilityValues];
// Generate options HTML
const impactOptions = sortedImpacts.map(impact =>
`<option value="${impact}">${impact.charAt(0).toUpperCase() + impact.slice(1)}</option>`
).join('');
const tagOptions = sortedTags.map(tag =>
`<option value="${tag}">${tag}</option>`
).join('');
const disabilityOptions = sortedDisabilities.map(disability =>
`<option value="${disability.toLowerCase()}">${disability}</option>`
).join('');
return `
<div class="mb-3 flex flex-wrap gap-4">
<!-- Impact Filter -->
<div class="flex-1 min-w-[200px]">
<label for="impact-filter" class="block font-medium text-gray-700 mb-1">Filter by Impact</label>
<select id="impact-filter" class="select2-filter w-full" multiple="multiple" data-placeholder="Select impacts...">
${impactOptions}
</select>
</div>
<!-- Standards/Tags Filter -->
<div class="flex-1 min-w-[200px]">
<label for="tag-filter" class="block font-medium text-gray-700 mb-1">Filter by Standard</label>
<select id="tag-filter" class="select2-filter w-full" multiple="multiple" data-placeholder="Select standards...">
${tagOptions}
</select>
</div>
<!-- Disabilities Filter -->
<div class="flex-1 min-w-[200px]">
<label for="disability-filter" class="block font-medium text-gray-700 mb-1">Filter by Disability</label>
<select id="disability-filter" class="select2-filter w-full" multiple="multiple" data-placeholder="Select disabilities...">
${disabilityOptions}
</select>
</div>
</div>
`;
}
function generateTabs(report) {
const tabs = [{
id: 'violations',
name: 'Violations',
count: report.violations.length || 0,
hoverClass: 'hover:text-red-600',
activeClass: '[&.active]:text-red-600 [&.active]:border-b-red-500'
},
{
id: 'incomplete',
name: 'Incomplete',
count: report.incomplete.length || 0,
hoverClass: 'hover:text-yellow-800',
activeClass: '[&.active]:text-yellow-800 [&.active]:border-b-yellow-800'
},
{
id: 'passes',
name: 'Passes',
count: report.passes.length || 0,
hoverClass: 'hover:text-green-500',
activeClass: '[&.active]:text-green-500 [&.active]:border-b-green-500'
}
];
let tabContent = '';
tabs.forEach(tab => {
let svgContent;
if (tab.id === 'violations') {
svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>`;
} else if (tab.id === 'incomplete') {
svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>`;
} else if (tab.id === 'passes') {
svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6L9 17l-5-5"></path>
</svg>`;
}
const tabName = tab.id;
const selectAllCheckbox = (tabName === 'violations' || tabName === 'incomplete')
? `<label for="select-all-checkbox-${tabName}" class="sr-only">Select all ${tabName}</label>
<input type="checkbox" id="select-all-checkbox-${tabName}" class="absolute left-5 w-4 h-4 mr-2" aria-label="Select all ${tabName}">`
: '';
tabContent += `
<div data-tab="${tab.id}" class="relative flex items-center justify-center py-2 border-b-2 hover:bg-gray-100 text-gray-500 ${tab.hoverClass} ${tab.activeClass}">
${selectAllCheckbox}
<button ${svgContent}
<span class="ml-2">${tab.name}</span>
<span class="issue-count ml-2 bg-gray-200 text-gray-800 px-2 py-0.5 rounded text-sm">${tab.count}</span>
</button></div>
`;
});
return `
<div class="mb-6">
<div class="grid grid-cols-[1fr_1fr_1fr] gap-2 border-b">
${tabContent}
</div>
</div>
`;
}
function generateTabContent(tabName, issueCards) {
const tabTypeText = {
'violations': 'violation',
'inapplicable': 'inapplicable',
'incomplete': 'incomplete',
'passes': 'passes'
}[tabName];
return `
<div data-tab-content="${tabName}" class="${tabName !== 'violations' ? 'hidden' : ''}">
${issueCards}
<div class="no-results-message p-4 my-4 text-center text-gray-500 bg-gray-50 rounded-md shadow-sm hidden">
No ${tabTypeText} issues found matching your search criteria.
</div>
</div>
`;
}
function generateIssueCards(issues, affected, screenshot) {
function getDisabilityTypes(ruleId) {
if (!affected || !Array.isArray(affected)) return [];
const rule = affected.find(item => item.ruleId === ruleId);
return rule ? rule['disabilityTypesAffected'] || [] : [];
}
let issueCards = '';
if (!issues || issues.length === 0) {
issueCards = `
<div class="no-results-message p-4 my-4 text-center text-gray-500 bg-gray-50 rounded-md shadow-sm">
No issues found matching your search criteria.
</div>
`;
} else {
for (let i = 0; i < issues.length; i++) {
const issue = issues[i];
issueCards += `
<div class="issue-card mb-4 border rounded-lg" data-id="issue-card-${issue.id}">
<div class="py-2 px-4">
<div class="flex items-center gap-5 w-full cursor-pointer relative" onclick="toggleIssueCard(this)">
<div class="flex items-center pl-1">
<label for="issue-${issue.id}" class="sr-only">Select issue ${issue.id}</label>
<input type="checkbox" id="issue-${issue.id}" class="issue-checkbox w-4 h-4" onclick="event.stopPropagation();">
</div>
<div class="flex justify-between items-center mb-4 w-full block cursor-pointer relative" onclick="toggleIssueCard(this)">
<div class="w-full">
<div class="relative gap-2 flex items-center mt-2 mb-3 h-6">
<span class="${issue.impact === 'critical' ? 'bg-red-600' : issue.impact === 'serious' ? 'bg-orange-600' : issue.impact === 'moderate' ? 'bg-yellow-600' : 'bg-green-600'} text-white issue-impact px-2 py-0.5 rounded text-sm">${issue.impact || 'minor'}</span>
<h2 class="text-base issue-id font-semibold">${issue.id}</h2>
<span class="absolute left-[400px] bg-gray-200 text-gray-700 px-2 py-0.5 rounded text-s font-medium">${issue.nodes.length} ${issue.nodes.length === 1 ? 'element' : 'elements'}</span>
</div>
<div class="issue_description" class="mb-2">
<p class="font-semibold text-black mb-2">${escapeHTML(issue.description)}</p>
</div>
<div class="tag_list flex flex-wrap gap-1">
Standards: ${issue.tags.map(tag => `<span class="border text-sm px-2 py-1 rounded">${tag}</span>`).join(' ')}
</div>
<div class="disability_list flex flex-wrap gap-1 mt-2">
Disabilities Affected: ${getDisabilityTypes(issue.id).map(disability =>
`<span class="bg-purple-100 border border-purple-300 text-purple-800 text-sm px-2 py-1 rounded">${escapeHTML(disability.name)}</span>`
).join(' ')}
</div>
</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="chevron-icon">
<path d="m9 18 9-9-9-9"/>
</svg>
</div>
<div class="issue-details hidden">
<div class="border-t pt-3">
<div class="flex">
<h2 class="font-medium mb-2 flex-1">How to fix:</h2>
<a href="${issue.helpUrl}" target="_blank" class="fix-link font-medium mb-2 text-blue-600 hover:text-blue-800">Learn more</a>
</div>
<div class="help-section">
<p>${escapeHTML(issue.help)}</p>
</div>
</div>
<!-- Screenshot Section -->
${screenshot ? `
<div class="border-t pt-3">
<h2 class="font-medium mb-2">Page Screenshot:</h2>
<div class="node-item">
${fs.existsSync(`${BASE_DIR}${PAGES_DIR}/${screenshot?.replace(".png", `_${i + 1}.png`)}`) ?
`<img src="./${screenshot.replace(".png", `_${i + 1}.png`)}" alt="${screenshot.replace(".png", `_${i + 1}.png`)} screenshot" class="w-full h-auto rounded" />` :
'<p class="text-gray-500">No screenshot available</p>'
}
</div>
</div>
` : ''}
<!-- Nodes Section - Compact Version -->
<div class="border-t pt-3">
<h2 class="font-medium mb-2">Affected Elements (${issue.nodes.length}):</h2>
<div class="nodes-list">
<div class="nodes-list">
${issue.nodes.map((node, index) => `
<div class="node-item">
<div class="node-html">
<span class="element-number bg-gray-200 text-gray-700 px-2 py-0.5 rounded text-sm mr-2">#${index + 1}</span>
<code>${escapeHTML(node.target[0])}</code>
<button class="copy-button" aria-label="Copy to clipboard" onclick="copyToClipboard('${escapeHTML(node.target[0])}')">
<svg xmlns="http://www.w3.org/2000/svg"
width="18" height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
focusable="false">
<rect x="8" y="8" width="12" height="12" rx="2" ry="2"></rect>
<rect x="4" y="4" width="12" height="12" rx="2" ry="2"></rect>
</svg>
</button>
<div style="margin: 5px 50px; font-size: 12px">${escapeHTML(node.html)}</div>
</div>
<button class="toggle-details">
<i class="fas fa-chevron-down"></i> Show failure details
</button>
<div class="node-details hidden">
<div class="failure-summary">
<h4>${node.any && node.any.length ? "Fix any of the following:" : (node.all && node.all.length ? "Fix ALL of the following:" : "Fix the following:")}</h4>
<ul class="failure-list">
${node.any && node.any.length
? node.any.map(item => `<li>${escapeHTML(item.message)}</li>`).join('')
: node.all && node.all.length
? node.all.map(item => `<li>${escapeHTML(item.message)}</li>`).join('')
: '<li>No specific failure details available</li>'}
</ul>
</div>
</div>
</div>
`).join('')}
</div>
</div>
</div>
</div>
</div>
</div>
`
}
}
return issueCards;
}
function combineIssueCards(issues) {
return `
${generateTabContent('violations', issues[0])}
${generateTabContent('incomplete', issues[1])}
${generateTabContent('passes', issues[2])}
`;
}
function readAllJsonAndPutIntoArray() {
const reports = [];
const filePath = path.join(process.cwd(), BASE_DIR, PAGES_DIR);
const files = fs.readdirSync(filePath);
files.forEach(file => {
if (file.endsWith('.json')) {
const report = JSON.parse(fs.readFileSync(path.join(filePath, file), 'utf8'));
reports.push(report);
}
});
return reports;
}
function generateSummaryCart(reports) {
const violationsRulesTotalCount = reports.reduce((sum, report) => {
return sum + (report.violations ? report.violations.length : 0);
}, 0);
const violationElementsTotalCount = reports.reduce((sum, report) => {
return sum + (report.violations ? report.violations.reduce((acc, violation) => acc + (violation.nodes ? violation.nodes.length : 0), 0) : 0);
}, 0);
const incompleteRulesTotalCount = reports.reduce((sum, report) => {
return sum + (report.incomplete ? report.incomplete.length : 0);
}, 0);
const incompleteElementsTotalCount = reports.reduce((sum, report) => {
return sum + (report.incomplete ? report.incomplete.reduce((acc, incomplete) => acc + (incomplete.nodes ? incomplete.nodes.length : 0), 0) : 0);
}, 0);
const passesRulesTotalCount = reports.reduce((sum, report) => {
return sum + (report.passes ? report.passes.length : 0);
}, 0);
const passesElementsTotalCount = reports.reduce((sum, report) => {
return sum + (report.passes ? report.passes.reduce((acc, pass) => acc + (pass.nodes ? pass.nodes.length : 0), 0) : 0);
}, 0);
const totalRulesReports = violationsRulesTotalCount + incompleteRulesTotalCount + passesRulesTotalCount;
const totalElementsReports = violationElementsTotalCount + incompleteElementsTotalCount + passesElementsTotalCount;
return `
<div class="icon-cards">
<div class="icon-card">
<div class="card-icon icon-total">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 18V6"></path><path d="m5 12 7-7 7 7"></path></svg>
</div>
<div class="icon-content">
<h2 class="icon-title">Total Reports</h2>
<p class="icon-value">${totalElementsReports} Elements</p>
<p class="rule-value">Across <strong>${totalRulesReports} Rules</strong></p>
</div>
</div>
<div class="icon-card">
<div class="card-icon icon-violations">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
</div>
<div class="icon-content">
<h2 class="icon-title" style="color: #c0392b;">Violations</h2>
<p class="icon-value" >${violationElementsTotalCount} Elements</p>
<p class="rule-value">Across <strong>${violationsRulesTotalCount} Rules</strong></p>
</div>
</div>
<div class="icon-card">
<div class="card-icon icon-incomplete">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>
</div>
<div class="icon-content">
<h2 class="icon-title" style="color: #8a4b00;">Incomplete</h2>
<p class="icon-value" >${incompleteElementsTotalCount} Elements</p>
<p class="rule-value">Across <strong>${incompleteRulesTotalCount} Rules</strong></p>
</div>
</div>
<div class="icon-card">
<div class="card-icon icon-passes">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>
</div>
<div class="icon-content">
<h2 class="icon-title" style="color: #17632a;">Passes</h2>
<p class="icon-value">${passesElementsTotalCount} Elements</p>
<p class="rule-value">Across <strong>${passesRulesTotalCount} Rules</strong></p>
</div>
</div>
</div>
`
}
function generateTableCards(reports) {
// Add sorting state and logic
const columns = [
{ key: 'page', label: 'Page' },
{key: "critical_elements", label: "Critical Elements"},
{key: "serious_elements", label: "Serious Elements"},
{ key: 'moderate_elements', label: 'Moderate Elements' },
{ key: 'minor_elements', label: 'Minor Elements' },
{ key: 'violations', label: 'Violations Rules' },
{ key: 'incomplete', label: 'Incomplete Rules' },
{ key: 'passes', label: 'Passes Rules' },
{ key: 'inapplicable', label: 'Inapplicable Rules' },
{ key: 'impacts', label: 'Impacts' }
];
// Prepare data for sorting
let tableData = reports.map(report => {
//const standard = report.standard || 'N/A';
const critical_elements = [...report.violations, ...report.incomplete]
.map(v => v.nodes.filter(node => node.impact === 'critical').length)
.reduce((sum, count) => sum + count, 0);
const serious_elements = [...report.violations, ...report.incomplete]
.map(v => v.nodes.filter(node => node.impact === 'serious').length)
.reduce((sum, count) => sum + count, 0);
const moderate_elements = [...report.violations, ...report.incomplete]
.map(v => v.nodes.filter(node => node.impact === 'moderate').length)
.reduce((sum, count) => sum + count, 0);
const minor_elements = [...report.violations, ...report.incomplete]
.map(v => v.nodes.filter(node => node.impact === 'minor').length)
.reduce((sum, count) => sum + count, 0);
const violations = report.violations ? report.violations.length : 0;
const incomplete = report.incomplete ? report.incomplete.length : 0;
const passes = report.passes ? report.passes.length : 0;
const inapplicable = report.inapplicable ? report.inapplicable.length : 0;
let critical = 0, serious = 0, moderate = 0, none = 0;
['violations', 'incomplete'].forEach(section => {
if (report[section]) {
report[section].forEach(issue => {
if (issue.impact === 'critical') critical++;
else if (issue.impact === 'serious') serious++;
else if (issue.impact === 'moderate') moderate++;
else none++;
});
}
});
return {
id: report.id,
pagePath: report.pagePath || report.id,
critical_elements,
serious_elements,
moderate_elements,
minor_elements,
violations,
incomplete,
passes,
inapplicable,
critical,
serious,
moderate,
none,
impactsSortKey: [critical_elements, serious_elements, moderate_elements, minor_elements, critical, serious, moderate, none],
//standard
};
});
// Default sort: Violations desc
let sortKey = 'critical_elements';
let sortDir = 'desc';
// Table header with sort icons
function getSortIcon(col) {
if (sortKey !== col) return '<span class="sort-icon" style="display:inline-block;width:1em;"></span>';
return sortDir === 'asc' ? '<span class="sort-icon" style="display:inline-block;width:1em;">↑</span>' : '<span class="sort-icon" style="display:inline-block;width:1em;">↓</span>';
}
let tableHeader = `
<tr>
<th class="border-col" data-sort="pagePath">Page ${getSortIcon('pagePath')}</th>
<th class="center-cell" data-sort="critical_elements">Critical ${getSortIcon('critical_elements')}</th>
<th class="center-cell" data-sort="serious_elements">Serious ${getSortIcon('serious_elements')}</th>
<th class="center-cell" data-sort="moderate_elements">Moderate ${getSortIcon('moderate_elements')}</th>
<th class="center-cell" data-sort="minor_elements">Minor ${getSortIcon('minor_elements')}</th>
<th class="center-cell" data-sort="violations">
Rules Status
<div class="tooltip-container">
<span class="tooltip-icon">ⓘ</span>
</div>
${getSortIcon('violations')}
</th>
</tr>
`;
// Sorting logic
function sortTableData() {
tableData.sort((a, b) => {
if (sortKey === 'impacts') {
for (let i = 0; i < 4; i++) {
if (a.impactsSortKey[i] !== b.impactsSortKey[i]) {
return sortDir === 'desc' ? (b.impactsSortKey[i] - a.impactsSortKey[i]) : (a.impactsSortKey[i] - b.impactsSortKey[i]);
}
}
return 0;
} else if (sortKey === 'pagePath') {
return (sortDir === 'asc' ? 1 : -1) * a.pagePath.localeCompare(b.pagePath);
} else if (typeof a[sortKey] === 'string') {
return (sortDir === 'asc' ? 1 : -1) * a[sortKey].localeCompare(b[sortKey]);
} else {
return sortDir === 'asc' ? (a[sortKey] - b[sortKey]) : (b[sortKey] - a[sortKey]);
}
});
}
sortTableData();
function renderTableRows() {
return tableData.map(row => `
<tr>
<td class="url-cell border-col"><a href="./pages/${row.id}.html">${row.pagePath}</a></td>
<td class="status-cell critical_elements center-cell">${row.critical_elements}</td>
<td class="status-cell serious_elements center-cell">${row.serious_elements}</td>
<td class="status-cell moderate_elements center-cell">${row.moderate_elements}</td>
<td class="status-cell minor_elements center-cell">${row.minor_elements}</td>
<td class="center-cell">
<span class='violation-badge violations'>${row.violations} Violations</span>
<span class='violation-badge incomplete'>${row.incomplete} Incomplete</span>
<span class='violation-badge passes'>${row.passes} Passes</span>
</td>
</tr>
`).join('');
}
// Table with header and rows
const tableHtml = `
<div class="table-container">
<table id="sortable-table">
<thead>${tableHeader}</thead>
<tbody id="table-body">
${renderTableRows()}
</tbody>
</table>
</div>
<div id="pagination-controls"></div>
<script>
(function() {
let sortKey = '${sortKey}';
let sortDir = '${sortDir}';
const tableData = ${JSON.stringify(tableData)};
function getSortIcon(col) {
if (sortKey !== col) return '<span class="sort-icon" style="display:inline-block;width:1em;"> </span>';
return sortDir === 'asc' ? '<span class="sort-icon" style="display:inline-block;width:1em;">↑</span>' : '<span class="sort-icon" style="display:inline-block;width:1em;">↓</span>';
}
function sortTableData() {
tableData.sort(function(a, b) {
if (sortKey === 'pagePath') {
return (sortDir === 'asc' ? 1 : -1) * a.pagePath.localeCompare(b.pagePath);
} else if (typeof a[sortKey] === 'string') {
return (sortDir === 'asc' ? 1 : -1) * a[sortKey].localeCompare(b[sortKey]);
} else {
return sortDir === 'asc' ? (a[sortKey] - b[sortKey]) : (b[sortKey] - a[sortKey]);
}
});
}
function renderTableRows() {
return tableData.map(function(row) {
return '<tr>' +
'<td class="url-cell border-col"><a href="./pages/' + row.id + '.html">' + row.pagePath + '</a></td>' +
'<td class="status-cell critical_elements center-cell">' + row.critical_elements + '</td>' +
'<td class="status-cell serious_elements center-cell">' + row.serious_elements + '</td>' +
'<td class="status-cell moderate_elements center-cell">' + row.moderate_elements + '</td>' +
'<td class="status-cell minor_elements center-cell">' + row.minor_elements + '</td>' +
'<td class="center-cell">' +
'<span class="violation-badge violations">' + row.violations + ' Violations</span> ' +
'<span class="violation-badge incomplete">' + row.incomplete + ' Incomplete</span> ' +
'<span class="violation-badge passes">' + row.passes + ' Passes</span>' +
'</td>' +
'</tr>';
}).join('');
}
function updateTable() {
sortTableData();
document.getElementById('table-body').innerHTML = renderTableRows();
// Update sort icons
document.querySelectorAll('#sortable-table th[data-sort]').forEach(function(th) {
const col = th.getAttribute('data-sort');
const textContent = th.textContent.replace(/[⇅↑↓]/g, '').trim();
th.innerHTML = textContent + (col === sortKey ? ' ' + getSortIcon(col) : '');
});
}
document.querySelectorAll('#sortable-table th[data-sort]').forEach(function(th) {
th.style.cursor = 'pointer';
th.addEventListener('click', function() {
const col = th.getAttribute('data-sort');
if (sortKey === col) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortKey = col;
sortDir = 'desc';
}
updateTable();
});
});
})();
</script>
`;
return tableHtml;
}
function generateImpactDistribution(reports) {
const countImpacts = (reports, impact) => {
return reports.reduce((count, report) => {
['violations', 'incomplete'].forEach(section => {
if (report[section]?.filter(v => v.impact === impact).length > 0) {
report[section]?.filter(v => v.impact === impact).forEach(item => {
count += item.nodes.length;
});
}
//count += report[section]?.filter(v => v.impact === impact)?.nodes.length || 0;
});
return count;
}, 0);
};
const totalCritical = countImpacts(reports, "critical");
const totalSerious = countImpacts(reports, "serious");
const totalModerate = countImpacts(reports, "moderate");
const totalMinor = countImpacts(reports, "minor");
return `
<div class="chart">
<h3 class="chart-title">Impact Distribution</h3>
<div class="chart-content" style="max-height: 300px; overflow-y: auto; display: flex; align-items: center; justify-content: center;">
<canvas id="impactDistributionChart" style="max-width: 100%; height: auto;"></canvas>
</div>
<script>
const ctx = document.getElementById('impactDistributionChart').getContext('2d');
new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Critical', 'Serious', 'Moderate', 'Minor'],
datasets: [{
data: [${totalCritical}, ${totalSerious}, ${totalModerate}, ${totalMinor}],
backgroundColor: ['#ef4444', '#f97316', '#facc15', '#22c55e'],
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
boxWidth: 20,
padding: 15,
font: {
size: 14
}
}
}
}
}
});
</script>
</div>`;
}
function generateDisabilityAffected(reports, affected) {
const disabilitiesArray = [];
reports.forEach(report => {
report.violations.forEach(violation => {
const rule = affected.find(item => item.ruleId === violation.id);
if (rule && rule['disabilityTypesAffected']) {
rule['disabilityTypesAffected'].forEach(disability => {
disabilitiesArray.push(disabilit