qa: flag empty alt (not just missing) — decorative-only exemption removed

An empty alt is valid only for purely decorative images; a content image
(photo/cover/screenshot) with empty alt is a real a11y gap. QA now surfaces
empty and missing alt as distinct findings.
This commit is contained in:
Carlos Arias 2026-07-24 10:28:12 +00:00
parent 18db0de37b
commit 182b1485ca

View file

@ -638,15 +638,22 @@ const qaNorm = (p) => { p = String(p).split("#")[0].split("?")[0]; if (p.length
const qaAbs = (href, pagePath) => { try { return new URL(href, QA_BASE + pagePath).href; } catch { return null; } }; const qaAbs = (href, pagePath) => { try { return new URL(href, QA_BASE + pagePath).href; } catch { return null; } };
const qaGrabAll = (re, html) => [...String(html).matchAll(re)].map((m) => m[1]); const qaGrabAll = (re, html) => [...String(html).matchAll(re)].map((m) => m[1]);
const qaFirst = (re, html) => { const m = String(html).match(re); return m ? m[1].trim() : ""; }; const qaFirst = (re, html) => { const m = String(html).match(re); return m ? m[1].trim() : ""; };
function qaImgsNoAlt(html) { // Classify each <img>: alt missing entirely, or present-but-empty (alt="" or a
// An image "has alt" if it carries an alt attribute at all — including an // bare `alt`). Both are surfaced — an empty alt is only correct for a purely
// empty one (alt="" or a bare `alt`), which is the valid, deliberate signal // decorative image, so a content image (photo, cover, screenshot) with empty
// for a decorative image. Only a total absence of alt is a finding. // alt is a real accessibility gap, not a pass.
const hasAlt = (tag) => /\salt(\s*=|[\s>\/])/i.test(tag); function qaAltIssues(html) {
return [...String(html).matchAll(/<img\b[^>]*>/gi)].map((m) => m[0]) const out = [];
.filter((tag) => !hasAlt(tag)) for (const m of String(html).matchAll(/<img\b[^>]*>/gi)) {
.map((tag) => (tag.match(/\bsrc=["']([^"']+)["']/i) || [])[1]) const tag = m[0];
.filter(Boolean); const src = (tag.match(/\bsrc=["']([^"']+)["']/i) || [])[1];
if (!src) continue;
const withVal = tag.match(/\salt\s*=\s*["']([^"']*)["']/i);
if (withVal) { if (withVal[1].trim() === "") out.push({ src, kind: "empty" }); }
else if (/\salt(\s|>|\/)/i.test(tag)) out.push({ src, kind: "empty" }); // bare `alt`
else out.push({ src, kind: "missing" });
}
return out;
} }
async function runQa() { async function runQa() {
@ -693,7 +700,12 @@ async function runQa() {
else if (/^https?:\/\//i.test(noHash) && !external.has(noHash)) external.set(noHash, path); else if (/^https?:\/\//i.test(noHash) && !external.has(noHash)) external.set(noHash, path);
} }
for (const src of qaGrabAll(/<img\b[^>]*\bsrc=["']([^"']+)["']/gi, html)) { const u = qaAbs(src, path); if (u && /^https?:/i.test(u)) images.add(u.split("#")[0]); } for (const src of qaGrabAll(/<img\b[^>]*\bsrc=["']([^"']+)["']/gi, html)) { const u = qaAbs(src, path); if (u && /^https?:/i.test(u)) images.add(u.split("#")[0]); }
for (const src of qaImgsNoAlt(html)) add("a11y", "warning", path, `Image without alt: ${src}`, `On ${path}, the image "${src}" has no alt text. Add descriptive alt text.`); for (const a of qaAltIssues(html)) {
if (a.kind === "empty") add("a11y", "warning", path, `Empty alt: ${a.src}`,
`On ${path}, the image "${a.src}" has an empty alt attribute. If it conveys meaning (a photo, cover, or screenshot), add descriptive alt text that explains what it shows; leave it empty only if it is purely decorative.`);
else add("a11y", "warning", path, `Missing alt: ${a.src}`,
`On ${path}, the image "${a.src}" has no alt attribute. Add descriptive alt text that explains what it shows.`);
}
} }
// duplicate titles across pages // duplicate titles across pages