hpcompaq435/accessaudit-scanner
0
1import type { ScannedViolation, Impact } from "./types.js";2 3export type Priority = "now" | "soon" | "later";4 5export interface Remediation {6 ruleId: string;7 priority: Priority;8 /** Who is hurt by this issue, in plain language */9 whoItAffects: string;10 /** Plain-English explanation of the problem */11 plainExplanation: string;12 /** Step-by-step fix instructions */13 howToFix: string;14 /** A concrete code example (may be empty) */15 codeExample: string;16}17 18export interface RemediatedViolation extends ScannedViolation {19 remediation: Remediation;20}21 22const PRIORITY_BY_IMPACT: Record<Impact, Priority> = {23 critical: "now",24 serious: "now",25 moderate: "soon",26 minor: "later",27};28 29const PRIORITY_ORDER: Record<Priority, number> = { now: 0, soon: 1, later: 2 };30 31type Template = Omit<Remediation, "ruleId" | "priority">;32 33/**34 * Curated, deterministic remediation guidance keyed by axe-core rule id.35 * Covers the most common WCAG findings. Anything not listed falls back to a36 * sensible generic message built from axe's own description + help URL, so37 * every violation still gets a useful card. No LLM, no API, no data leaves38 * the server — and no risk of hallucinated compliance advice.39 */40const TEMPLATES: Record<string, Template> = {41 "image-alt": {42 whoItAffects: "Blind and low-vision people using screen readers.",43 plainExplanation:44 "Images are missing alt text, so screen readers cannot describe them — they announce nothing or just the file name.",45 howToFix:46 "Add a descriptive alt attribute to each meaningful image. For purely decorative images, use an empty alt (alt=\"\") so screen readers skip them.",47 codeExample:48 '<img src="chart.png" alt="Revenue rose 20% in Q3">\n<!-- decorative image: --> <img src="divider.png" alt="">',49 },50 "input-image-alt": {51 whoItAffects: "Screen-reader users operating image buttons.",52 plainExplanation:53 'An <input type="image"> button has no alt text, so its purpose is not announced.',54 howToFix: "Add an alt attribute describing the button's action.",55 codeExample: '<input type="image" src="search.png" alt="Search">',56 },57 "area-alt": {58 whoItAffects: "Screen-reader users navigating image maps.",59 plainExplanation: "An image-map <area> link has no accessible name.",60 howToFix: "Add alt text describing where the area links to.",61 codeExample: '<area shape="rect" coords="0,0,80,40" href="/home" alt="Home">',62 },63 "color-contrast": {64 whoItAffects:65 "People with low vision or colour-blindness, and anyone in bright sunlight.",66 plainExplanation:67 "Text does not have enough contrast against its background. WCAG AA requires at least 4.5:1 for normal text and 3:1 for large text.",68 howToFix:69 "Darken the text or lighten the background until the ratio passes. Verify with a contrast checker.",70 codeExample:71 "/* before: #999 on #fff = 2.8:1 (fails) */\n/* after: */ color: #595959; /* on #fff = 7:1 (passes) */",72 },73 "link-name": {74 whoItAffects: "Screen-reader and voice-control users.",75 plainExplanation:76 'A link has no discernible text (often an icon-only link), so it is announced as just "link".',77 howToFix:78 "Add visible text, or an aria-label / visually-hidden text describing the destination.",79 codeExample: '<a href="/cart" aria-label="View cart"><svg aria-hidden="true">…</svg></a>',80 },81 "button-name": {82 whoItAffects: "Screen-reader and voice-control users.",83 plainExplanation:84 "A button has no accessible name, so its purpose is not announced.",85 howToFix: "Put text inside the button, or add an aria-label.",86 codeExample: '<button aria-label="Close dialog"><svg aria-hidden="true">…</svg></button>',87 },88 label: {89 whoItAffects: "Screen-reader users filling in forms.",90 plainExplanation:91 "A form field has no associated label, so its purpose is not announced when focused.",92 howToFix:93 "Add a <label> linked via for/id, or wrap the input inside the label.",94 codeExample: '<label for="email">Email</label>\n<input id="email" type="email">',95 },96 "select-name": {97 whoItAffects: "Screen-reader users.",98 plainExplanation: "A <select> dropdown has no accessible name.",99 howToFix: "Associate a <label> with the select via for/id.",100 codeExample: '<label for="country">Country</label>\n<select id="country">…</select>',101 },102 "document-title": {103 whoItAffects:104 "Everyone — especially screen-reader users and people with many tabs open.",105 plainExplanation:106 "The page has no <title>, so it cannot be identified in browser tabs, history, or by screen readers.",107 howToFix: "Add a unique, descriptive <title> in the <head>.",108 codeExample: "<title>Checkout — Acme Shop</title>",109 },110 "html-has-lang": {111 whoItAffects:112 "Screen-reader users (for correct pronunciation) and translation tools.",113 plainExplanation:114 "The <html> element has no lang attribute, so assistive tech cannot tell what language the page is in.",115 howToFix: "Add a lang attribute to <html>.",116 codeExample: '<html lang="en">',117 },118 "html-lang-valid": {119 whoItAffects: "Screen-reader users.",120 plainExplanation: "The lang attribute value is not a valid language code.",121 howToFix: "Use a valid BCP-47 code, e.g. en, de, id.",122 codeExample: '<html lang="de">',123 },124 "landmark-one-main": {125 whoItAffects: "Screen-reader users.",126 plainExplanation:127 "The page has no <main> landmark, so users cannot jump straight to the primary content.",128 howToFix: "Wrap the primary content in a single <main> element.",129 codeExample: "<main>\n <!-- primary page content -->\n</main>",130 },131 region: {132 whoItAffects: "Screen-reader users who navigate by region/landmark.",133 plainExplanation:134 "Some content sits outside any landmark region, making it harder to navigate the page quickly.",135 howToFix:136 "Wrap page sections in landmarks: <header>, <nav>, <main>, <footer>.",137 codeExample: "<header>…</header>\n<main>…</main>\n<footer>…</footer>",138 },139 "heading-order": {140 whoItAffects: "Screen-reader users who navigate by headings.",141 plainExplanation:142 "Heading levels skip (e.g. an h1 is followed by an h3), breaking the document outline.",143 howToFix:144 "Use heading levels in order without skipping; change size with CSS, not by picking a different level.",145 codeExample: "<h1>Title</h1>\n <h2>Section</h2>\n <h3>Subsection</h3>",146 },147 "empty-heading": {148 whoItAffects: "Screen-reader users.",149 plainExplanation:150 "A heading element is empty, creating a confusing contentless entry in the outline.",151 howToFix: "Add text to the heading, or remove it.",152 codeExample: "<h2>Pricing</h2>",153 },154 "page-has-heading-one": {155 whoItAffects: "Screen-reader users.",156 plainExplanation:157 "The page has no <h1>, so there is no clear top-level title in the outline.",158 howToFix: "Add one <h1> describing the page's main topic.",159 codeExample: "<h1>Accessibility report</h1>",160 },161 list: {162 whoItAffects: "Screen-reader users.",163 plainExplanation:164 "A <ul>/<ol> contains elements other than <li>, so the list semantics break.",165 howToFix:166 "Ensure only <li> elements are direct children of the list.",167 codeExample: "<ul>\n <li>One</li>\n <li>Two</li>\n</ul>",168 },169 listitem: {170 whoItAffects: "Screen-reader users.",171 plainExplanation:172 "An <li> is not contained in a <ul> or <ol>, so it is not announced as a list item.",173 howToFix: "Wrap <li> elements in a <ul> or <ol>.",174 codeExample: "<ul><li>Item</li></ul>",175 },176 "duplicate-id-active": {177 whoItAffects: "Screen-reader users and scripts relying on ids.",178 plainExplanation:179 "The same id is used by more than one active element, which breaks label/ARIA associations and scripting.",180 howToFix: "Make every id unique on the page.",181 codeExample: '<input id="email-1"> … <input id="email-2">',182 },183 "duplicate-id-aria": {184 whoItAffects: "Screen-reader users.",185 plainExplanation:186 "An id referenced by ARIA (e.g. aria-labelledby) is used more than once, so the wrong element may be referenced.",187 howToFix: "Make ARIA-referenced ids unique.",188 codeExample: '<h2 id="sec-billing">Billing</h2>\n<section aria-labelledby="sec-billing">…</section>',189 },190 "aria-required-attr": {191 whoItAffects: "Screen-reader users.",192 plainExplanation:193 "An element with an ARIA role is missing an attribute that role requires, so its state is not announced.",194 howToFix:195 "Add the required attribute(s) for the role (see the rule reference), or use a native HTML element instead.",196 codeExample: '<div role="checkbox" aria-checked="false" tabindex="0">…</div>',197 },198 "aria-valid-attr-value": {199 whoItAffects: "Screen-reader users.",200 plainExplanation:201 "An ARIA attribute has an invalid value (or points to an id that does not exist), so assistive tech may ignore it.",202 howToFix:203 "Use a valid value, and make sure any id referenced by aria-* exists on the page.",204 codeExample: '<button aria-expanded="false" aria-controls="menu">Menu</button>',205 },206 "aria-roles": {207 whoItAffects: "Screen-reader users.",208 plainExplanation: "An element uses an ARIA role that is not valid.",209 howToFix:210 "Use a valid ARIA role, or prefer a native HTML element with built-in semantics.",211 codeExample: "<nav>…</nav> <!-- instead of <div role=\"navigation\"> -->",212 },213 "aria-hidden-focus": {214 whoItAffects: "Screen-reader users.",215 plainExplanation:216 'A focusable element sits inside an aria-hidden="true" container, so it is reachable by keyboard but invisible to screen readers.',217 howToFix:218 "Remove aria-hidden from the container, or make the inner element non-focusable while hidden (tabindex=-1).",219 codeExample: '<div aria-hidden="true"><button tabindex="-1">…</button></div>',220 },221 "frame-title": {222 whoItAffects: "Screen-reader users.",223 plainExplanation:224 "An <iframe> has no title, so its purpose is not announced.",225 howToFix: "Add a descriptive title attribute to the iframe.",226 codeExample: '<iframe src="…" title="Payment form"></iframe>',227 },228 bypass: {229 whoItAffects: "Keyboard and screen-reader users.",230 plainExplanation:231 "There is no way to skip repeated blocks (like navigation) to reach the main content.",232 howToFix:233 "Add a skip link at the top of the page that targets <main>, and/or provide landmarks and headings.",234 codeExample:235 '<a class="skip-link" href="#main">Skip to content</a>\n…\n<main id="main">…</main>',236 },237 "meta-viewport": {238 whoItAffects: "Low-vision users who need to zoom.",239 plainExplanation:240 "The viewport meta tag disables zooming (user-scalable=no or a low maximum-scale), preventing people from enlarging text.",241 howToFix:242 "Remove user-scalable=no and do not cap maximum-scale below 5.",243 codeExample: '<meta name="viewport" content="width=device-width, initial-scale=1">',244 },245 "th-has-data-cells": {246 whoItAffects: "Screen-reader users reading data tables.",247 plainExplanation:248 "Table header cells are not properly associated with data cells, so the relationships are lost.",249 howToFix:250 'Use <th scope="col"> / scope="row", and ensure each header has matching data cells.',251 codeExample: '<th scope="col">Price</th>',252 },253 "video-caption": {254 whoItAffects: "Deaf and hard-of-hearing users.",255 plainExplanation: "A <video> has no captions track.",256 howToFix: 'Provide captions via a <track kind="captions"> element.',257 codeExample:258 '<video><track kind="captions" src="cc.vtt" srclang="en" label="English"></video>',259 },260 "nested-interactive": {261 whoItAffects: "Screen-reader and keyboard users.",262 plainExplanation:263 "Interactive controls are nested inside each other (e.g. a button inside a link), causing unpredictable focus.",264 howToFix: "Keep one control per interactive element; do not nest them.",265 codeExample: "<button>Buy</button> <!-- not <a><button>…</button></a> -->",266 },267 tabindex: {268 whoItAffects: "Keyboard users.",269 plainExplanation:270 "A positive tabindex forces an unnatural tab order that is hard to follow.",271 howToFix:272 'Use tabindex="0" (or none) and rely on DOM order; avoid positive values.',273 codeExample: '<div tabindex="0">…</div>',274 },275};276 277function genericRemediation(v: ScannedViolation): Template {278 const who =279 v.impact === "critical" || v.impact === "serious"280 ? "People using screen readers or keyboard navigation may be blocked."281 : v.impact === "moderate"282 ? "Some users with disabilities will have a noticeably degraded experience."283 : "A small number of users may be inconvenienced.";284 return {285 whoItAffects: who,286 plainExplanation: v.description || v.help,287 howToFix: `Follow the detailed guidance and examples for this rule: ${v.helpUrl}`,288 codeExample: "",289 };290}291 292function buildRemediation(v: ScannedViolation): Remediation {293 const t = TEMPLATES[v.id] ?? genericRemediation(v);294 return { ruleId: v.id, priority: PRIORITY_BY_IMPACT[v.impact], ...t };295}296 297/**298 * Enrich every violation with deterministic, rule-based remediation guidance.299 * Dedupes by rule id (repeated findings share one lookup) and sorts by priority.300 * Synchronous, but safe to `await` at call sites.301 */302export function remediateAll(303 violations: ScannedViolation[],304): RemediatedViolation[] {305 const fixes = new Map<string, Remediation>();306 for (const v of violations) {307 if (!fixes.has(v.id)) fixes.set(v.id, buildRemediation(v));308 }309 return violations310 .map((v) => ({ ...v, remediation: fixes.get(v.id)! }))311 .sort(312 (a, b) =>313 PRIORITY_ORDER[a.remediation.priority] -314 PRIORITY_ORDER[b.remediation.priority],315 );316}317 318/** Exposed for tests / tooling. */319export const TEMPLATED_RULE_IDS = Object.keys(TEMPLATES);320 