auditmysite 1.7.2

Resource-efficient WCAG 2.2 AA Accessibility Checker in Rust
Documentation
//! WCAG 3.1.1 - Language of Page (lang/xml:lang agreement)
//!
//! `html-xml-lang-mismatch`: when both lang and xml:lang are present they must agree.
//!
//! Whether `lang` itself is a valid BCP 47 tag is checked by the shared rule
//! `document/lang-invalid` (see `wcag::shared`). The own `valid-lang` check
//! that used to live here read the same `<html lang>` and reported the same
//! defect a second time; it was removed once the shared id was adopted.
//!
//! Despite the module name, this does not implement 3.1.2 Language of Parts
//! (per-element `lang` switches for foreign-language passages) — the check
//! below is tagged and scored as 3.1.1 (see `RULE_LANG_MISMATCH`), consistent
//! with `taxonomy/rules.rs` and `page_rules.rs` (`"3.1.1/language-extended"`).
//!
//! DOM-level rule: reads `<html lang>`/`<html xml:lang>` directly via CDP.
//! The AX tree exposes a synthesized `language` property (see `language.rs`,
//! #QA-001), not the raw `lang`/`xmlLang` attribute values this check needs
//! to validate — an earlier tree-based implementation read AX property names
//! (`lang`, `xmlLang`) that don't exist at all, and so never fired in
//! production (#QA-030).

use chromiumoxide::Page;

use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation};

pub(super) const RULE_LANG_MISMATCH: RuleMetadata = RuleMetadata {
    id: "3.1.1",
    name: "Language Attribute Mismatch",
    level: WcagLevel::A,
    severity: Severity::Medium,
    description: "lang and xml:lang attributes must specify the same language",
    help_url: "https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html",
    axe_id: "html-xml-lang-mismatch",
    tags: &["wcag2a", "wcag311", "cat.language"],
};

const LANG_ATTRS_JS: &str = "({ \
    lang: document.documentElement.getAttribute('lang') || '', \
    xmlLang: document.documentElement.getAttribute('xml:lang') || '' \
})";

/// Extract the primary subtag from a BCP 47 tag (everything before the first `-`).
fn primary_subtag(tag: &str) -> &str {
    tag.split('-').next().unwrap_or(tag)
}

/// Run extended language-related checks against the live DOM.
pub async fn check_language_extended_with_page(page: &Page) -> Vec<Violation> {
    let val = match crate::wcag::types::evaluate_or_fail_for(
        page,
        "language-extended",
        crate::cli::WcagLevel::A,
        LANG_ATTRS_JS,
    )
    .await
    {
        Ok(v) => v,
        Err(violations) => return violations,
    };

    let lang = val.get("lang").and_then(|v| v.as_str()).unwrap_or("");
    let xml_lang = val.get("xmlLang").and_then(|v| v.as_str()).unwrap_or("");

    let mut violations = Vec::new();

    // Mismatch between lang and xml:lang
    if !lang.is_empty() && !xml_lang.is_empty() {
        let primary_a = primary_subtag(lang).to_lowercase();
        let primary_b = primary_subtag(xml_lang).to_lowercase();
        if primary_a != primary_b {
            violations.push(
                Violation::new(
                    RULE_LANG_MISMATCH.id,
                    RULE_LANG_MISMATCH.name,
                    RULE_LANG_MISMATCH.level,
                    RULE_LANG_MISMATCH.severity,
                    format!(
                        "lang=\"{}\" and xml:lang=\"{}\" specify different primary languages",
                        lang, xml_lang
                    ),
                    "html",
                )
                .with_selector("html")
                .with_fix("Ensure lang and xml:lang use the same primary language subtag")
                .with_rule_id(RULE_LANG_MISMATCH.axe_id)
                .with_help_url(RULE_LANG_MISMATCH.help_url),
            );
        }
    }

    violations
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_primary_subtag_extraction() {
        assert_eq!(primary_subtag("en-US"), "en");
        assert_eq!(primary_subtag("zh-Hans"), "zh");
        assert_eq!(primary_subtag("de"), "de");
    }
}