- Add 100 blog posts covering AI, development, and tech topics - Add .env.example for environment configuration - Add accessibility and lighthouse audit scripts - Remove obsolete SEO reports and temporary files - Remove dev-dist build artifacts and backup files - Remove unused portrait images (moved/consolidated elsewhere) - Update contact form and component improvements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
494 lines
15 KiB
JavaScript
494 lines
15 KiB
JavaScript
/**
|
|
* Axe Accessibility Audit Script
|
|
*
|
|
* This script runs axe-core accessibility scans on all main pages of the portfolio site.
|
|
* It generates a comprehensive report with WCAG references.
|
|
*
|
|
* Usage:
|
|
* node scripts/axe-accessibility-audit.js
|
|
*
|
|
* Prerequisites:
|
|
* - Development server running on http://localhost:3000
|
|
* - Install deps: npm install puppeteer @axe-core/puppeteer --no-save
|
|
*/
|
|
|
|
const { AxePuppeteer } = require('@axe-core/puppeteer');
|
|
const puppeteer = require('puppeteer');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// Configuration
|
|
const BASE_URL = 'http://localhost:3000';
|
|
const LOCALES = ['de', 'en', 'sr'];
|
|
const OUTPUT_DIR = path.join(__dirname, '..', '.auto-claude', 'specs', '001-suche-nach-optimierungsvorschl-gen');
|
|
|
|
// Pages to audit (main public pages)
|
|
const PAGES = [
|
|
{ path: '', name: 'Home' },
|
|
{ path: '/about', name: 'About' },
|
|
{ path: '/blog', name: 'Blog Index' },
|
|
{ path: '/contact', name: 'Contact' },
|
|
{ path: '/leistungen', name: 'Services' },
|
|
{ path: '/portfolio', name: 'Portfolio Index' },
|
|
{ path: '/imprint', name: 'Imprint' },
|
|
{ path: '/privacy', name: 'Privacy' },
|
|
{ path: '/terms', name: 'Terms' },
|
|
];
|
|
|
|
// WCAG 2.1 Impact mapping
|
|
const IMPACT_ORDER = {
|
|
'critical': 0,
|
|
'serious': 1,
|
|
'moderate': 2,
|
|
'minor': 3
|
|
};
|
|
|
|
const WCAG_TAGS = {
|
|
'wcag2a': 'WCAG 2.0 Level A',
|
|
'wcag2aa': 'WCAG 2.0 Level AA',
|
|
'wcag2aaa': 'WCAG 2.0 Level AAA',
|
|
'wcag21a': 'WCAG 2.1 Level A',
|
|
'wcag21aa': 'WCAG 2.1 Level AA',
|
|
'wcag22aa': 'WCAG 2.2 Level AA',
|
|
'best-practice': 'Best Practice'
|
|
};
|
|
|
|
/**
|
|
* Run axe audit on a single page
|
|
*/
|
|
async function auditPage(browser, url, pageName) {
|
|
const page = await browser.newPage();
|
|
await page.setViewport({ width: 1280, height: 800 });
|
|
|
|
console.log(` Scanning: ${url}`);
|
|
|
|
try {
|
|
await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 });
|
|
|
|
// Wait for dynamic content to load
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Run axe analysis
|
|
const results = await new AxePuppeteer(page)
|
|
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'])
|
|
.analyze();
|
|
|
|
await page.close();
|
|
|
|
return {
|
|
url,
|
|
pageName,
|
|
timestamp: new Date().toISOString(),
|
|
violations: results.violations,
|
|
passes: results.passes.length,
|
|
incomplete: results.incomplete,
|
|
inapplicable: results.inapplicable.length
|
|
};
|
|
} catch (error) {
|
|
await page.close();
|
|
return {
|
|
url,
|
|
pageName,
|
|
timestamp: new Date().toISOString(),
|
|
error: error.message,
|
|
violations: [],
|
|
passes: 0,
|
|
incomplete: [],
|
|
inapplicable: 0
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format violations for markdown output
|
|
*/
|
|
function formatViolation(violation) {
|
|
const wcagTags = violation.tags
|
|
.filter(tag => tag.startsWith('wcag') || tag === 'best-practice')
|
|
.map(tag => WCAG_TAGS[tag] || tag)
|
|
.join(', ');
|
|
|
|
const nodes = violation.nodes.map(node => ({
|
|
html: node.html.substring(0, 200),
|
|
target: node.target.join(', '),
|
|
failureSummary: node.failureSummary
|
|
}));
|
|
|
|
return {
|
|
id: violation.id,
|
|
impact: violation.impact,
|
|
description: violation.description,
|
|
help: violation.help,
|
|
helpUrl: violation.helpUrl,
|
|
wcagTags,
|
|
nodeCount: nodes.length,
|
|
nodes: nodes.slice(0, 5) // Limit to first 5 examples
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Aggregate violations across all pages
|
|
*/
|
|
function aggregateViolations(allResults) {
|
|
const violationMap = new Map();
|
|
|
|
for (const result of allResults) {
|
|
if (result.error) continue;
|
|
|
|
for (const violation of result.violations) {
|
|
const key = violation.id;
|
|
|
|
if (!violationMap.has(key)) {
|
|
violationMap.set(key, {
|
|
...formatViolation(violation),
|
|
affectedPages: [],
|
|
totalInstances: 0
|
|
});
|
|
}
|
|
|
|
const aggregated = violationMap.get(key);
|
|
aggregated.affectedPages.push({
|
|
url: result.url,
|
|
pageName: result.pageName,
|
|
instances: violation.nodes.length
|
|
});
|
|
aggregated.totalInstances += violation.nodes.length;
|
|
}
|
|
}
|
|
|
|
// Sort by impact severity, then by total instances
|
|
return Array.from(violationMap.values()).sort((a, b) => {
|
|
const impactDiff = IMPACT_ORDER[a.impact] - IMPACT_ORDER[b.impact];
|
|
if (impactDiff !== 0) return impactDiff;
|
|
return b.totalInstances - a.totalInstances;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Generate markdown report
|
|
*/
|
|
function generateMarkdownReport(allResults, aggregatedViolations) {
|
|
const timestamp = new Date().toISOString();
|
|
|
|
// Calculate statistics
|
|
const totalPages = allResults.filter(r => !r.error).length;
|
|
const errorPages = allResults.filter(r => r.error).length;
|
|
const totalViolations = aggregatedViolations.length;
|
|
const totalInstances = aggregatedViolations.reduce((sum, v) => sum + v.totalInstances, 0);
|
|
|
|
const bySeverity = {
|
|
critical: aggregatedViolations.filter(v => v.impact === 'critical'),
|
|
serious: aggregatedViolations.filter(v => v.impact === 'serious'),
|
|
moderate: aggregatedViolations.filter(v => v.impact === 'moderate'),
|
|
minor: aggregatedViolations.filter(v => v.impact === 'minor')
|
|
};
|
|
|
|
let report = `# Axe Accessibility Audit Report
|
|
|
|
**Generated:** ${timestamp}
|
|
**Tool:** axe-core via @axe-core/puppeteer
|
|
**Standards:** WCAG 2.0 A/AA, WCAG 2.1 A/AA, Best Practices
|
|
|
|
---
|
|
|
|
## Executive Summary
|
|
|
|
| Metric | Value |
|
|
|--------|-------|
|
|
| Pages Scanned | ${totalPages} |
|
|
| Pages with Errors | ${errorPages} |
|
|
| Unique Violations | ${totalViolations} |
|
|
| Total Instances | ${totalInstances} |
|
|
| Critical Issues | ${bySeverity.critical.length} |
|
|
| Serious Issues | ${bySeverity.serious.length} |
|
|
| Moderate Issues | ${bySeverity.moderate.length} |
|
|
| Minor Issues | ${bySeverity.minor.length} |
|
|
|
|
`;
|
|
|
|
// Severity breakdown
|
|
report += `### Severity Distribution
|
|
|
|
`;
|
|
|
|
if (bySeverity.critical.length > 0) {
|
|
report += `🔴 **Critical (${bySeverity.critical.length}):** ${bySeverity.critical.map(v => v.id).join(', ')}\n\n`;
|
|
} else {
|
|
report += `✅ **Critical (0):** No critical issues found\n\n`;
|
|
}
|
|
|
|
if (bySeverity.serious.length > 0) {
|
|
report += `🟠 **Serious (${bySeverity.serious.length}):** ${bySeverity.serious.map(v => v.id).join(', ')}\n\n`;
|
|
} else {
|
|
report += `✅ **Serious (0):** No serious issues found\n\n`;
|
|
}
|
|
|
|
if (bySeverity.moderate.length > 0) {
|
|
report += `🟡 **Moderate (${bySeverity.moderate.length}):** ${bySeverity.moderate.map(v => v.id).join(', ')}\n\n`;
|
|
} else {
|
|
report += `✅ **Moderate (0):** No moderate issues found\n\n`;
|
|
}
|
|
|
|
if (bySeverity.minor.length > 0) {
|
|
report += `🟢 **Minor (${bySeverity.minor.length}):** ${bySeverity.minor.map(v => v.id).join(', ')}\n\n`;
|
|
} else {
|
|
report += `✅ **Minor (0):** No minor issues found\n\n`;
|
|
}
|
|
|
|
report += `---
|
|
|
|
## Page-by-Page Results
|
|
|
|
`;
|
|
|
|
for (const result of allResults) {
|
|
const locale = result.url.split('/')[3] || 'root';
|
|
const violationCount = result.violations?.length || 0;
|
|
const status = result.error ? '❌ Error' : (violationCount === 0 ? '✅ Pass' : `⚠️ ${violationCount} issues`);
|
|
|
|
report += `### ${result.pageName} (${locale.toUpperCase()})
|
|
|
|
**URL:** ${result.url}
|
|
**Status:** ${status}
|
|
`;
|
|
|
|
if (result.error) {
|
|
report += `**Error:** ${result.error}\n\n`;
|
|
} else {
|
|
report += `**Passes:** ${result.passes} | **Violations:** ${violationCount} | **Incomplete:** ${result.incomplete.length}\n\n`;
|
|
|
|
if (violationCount > 0) {
|
|
report += `**Issues found:**\n`;
|
|
for (const v of result.violations) {
|
|
report += `- \`${v.id}\` (${v.impact}): ${v.help} - ${v.nodes.length} instance(s)\n`;
|
|
}
|
|
report += '\n';
|
|
}
|
|
}
|
|
}
|
|
|
|
report += `---
|
|
|
|
## Detailed Violations
|
|
|
|
`;
|
|
|
|
for (const violation of aggregatedViolations) {
|
|
const impactEmoji = {
|
|
'critical': '🔴',
|
|
'serious': '🟠',
|
|
'moderate': '🟡',
|
|
'minor': '🟢'
|
|
}[violation.impact];
|
|
|
|
report += `### ${impactEmoji} ${violation.id}
|
|
|
|
**Impact:** ${violation.impact.toUpperCase()}
|
|
**WCAG:** ${violation.wcagTags || 'N/A'}
|
|
**Help:** ${violation.help}
|
|
**Total Instances:** ${violation.totalInstances}
|
|
**Documentation:** [Deque ${violation.id}](${violation.helpUrl})
|
|
|
|
**Description:** ${violation.description}
|
|
|
|
**Affected Pages:**
|
|
`;
|
|
|
|
for (const page of violation.affectedPages) {
|
|
report += `- ${page.pageName} (${page.url}): ${page.instances} instance(s)\n`;
|
|
}
|
|
|
|
report += '\n**Example Elements:**\n\n```html\n';
|
|
for (const node of violation.nodes.slice(0, 3)) {
|
|
report += `<!-- Target: ${node.target} -->\n`;
|
|
report += `${node.html}\n\n`;
|
|
}
|
|
report += '```\n\n';
|
|
|
|
if (violation.nodes[0]?.failureSummary) {
|
|
report += `**Fix:** ${violation.nodes[0].failureSummary}\n\n`;
|
|
}
|
|
|
|
report += `---\n\n`;
|
|
}
|
|
|
|
// Recommendations section
|
|
report += `## Recommendations
|
|
|
|
### Immediate Actions (Critical & Serious)
|
|
|
|
`;
|
|
|
|
for (const v of [...bySeverity.critical, ...bySeverity.serious]) {
|
|
report += `1. **Fix \`${v.id}\`**: ${v.help} - Affects ${v.affectedPages.length} page(s), ${v.totalInstances} total instance(s)\n`;
|
|
}
|
|
|
|
if (bySeverity.critical.length === 0 && bySeverity.serious.length === 0) {
|
|
report += `✅ No critical or serious issues to fix immediately.\n`;
|
|
}
|
|
|
|
report += `
|
|
### Short-term Improvements (Moderate)
|
|
|
|
`;
|
|
|
|
for (const v of bySeverity.moderate) {
|
|
report += `1. **Fix \`${v.id}\`**: ${v.help} - Affects ${v.affectedPages.length} page(s)\n`;
|
|
}
|
|
|
|
if (bySeverity.moderate.length === 0) {
|
|
report += `✅ No moderate issues to address.\n`;
|
|
}
|
|
|
|
report += `
|
|
### Nice-to-have (Minor & Best Practices)
|
|
|
|
`;
|
|
|
|
for (const v of bySeverity.minor) {
|
|
report += `1. **Consider \`${v.id}\`**: ${v.help}\n`;
|
|
}
|
|
|
|
if (bySeverity.minor.length === 0) {
|
|
report += `✅ No minor issues found.\n`;
|
|
}
|
|
|
|
report += `
|
|
---
|
|
|
|
## WCAG Compliance Summary
|
|
|
|
Based on axe automated testing, the following WCAG conformance levels have been evaluated:
|
|
|
|
| Level | Status | Notes |
|
|
|-------|--------|-------|
|
|
| WCAG 2.0 A | ${bySeverity.critical.length === 0 ? '✅ Pass' : '❌ Fail'} | ${bySeverity.critical.length > 0 ? `${bySeverity.critical.length} critical violations` : 'No critical violations'} |
|
|
| WCAG 2.0 AA | ${(bySeverity.critical.length + bySeverity.serious.length) === 0 ? '✅ Pass' : '⚠️ Issues'} | ${(bySeverity.critical.length + bySeverity.serious.length) > 0 ? `${bySeverity.critical.length + bySeverity.serious.length} issues` : 'No serious violations'} |
|
|
| WCAG 2.1 AA | ${(bySeverity.critical.length + bySeverity.serious.length) === 0 ? '✅ Pass' : '⚠️ Issues'} | Same as above |
|
|
|
|
**Note:** Automated testing can only catch ~30-40% of accessibility issues. Manual testing for keyboard navigation,
|
|
screen reader compatibility, and cognitive accessibility is also required for full WCAG compliance.
|
|
|
|
---
|
|
|
|
## Testing Methodology
|
|
|
|
- **Tool:** axe-core via @axe-core/puppeteer
|
|
- **Browser:** Chromium (Puppeteer)
|
|
- **Viewport:** 1280x800 (Desktop)
|
|
- **Standards Tested:** wcag2a, wcag2aa, wcag21a, wcag21aa, best-practice
|
|
- **Locales Tested:** German (de), English (en), Serbian (sr)
|
|
|
|
## Next Steps
|
|
|
|
1. Address all critical and serious violations first
|
|
2. Fix moderate issues for full WCAG 2.1 AA compliance
|
|
3. Perform manual keyboard navigation testing
|
|
4. Test with screen readers (NVDA, VoiceOver)
|
|
5. Review color contrast manually for custom components
|
|
6. Test with real users with disabilities if possible
|
|
`;
|
|
|
|
return report;
|
|
}
|
|
|
|
/**
|
|
* Main execution
|
|
*/
|
|
async function main() {
|
|
console.log('🔍 Axe Accessibility Audit Starting...\n');
|
|
|
|
// Check if dev server is running
|
|
try {
|
|
const http = require('http');
|
|
await new Promise((resolve, reject) => {
|
|
http.get(BASE_URL, (res) => {
|
|
if (res.statusCode === 200 || res.statusCode === 301 || res.statusCode === 302) {
|
|
resolve();
|
|
} else {
|
|
reject(new Error(`Server returned ${res.statusCode}`));
|
|
}
|
|
}).on('error', reject);
|
|
});
|
|
} catch (error) {
|
|
console.error('❌ Development server not running!');
|
|
console.error(' Please start the server with: pnpm dev');
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('✅ Development server detected\n');
|
|
|
|
// Launch browser
|
|
const browser = await puppeteer.launch({
|
|
headless: 'new',
|
|
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
|
});
|
|
|
|
const allResults = [];
|
|
|
|
try {
|
|
// Test each locale and page combination
|
|
for (const locale of LOCALES) {
|
|
console.log(`\n📍 Scanning locale: ${locale.toUpperCase()}`);
|
|
|
|
for (const page of PAGES) {
|
|
const url = `${BASE_URL}/${locale}${page.path}`;
|
|
const result = await auditPage(browser, url, page.name);
|
|
allResults.push(result);
|
|
}
|
|
}
|
|
|
|
console.log('\n\n📊 Processing results...');
|
|
|
|
// Aggregate violations
|
|
const aggregatedViolations = aggregateViolations(allResults);
|
|
|
|
// Generate report
|
|
const report = generateMarkdownReport(allResults, aggregatedViolations);
|
|
|
|
// Save report
|
|
const reportPath = path.join(OUTPUT_DIR, 'axe-accessibility-audit.md');
|
|
fs.writeFileSync(reportPath, report);
|
|
|
|
// Save JSON data for further analysis
|
|
const jsonPath = path.join(OUTPUT_DIR, 'axe-accessibility-audit.json');
|
|
fs.writeFileSync(jsonPath, JSON.stringify({
|
|
timestamp: new Date().toISOString(),
|
|
summary: {
|
|
pagesScanned: allResults.filter(r => !r.error).length,
|
|
uniqueViolations: aggregatedViolations.length,
|
|
totalInstances: aggregatedViolations.reduce((sum, v) => sum + v.totalInstances, 0),
|
|
bySeverity: {
|
|
critical: aggregatedViolations.filter(v => v.impact === 'critical').length,
|
|
serious: aggregatedViolations.filter(v => v.impact === 'serious').length,
|
|
moderate: aggregatedViolations.filter(v => v.impact === 'moderate').length,
|
|
minor: aggregatedViolations.filter(v => v.impact === 'minor').length
|
|
}
|
|
},
|
|
violations: aggregatedViolations,
|
|
pageResults: allResults
|
|
}, null, 2));
|
|
|
|
console.log(`\n✅ Audit complete!`);
|
|
console.log(` Report: ${reportPath}`);
|
|
console.log(` JSON: ${jsonPath}`);
|
|
|
|
// Print summary
|
|
console.log('\n📈 Summary:');
|
|
console.log(` Pages scanned: ${allResults.filter(r => !r.error).length}`);
|
|
console.log(` Unique violations: ${aggregatedViolations.length}`);
|
|
console.log(` Critical: ${aggregatedViolations.filter(v => v.impact === 'critical').length}`);
|
|
console.log(` Serious: ${aggregatedViolations.filter(v => v.impact === 'serious').length}`);
|
|
console.log(` Moderate: ${aggregatedViolations.filter(v => v.impact === 'moderate').length}`);
|
|
console.log(` Minor: ${aggregatedViolations.filter(v => v.impact === 'minor').length}`);
|
|
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
}
|
|
|
|
main().catch(error => {
|
|
console.error('Fatal error:', error);
|
|
process.exit(1);
|
|
});
|