use crate::accessibility::AXTree;
use crate::cli::WcagLevel;
use crate::wcag::types::{FindingKind, RuleMetadata, Severity, Violation, WcagResults};
pub const BYPASS_BLOCKS_RULE: RuleMetadata = RuleMetadata {
id: "2.4.1",
name: "Bypass Blocks",
level: WcagLevel::A,
severity: Severity::Medium,
description: "A mechanism is available to bypass blocks of content that are repeated",
help_url: "https://www.w3.org/WAI/WCAG21/Understanding/bypass-blocks.html",
axe_id: "bypass",
tags: &["wcag2a", "wcag241", "cat.keyboard"],
};
pub fn check_bypass_blocks(tree: &AXTree) -> WcagResults {
let mut results = WcagResults::new();
results.nodes_checked = tree.len();
let has_skip_link = has_skip_navigation(tree);
let has_main_landmark = has_landmark(tree, "main");
if !has_skip_link && !has_main_landmark {
let violation = Violation::new(
BYPASS_BLOCKS_RULE.id,
BYPASS_BLOCKS_RULE.name,
BYPASS_BLOCKS_RULE.level,
BYPASS_BLOCKS_RULE.severity,
"No bypass mechanism found (neither skip link nor <main> landmark)",
"page",
)
.with_fix(
"Add a skip link (e.g. <a href=\"#main\">Skip to content</a>) \
or wrap the main content in a <main> element",
)
.with_help_url(BYPASS_BLOCKS_RULE.help_url);
results.add_violation(violation);
} else {
results.passes += 1;
if has_skip_link {
results.add_violation(
Violation::new(
BYPASS_BLOCKS_RULE.id,
BYPASS_BLOCKS_RULE.name,
BYPASS_BLOCKS_RULE.level,
Severity::Low,
"Skip navigation link detected — keyboard users can bypass repeated blocks",
"page",
)
.with_help_url(BYPASS_BLOCKS_RULE.help_url)
.with_kind(FindingKind::Positive),
);
}
if has_main_landmark {
results.add_violation(
Violation::new(
BYPASS_BLOCKS_RULE.id,
BYPASS_BLOCKS_RULE.name,
BYPASS_BLOCKS_RULE.level,
Severity::Low,
"<main> landmark detected — assistive technology users can navigate directly to main content",
"page",
)
.with_help_url(BYPASS_BLOCKS_RULE.help_url)
.with_kind(FindingKind::Positive),
);
}
}
let heading_count = count_headings(tree);
if heading_count == 0 {
let violation = Violation::new(
BYPASS_BLOCKS_RULE.id,
BYPASS_BLOCKS_RULE.name,
BYPASS_BLOCKS_RULE.level,
BYPASS_BLOCKS_RULE.severity,
"No headings found for content navigation",
"page",
)
.with_fix("Add headings (h1-h6) to structure your content")
.with_help_url(BYPASS_BLOCKS_RULE.help_url);
results.add_violation(violation);
} else {
results.passes += 1;
}
results
}
fn has_skip_navigation(tree: &AXTree) -> bool {
let skip_patterns = [
"skip to",
"skip navigation",
"skip to content",
"skip to main",
"jump to",
"jump to content",
"go to main",
"go to content",
"zum hauptinhalt",
"zum inhalt",
"navigation überspringen",
"zum inhalt springen",
"hauptinhalt",
"aller au contenu",
"passer la navigation",
"accéder au contenu",
"aller au menu principal",
"ir al contenido",
"saltar navegación",
"ir al contenido principal",
"vai al contenuto",
"salta la navigazione",
"vai al contenuto principale",
"ir para o conteúdo",
"pular navegação",
"ir para o conteúdo principal",
"ga naar inhoud",
"navigatie overslaan",
"ga naar hoofdinhoud",
"hoppa till innehåll",
"hoppa över navigering",
"hopp til innhold",
"hopp over navigasjon",
"gå til indhold",
"spring navigation over",
"siirry sisältöön",
"ohita navigaatio",
"przejdź do treści",
"pomiń nawigację",
"içeriğe geç",
"gezinmeyi atla",
"přejít na obsah",
"přeskočit navigaci",
];
tree.iter().any(|node| {
if node.role.as_deref() == Some("link") {
if let Some(name) = &node.name {
let name_lower = name.to_lowercase();
return skip_patterns
.iter()
.any(|pattern| name_lower.contains(pattern));
}
}
false
})
}
fn has_landmark(tree: &AXTree, landmark_type: &str) -> bool {
tree.iter().any(|node| {
node.role
.as_deref()
.map(|r| r.to_lowercase() == landmark_type.to_lowercase())
.unwrap_or(false)
})
}
fn count_headings(tree: &AXTree) -> usize {
tree.iter()
.filter(|node| node.role.as_deref() == Some("heading"))
.count()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::AXNode;
fn create_node(id: &str, role: &str, name: Option<&str>) -> AXNode {
AXNode {
node_id: id.to_string(),
ignored: false,
ignored_reasons: vec![],
role: Some(role.to_string()),
name: name.map(String::from),
name_source: None,
description: None,
value: None,
properties: vec![],
child_ids: vec![],
parent_id: None,
backend_dom_node_id: None,
}
}
#[test]
fn test_bypass_blocks_rule_metadata() {
assert_eq!(BYPASS_BLOCKS_RULE.id, "2.4.1");
assert_eq!(BYPASS_BLOCKS_RULE.level, WcagLevel::A);
}
#[test]
fn test_has_skip_navigation() {
let tree = AXTree::from_nodes(vec![
create_node("1", "link", Some("Skip to main content")),
create_node("2", "main", None),
]);
assert!(has_skip_navigation(&tree));
}
#[test]
fn test_no_skip_navigation() {
let tree = AXTree::from_nodes(vec![
create_node("1", "link", Some("Home")),
create_node("2", "link", Some("About")),
]);
assert!(!has_skip_navigation(&tree));
}
#[test]
fn test_has_main_landmark() {
let tree = AXTree::from_nodes(vec![create_node("1", "main", None)]);
assert!(has_landmark(&tree, "main"));
}
#[test]
fn test_page_with_proper_landmarks() {
let tree = AXTree::from_nodes(vec![
create_node("1", "banner", None),
create_node("2", "navigation", None),
create_node("3", "main", None),
create_node("4", "contentinfo", None),
create_node("5", "heading", Some("Page Title")),
]);
let results = check_bypass_blocks(&tree);
assert!(!results
.violations
.iter()
.any(|v| v.message.contains("No skip navigation")));
assert!(!results
.violations
.iter()
.any(|v| v.message.contains("Missing main landmark")));
}
#[test]
fn test_page_without_landmarks() {
let tree = AXTree::from_nodes(vec![
create_node("1", "generic", None),
create_node("2", "paragraph", Some("Some text")),
]);
let results = check_bypass_blocks(&tree);
assert!(results
.violations
.iter()
.any(|v| v.message.contains("No bypass mechanism found")));
}
}