import axios from 'axios'; import * as cheerio from 'cheerio'; import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; /** * WCAG Audit Script * * 1. Fetches sitemap.xml from the target URL * 2. Extracts all URLs * 3. Runs pa11y-ci on those URLs */ const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20; const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026'; async function main() { console.log(`\n๐Ÿš€ Starting WCAG Audit for: ${targetUrl}`); console.log(`๐Ÿ“Š Limit: ${limit} pages\n`); try { // 1. Fetch Sitemap const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`; console.log(`๐Ÿ“ฅ Fetching sitemap from ${sitemapUrl}...`); const response = await axios.get(sitemapUrl, { headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`, }, validateStatus: (status) => status < 400, }); const $ = cheerio.load(response.data, { xmlMode: true }); let urls = $('url loc') .map((i, el) => $(el).text()) .get(); // Cleanup, filter and normalize domains to targetUrl const urlPattern = /https?:\/\/[^\/]+/; urls = [...new Set(urls)] .filter((u) => u.startsWith('http')) .map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, ''))) .sort(); console.log(`โœ… Found ${urls.length} URLs in sitemap.`); if (urls.length === 0) { console.error('โŒ No URLs found in sitemap. Is the site up?'); process.exit(1); } if (urls.length > limit) { console.log( `โš ๏ธ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`, ); const home = urls.filter((u) => u.endsWith('/de') || u.endsWith('/en') || u === targetUrl); const others = urls.filter((u) => !home.includes(u)); urls = [...home, ...others.slice(0, limit - home.length)]; } console.log(`๐Ÿงช Pages to be tested:`); urls.forEach((u) => console.log(` - ${u}`)); // 2. Prepare pa11y-ci config const baseConfigPath = path.join(process.cwd(), '.pa11yci.json'); let baseConfig: any = { defaults: {} }; if (fs.existsSync(baseConfigPath)) { baseConfig = JSON.parse(fs.readFileSync(baseConfigPath, 'utf8')); } // Extract domain for cookie const urlObj = new URL(targetUrl); const domain = urlObj.hostname; // Update config with discovered URLs and gatekeeper cookie const tempConfig = { ...baseConfig, defaults: { ...baseConfig.defaults, threshold: 0, // Force threshold to 0 so all errors are shown in JSON runners: ['axe'], ignore: [...(baseConfig.defaults?.ignore || []), 'color-contrast'], chromeLaunchConfig: { ...baseConfig.defaults?.chromeLaunchConfig, ...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}), args: [ ...(baseConfig.defaults?.chromeLaunchConfig?.args || []), '--no-sandbox', '--disable-setuid-sandbox', ], }, headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`, }, timeout: 60000, // Increase timeout for slower pages }, urls: urls, }; // Create output directory const outputDir = path.join(process.cwd(), '.pa11yci'); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } const tempConfigPath = path.join(outputDir, 'config.temp.json'); const reportPath = path.join(outputDir, 'report.json'); fs.writeFileSync(tempConfigPath, JSON.stringify(tempConfig, null, 2)); // 3. Execute pa11y-ci console.log(`\n๐Ÿ’ป Executing pa11y-ci...`); const pa11yCommand = `npx pa11y-ci --config .pa11yci/config.temp.json --reporter json > .pa11yci/report.json`; try { execSync(pa11yCommand, { encoding: 'utf8', stdio: 'inherit', }); } catch (err: any) { // pa11y-ci exits with non-zero if issues are found, which is expected } // 4. Summarize Results if (fs.existsSync(reportPath)) { const reportData = JSON.parse(fs.readFileSync(reportPath, 'utf8')); console.log(`\n๐Ÿ“Š WCAG Audit Summary:\n`); const summaryTable = Object.keys(reportData.results).map((url) => { const results = reportData.results[url]; // Results might have errors or just a top level message if it crashed let errors = 0; let warnings = 0; let notices = 0; if (Array.isArray(results)) { // pa11y action execution errors come as objects with a message but no type const actionErrors = results.filter((r: any) => !r.type && r.message).length; errors = results.filter((r: any) => r.type === 'error').length + actionErrors; warnings = results.filter((r: any) => r.type === 'warning').length; notices = results.filter((r: any) => r.type === 'notice').length; } // Clean URL for display const displayUrl = url.replace(targetUrl, '') || '/'; return { URL: displayUrl.length > 50 ? displayUrl.substring(0, 47) + '...' : displayUrl, Errors: errors, Warnings: warnings, Notices: notices, Status: errors === 0 ? 'โœ…' : 'โŒ', }; }); console.table(summaryTable); const totalErrors = summaryTable.reduce((acc, curr) => acc + curr.Errors, 0); const totalPages = summaryTable.length; const cleanPages = summaryTable.filter((p) => p.Errors === 0).length; console.log(`\n๐Ÿ“ˆ Result: ${cleanPages}/${totalPages} pages are error-free.`); if (totalErrors > 0) { console.log(` Total Errors discovered: ${totalErrors}`); process.exitCode = 1; } } console.log(`\nโœจ WCAG Audit completed!`); } catch (error: any) { console.error(`\nโŒ Error during WCAG Audit:`); if (axios.isAxiosError(error)) { console.error(`Status: ${error.response?.status}`); console.error(`URL: ${error.config?.url}`); } else { console.error(error.message); } process.exit(1); } finally { // Clean up temp config file, keep report const tempConfigPath = path.join(process.cwd(), '.pa11yci/config.temp.json'); if (fs.existsSync(tempConfigPath)) fs.unlinkSync(tempConfigPath); } } main();