1use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
18use std::collections::BTreeSet;
19
20const NAME: &str = "lang_consistency";
21
22#[derive(Debug, Clone, Copy)]
32pub struct LangConsistencyGate;
33
34impl AuditGate for LangConsistencyGate {
35 fn name(&self) -> &'static str {
36 NAME
37 }
38
39 fn explain(&self) -> &'static str {
40 "Compares every JSON-LD `inLanguage` value on a page against \
41 the page's <html lang> attribute. Comparison is BCP-47 \
42 subtag-aware: regional variants of the same base language \
43 (en vs en-GB) are consistent; different base languages \
44 (en-GB on <html lang=\"hi\">) raise a LANG-MISMATCH warning. \
45 Pages missing <html lang> or without JSON-LD inLanguage are \
46 skipped (the WCAG and jsonld gates own those checks)."
47 }
48
49 fn run(&self, site: &Site, _opts: &AuditOptions) -> Vec<Finding> {
50 let mut findings = Vec::new();
51 for path in &site.html_files {
52 let Ok(html) = site.read(path) else { continue };
53 let rel = site.rel(path);
54 let Some(page_lang) = extract_html_lang(&html) else {
55 continue;
56 };
57 let page_base = base_lang(&page_lang);
58 if page_base.is_empty() {
59 continue;
60 }
61 let mut seen: BTreeSet<String> = BTreeSet::new();
63 for in_lang in extract_in_languages(&html) {
64 let in_base = base_lang(&in_lang);
65 if in_base.is_empty() || in_base == page_base {
66 continue;
67 }
68 if !seen.insert(in_lang.clone()) {
69 continue;
70 }
71 findings.push(
72 Finding::new(
73 NAME,
74 Severity::Warn,
75 format!(
76 "JSON-LD inLanguage `{in_lang}` does not match \
77 <html lang=\"{page_lang}\"> (base `{in_base}` \
78 vs `{page_base}`)"
79 ),
80 )
81 .with_code("LANG-MISMATCH")
82 .with_path(rel.clone()),
83 );
84 }
85 }
86 findings
87 }
88}
89
90fn extract_html_lang(html: &str) -> Option<String> {
92 let lower = html.to_ascii_lowercase();
93 let start = lower.find("<html")?;
94 let end = super::find_tag_end(html, start);
95 super::hreflang_attr(&html[start..end], "lang").filter(|l| !l.is_empty())
96}
97
98fn extract_in_languages(html: &str) -> Vec<String> {
102 let mut out = Vec::new();
103 for block in extract_jsonld_blocks(html) {
104 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&block) {
105 collect_in_language(&value, &mut out);
106 }
107 }
108 out
109}
110
111fn extract_jsonld_blocks(html: &str) -> Vec<String> {
115 let mut out = Vec::new();
116 let lower = html.to_ascii_lowercase();
117 let mut cursor = 0;
118 while let Some(rel) = lower[cursor..].find("<script") {
119 let abs = cursor + rel;
120 let tag_end = super::find_tag_end(html, abs);
121 let is_ld = super::hreflang_attr(&html[abs..tag_end], "type")
122 .is_some_and(|t| t.eq_ignore_ascii_case("application/ld+json"));
123 let close = lower[tag_end..]
124 .find("</script")
125 .map_or(lower.len(), |e| tag_end + e);
126 if is_ld && close > tag_end {
127 out.push(html[tag_end..close].to_string());
128 }
129 cursor = close.max(tag_end);
130 }
131 out
132}
133
134fn collect_in_language(value: &serde_json::Value, out: &mut Vec<String>) {
136 match value {
137 serde_json::Value::Object(map) => {
138 for (key, val) in map {
139 if key == "inLanguage" {
140 match val {
141 serde_json::Value::String(s) => out.push(s.clone()),
142 serde_json::Value::Object(obj) => {
143 if let Some(serde_json::Value::String(s)) =
144 obj.get("name")
145 {
146 out.push(s.clone());
147 }
148 }
149 _ => {}
150 }
151 }
152 collect_in_language(val, out);
153 }
154 }
155 serde_json::Value::Array(items) => {
156 for item in items {
157 collect_in_language(item, out);
158 }
159 }
160 _ => {}
161 }
162}
163
164fn base_lang(tag: &str) -> String {
167 tag.trim()
168 .split(['-', '_'])
169 .next()
170 .unwrap_or("")
171 .to_ascii_lowercase()
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use std::path::PathBuf;
178
179 fn site(html: &str) -> Site {
180 let tmp = tempfile::tempdir().unwrap();
181 let path = tmp.path().join("page.html");
182 std::fs::write(&path, html).unwrap();
183 let root = tmp.path().to_path_buf();
184 std::mem::forget(tmp);
185 Site {
186 root,
187 html_files: vec![path],
188 }
189 }
190
191 fn page(lang: &str, in_language: &str) -> String {
192 format!(
193 "<!doctype html><html lang={lang}><head>\
194 <script type=application/ld+json>\
195 {{\"@context\":\"https://schema.org\",\"@type\":\"WebPage\",\
196 \"inLanguage\":\"{in_language}\"}}</script>\
197 </head><body></body></html>"
198 )
199 }
200
201 #[test]
202 fn matching_base_languages_are_clean() {
203 let f = LangConsistencyGate
205 .run(&site(&page("en", "en-GB")), &AuditOptions::default());
206 assert!(f.is_empty(), "regional variant must pass: {f:?}");
207 }
208
209 #[test]
210 fn exact_match_is_clean() {
211 let f = LangConsistencyGate
212 .run(&site(&page("en-GB", "en-GB")), &AuditOptions::default());
213 assert!(f.is_empty(), "got {f:?}");
214 }
215
216 #[test]
217 fn differing_base_languages_flag_lang_mismatch() {
218 let f = LangConsistencyGate
220 .run(&site(&page("hi", "en-GB")), &AuditOptions::default());
221 let m: Vec<_> = f
222 .iter()
223 .filter(|x| x.code.as_deref() == Some("LANG-MISMATCH"))
224 .collect();
225 assert_eq!(m.len(), 1, "expected one mismatch: {f:?}");
226 assert_eq!(m[0].severity, Severity::Warn);
227 assert!(m[0].message.contains("en-GB"));
228 assert!(m[0].message.contains("hi"));
229 }
230
231 #[test]
232 fn missing_html_lang_is_skipped() {
233 let html = "<!doctype html><html><head>\
234 <script type=\"application/ld+json\">\
235 {\"@type\":\"WebPage\",\"inLanguage\":\"en\"}</script>\
236 </head><body></body></html>";
237 let f = LangConsistencyGate.run(&site(html), &AuditOptions::default());
238 assert!(f.is_empty(), "no <html lang> is WCAG's job: {f:?}");
239 }
240
241 #[test]
242 fn no_jsonld_in_language_is_clean() {
243 let html = "<!doctype html><html lang=\"en\"><head>\
244 <script type=\"application/ld+json\">\
245 {\"@type\":\"WebPage\",\"name\":\"x\"}</script>\
246 </head><body></body></html>";
247 let f = LangConsistencyGate.run(&site(html), &AuditOptions::default());
248 assert!(f.is_empty(), "got {f:?}");
249 }
250
251 #[test]
252 fn nested_graph_in_language_is_found() {
253 let html = "<!doctype html><html lang=\"hi\"><head>\
254 <script type=\"application/ld+json\">\
255 {\"@graph\":[{\"@type\":\"Article\",\"inLanguage\":\"en\"}]}\
256 </script></head><body></body></html>";
257 let f = LangConsistencyGate.run(&site(html), &AuditOptions::default());
258 assert!(
259 f.iter().any(|x| x.code.as_deref() == Some("LANG-MISMATCH")),
260 "must recurse into @graph: {f:?}"
261 );
262 }
263
264 #[test]
265 fn language_object_form_contributes_name() {
266 let html = "<!doctype html><html lang=\"hi\"><head>\
267 <script type=\"application/ld+json\">\
268 {\"@type\":\"WebPage\",\"inLanguage\":\
269 {\"@type\":\"Language\",\"name\":\"en\"}}</script>\
270 </head><body></body></html>";
271 let f = LangConsistencyGate.run(&site(html), &AuditOptions::default());
272 assert!(
273 f.iter().any(|x| x.code.as_deref() == Some("LANG-MISMATCH")),
274 "Language object form must be read: {f:?}"
275 );
276 }
277
278 #[test]
279 fn duplicate_mismatches_dedup_per_page() {
280 let html = "<!doctype html><html lang=\"hi\"><head>\
281 <script type=\"application/ld+json\">\
282 {\"@type\":\"WebPage\",\"inLanguage\":\"en\"}</script>\
283 <script type=\"application/ld+json\">\
284 {\"@type\":\"Article\",\"inLanguage\":\"en\"}</script>\
285 </head><body></body></html>";
286 let f = LangConsistencyGate.run(&site(html), &AuditOptions::default());
287 assert_eq!(f.len(), 1, "same tag reported once per page: {f:?}");
288 }
289
290 #[test]
291 fn unparseable_jsonld_is_ignored() {
292 let html = "<!doctype html><html lang=\"en\"><head>\
293 <script type=\"application/ld+json\">{ not json </script>\
294 </head><body></body></html>";
295 let f = LangConsistencyGate.run(&site(html), &AuditOptions::default());
296 assert!(f.is_empty(), "jsonld gate owns parse errors: {f:?}");
297 }
298
299 #[test]
300 fn underscore_locale_form_is_tolerated() {
301 let f = LangConsistencyGate
303 .run(&site(&page("en", "en_GB")), &AuditOptions::default());
304 assert!(f.is_empty(), "underscore variant shares base en: {f:?}");
305 }
306
307 #[test]
308 fn empty_site_produces_no_findings() {
309 let s = Site {
310 root: PathBuf::from("/nonexistent"),
311 html_files: Vec::new(),
312 };
313 let f = LangConsistencyGate.run(&s, &AuditOptions::default());
314 assert!(f.is_empty());
315 }
316
317 #[test]
318 fn base_lang_normalises_case_and_subtags() {
319 assert_eq!(base_lang("EN-gb"), "en");
320 assert_eq!(base_lang("hi"), "hi");
321 assert_eq!(base_lang(" fr-CA "), "fr");
322 assert_eq!(base_lang(""), "");
323 }
324
325 #[test]
326 fn html_lang_with_empty_base_subtag_is_skipped() {
327 let f = LangConsistencyGate
330 .run(&site(&page("\"-GB\"", "en")), &AuditOptions::default());
331 assert!(f.is_empty(), "empty base subtag must skip: {f:?}");
332 }
333
334 #[test]
335 fn non_string_in_language_value_is_ignored() {
336 let html = "<!doctype html><html lang=\"hi\"><head>\
337 <script type=\"application/ld+json\">\
338 {\"@type\":\"WebPage\",\"inLanguage\":42}</script>\
339 </head><body></body></html>";
340 let f = LangConsistencyGate.run(&site(html), &AuditOptions::default());
341 assert!(f.is_empty(), "numeric inLanguage is ignored: {f:?}");
342 }
343
344 #[test]
345 fn unreadable_html_file_is_skipped() {
346 let tmp = tempfile::tempdir().unwrap();
347 let bogus = tmp.path().join("ghost.html");
348 let s = Site {
349 root: tmp.path().to_path_buf(),
350 html_files: vec![bogus],
351 };
352 std::mem::forget(tmp);
353 let f = LangConsistencyGate.run(&s, &AuditOptions::default());
354 assert!(f.is_empty());
355 }
356
357 #[test]
358 fn fragment_without_html_tag_yields_no_lang() {
359 assert_eq!(extract_html_lang("<body>no html tag</body>"), None);
360 }
361
362 #[test]
363 fn non_jsonld_script_blocks_are_ignored() {
364 let blocks = extract_jsonld_blocks(
365 "<script>var x = 1;</script>\
366 <script type=\"application/ld+json\">{}</script>",
367 );
368 assert_eq!(blocks.len(), 1);
369 assert_eq!(blocks[0], "{}");
370 }
371
372 #[test]
373 fn language_object_without_name_is_ignored() {
374 let html = "<!doctype html><html lang=\"hi\"><head>\
375 <script type=\"application/ld+json\">\
376 {\"@type\":\"WebPage\",\"inLanguage\":\
377 {\"@type\":\"Language\"}}</script>\
378 </head><body></body></html>";
379 let f = LangConsistencyGate.run(&site(html), &AuditOptions::default());
380 assert!(f.is_empty(), "nameless Language object is ignored: {f:?}");
381 }
382
383 #[test]
384 fn metadata_methods_exposed() {
385 let g = LangConsistencyGate;
386 assert_eq!(g.name(), "lang_consistency");
387 assert!(g.explain().contains("inLanguage"));
388 let _copy: LangConsistencyGate = g;
389 let _clone = g;
390 assert!(format!("{g:?}").contains("LangConsistencyGate"));
391 }
392}