1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
//! 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");
}
}