=== REPORT ASSEMBLY REFERENCES === 3:// lib/a11y-rendered.js loads accessibility-audit.html in headless Chrome and calls checkA11y() from this file 258: checkA11y(d.finalUrl || url); 1050:// once), or from window.__a11yAxeSrc when the server's check (lib/a11y-rendered.js) provides it from disk. 1053:function a11yAxeSources() { 1054: if (window.__a11yAxeSrc && window.__a11yAxeSrc.axe && window.__a11yAxeSrc.run) return Promise.resolve(window.__a11yAxeSrc); 1055: if (!window.__a11yAxeSrcP) { 1059: window.__a11yAxeSrcP = Promise.all([get('axe.min.js'), get('run.js')]).then(([axe, run]) => ({ axe, run })).catch(() => { window.__a11yAxeSrcP = null; return null; }); 1061: return window.__a11yAxeSrcP; 1064:function a11yAxeDoc(html, baseUrl, srcs) { 1085:function a11yAxe(html, baseUrl, cssOk) { 1093: if (e.data.a11yAxe === 2) { started = true; return; } 1094: if (e.data.a11yAxe !== 1) return; 1096: if (!cssOk) r.violations = r.violations.filter(v => !/^(link-in-text-block|scrollable-region-focusable|avoid-inline-spacing)$/.test(v.id)); 1101: a11yAxeSources().then(srcs => { 1104: let src; try { src = a11yAxeDoc(html, baseUrl, srcs); } catch (e) { return finish({ ok: false, error: 'the page could not be prepared' }); } 1251:function checkA11y(baseUrl) { 1269: .then(prep => a11yContrastStep(prep, baseUrl).then(c => a11yAxe(prep.html, baseUrl || '', c && c.status === 'ok').then(ax => { c.axe = ax; return c; }))) 1281: .then(c => checkA11yReport(c)) 1282: .catch(() => checkA11yReport({ status: 'skipped', reason: 'the page could not be rendered for measuring' })); 1292:function checkA11yReport(contrast) { 1777: // Extra WCAG checks by axe-core (see a11yAxe): each failed rule is one issue. Serious and critical failures 1887: window.a11yLastReport = { issues: issues.map(i => Object.assign({}, i)), passes: passes.slice(), score: score, errorCount: errorCount, warnCount: warnCount, passCount: passes.length, notes: (typeof notes !== 'undefined' ? notes.slice() : []), coverage: a11yCoverage, contrast: contrast ? contrast.status : null }; 2179: on('#a11y-paste-btn', 'click', function () { SEOGate.check('accessibility', function () { window.a11yBuiltByScripts = false; window.a11yKeyboard = null; window.a11yKeyboardP = null; checkA11y(); }, true); }); === REPORT ASSEMBLY CONTEXT === --- Lines 1-27 --- 1: // accessibility-audit.js: the Accessibility Audit page (accessibility-audit.html). 2: // The checker below is the same code that ran on accessibility-audit.html, moved here unchanged. 3: // lib/a11y-rendered.js loads accessibility-audit.html in headless Chrome and calls checkA11y() from this file 4: // for the full audit, so a change here changes both the page tool and the full audit. 5: 6: function switchTabGroup(el, prefix) { 7: const parent = el.parentElement; 8: const tabs = parent.querySelectorAll('.tab'); 9: tabs.forEach(t => t.classList.remove('active')); 10: el.classList.add('active'); 11: const idx = Array.from(tabs).indexOf(el); 12: document.querySelectorAll(`[id^="${prefix}-"]`).forEach(tc => tc.classList.remove('active')); 13: const target = document.getElementById(prefix + '-' + idx); 14: if (target) target.classList.add('active'); 15: } 16: 17: const BACKEND_URL = ''; 18: 19: async function fetchRawHtml(url, referer) { 20: try { 21: const r = await fetch(`${BACKEND_URL}/api/rawhtml`, { 22: method: 'POST', headers: { 'Content-Type': 'application/json' }, 23: credentials: 'include', body: JSON.stringify(referer ? { url, referer } : { url }) 24: }); 25: const ct = r.headers.get('content-type') || ''; 26: if (ct.includes('application/json')) { 27: const d = await r.json(); --- Lines 246-282 --- 246: } 247: } 248: if (botCheck || (words < 40 && linkCount <= 1)) { 249: a11yStop(botCheck ? 'This site sent a bot-check page instead of the real page, so it was not audited' 250: : 'The page came back almost empty, so it was not audited', 251: (botCheck ? 'The site answered our server with a security check (for example a "Just a moment" or "Access Denied" page) instead of the page your visitors see.' 252: : `The page holds only ${words} word${words === 1 ? '' : 's'} and ${linkCount} link${linkCount === 1 ? '' : 's'}, even after running its own scripts. Either the site blocked our checker with a short notice page, or its scripts did not finish building the page for our checker.`) + 253: ' Auditing it would describe the wrong page, so no score is given. To audit it, open the page in your browser, view the source (or copy it from the browser\'s developer tools after the page has loaded), and use Paste HTML.'); 254: return; 255: } 256: document.getElementById('a11y-input').value = d.html; 257: // the page's real address after redirects (e.g. australia.gov.au -> my.gov.au), as a browser uses it 258: checkA11y(d.finalUrl || url); 259: } 260: 261: // ── Colour contrast (WCAG 2.1 SC 1.4.3, Level AA) ────────────── 262: // Renders the page in a hidden, script-free sandboxed frame with its own 263: // stylesheets, then measures each visible text element's colour against the 264: // background it sits on. Text over images/gradients cannot be measured and is 265: // counted separately rather than guessed. If any stylesheet cannot load, the 266: // check is skipped - measuring without the page's real CSS would be wrong. 267: function a11yParseColor(c) { 268: c = (c || '').trim(); 269: const m = c.match(/^rgba?\(([^)]+)\)$/i); 270: if (m) { 271: const p = m[1].split(/[\s,\/]+/).filter(Boolean).map(parseFloat); 272: if (p.length < 3 || p.slice(0, 3).some(isNaN)) return null; 273: return { r: p[0], g: p[1], b: p[2], a: p.length > 3 && !isNaN(p[3]) ? p[3] : 1 }; 274: } 275: return a11yParseModernColor(c); 276: } 277: 278: // Colours Chrome reports in the newer CSS colour spaces (CSS Color 4): oklab(), oklch(), lab(), lch() and 279: // color(srgb | srgb-linear | display-p3 ...). Sites built with Tailwind 4 and similar give every colour this way 280: // (Tailwind: text-black/20 is reported as "oklab(0 0 0 / 0.2)"). Converted to screen sRGB with the CSS Color 4 281: // formulas; colours outside sRGB are clipped to the nearest screen colour. 282: function a11yParseModernColor(c) { --- Lines 1038-1128 --- 1038: document.body.appendChild(fr); 1039: }); 1040: } 1041: 1042: // Extra WCAG A/AA checks with axe-core (Deque Systems, open source, MPL-2.0: the engine most professional audits 1043: // use), for what this tool does not check itself: ARIA attributes and roles, list structure, nested controls, 1044: // zoom blocked by the viewport tag, scroll areas the keyboard cannot reach, links told apart by colour only, 1045: // the page title and others (the rule list is in /a11y-axe/run.js). The page is checked in a separate sandboxed 1046: // frame that can run axe but cannot reach this page or its data: all of the audited page's own scripts, event 1047: // attributes, javascript: links, embedded frames and objects are taken out first. axe and its runner are placed 1048: // in the frame as inline scripts: a sandboxed frame is not "this site", so it may not load scripts by address 1049: // under this page's security policy, which does allow inline scripts. Their text comes from /a11y-axe/ (fetched 1050: // once), or from window.__a11yAxeSrc when the server's check (lib/a11y-rendered.js) provides it from disk. 1051: const A11Y_AXE_BASE = /^https?:$/.test(location.protocol) ? location.origin + '/a11y-axe/' : 'https://aiwebpageseo.com/a11y-axe/'; 1052: 1053: function a11yAxeSources() { 1054: if (window.__a11yAxeSrc && window.__a11yAxeSrc.axe && window.__a11yAxeSrc.run) return Promise.resolve(window.__a11yAxeSrc); 1055: if (!window.__a11yAxeSrcP) { 1056: // Sent with this site's cookies, so the server's firewall knows the visitor has already passed its check; anything 1057: // that is not a script (a firewall check page, an error page) counts as not loaded. 1058: const get = f => fetch(A11Y_AXE_BASE + f, { credentials: 'same-origin' }).then(r => r.ok && /javascript/i.test(r.headers.get('content-type') || '') ? r.text() : Promise.reject(new Error('HTTP ' + r.status))); 1059: window.__a11yAxeSrcP = Promise.all([get('axe.min.js'), get('run.js')]).then(([axe, run]) => ({ axe, run })).catch(() => { window.__a11yAxeSrcP = null; return null; }); 1060: } 1061: return window.__a11yAxeSrcP; 1062: } 1063: 1064: function a11yAxeDoc(html, baseUrl, srcs) { 1065: const d = new DOMParser().parseFromString(html, 'text/html'); 1066: d.querySelectorAll('script, noscript, meta[http-equiv], object, embed, applet, frame, frameset, portal, animate, set, animateMotion, animateTransform, base').forEach(n => n.remove()); 1067: d.querySelectorAll('iframe').forEach(f => { f.removeAttribute('src'); f.removeAttribute('srcdoc'); }); 1068: for (const el of d.querySelectorAll('*')) { 1069: for (const a of [...el.attributes]) { 1070: const n = a.name.toLowerCase(); 1071: if (n.startsWith('on') || (/^(href|src|action|formaction|xlink:href|data|poster|background)$/.test(n) && /^\s*(javascript|vbscript|data:text\/html)/i.test(a.value))) el.removeAttribute(a.name); 1072: } 1073: } 1074: const head = d.head || d.documentElement.insertBefore(d.createElement('head'), d.body); 1075: if (baseUrl) { const b = d.createElement('base'); b.setAttribute('href', baseUrl); head.insertBefore(b, head.firstChild); } 1076: const st = d.createElement('style'); st.textContent = '*,*::before,*::after{animation:none!important;transition:none!important;content-visibility:visible!important}'; head.appendChild(st); 1077: const body = d.body || d.documentElement.appendChild(d.createElement('body')); 1078: // A literal "' + d.documentElement.outerHTML; 1081: } 1082: 1083: // cssOk: false when the page's stylesheets could not all be fetched; the checks that depend on how the page 1084: // looks (link colours, scroll areas) are then left out rather than judged without the CSS. 1085: function a11yAxe(html, baseUrl, cssOk) { 1086: return new Promise(resolve => { 1087: let done = false; 1088: const fr = document.createElement('iframe'); 1089: const finish = r => { if (done) return; done = true; window.removeEventListener('message', onMsg); clearTimeout(timer); try { fr.remove(); } catch (e) {} resolve(r); }; 1090: let started = false; 1091: const onMsg = e => { 1092: if (!fr.contentWindow || e.source !== fr.contentWindow || !e.data) return; 1093: if (e.data.a11yAxe === 2) { started = true; return; } 1094: if (e.data.a11yAxe !== 1) return; 1095: const r = { ok: !!e.data.ok, error: e.data.error, version: e.data.version, violations: Array.isArray(e.data.violations) ? e.data.violations : [] }; 1096: if (!cssOk) r.violations = r.violations.filter(v => !/^(link-in-text-block|scrollable-region-focusable|avoid-inline-spacing)$/.test(v.id)); 1097: finish(r); 1098: }; 1099: window.addEventListener('message', onMsg); 1100: const timer = setTimeout(() => finish({ ok: false, error: 'the checks took too long' }), 25000); 1101: a11yAxeSources().then(srcs => { 1102: if (done) return; 1103: if (!srcs) return finish({ ok: false, error: 'the checker could not be loaded' }); 1104: let src; try { src = a11yAxeDoc(html, baseUrl, srcs); } catch (e) { return finish({ ok: false, error: 'the page could not be prepared' }); } 1105: fr.setAttribute('sandbox', 'allow-scripts'); // scripts (axe only) may run; no access to this page, no pop-ups, no navigation 1106: fr.setAttribute('aria-hidden', 'true'); 1107: fr.setAttribute('tabindex', '-1'); 1108: fr.style.cssText = 'position:fixed;left:-20000px;top:0;width:1280px;height:900px;border:0;opacity:0;pointer-events:none'; 1109: // The frame has loaded (its scripts have run) but the checker never said it started: it could not be loaded. 1110: fr.onload = () => setTimeout(() => { if (!started) finish({ ok: false, error: 'the checker could not be loaded' }); }, 3000); 1111: fr.srcdoc = src; 1112: document.body.appendChild(fr); 1113: }); 1114: }); 1115: } 1116: 1117: // The render frame sits inside this page, so it inherits this page's security 1118: // policy (CSP), which only allows stylesheets from this site. The audited site's 1119: // own stylesheets would be refused and the page measured without its CSS. 1120: // So each stylesheet is fetched through the site's own page fetcher and placed 1121: // in the frame as inline CSS (allowed by the policy), in the same order, with 1122: // its media rule, @import rules followed and url() paths made absolute. 1123: // Any stylesheet that cannot be fetched is reported, and contrast is not measured. 1124: // What follows the address in an @import can be: layer / layer(name), then supports(condition), then 1125: // media queries (e.g. @import url(x.css) layer(base) supports(display:grid) screen). Each becomes the 1126: // matching wrapper (@layer, @supports, @media) so the imported CSS applies exactly as the browser would. 1127: function a11yWrapImport(cond, css) { 1128: let rest = (cond || '').trim(), layer = null, supports = null; --- Lines 1239-1316 --- 1239: st.setAttribute('data-a11y-from', url); 1240: st.textContent = safe(media && !/^all$/i.test(media) ? '@media ' + media + '{' + css + '}' : css); 1241: l.replaceWith(st); 1242: })); 1243: }); 1244: d.querySelectorAll('style:not([data-a11y-from])').forEach(st => { 1245: if (!/@import/i.test(st.textContent || '')) return; 1246: jobs.push(fixCss(st.textContent, base, 1).then(css => { st.textContent = safe(css); })); 1247: }); 1248: return Promise.all(jobs).then(() => ({ html: '' + d.documentElement.outerHTML, failed: [...new Set(failed)] })); 1249: } 1250: 1251: function checkA11y(baseUrl) { 1252: window.a11yBaseUrl = baseUrl || ''; 1253: if (!window.a11yInnerFixed) window.a11yInner = null; // set again below for a page checked by its address (the server's full audit sets it itself) 1254: const html = document.getElementById('a11y-input').value.trim(); 1255: if (!html) return; 1256: if (a11yIsStandaloneSvg(html)) { 1257: const box = document.getElementById('a11y-results'); 1258: if (box) { 1259: box.textContent = 1260: 'Standalone SVG — HTML audit not applicable. ' + 1261: 'No HTML accessibility score assigned.'; 1262: } 1263: return; 1264: } 1265: const el = document.getElementById('a11y-results'); 1266: if (!el) return; 1267: el.innerHTML = `
Measuring colour contrast…
`; 1268: a11yInlineSheets(html, baseUrl || '') 1269: .then(prep => a11yContrastStep(prep, baseUrl).then(c => a11yAxe(prep.html, baseUrl || '', c && c.status === 'ok').then(ax => { c.axe = ax; return c; }))) 1270: .then(c => { 1271: if (!window.a11yKeyboardP) return c; 1272: const box = el.querySelector('.card div + div'); if (box) box.textContent = 'Checking keyboard focus…'; 1273: return Promise.race([window.a11yKeyboardP, new Promise(r => setTimeout(() => r({ failed: true }), 180000))]).then(kb => { window.a11yKeyboard = kb; return c; }); 1274: }) 1275: .then(c => { 1276: const kb = window.a11yKeyboard, inner = kb && kb.site && kb.site.inner; 1277: if (!window.a11yKeyboardP || !inner || !inner.length) return c; 1278: const nIn = Math.min(2, inner.length), box = el.querySelector('.card div + div'); if (box) box.textContent = 'Checking ' + nIn + ' more page' + (nIn > 1 ? 's' : '') + ' of the site…'; 1279: return Promise.race([fetchA11yPages(window.a11yBaseUrl, inner), new Promise(r => setTimeout(() => r([]), 300000))]).then(pages => { window.a11yInner = { home: kb.site, pages: pages || [], tried: nIn }; return c; }); 1280: }) 1281: .then(c => checkA11yReport(c)) 1282: .catch(() => checkA11yReport({ status: 'skipped', reason: 'the page could not be rendered for measuring' })); 1283: } 1284: 1285: function a11yContrastStep(prep, baseUrl) { 1286: return Promise.resolve().then(() => { 1287: if (prep.failed.length) return { status: 'skipped', reason: prep.failed.length + ' stylesheet' + (prep.failed.length > 1 ? 's' : '') + ' could not be fetched (' + prep.failed.slice(0, 3).map(h => String(h).split('/').pop().split('?')[0] || h).join(', ') + (prep.failed.length > 3 ? ', …' : '') + '), so the colours on screen are not known' }; 1288: return a11yContrast(prep.html, baseUrl || ''); 1289: }).catch(() => ({ status: 'skipped', reason: 'the page could not be rendered for measuring' })); 1290: } 1291: 1292: function checkA11yReport(contrast) { 1293: a11yQueueRelease(); 1294: // Report text is shown as plain text: fix examples contain tags, and examples quote the audited page's own text. 1295: const a11yEsc = v => String(v == null ? '' : v).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); 1296: const raw = document.getElementById('a11y-input').value.trim(); 1297: if (!raw) return; 1298: const html = a11yDropNoscript(raw); 1299: const el = document.getElementById('a11y-results'); 1300: if (!el) return; 1301: const issues = [], passes = [], notes = []; 1302: if (window.a11yBuiltByScripts) notes.push('The page was loaded with its own scripts running and the finished page was audited, as your visitors see it after it loads.'); 1303: const a11yCut = (t, n) => { t = String(t || ''); return t.length > n ? t.slice(0, n - 1).trimEnd() + '…' : t; }; 1304: 1305: // Contrast 1306: if (contrast && contrast.status === 'ok') { 1307: const f = contrast.fails; 1308: if (f.length) { 1309: // Each example once, with how many elements share it. 1310: const a11yGroup = (list, fmt, unit) => { const m = new Map(); list.forEach(x => { const k = fmt(x); m.set(k, (m.get(k) || 0) + 1); }); return [...m].map(([k, c]) => k + (c > 1 ? ` (${c} ${unit})` : '')); }; 1311: const exList = a11yGroup(f, x => `"${x.text}" ${x.ratio}:1 (needs ${x.need}:1, ${x.fg} on ${x.bg})`, 'elements'); 1312: const ex = exList.slice(0, 5).join('; '); 1313: issues.push({type:'error',label:`${f.length} text element${f.length>1?'s':''} below WCAG contrast minimum`,desc:`Text must have a contrast ratio of at least 4.5:1 against its background (3:1 for large text: 24px, or 18.66px bold). Examples: ${ex}${exList.length>5?'; …':''}`,fix:'Darken the text colour or lighten the background (or the reverse on dark backgrounds) until each pair reaches the minimum ratio.'}); 1314: } else if (contrast.checked) { 1315: passes.push(`Text contrast meets WCAG AA (${contrast.checked} text element${contrast.checked>1?'s':''} measured)`); 1316: } --- Lines 1765-1801 --- 1765: if (!hasSearch && !hasMap) issues.push({type:'warning',label:'No site search or site map found',desc:'Besides the menu, visitors need another way to find pages, such as a search box or a site map page. Neither was found on this page. A small site whose menu links to every page, or another way such as an A to Z list, can also meet this (WCAG 2.4.5 Multiple Ways, Level AA).',fix:'Add a site search, or a site map page that lists every page, and link to it from every page (usually in the footer).'}); 1766: else passes.push(hasSearch ? 'Site search found (more than one way to find pages)' : 'Site map link found (more than one way to find pages)'); 1767: } 1768: 1769: // Auto-play media 1770: // (a video the browser found has no sound track plays no sound, so it is not counted; media playing sound by 1771: // itself right now without controls is counted even when a script started it) 1772: const autoplayMedia = [...html.matchAll(/<(video|audio)[^>]*autoplay[^>]*>/gi)]; 1773: const autoplayNoMute = autoplayMedia.filter(m => !/muted/i.test(m[0]) && !/data-a11y-sound="no"/i.test(m[0])); 1774: for (const m of html.matchAll(/<(video|audio)[^>]*data-a11y-playing-sound="yes"[^>]*>/gi)) if (!/autoplay/i.test(m[0]) && !/\scontrols[\s>=]/i.test(m[0])) autoplayNoMute.push(m); 1775: if (autoplayNoMute.length) issues.push({type:'error',label:`${autoplayNoMute.length} auto-playing media element${autoplayNoMute.length>1?'s':''} without mute`,desc:'Audio or video that plays sound by itself as the page opens talks over screen readers and is disorienting; sound that plays for more than 3 seconds needs a way to pause or stop it (WCAG 1.4.2 Audio Control, Level A).',fix:'Add the muted attribute to auto-playing video, or remove autoplay entirely.'}); 1776: 1777: // Extra WCAG checks by axe-core (see a11yAxe): each failed rule is one issue. Serious and critical failures 1778: // count as errors, moderate and minor ones as warnings. The examples quote the page's own markup. 1779: const a11yAx = contrast && contrast.axe; 1780: if (a11yAx && a11yAx.ok) { 1781: const wcagOf = tags => (tags || []).map(t => { const m = /^wcag(\d)(\d)(\d+)$/.exec(t); return m ? m[1] + '.' + m[2] + '.' + m[3] : ''; }).filter(Boolean); 1782: const a11yAxWcag = a11yAx.violations.filter(v => !v.bp); 1783: // Best-practice failures (not WCAG requirements) are listed as notes and never lower the score. 1784: for (const v of a11yAx.violations.filter(v => v.bp)) { 1785: const ex = (v.ex || []).map(x => a11yCut(x.html, 120)).join('; '); 1786: notes.push(`Best practice, not a WCAG requirement (not scored): ${v.n} element${v.n > 1 ? 's' : ''}: ${a11yCut(v.help, 110)}.${ex ? ' Examples: ' + ex : ''}`); 1787: } 1788: for (const v of a11yAxWcag) { 1789: const sc = wcagOf(v.tags); 1790: const ex = (v.ex || []).map(x => a11yCut(x.html, 120) + (x.fix ? ' (' + a11yCut(String(x.fix).replace(/^Fix (any|all) of the following:\s*/i, '').replace(/\s+/g, ' '), 140) + ')' : '')).join('; '); 1791: issues.push({ type: /^(critical|serious)$/.test(v.impact || '') ? 'error' : 'warning', 1792: label: `${v.n} element${v.n > 1 ? 's' : ''}: ${a11yCut(v.help, 110)}${sc.length ? ' (WCAG ' + sc.join(', ') + ')' : ''}`, 1793: desc: String(v.description || '').replace(/\.?\s*$/, '.') + ' Checked with axe-core.' + (ex ? ' Examples: ' + ex : ''), 1794: fix: 'Fix each element listed: ' + a11yCut(String(v.help || ''), 200) + '.', axe: v.id }); 1795: } 1796: if (!a11yAxWcag.length) passes.push('ARIA, list structure, zoom, page title and other WCAG checks passed (axe-core)'); 1797: } else if (a11yAx) notes.push('The extra WCAG checks (ARIA, list structure, zoom, page title and others) could not run on this page: ' + (a11yAx.error || 'unknown reason') + '.'); 1798: 1799: // Inner pages (window.a11yInner, from the server: two pages from the site's menu, each given this same report). A 1800: // problem already reported for this page is not repeated; a problem found only on an inner page is added, marked 1801: // with that page. The menu and the help ways are compared across the pages (WCAG 3.2.3, 3.2.6). --- Lines 1875-1911 --- 1875: const hit = new Map(); for (const i of issues) for (const c of scs(i)) { const h = hit.get(c) || { e: 0, w: 0 }; if (i.type === 'error') h.e++; else h.w++; hit.set(c, h); } 1876: return L.map(([sc, level, name, how, ran, person]) => { const h = hit.get(sc); 1877: const status = h && h.e ? 'fail' : h && h.w ? 'warn' : !how ? 'person' : !ran ? 'notrun' : 'pass'; 1878: return { sc, level, name, status, partly: how === 'part', person }; }); 1879: })(); 1880: 1881: // Score 1882: const errorCount = issues.filter(i => i.type === 'error').length; 1883: const warnCount = issues.filter(i => i.type === 'warning').length; 1884: const score = Math.max(0, 100 - (errorCount * 15) - (warnCount * 5)); 1885: // The finished report as data, for the server's full audit (lib/a11y-rendered.js), which runs this 1886: // same check in headless Chrome so customers get the page tool's answers there too. 1887: window.a11yLastReport = { issues: issues.map(i => Object.assign({}, i)), passes: passes.slice(), score: score, errorCount: errorCount, warnCount: warnCount, passCount: passes.length, notes: (typeof notes !== 'undefined' ? notes.slice() : []), coverage: a11yCoverage, contrast: contrast ? contrast.status : null }; 1888: const scoreCol = score >= 80 ? '#22c55e' : score >= 50 ? '#c2410c' : '#ef4444'; 1889: const ringCls = score >= 80 ? 'ring-good' : score >= 50 ? 'ring-warn' : 'ring-bad'; 1890: 1891: let html2 = `
Accessibility Audit Results
1892:
1893:
${score}
Score / 100
1894:
${errorCount}
Errors
1895:
${warnCount}
Warnings
1896:
${passes.length}
Passed
1897:
`; 1898: 1899: if (issues.length) { 1900: const errors = issues.filter(i => i.type === 'error'); 1901: const warns = issues.filter(i => i.type === 'warning'); 1902: if (errors.length) { 1903: html2 += `
Errors — Must Fix (${errors.length})
`; 1904: errors.forEach(i => { html2 += `
${a11yEsc(i.label)}
${a11yEsc(i.desc)}
${i.fix?`
Fix: ${a11yEsc(i.fix)}
`:''}
`; }); 1905: html2 += '
'; 1906: } 1907: if (warns.length) { 1908: html2 += `
Warnings (${warns.length})
`; 1909: warns.forEach(i => { html2 += `
${a11yEsc(i.label)}
${a11yEsc(i.desc)}
${i.fix?`
Fix: ${a11yEsc(i.fix)}
`:''}
`; }); 1910: html2 += '
'; 1911: } --- Lines 2167-2203 --- 2167: sync(); 2168: }); 2169: })(); 2170: 2171: 2172: // Page wiring (replaces the inline onclick/onblur handlers the tool had on performance-tools.html). 2173: (function () { 2174: function on(sel, ev, fn) { document.querySelectorAll(sel).forEach(function (el) { el.addEventListener(ev, fn); }); } 2175: function wire() { 2176: on('[data-tabgroup]', 'click', function () { switchTabGroup(this, this.getAttribute('data-tabgroup')); }); 2177: on('#a11y-url', 'blur', function () { window.a11yCheckUrl(); }); 2178: on('#a11y-run-btn', 'click', function () { a11yQueueRun(function () { return SEOGate.check('accessibility', function () { return fetchAndCheckA11y(); }, true); }); }); 2179: on('#a11y-paste-btn', 'click', function () { SEOGate.check('accessibility', function () { window.a11yBuiltByScripts = false; window.a11yKeyboard = null; window.a11yKeyboardP = null; checkA11y(); }, true); }); 2180: on('#a11y-sample-btn', 'click', function () { loadA11ySample(); }); 2181: on('#seo-gate-close', 'click', function () { SEOGate.close(); }); 2182: } 2183: if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire); else wire(); 2184: })(); 2185: 2186: 2187: (function () { 2188: var FIELDS = [ 2189: ['a11y-url', function () { a11yCheckUrl(); }] 2190: ]; 2191: // Only call the endpoint once the value could plausibly be a URL, so typing 2192: // does not fire a request per keystroke. 2193: function plausible(v) { 2194: v = (v || '').trim(); 2195: return v.length > 6 && v.indexOf('.') > 0 && !/\s/.test(v); 2196: } 2197: function wire(id, fn) { 2198: var el = document.getElementById(id); 2199: if (!el) return; 2200: var timer = null, lastChecked = ''; 2201: function run() { 2202: var v = el.value.trim(); 2203: if (!plausible(v) || v === lastChecked) return; === RENDERED REPORT HANDOFF === // html: the page as the full audit fetched it; url: its address (for relative links); // fetchCss(url, referer) -> Promise<{ ok, html, status }>: the app's guarded fetch. // One page's report: the Accessibility page tool run in headless Chrome on the page's copy. inner (optional) holds the // inner pages already checked, which the tool adds to this report (see a11yInner in the tool page). async function toolReport(html, url, builtByScripts, keyboard, fetchCss, inner) { await acquire(); let page = null, timer = null; try { const browser = await getBrowser(); page = await browser.newPage(); await page.setViewport({ width: 1280, height: 900 }); await page.setRequestInterception(true); page.on('request', req => { const u = req.url(); if (u.startsWith(AXE_URL)) { const f = AXE_FILES[u.slice(AXE_URL.length).split(/[?#]/)[0]]; if (!f) return req.abort().catch(() => {}); return fs.promises.readFile(f).then(body => req.respond({ status: 200, contentType: 'application/javascript', body })).catch(() => req.abort().catch(() => {})); } if (/^(file|data|about|blob):/i.test(u)) { // the only file Chrome may open is the tool page itself if (/^file:/i.test(u) && u.split('#')[0].split('?')[0] !== TOOL) return req.abort().catch(() => {}); return req.continue().catch(() => {}); } let host; try { const p = new URL(u); if (!/^https?:$/.test(p.protocol)) return req.abort().catch(() => {}); host = p.hostname; } catch (e) { return req.abort().catch(() => {}); } if (BLOCK_HOSTS.test(host)) return req.abort().catch(() => {}); publicHost(host).then(ok => (ok ? req.continue() : req.abort())).catch(() => req.abort().catch(() => {})); }); await page.exposeFunction('__a11yFetch', async (u, ref) => { try { const p = new URL(u); if (!/^https?:$/.test(p.protocol) || !(await publicHost(p.hostname))) return { ok: false, html: '', error: 'blocked' }; return await fetchCss(u, ref || null); } catch (e) { return { ok: false, html: '', error: String(e && e.message || e) }; } }); await page.goto(TOOL, { waitUntil: 'domcontentloaded', timeout: 20000 }); await page.addStyleTag({ path: TOOL_CSS }).catch(() => {}); await page.addScriptTag({ path: TOOL_JS }); // axe-core and its runner, from this server's own copy, for the tool page's extra WCAG checks (see a11yAxe). let axeSrc = null; try { axeSrc = { axe: await fs.promises.readFile(AXE_FILES['axe.min.js'], 'utf8'), run: await fs.promises.readFile(AXE_FILES['run.js'], 'utf8') }; } catch (e) { axeSrc = null; } const run = page.evaluate(async (h, base, byScripts, axeSrc, kb, inner) => { if (axeSrc) window.__a11yAxeSrc = axeSrc; window.a11yKeyboard = kb || null; window.a11yKeyboardP = null; window.a11yInner = inner || null; window.a11yInnerFixed = true; window.fetchRawHtml = (u, r) => window.__a11yFetch(u, r || null); window.a11yBuiltByScripts = byScripts; window.a11yLastReport = null; document.getElementById('a11y-input').value = h; checkA11y(base); const until = Date.now() + 58000; while (!window.a11yLastReport && Date.now() < until) await new Promise(r => setTimeout(r, 100)); return window.a11yLastReport; }, html, url || '', builtByScripts, axeSrc, keyboard, inner || null); const report = await Promise.race([run, new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('rendered accessibility check timed out')), TIMEOUT_MS); })]); if (!report || !Array.isArray(report.issues)) throw new Error('rendered accessibility check returned no report'); report.rendered = true; return report; } finally { if (timer) clearTimeout(timer); if (page) await page.close().catch(() => {}); release(); } } // The two inner pages, each loaded as visitors see it and given the full report on its own. A page that cannot be // loaded that way (error status, bot-check, almost empty, a different site after redirects) is left out. // onProgress (optional) is called while they run, so a caller streaming results can show the audit is still working. async function innerReports(homeUrl, urls, fetchCss, onProgress) { const pages = []; === VALIDATOR EXPORTS === 'use strict'; const RULES = { '9e45ec': { property: 'word-spacing', threshold: 0.16 }, '24afc2': { property: 'letter-spacing', threshold: 0.12 } }; function validateTextSpacing(ruleId) { const rule = RULES[ruleId]; if (!rule) throw new Error('Unsupported ACT rule: ' + ruleId); // ACT applicability is restricted to HTML documents. if (document.documentElement.namespaceURI !== 'http://www.w3.org/1999/xhtml') { return { outcome: 'inapplicable', findings: [] }; } const findings = []; let applicable = false; const walker = document.createTreeWalker( document.body || document.documentElement, NodeFilter.SHOW_TEXT ); while (walker.nextNode()) { const node = walker.currentNode; if (!node.textContent || !node.textContent.trim()) continue; const element = node.parentElement; } if (!relevant) continue; applicable = true; const fontSize = parseFloat(style.fontSize) || 16; const actualSpacing = parseFloat( style.getPropertyValue(rule.property) ) || 0; const minimum = fontSize * rule.threshold; if (actualSpacing < minimum - 0.01) { findings.push({ ruleId, property: rule.property, selector: element.tagName.toLowerCase(), actualPx: actualSpacing, minimumPx: minimum, message: rule.property + ' is restricted below the required ACT threshold' }); } } return { outcome: findings.length ? 'failed' : applicable ? 'passed' : 'inapplicable', findings }; } module.exports = { validateTextSpacing }; === VALIDATOR OUTPUT EXAMPLE === 9e45ec { "ruleId": "9e45ec", "title": "Failed Example 1", "url": "https://www.w3.org/WAI/content-assets/wcag-act-rules/testcases/9e45ec/31d185e51a8be241f8a75d09deae69d3937f0329.html", "expected": "failed", "actual": "failed", "findings": [ { "ruleId": "9e45ec", "property": "word-spacing", "selector": "p", "actualPx": 1.6, "minimumPx": 2.56, "message": "word-spacing is restricted below the required ACT threshold" } ], "status": "matched" } 24afc2 { "ruleId": "24afc2", "title": "Failed Example 1", "url": "https://www.w3.org/WAI/content-assets/wcag-act-rules/testcases/24afc2/8383685465c6a417cb86e192d1e9157bd5feee99.html", "expected": "failed", "actual": "failed", "findings": [ { "ruleId": "24afc2", "property": "letter-spacing", "selector": "p", "actualPx": 1.6, "minimumPx": 1.92, "message": "letter-spacing is restricted below the required ACT threshold" } ], "status": "matched" } Production files changed: NO