a11y-rules 0.2.0

Accessibility rules generic over the document model: one rule set for build-time, CI and in-page surfaces.
Documentation
//! Tier-2-Regeln: brauchen einen echten Accessible Name.
//!
//! Warum das nicht strukturell geht: Der Accessible Name eines Links oder
//! Buttons entsteht nach einem mehrstufigen Verfahren — `aria-labelledby` löst
//! Verweisketten auf, versteckte Teilbäume zählen unter bestimmten Bedingungen
//! doch mit, `alt` eines eingebetteten Bildes fließt ein. Eine Näherung aus
//! Teilbaumtext plus `aria-label` liegt in genau den Fällen falsch, in denen
//! es darauf ankommt.
//!
//! Hosts ohne [`Semantics`] melden diese Regeln als `UNTESTED` — siehe
//! [`crate::run`].
//!
//! [`Semantics`]: a11y_dom::Semantics

use a11y_dom::{elements, Node, NodeId, Semantics, Tier};
use a11y_report::{Finding, Location, Severity};

use crate::registry::{Meta, SemanticsRule};

fn at(id: NodeId) -> Location {
    Location::node(id.to_string())
}

fn named<'n, D: Semantics>(doc: &'n D, n: D::N<'n>) -> bool {
    doc.accessible_name(n).is_some_and(|s| !s.trim().is_empty())
}

fn link_names<D: Semantics>(doc: &D, out: &mut Vec<Finding>) {
    for n in elements(doc) {
        if !n.is_element("a") || !n.has_attr("href") || doc.is_ignored(n) {
            continue;
        }
        if !named(doc, n) {
            out.push(
                Finding::fail(
                    "links/name-missing",
                    "Der Link hat keinen zugänglichen Namen.",
                )
                .with_severity(Severity::Critical)
                .with_wcag(["2.4.4", "4.1.2"])
                .at(at(n.id())),
            );
        }
    }
}

fn button_names<D: Semantics>(doc: &D, out: &mut Vec<Finding>) {
    for n in elements(doc) {
        let ist_button = n.is_element("button") || doc.role(n).as_deref() == Some("button");
        if !ist_button || doc.is_ignored(n) {
            continue;
        }
        if !named(doc, n) {
            out.push(
                Finding::fail(
                    "buttons/name-missing",
                    "Der Button hat keinen zugänglichen Namen.",
                )
                .with_severity(Severity::Critical)
                .with_wcag(["4.1.2"])
                .at(at(n.id())),
            );
        }
    }
}

fn svg_names<D: Semantics>(doc: &D, out: &mut Vec<Finding>) {
    for n in elements(doc) {
        if !n.is_element("svg") || doc.is_ignored(n) {
            continue;
        }
        // Rein dekoratives SVG ist korrekt ausgezeichnet und gemeint.
        if matches!(n.attr("role"), Some("presentation") | Some("none"))
            || n.attr("aria-hidden") == Some("true")
        {
            continue;
        }
        if !named(doc, n) {
            out.push(
                Finding::fail(
                    "svg/name-missing",
                    "Das SVG hat keinen zugänglichen Namen und ist nicht als dekorativ ausgezeichnet.",
                )
                .with_severity(Severity::High)
                .with_wcag(["1.1.1"])
                .at(at(n.id())),
            );
        }
    }
}

/// Gleicher Linktext, unterschiedliches Ziel — für Screenreader-Nutzer, die
/// sich eine Linkliste ausgeben lassen, nicht unterscheidbar.
fn ambiguous_link_names<D: Semantics>(doc: &D, out: &mut Vec<Finding>) {
    use std::collections::HashMap;
    let mut nach_name: HashMap<String, Vec<(NodeId, String)>> = HashMap::new();

    for n in elements(doc) {
        if !n.is_element("a") || doc.is_ignored(n) {
            continue;
        }
        let (Some(name), Some(href)) = (doc.accessible_name(n), n.attr("href")) else {
            continue;
        };
        let key = name.trim().to_lowercase();
        if key.is_empty() {
            continue;
        }
        nach_name
            .entry(key)
            .or_default()
            .push((n.id(), href.to_string()));
    }

    for (name, treffer) in nach_name {
        if treffer.len() < 2 {
            continue;
        }
        let ziele: std::collections::HashSet<&str> =
            treffer.iter().map(|(_, h)| h.as_str()).collect();
        if ziele.len() < 2 {
            continue; // gleicher Text, gleiches Ziel: unproblematisch
        }
        for (id, _) in &treffer {
            out.push(
                Finding::review(
                    "links/ambiguous-name",
                    format!("Mehrere Links heißen \"{name}\", zeigen aber auf verschiedene Ziele."),
                )
                .with_severity(Severity::Medium)
                .with_wcag(["2.4.4"])
                .at(at(*id)),
            );
        }
    }
}

/// Die Deklarationen. Siehe [`crate::structure::METAS`] zur Begründung der
/// Trennung von den Funktionen.
pub const METAS: &[Meta] = &[
    Meta {
        ids: &["links/name-missing"],
        tier: Tier::Semantics,
        wcag: &["2.4.4", "4.1.2"],
        severity: Severity::Critical,
        help: "Jeder Link braucht einen Namen, der sein Ziel beschreibt.",
    },
    Meta {
        ids: &["buttons/name-missing"],
        tier: Tier::Semantics,
        wcag: &["4.1.2"],
        severity: Severity::Critical,
        help: "Jeder Button braucht einen Namen, der seine Wirkung beschreibt.",
    },
    Meta {
        ids: &["svg/name-missing"],
        tier: Tier::Semantics,
        wcag: &["1.1.1"],
        severity: Severity::High,
        help: "Informative SVGs brauchen einen Namen, dekorative role=\"presentation\".",
    },
    Meta {
        ids: &["links/ambiguous-name"],
        tier: Tier::Semantics,
        wcag: &["2.4.4"],
        severity: Severity::Medium,
        help: "Gleich benannte Links sollten auf dasselbe Ziel zeigen.",
    },
];

/// Die Auswertungsfunktionen, in derselben Reihenfolge wie [`METAS`].
fn funktionen<D: Semantics>() -> [fn(&D, &mut Vec<Finding>); 4] {
    [link_names, button_names, svg_names, ambiguous_link_names]
}

/// Alle Tier-2-Regeln.
pub fn rules<D: Semantics>() -> Vec<SemanticsRule<D>> {
    METAS
        .iter()
        .zip(funktionen::<D>())
        .map(|(meta, run)| SemanticsRule { meta: *meta, run })
        .collect()
}