auditmysite 1.2.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,
    QualityScoreContext, ReadingItem, ScreenReaderSummary, SrAuditIssue, SrAuditQuality,
    SrAuditReport, SrAuditSummary,
};

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

/// Builds the screen-reader audit report. All issue messages and announcements
/// are baked in canonical English so the stored struct, JSON envelope and
/// sidecar file are language-neutral (#406). The PDF presentation layer
/// re-derives localized issues from the reading sequence.
///
/// `detect_locale` is the page/run language used only for *detection* (loading
/// the generic-link-text stopword list and scoring name quality), independent
/// of the English message language — so German pages keep flagging generic
/// German link texts even though messages are stored in English.
///
/// `pattern_analysis`, when available, lets the missing-navigation-landmark
/// check (#504) recognize a collapsed hamburger/disclosure menu instead of
/// reporting the navigation as unreachable — the static AXTree captured
/// before interaction never contains the collapsed nav.
pub fn build_sr_audit_report(
    url: &str,
    timestamp: chrono::DateTime<chrono::Utc>,
    tree: &AXTree,
    detect_locale: &str,
    pattern_analysis: Option<&crate::patterns::PatternAnalysis>,
) -> SrAuditReport {
    let i18n = I18n::new("en").expect("english locale parses");
    let reading_items = linearize(tree);
    let navigation_views = navigation_views(&reading_items);
    let has_disclosure_menu_pattern =
        pattern_analysis.is_some_and(|patterns| patterns.has_recognized("DisclosureMenu"));
    // Detection uses the page/run language so generic link-text stopwords match;
    // messages are baked in canonical English for the language-neutral struct.
    let issues = analyze_reading_sequence(
        &reading_items,
        &navigation_views,
        detect_locale,
        true,
        has_disclosure_menu_pattern,
    );
    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, detect_locale),
            landmark_quality_score: landmark_quality_score(&navigation_views),
            heading_quality_score: heading_quality_score(&navigation_views),
            quality_score_context: QualityScoreContext::default(),
            audit_quality: detect_audit_quality(reading_items.len(), &navigation_views),
        },
        reading_sequence,
        navigation_views,
        issues,
        bfsg_compliance,
    }
}

/// Flags a likely consent-blocked audit (#483): too few announced nodes and no
/// structural landmark (banner/navigation/main/contentinfo). On such pages the
/// quality scores and BFSG verdict reflect an audit limitation, not a genuine
/// accessibility failure, so downstream consumers can qualify the findings.
fn detect_audit_quality(total_nodes: usize, views: &NavigationViews) -> SrAuditQuality {
    const CONSENT_WALL_NODE_THRESHOLD: usize = 200;
    let has_structural_landmark = views.landmarks.iter().any(|l| {
        matches!(
            l.role.as_str(),
            "banner" | "navigation" | "main" | "contentinfo"
        ) && l.quality != navigator::LandmarkQuality::MissingMain
    });
    if total_nodes < CONSENT_WALL_NODE_THRESHOLD && !has_structural_landmark {
        SrAuditQuality::ConsentWallSuspected
    } else {
        SrAuditQuality::Ok
    }
}

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,
                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);
    }
}