Skip to main content

ssg/audit/gates/
lang_consistency.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Language consistency gate (v0.0.47 plan §2 item 1.5 / A5).
5//!
6//! Per page, compares every JSON-LD `inLanguage` declaration against
7//! the page's `<html lang>` attribute. The comparison is BCP-47
8//! subtag-aware: only the *base* (primary) language subtag is
9//! compared, so `inLanguage: "en-GB"` on `<html lang="en">` is
10//! consistent, while `inLanguage: "en-GB"` on `<html lang="hi">` is a
11//! `LANG-MISMATCH` warning — search engines receiving two different
12//! languages for one document will trust neither.
13//!
14//! Pages without a `<html lang>` (WCAG 3.1.1 covers that) or without
15//! any JSON-LD `inLanguage` produce no findings.
16
17use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
18use std::collections::BTreeSet;
19
20const NAME: &str = "lang_consistency";
21
22/// Language consistency gate: JSON-LD `inLanguage` vs `<html lang>`.
23///
24/// # Examples
25///
26/// ```
27/// use ssg::audit::AuditGate;
28/// use ssg::audit::gates::lang_consistency::LangConsistencyGate;
29/// assert_eq!(LangConsistencyGate.name(), "lang_consistency");
30/// ```
31#[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            // Dedup: one finding per distinct mismatching tag per page.
62            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
90/// Returns the `lang` attribute of the first `<html>` tag, if any.
91fn 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
98/// Collects every string-valued `inLanguage` across all JSON-LD blocks
99/// on the page (recursing into `@graph`, arrays, and nested objects;
100/// `{"@type": "Language", "name": …}` objects contribute their name).
101fn 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
111/// Returns the raw text of every `<script type="application/ld+json">`
112/// element. Attribute matching is quoting-, case-, and order-tolerant
113/// so minified pages are parsed correctly.
114fn 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
134/// Recursively collects `inLanguage` values from a JSON-LD value.
135fn 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
164/// Returns the lowercase BCP-47 primary language subtag (`en-GB` →
165/// `en`, `hi` → `hi`).
166fn 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        // en vs en-GB share base `en` — consistent.
204        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        // inLanguage en-GB on <html lang="hi"> → base en vs hi.
219        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        // og:locale style `en_GB` sometimes leaks into inLanguage.
302        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        // `lang="-GB"` yields an empty primary subtag; the page is
328        // skipped rather than compared against a meaningless base.
329        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}