// Theme 1 — Images.
import type { Doc, El } from "../parse/html.js";
import { attr, hasBoundAttr, boundAttr, hasDynamicSpread, visibleText, ancestors, descendants } from "../parse/html.js";
import { accessibleName, isNameExempt } from "../name.js";
import type { Rule, RuleFinding } from "./rule.js";
// Not exposed to assistive tech (aria-hidden / [hidden] / an inline display:none or
// visibility:hidden, on the element or an ancestor), or explicitly presentational —
// either way there is no accessible name to demand. Shared with the other rule families
// via src/name.ts so the whole engine agrees on what "not exposed" means.
const isHidden = isNameExempt;
// A dynamically-bound name (`:aria-label`, `v-bind:aria-labelledby`) names the element
// even though we cannot resolve its value — treat it as present, not missing.
const named = (el: El): boolean => !!(boundAttr(el, "aria-label") ?? "").trim() || hasBoundAttr(el, "aria-labelledby");
const imgAltMissing: Rule = {
id: "img-alt-missing",
criteria: ["1.1.1"],
severity: "bloquant",
run(doc: Doc): RuleFinding[] {
const out: RuleFinding[] = [];
for (const el of doc.elements) {
const isImg = el.tag === "img" || el.tag === "area" || (attr(el, "role") ?? "") === "img";
if (!isImg) continue;
if (isHidden(el) && el.tag !== "area") continue;
// alt="" is the decorative opt-out (maps the image to presentation); a whitespace-only
// alt is NOT — it provides no accessible name yet keeps the image in the a11y tree.
const altLiteral = attr(el, "alt");
const whitespaceAlt = altLiteral !== undefined && altLiteral !== "" && altLiteral.trim() === "";
if ((hasBoundAttr(el, "alt") && !whitespaceAlt) || named(el)) continue; // alt="" / :alt="x" / aria-* → present
if (hasDynamicSpread(el)) continue; // {...props} / Svelte {alt} shorthand may carry alt
// role="img" on a non- element (e.g.