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;
}
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())),
);
}
}
}
const NICHTSSAGEND: &[&str] = &[
"hier",
"hier klicken",
"klick hier",
"klicken sie hier",
"siehe hier",
"mehr",
"mehr dazu",
"mehr erfahren",
"mehr lesen",
"weiterlesen",
"weiter",
"dieser link",
"link",
"click here",
"click",
"here",
"more",
"read more",
"learn more",
"see more",
"continue",
"this link",
"link here",
];
fn generic_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;
}
let Some(name) = doc.accessible_name(n) else {
continue;
};
let key = name
.trim()
.trim_end_matches(['.', '!', '…', '>', '›', '→'])
.trim()
.to_lowercase();
if NICHTSSAGEND.contains(&key.as_str()) {
out.push(
Finding::review(
"links/generic-name",
format!(
"Der Linktext \"{}\" sagt nichts über das Ziel.",
name.trim()
),
)
.with_severity(Severity::Medium)
.with_wcag(["2.4.4"])
.at(at(n.id())),
);
}
}
}
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; }
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)),
);
}
}
}
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.",
},
Meta {
ids: &["links/generic-name"],
tier: Tier::Semantics,
wcag: &["2.4.4"],
severity: Severity::Medium,
help: "Der Linktext soll auch ohne den umgebenden Satz sagen, wohin er führt.",
},
];
fn funktionen<D: Semantics>() -> [fn(&D, &mut Vec<Finding>); 5] {
[
link_names,
button_names,
svg_names,
ambiguous_link_names,
generic_link_names,
]
}
pub fn rules<D: Semantics>() -> Vec<SemanticsRule<D>> {
METAS
.iter()
.zip(funktionen::<D>())
.map(|(meta, run)| SemanticsRule { meta: *meta, run })
.collect()
}