#!/usr/bin/env node // Small static accessibility check for the Nordprix WCAG remediation sample. // Node built-ins only. It reads one HTML file and the local stylesheets it links, // and reports what a static check can see: page language and title, text // alternatives, form control names, autocomplete, link text, heading order, // scripted "div buttons", live regions, text and border contrast from the CSS, // focus styles and target sizes. It is not a full rule engine: keyboard use, // screen reader output and anything created by script must be checked by hand. // // Usage: node check.mjs [more.html ...] import { readFileSync } from "node:fs"; import { dirname, resolve, basename } from "node:path"; const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]); const RAW = new Set(["script", "style"]); const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'", apos: "'", nbsp: " ", times: "×" }; const GENERIC_LINK_TEXT = new Set(["click here", "here", "more", "read more", "learn more", "click", "this", "link", "details"]); function decode(text) { return text.replace(/&(#?\w+);/g, (m, name) => ENTITIES[name] ?? m); } // ---------- HTML parsing (enough for well-formed pages) ---------- function parseHtml(source) { const root = { tag: "#root", attrs: {}, children: [], parent: null }; let current = root; const token = /|]*>|<\/([a-zA-Z0-9]+)\s*>|<([a-zA-Z0-9]+)((?:\s+[^\s"'>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*\/?>|([^<]+)/gi; let m; while ((m = token.exec(source))) { if (m[1]) { const tag = m[1].toLowerCase(); let node = current; while (node && node.tag !== tag) node = node.parent; if (node && node.parent) current = node.parent; } else if (m[2]) { const tag = m[2].toLowerCase(); const attrs = {}; const attrPattern = /([^\s"'>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; let a; while ((a = attrPattern.exec(m[3] ?? ""))) attrs[a[1].toLowerCase()] = decode(a[2] ?? a[3] ?? a[4] ?? ""); const node = { tag, attrs, children: [], parent: current }; current.children.push(node); if (RAW.has(tag)) { const end = source.toLowerCase().indexOf(` { if (n.tag !== "#text" && predicate(n)) out.push(n); }); return out; } function byId(root, id) { return all(root, (n) => n.attrs.id === id)[0]; } function isHidden(node) { return node.attrs && ("hidden" in node.attrs || node.attrs["aria-hidden"] === "true"); } function text(node) { if (node.tag === "#text") return node.text; if (isHidden(node)) return ""; if (node.tag === "img") return node.attrs.alt ?? ""; return (node.children ?? []).map(text).join(""); } const clean = (s) => s.replace(/\s+/g, " ").trim(); function ancestor(node, tag) { for (let p = node.parent; p; p = p.parent) if (p.tag === tag) return p; return undefined; } function describe(node) { const id = node.attrs.id ? `#${node.attrs.id}` : ""; const cls = node.attrs.class ? `.${node.attrs.class.split(/\s+/).join(".")}` : ""; return `<${node.tag}${id}${cls}>`; } // ---------- CSS parsing and contrast ---------- function parseCss(source) { const rules = []; const body = source.replace(/\/\*[\s\S]*?\*\//g, ""); const pattern = /([^{}]+)\{([^{}]*)\}/g; let m; while ((m = pattern.exec(body))) { const decls = {}; for (const part of m[2].split(";")) { const i = part.indexOf(":"); if (i > 0) decls[part.slice(0, i).trim().toLowerCase()] = part.slice(i + 1).trim(); } for (const selector of m[1].split(",")) rules.push({ selector: selector.trim().replace(/\s+/g, " "), decls }); } return rules; } function hex(value) { const m = /#([0-9a-f]{6}|[0-9a-f]{3})\b/i.exec(value ?? ""); if (!m) return undefined; let h = m[1].toLowerCase(); if (h.length === 3) h = h.split("").map((c) => c + c).join(""); return `#${h}`; } // WCAG 2.2 relative luminance and contrast ratio (https://www.w3.org/TR/WCAG22/#dfn-relative-luminance) export function luminance(color) { const channel = (i) => { const s = parseInt(color.slice(i, i + 2), 16) / 255; return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }; return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5); } export function contrast(a, b) { const [l1, l2] = [luminance(a), luminance(b)].sort((x, y) => y - x); return (l1 + 0.05) / (l2 + 0.05); } function background(rules, selector) { const own = rules.find((r) => r.selector === selector && (hex(r.decls["background-color"]) || hex(r.decls.background))); if (own) return hex(own.decls["background-color"]) ?? hex(own.decls.background); if (/placeholder/.test(selector)) { const field = rules.find((r) => /^input\b/.test(r.selector) && (hex(r.decls.background) || hex(r.decls["background-color"]))); if (field) return hex(field.decls.background) ?? hex(field.decls["background-color"]); } const parts = selector.split(" "); for (let i = parts.length - 1; i > 0; i--) { const prefix = parts.slice(0, i).join(" "); const found = rules.find((r) => r.selector === prefix && (hex(r.decls.background) || hex(r.decls["background-color"]))); if (found) return hex(found.decls.background) ?? hex(found.decls["background-color"]); } const body = rules.find((r) => r.selector === "body"); return hex(body?.decls.background) ?? hex(body?.decls["background-color"]) ?? "#ffffff"; } function isLarge(rule) { const size = /(\d+(?:\.\d+)?)px/.exec(rule.decls["font-size"] ?? rule.decls.font ?? ""); const px = size ? Number(size[1]) : 16; const bold = /bold|[6-9]00/.test(`${rule.decls["font-weight"] ?? ""} ${rule.decls.font ?? ""}`); return px >= 24 || (bold && px >= 18.66); } // ---------- checks ---------- function checkPage(file) { const html = readFileSync(file, "utf8"); const root = parseHtml(html); const results = []; const add = (status, sc, message) => results.push({ status, sc, message }); const htmlEl = all(root, (n) => n.tag === "html")[0]; if (htmlEl?.attrs.lang) add("PASS", "3.1.1", ``); else add("FAIL", "3.1.1", " has no lang attribute"); const title = clean(text(all(root, (n) => n.tag === "title")[0] ?? { tag: "#text", text: "" })); add(title ? "PASS" : "FAIL", "2.4.2", title ? ` is "${title}"` : "<title> is missing or empty"); for (const img of all(root, (n) => n.tag === "img")) { if (!("alt" in img.attrs)) add("FAIL", "1.1.1", `<img src="${img.attrs.src}"> has no alt attribute`); else if (img.attrs.alt === "") add("INFO", "1.1.1", `<img src="${img.attrs.src}"> has alt="" (decorative)`); else add("PASS", "1.1.1", `<img src="${img.attrs.src}"> alt="${img.attrs.alt}"`); } const controls = all(root, (n) => ["input", "select", "textarea"].includes(n.tag) && !["hidden", "submit", "button", "reset", "image"].includes(n.attrs.type)); for (const control of controls) { const id = control.attrs.id; let name = ""; let source = ""; if (control.attrs["aria-labelledby"]) { name = control.attrs["aria-labelledby"].split(/\s+/).map((ref) => clean(text(byId(root, ref) ?? { tag: "#text", text: "" }))).join(" "); source = "aria-labelledby"; } if (!name && control.attrs["aria-label"]) { name = control.attrs["aria-label"]; source = "aria-label"; } if (!name && id) { const label = all(root, (n) => n.tag === "label" && n.attrs.for === id)[0]; if (label) { name = clean(text(label)); source = "label[for]"; } } if (!name) { const wrap = ancestor(control, "label"); if (wrap) { name = clean(text(wrap)); source = "wrapping label"; } } if (!name && control.attrs.title) { name = control.attrs.title; source = "title"; } if (name) add("PASS", "1.3.1/4.1.2", `${describe(control)} is labelled by ${source}: "${name}"`); else if (control.attrs.placeholder) add("FAIL", "3.3.2", `${describe(control)} has only a placeholder ("${control.attrs.placeholder}") and no label`); else add("FAIL", "1.3.1/4.1.2", `${describe(control)} has no programmatic label`); if (/^(name|email|tel|phone)$/.test(control.attrs.name ?? "")) { if (control.attrs.autocomplete && control.attrs.autocomplete !== "off") add("PASS", "1.3.5", `${describe(control)} autocomplete="${control.attrs.autocomplete}"`); else add("FAIL", "1.3.5", `${describe(control)} collects "${control.attrs.name}" without an autocomplete value`); } } for (const link of all(root, (n) => n.tag === "a" && "href" in n.attrs)) { const name = clean(link.attrs["aria-label"] ?? text(link)); if (!name) { add("FAIL", "2.4.4/4.1.2", `link to ${link.attrs.href} has no accessible name`); continue; } if (GENERIC_LINK_TEXT.has(name.toLowerCase())) { const block = ancestor(link, "p") ?? ancestor(link, "li") ?? link.parent; const siblings = all(block, (n) => n.tag === "a" && GENERIC_LINK_TEXT.has(clean(text(n)).toLowerCase())); if (siblings.length > 1) add("FAIL", "2.4.4", `"${name}" (to ${link.attrs.href}) shares its paragraph with another vague link, so context cannot tell them apart`); else add("WARN", "2.4.4", `"${name}" (to ${link.attrs.href}) is vague on its own; check the surrounding sentence by hand`); } } for (const button of all(root, (n) => n.tag === "button")) { const name = clean(button.attrs["aria-label"] ?? text(button)); if (!name) add("FAIL", "4.1.2", `${describe(button)} has no accessible name`); else if (!/[a-z0-9]/i.test(name)) add("WARN", "4.1.2", `${describe(button)} is named only "${name}"`); else add("PASS", "4.1.2", `${describe(button)} is named "${name}"`); } let last = 0; for (const heading of all(root, (n) => /^h[1-6]$/.test(n.tag))) { const level = Number(heading.tag[1]); if (last && level > last + 1) add("WARN", "1.3.1 (advisory, G141)", `<${heading.tag}> "${clean(text(heading))}" follows <h${last}>, skipping a level`); last = level; } const fakeButtons = all(root, (n) => !["button", "a", "input", "summary", "select"].includes(n.tag) && /\b(btn|button)\b/.test(n.attrs.class ?? "")); for (const el of fakeButtons) { const ok = el.attrs.role === "button" && el.attrs.tabindex === "0"; add(ok ? "WARN" : "FAIL", "2.1.1/4.1.2", `${describe(el)} "${clean(text(el))}" looks like a button but is not one${ok ? " (has role and tabindex; check key handling by hand)" : ": no button role and not in the tab order"}`); } if (all(root, (n) => n.tag === "form").length > 0) { const live = all(root, (n) => ["status", "alert"].includes(n.attrs.role) || "aria-live" in n.attrs); if (live.length > 0) add("PASS", "4.1.3", `live region present: ${live.map(describe).join(", ")}`); else add("WARN", "4.1.3", "form has no live region (role=status/alert or aria-live); check how results are announced"); for (const control of controls.filter((c) => ["name", "email", "service", "date"].includes(c.attrs.id))) { const refs = (control.attrs["aria-describedby"] ?? "").split(/\s+/).filter(Boolean); const errorTarget = refs.map((r) => byId(root, r)).find((n) => n && /error/.test(`${n.attrs.id} ${n.attrs.class ?? ""}`)); if (errorTarget) add("PASS", "3.3.1", `${describe(control)} is described by an error message container ${describe(errorTarget)}`); else add("WARN", "3.3.1", `${describe(control)} has no error text tied to it by aria-describedby`); } } // CSS const sheets = all(root, (n) => n.tag === "link" && n.attrs.rel === "stylesheet" && !/^(https?:)?\//.test(n.attrs.href)); const rules = sheets.flatMap((s) => parseCss(readFileSync(resolve(dirname(file), s.attrs.href), "utf8"))); for (const rule of rules) { const fg = hex(rule.decls.color); if (!fg || /:focus|:hover|\[hidden\]/.test(rule.selector)) continue; const bg = background(rules, rule.selector); const ratio = contrast(fg, bg); const need = isLarge(rule) ? 3 : 4.5; add(ratio >= need ? "PASS" : "FAIL", "1.4.3", `${rule.selector}: text ${fg} on ${bg} = ${ratio.toFixed(2)}:1 (needs ${need}:1)`); } for (const rule of rules.filter((r) => /^input\b/.test(r.selector) && r.decls.border)) { const border = hex(rule.decls.border); const bg = background(rules, rule.selector); if (!border) continue; const ratio = contrast(border, bg); add(ratio >= 3 ? "PASS" : "FAIL", "1.4.11", `form field border ${border} on ${bg} = ${ratio.toFixed(2)}:1 (needs 3:1)`); } const removed = rules.filter((r) => /:focus\b/.test(r.selector) && /^(none|0)\b/.test(r.decls.outline ?? "")); const general = rules.filter((r) => /^(\*)?:focus-visible$/.test(r.selector) && r.decls.outline && !/^(none|0)\b/.test(r.decls.outline)); for (const r of removed) { add(general.length ? "WARN" : "FAIL", "2.4.7", `${r.selector} sets outline: ${r.decls.outline}${general.length ? " (a general :focus-visible rule exists; check by hand)" : " and no general :focus-visible rule replaces it"}`); } for (const r of general) { const color = hex(r.decls.outline); const bg = background(rules, "body"); add("PASS", "2.4.7", `${r.selector} { outline: ${r.decls.outline} }${color ? `; ring ${color} on ${bg} = ${contrast(color, bg).toFixed(2)}:1` : ""}`); } for (const rule of rules.filter((r) => /close|button|btn/.test(r.selector) && /px/.test(r.decls.width ?? "") && /px/.test(r.decls.height ?? ""))) { const w = parseFloat(rule.decls.width); const h = parseFloat(rule.decls.height); const ok = w >= 24 && h >= 24; add(ok ? "PASS" : "FAIL", "2.5.8", `${rule.selector} is ${w} x ${h} CSS px${ok ? "" : " (under 24 x 24; spacing exception checked in the browser)"}`); } return results; } const files = process.argv.slice(2); if (files.length === 0) { console.error("Usage: node check.mjs <page.html> [more.html ...]"); process.exit(2); } for (const file of files) { const results = checkPage(file); const count = (s) => results.filter((r) => r.status === s).length; console.log(`== ${basename(file)}: ${count("FAIL")} fail, ${count("WARN")} warn, ${count("PASS")} pass, ${count("INFO")} info`); for (const r of results) console.log(`${r.status.padEnd(4)} ${r.sc.padEnd(22)} ${r.message}`); console.log(""); }