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 if let Some(end) =
123 crate::plugins_group::seo::jsonld::comment_end_covering(&lower, abs)
124 {
125 cursor = end;
126 continue;
127 }
128 let tag_end = super::find_tag_end(html, abs);
129 let is_ld = super::hreflang_attr(&html[abs..tag_end], "type")
130 .is_some_and(|t| t.eq_ignore_ascii_case("application/ld+json"));
131 let close = lower[tag_end..]
132 .find("</script")
133 .map_or(lower.len(), |e| tag_end + e);
134 if is_ld && close > tag_end {
135 out.push(html[tag_end..close].to_string());
136 }
137 cursor = close.max(tag_end);
138 }
139 out
140}
141
142fn collect_in_language(value: &serde_json::Value, out: &mut Vec<String>) {
144 match value {
145 serde_json::Value::Object(map) => {
146 for (key, val) in map {
147 if key == "inLanguage" {
148 match val {
149 serde_json::Value::String(s) => out.push(s.clone()),
150 serde_json::Value::Object(obj) => {
151 if let Some(serde_json::Value::String(s)) =
152 obj.get("name")
153 {
154 out.push(s.clone());
155 }
156 }
157 _ => {}
158 }
159 }
160 collect_in_language(val, out);
161 }
162 }
163 serde_json::Value::Array(items) => {
164 for item in items {
165 collect_in_language(item, out);
166 }
167 }
168 _ => {}
169 }
170}
171
172fn base_lang(tag: &str) -> String {
175 tag.trim()
176 .split(['-', '_'])
177 .next()
178 .unwrap_or("")
179 .to_ascii_lowercase()
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use std::path::PathBuf;
186
187 fn site(html: &str) -> Site {
188 let tmp = tempfile::tempdir().unwrap();
189 let path = tmp.path().join("page.html");
190 std::fs::write(&path, html).unwrap();
191 let root = tmp.path().to_path_buf();
192 std::mem::forget(tmp);
193 Site {
194 root,
195 html_files: vec![path],
196 }
197 }
198
199 fn page(lang: &str, in_language: &str) -> String {
200 format!(
201 "<!doctype html><html lang={lang}><head>\
202 <script type=application/ld+json>\
203 {{\"@context\":\"https://schema.org\",\"@type\":\"WebPage\",\
204 \"inLanguage\":\"{in_language}\"}}</script>\
205 </head><body></body></html>"
206 )
207 }
208
209 #[test]
210 fn matching_base_languages_are_clean() {
211 let f = LangConsistencyGate
213 .run(&site(&page("en", "en-GB")), &AuditOptions::default());
214 assert!(f.is_empty(), "regional variant must pass: {f:?}");
215 }
216
217 #[test]
218 fn exact_match_is_clean() {
219 let f = LangConsistencyGate
220 .run(&site(&page("en-GB", "en-GB")), &AuditOptions::default());
221 assert!(f.is_empty(), "got {f:?}");
222 }
223
224 #[test]
225 fn differing_base_languages_flag_lang_mismatch() {
226 let f = LangConsistencyGate
228 .run(&site(&page("hi", "en-GB")), &AuditOptions::default());
229 let m: Vec<_> = f
230 .iter()
231 .filter(|x| x.code.as_deref() == Some("LANG-MISMATCH"))
232 .collect();
233 assert_eq!(m.len(), 1, "expected one mismatch: {f:?}");
234 assert_eq!(m[0].severity, Severity::Warn);
235 assert!(m[0].message.contains("en-GB"));
236 assert!(m[0].message.contains("hi"));
237 }
238
239 #[test]
240 fn missing_html_lang_is_skipped() {
241 let html = "<!doctype html><html><head>\
242 <script type=\"application/ld+json\">\
243 {\"@type\":\"WebPage\",\"inLanguage\":\"en\"}</script>\
244 </head><body></body></html>";
245 let f = LangConsistencyGate.run(&site(html), &AuditOptions::default());
246 assert!(f.is_empty(), "no <html lang> is WCAG's job: {f:?}");
247 }
248
249 #[test]
250 fn no_jsonld_in_language_is_clean() {
251 let html = "<!doctype html><html lang=\"en\"><head>\
252 <script type=\"application/ld+json\">\
253 {\"@type\":\"WebPage\",\"name\":\"x\"}</script>\
254 </head><body></body></html>";
255 let f = LangConsistencyGate.run(&site(html), &AuditOptions::default());
256 assert!(f.is_empty(), "got {f:?}");
257 }
258
259 #[test]
260 fn nested_graph_in_language_is_found() {
261 let html = "<!doctype html><html lang=\"hi\"><head>\
262 <script type=\"application/ld+json\">\
263 {\"@graph\":[{\"@type\":\"Article\",\"inLanguage\":\"en\"}]}\
264 </script></head><body></body></html>";
265 let f = LangConsistencyGate.run(&site(html), &AuditOptions::default());
266 assert!(
267 f.iter().any(|x| x.code.as_deref() == Some("LANG-MISMATCH")),
268 "must recurse into @graph: {f:?}"
269 );
270 }
271
272 #[test]
273 fn language_object_form_contributes_name() {
274 let html = "<!doctype html><html lang=\"hi\"><head>\
275 <script type=\"application/ld+json\">\
276 {\"@type\":\"WebPage\",\"inLanguage\":\
277 {\"@type\":\"Language\",\"name\":\"en\"}}</script>\
278 </head><body></body></html>";
279 let f = LangConsistencyGate.run(&site(html), &AuditOptions::default());
280 assert!(
281 f.iter().any(|x| x.code.as_deref() == Some("LANG-MISMATCH")),
282 "Language object form must be read: {f:?}"
283 );
284 }
285
286 #[test]
287 fn duplicate_mismatches_dedup_per_page() {
288 let html = "<!doctype html><html lang=\"hi\"><head>\
289 <script type=\"application/ld+json\">\
290 {\"@type\":\"WebPage\",\"inLanguage\":\"en\"}</script>\
291 <script type=\"application/ld+json\">\
292 {\"@type\":\"Article\",\"inLanguage\":\"en\"}</script>\
293 </head><body></body></html>";
294 let f = LangConsistencyGate.run(&site(html), &AuditOptions::default());
295 assert_eq!(f.len(), 1, "same tag reported once per page: {f:?}");
296 }
297
298 #[test]
299 fn unparseable_jsonld_is_ignored() {
300 let html = "<!doctype html><html lang=\"en\"><head>\
301 <script type=\"application/ld+json\">{ not json </script>\
302 </head><body></body></html>";
303 let f = LangConsistencyGate.run(&site(html), &AuditOptions::default());
304 assert!(f.is_empty(), "jsonld gate owns parse errors: {f:?}");
305 }
306
307 #[test]
308 fn underscore_locale_form_is_tolerated() {
309 let f = LangConsistencyGate
311 .run(&site(&page("en", "en_GB")), &AuditOptions::default());
312 assert!(f.is_empty(), "underscore variant shares base en: {f:?}");
313 }
314
315 #[test]
316 fn empty_site_produces_no_findings() {
317 let s = Site {
318 root: PathBuf::from("/nonexistent"),
319 html_files: Vec::new(),
320 };
321 let f = LangConsistencyGate.run(&s, &AuditOptions::default());
322 assert!(f.is_empty(), "{:?}", f);
323 }
324
325 #[test]
326 fn base_lang_normalises_case_and_subtags() {
327 assert_eq!(base_lang("EN-gb"), "en");
328 assert_eq!(base_lang("hi"), "hi");
329 assert_eq!(base_lang(" fr-CA "), "fr");
330 assert_eq!(base_lang(""), "");
331 }
332
333 #[test]
334 fn html_lang_with_empty_base_subtag_is_skipped() {
335 let f = LangConsistencyGate
338 .run(&site(&page("\"-GB\"", "en")), &AuditOptions::default());
339 assert!(f.is_empty(), "empty base subtag must skip: {f:?}");
340 }
341
342 #[test]
343 fn non_string_in_language_value_is_ignored() {
344 let html = "<!doctype html><html lang=\"hi\"><head>\
345 <script type=\"application/ld+json\">\
346 {\"@type\":\"WebPage\",\"inLanguage\":42}</script>\
347 </head><body></body></html>";
348 let f = LangConsistencyGate.run(&site(html), &AuditOptions::default());
349 assert!(f.is_empty(), "numeric inLanguage is ignored: {f:?}");
350 }
351
352 #[test]
353 fn unreadable_html_file_is_skipped() {
354 let tmp = tempfile::tempdir().unwrap();
355 let bogus = tmp.path().join("ghost.html");
356 let s = Site {
357 root: tmp.path().to_path_buf(),
358 html_files: vec![bogus],
359 };
360 std::mem::forget(tmp);
361 let f = LangConsistencyGate.run(&s, &AuditOptions::default());
362 assert!(f.is_empty(), "{:?}", f);
363 }
364
365 #[test]
366 fn fragment_without_html_tag_yields_no_lang() {
367 assert_eq!(extract_html_lang("<body>no html tag</body>"), None);
368 }
369
370 #[test]
371 fn non_jsonld_script_blocks_are_ignored() {
372 let blocks = extract_jsonld_blocks(
373 "<script>var x = 1;</script>\
374 <script type=\"application/ld+json\">{}</script>",
375 );
376 assert_eq!(blocks.len(), 1);
377 assert_eq!(blocks[0], "{}");
378 }
379
380 #[test]
381 fn jsonld_tag_quoted_in_a_comment_is_skipped() {
382 let html = r#"<html lang="fr"><head>
383<!-- Inline <script type="application/ld+json"> blocks are hashed -->
384<script src="/a.js"></script>
385<script type="application/ld+json">{"inLanguage":"fr-FR"}</script>
386</head></html>"#;
387 let blocks = extract_jsonld_blocks(html);
388 assert_eq!(blocks.len(), 1);
389 assert!(blocks[0].contains("fr-FR"));
390 }
391
392 #[test]
393 fn language_object_without_name_is_ignored() {
394 let html = "<!doctype html><html lang=\"hi\"><head>\
395 <script type=\"application/ld+json\">\
396 {\"@type\":\"WebPage\",\"inLanguage\":\
397 {\"@type\":\"Language\"}}</script>\
398 </head><body></body></html>";
399 let f = LangConsistencyGate.run(&site(html), &AuditOptions::default());
400 assert!(f.is_empty(), "nameless Language object is ignored: {f:?}");
401 }
402
403 #[test]
404 fn metadata_methods_exposed() {
405 let g = LangConsistencyGate;
406 assert_eq!(g.name(), "lang_consistency");
407 assert!(g.explain().contains("inLanguage"));
408 let _copy: LangConsistencyGate = g;
409 let _clone = g;
410 assert!(format!("{g:?}").contains("LangConsistencyGate"));
411 }
412}