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        // A `<script` quoted inside an HTML comment is prose, not a block
121        // (see `plugins::seo::jsonld::comment_end_covering`).
122        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
142/// Recursively collects `inLanguage` values from a JSON-LD value.
143fn 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
172/// Returns the lowercase BCP-47 primary language subtag (`en-GB` →
173/// `en`, `hi` → `hi`).
174fn 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        // en vs en-GB share base `en` — consistent.
212        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        // inLanguage en-GB on <html lang="hi"> → base en vs hi.
227        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        // og:locale style `en_GB` sometimes leaks into inLanguage.
310        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        // `lang="-GB"` yields an empty primary subtag; the page is
336        // skipped rather than compared against a meaningless base.
337        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}