use chromiumoxide::Page;
use crate::cli::WcagLevel;
use crate::wcag::types::{Outcome, RuleMetadata, Severity, Violation};
pub(super) const LANGUAGE_OF_PARTS_RULE: RuleMetadata = RuleMetadata {
id: "3.1.2",
name: "Language of Parts",
level: WcagLevel::AA,
severity: Severity::Medium,
description: "Long passages in another language identify that language programmatically",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html",
axe_id: "language-of-parts",
tags: &["wcag2aa", "wcag312", "cat.language", "heuristic"],
};
const LANGUAGE_OF_PARTS_JS: &str = r#"
(() => {
const documentLanguage = (document.documentElement.lang || '').split('-')[0].toLowerCase();
if (!['de', 'en'].includes(documentLanguage)) return { supported: false, candidates: [] };
const markers = {
de: new Set(['der','die','das','und','ist','sind','mit','für','von','auf','eine','einer','nicht','werden','wird','auch','dass']),
en: new Set(['the','and','is','are','with','for','from','this','that','not','will','also','have','has','your','our'])
};
/*CSS_SELECTOR*/
const candidates = [];
for (const element of document.querySelectorAll('p, li, dd, td, figcaption')) {
if (__amsReal(candidates) >= 5 || element.closest('code, pre, address, blockquote, [translate="no"]')) continue;
const text = (element.innerText || '').replace(/\s+/g, ' ').trim();
const words = (text.toLowerCase().match(/[\p{L}]+/gu) || []);
if (text.length < 100 || words.length < 16) continue;
const declared = (element.closest('[lang]')?.lang || documentLanguage).split('-')[0].toLowerCase();
if (declared !== documentLanguage) continue;
const de = words.filter(word => markers.de.has(word)).length;
const en = words.filter(word => markers.en.has(word)).length;
const detected = de >= 5 && en <= 1 ? 'de' : en >= 5 && de <= 1 ? 'en' : null;
if (detected && detected !== documentLanguage) {
__amsPush(candidates, element, { selector: __amsCssSelector(element), detected, sample: text.slice(0, 120) }, 5);
}
}
return { supported: true, documentLanguage, candidates };
})()
"#;
pub async fn check_language_of_parts_with_page(page: &Page) -> Vec<Violation> {
let value = match page
.evaluate(LANGUAGE_OF_PARTS_JS.replace(
"/*CSS_SELECTOR*/",
crate::accessibility::js_helpers::CSS_SELECTOR_JS,
))
.await
{
Ok(result) => result.value().cloned().unwrap_or_default(),
Err(_) => {
return vec![crate::wcag::technical_rule_failure(
&LANGUAGE_OF_PARTS_RULE,
"page_evaluation_failed",
)]
}
};
if !value
.get("supported")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
return Vec::new();
}
value
.get("candidates")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(|candidate| {
let selector = candidate.get("selector")?.as_str()?;
let detected = candidate.get("detected")?.as_str()?;
let sample = candidate.get("sample")?.as_str()?;
Some(
Violation::new(
LANGUAGE_OF_PARTS_RULE.id,
LANGUAGE_OF_PARTS_RULE.name,
LANGUAGE_OF_PARTS_RULE.level,
LANGUAGE_OF_PARTS_RULE.severity,
format!(
"A long passage strongly resembles language '{detected}' but inherits the page language: \"{sample}\""
),
selector,
)
.with_selector(selector)
.with_rule_id(LANGUAGE_OF_PARTS_RULE.axe_id)
.with_kind(Outcome::Review)
.with_fix(format!(
"Confirm the passage language and add lang=\"{detected}\" when it differs from the surrounding content"
))
.with_help_url(LANGUAGE_OF_PARTS_RULE.help_url),
)
})
.collect()
}