auditmysite 0.45.0

Resource-efficient WCAG 2.1 Accessibility Checker in Rust
Documentation
//! Screen reader audit primitives built from the browser AXTree.

pub mod analyzer;
pub mod announcer;
pub mod bfsg;
pub mod linearizer;
pub mod navigator;
pub mod types;

pub use analyzer::{analyze_reading_sequence, name_quality_score};
pub use announcer::{announce, announce_localized};
pub use bfsg::{map_to_bfsg, wcag_21_aa_criteria, BfsgMapping};
pub use linearizer::{linearize, linearize_with_ignored};
pub use navigator::{navigation_views, NavigationViews};
pub use types::{
    AnnouncedReadingItem, BfsgCompliance, BfsgVerdict, BfsgViolation, IgnoredReadingNode,
    ReadingItem, SrAuditIssue, SrAuditReport, SrAuditSummary,
};

use crate::accessibility::AXTree;
use crate::i18n::I18n;

pub fn build_sr_audit_report(
    url: &str,
    timestamp: chrono::DateTime<chrono::Utc>,
    tree: &AXTree,
    locale: &str,
) -> SrAuditReport {
    let i18n =
        I18n::new(locale).unwrap_or_else(|_| I18n::new("de").expect("default locale parses"));
    let reading_items = linearize(tree);
    let navigation_views = navigation_views(&reading_items);
    let issues = analyze_reading_sequence(&reading_items, &navigation_views, locale);
    let bfsg_compliance = bfsg_compliance(&issues);
    let reading_sequence = reading_items
        .iter()
        .cloned()
        .map(|item| AnnouncedReadingItem {
            announcement: announce_localized(&item, &i18n),
            item,
        })
        .collect::<Vec<_>>();

    SrAuditReport {
        schema_version: "1.0",
        report_type: "screen_reader_audit",
        url: url.to_string(),
        timestamp,
        tool_version: env!("CARGO_PKG_VERSION"),
        summary: SrAuditSummary {
            total_announced_nodes: reading_items.len(),
            tab_stops: reading_items.iter().filter(|item| item.tab_stop).count(),
            bfsg_violations: bfsg_compliance.violations.len(),
            name_quality_score: name_quality_score(&reading_items, locale),
            landmark_quality_score: landmark_quality_score(&navigation_views),
            heading_quality_score: heading_quality_score(&navigation_views),
        },
        reading_sequence,
        navigation_views,
        issues,
        bfsg_compliance,
    }
}

fn landmark_quality_score(views: &NavigationViews) -> u32 {
    // Score based on presence of all four required ARIA landmark roles.
    // A page with only `main` used to score 100 % — that was misleading.
    const REQUIRED: &[&str] = &["banner", "navigation", "contentinfo", "main"];
    let present_count = REQUIRED
        .iter()
        .filter(|&&role| {
            views
                .landmarks
                .iter()
                .any(|l| l.role == role && l.quality != navigator::LandmarkQuality::MissingMain)
        })
        .count();
    percentage(present_count, REQUIRED.len())
}

fn heading_quality_score(views: &NavigationViews) -> u32 {
    percentage(
        views
            .headings
            .iter()
            .filter(|item| item.quality == navigator::HeadingQuality::Good)
            .count(),
        views.headings.len(),
    )
}

fn percentage(passed: usize, total: usize) -> u32 {
    if total == 0 {
        100
    } else {
        ((passed as f64 / total as f64) * 100.0).round() as u32
    }
}

fn bfsg_compliance(issues: &[SrAuditIssue]) -> BfsgCompliance {
    let violations = issues
        .iter()
        .filter_map(|issue| {
            let wcag = issue.wcag_criterion.as_deref()?;
            let mapping = map_to_bfsg(wcag)?;
            mapping.fix_required.then(|| BfsgViolation {
                wcag_criterion: wcag.to_string(),
                en_301_549_clause: Some(mapping.en_301549_clause.to_string()),
                bfsg_reference: Some(mapping.bfsg_paragraph.to_string()),
                fix_required: mapping.fix_required,
                deadline: Some(mapping.deadline.to_string()),
                affected_node_ids: issue.affected_node_ids.clone(),
            })
        })
        .collect::<Vec<_>>();
    let failed: std::collections::HashSet<_> = violations
        .iter()
        .map(|violation| violation.wcag_criterion.as_str())
        .collect();
    let passed_criteria = wcag_21_aa_criteria()
        .iter()
        .filter(|criterion| !failed.contains(criterion.wcag))
        .map(|criterion| criterion.wcag.to_string())
        .collect();

    BfsgCompliance {
        verdict: if violations.is_empty() {
            BfsgVerdict::Compliant
        } else {
            BfsgVerdict::NonCompliant
        },
        violations,
        passed_criteria,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::screen_reader::navigator::navigation_views;
    use crate::screen_reader::ReadingItem;

    fn item(seq: usize, role: &str) -> ReadingItem {
        ReadingItem {
            seq,
            role: Some(role.to_string()),
            name: Some(role.to_string()),
            description: None,
            value: None,
            states: vec![],
            tab_stop: false,
            depth: 0,
            node_id: format!("node-{seq}"),
        }
    }

    #[test]
    fn landmark_quality_score_only_main_is_low() {
        let views = navigation_views(&[item(0, "main")]);
        // 1 of 4 required landmarks → 25 %
        assert_eq!(landmark_quality_score(&views), 25);
    }

    #[test]
    fn landmark_quality_score_all_required_is_100() {
        let views = navigation_views(&[
            item(0, "banner"),
            item(1, "navigation"),
            item(2, "main"),
            item(3, "contentinfo"),
        ]);
        assert_eq!(landmark_quality_score(&views), 100);
    }
}