/** * Report rendering. Pure string building so the output format is testable and * the CLI stays a thin shell. */ import type { AuditReport, FlatNode } from './types'; function ruleSummary( nodes: FlatNode[] ): Array<{ rule: string; impact: string; count: number; helpUrl: string; help: string }> { const byRule = new Map< string, { rule: string; impact: string; count: number; helpUrl: string; help: string } >(); for (const node of nodes) { const existing = byRule.get(node.rule); if (existing) existing.count += 1; else byRule.set(node.rule, { rule: node.rule, impact: node.impact, count: 1, helpUrl: node.helpUrl, help: node.help, }); } return [...byRule.values()].sort((a, b) => b.count - a.count || a.rule.localeCompare(b.rule)); } /** Human-readable console report. */ export function renderConsole(report: AuditReport): string { const lines: string[] = []; lines.push(''); lines.push(` a11y-audit — ${report.name}`); lines.push( ` axe-core ${report.axeVersion} · tags: ${report.standard.join(', ')} · failing on: ${report.failOn.join(', ')}` ); lines.push( ` base URL: ${report.baseUrl}` + (report.blockExternalRequests ? ' (external requests blocked)' : '') ); lines.push(''); for (const route of report.routes) { const status = route.blocking.length === 0 ? 'PASS' : 'FAIL'; const redirect = route.finalUrl.endsWith(route.route) ? '' : ` -> ${route.finalUrl}`; lines.push( ` [${status}] ${route.routeName} @ ${route.viewport}${redirect}` + `\n ${route.blocking.length} blocking · ${route.allowed.length} allowlisted · ` + `${route.advisory.length} advisory · ${route.passCount} checks passed · ` + `${route.incompleteCount} needs review · ${route.renderedTextLength} chars rendered` + `${route.domSettled ? '' : ' (DOM never went quiet)'}` ); for (const entry of ruleSummary(route.blocking)) { lines.push(` x ${entry.rule} (${entry.impact}) x${entry.count} — ${entry.help}`); for (const node of route.blocking.filter((n) => n.rule === entry.rule).slice(0, 5)) { lines.push(` ${node.target.join(' , ')}`); } const hidden = route.blocking.filter((n) => n.rule === entry.rule).length - 5; if (hidden > 0) lines.push(` ... and ${hidden} more node(s)`); lines.push(` ${entry.helpUrl}`); } for (const node of route.allowed) { lines.push( ` ~ ${node.rule} (${node.impact}) allowlisted [#${node.allowlistIndex}` + `${node.allowlistTicket ? ` ${node.allowlistTicket}` : ''}]: ${node.allowlistReason}` ); } } lines.push(''); lines.push( ` TOTAL: ${report.totals.blocking} blocking · ${report.totals.allowed} allowlisted · ` + `${report.totals.advisory} advisory across ${report.totals.routesAudited} route/viewport runs` ); if (report.expiredAllowlistEntries.length > 0) { lines.push(''); lines.push(' EXPIRED allowlist entries (they no longer suppress anything):'); for (const entry of report.expiredAllowlistEntries) { lines.push(` - allowlist[${entry.index}] ${entry.rule} expired ${entry.expires}`); } } if (report.staleAllowlistEntries.length > 0) { lines.push(''); lines.push(' STALE allowlist entries (matched nothing this run — delete them):'); for (const entry of report.staleAllowlistEntries) { lines.push(` - allowlist[${entry.index}] ${entry.rule}: ${entry.reason}`); } } lines.push(''); return lines.join('\n'); } /** GitHub step-summary flavoured markdown. */ export function renderMarkdown(report: AuditReport): string { const lines: string[] = []; const verdict = report.totals.blocking === 0 ? 'PASS' : 'FAIL'; lines.push(`## Accessibility (WCAG 2.1 AA) — ${report.name}: ${verdict}`); lines.push(''); lines.push( `axe-core \`${report.axeVersion}\`, tags \`${report.standard.join(', ')}\`, failing on ` + `\`${report.failOn.join(', ')}\`.` ); lines.push(''); lines.push('| Route | Viewport | Blocking | Allowlisted | Advisory |'); lines.push('| --- | --- | ---: | ---: | ---: |'); for (const route of report.routes) { lines.push( `| \`${route.routeName}\` | ${route.viewport} | ${route.blocking.length} | ` + `${route.allowed.length} | ${route.advisory.length} |` ); } const allBlocking = report.routes.flatMap((r) => r.blocking); if (allBlocking.length > 0) { lines.push(''); lines.push('### Blocking violations'); lines.push(''); lines.push('| Rule | Impact | Nodes | Help |'); lines.push('| --- | --- | ---: | --- |'); for (const entry of ruleSummary(allBlocking)) { lines.push( `| \`${entry.rule}\` | ${entry.impact} | ${entry.count} | [${entry.help}](${entry.helpUrl}) |` ); } } if (report.staleAllowlistEntries.length > 0 || report.expiredAllowlistEntries.length > 0) { lines.push(''); lines.push('### Allowlist hygiene'); for (const entry of report.expiredAllowlistEntries) { lines.push(`- expired: \`allowlist[${entry.index}]\` \`${entry.rule}\` (${entry.expires})`); } for (const entry of report.staleAllowlistEntries) { lines.push(`- stale: \`allowlist[${entry.index}]\` \`${entry.rule}\` matched nothing`); } } lines.push(''); return lines.join('\n'); }