Skip to main content

ssg/audit/gates/
jsonld.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! JSON-LD (Schema.org) semantic audit gate.
5//!
6//! Delegates to [`crate::seo::jsonld::validate_jsonld`] so this gate
7//! and the build-time `JsonLdPlugin` agree on the same required-field
8//! matrix per `@type`.
9
10use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
11use crate::seo::validate_jsonld;
12
13const NAME: &str = "jsonld";
14
15/// JSON-LD Schema.org gate.
16///
17/// # Examples
18///
19/// ```
20/// use ssg::audit::AuditGate;
21/// use ssg::audit::gates::jsonld::JsonLdGate;
22/// assert_eq!(JsonLdGate.name(), "jsonld");
23/// ```
24#[derive(Debug, Clone, Copy)]
25pub struct JsonLdGate;
26
27impl AuditGate for JsonLdGate {
28    fn name(&self) -> &'static str {
29        NAME
30    }
31
32    fn explain(&self) -> &'static str {
33        "Extracts every <script type=\"application/ld+json\"> block on \
34         each page, asserts it parses as JSON, and validates the \
35         required fields for its declared @type (Article, WebPage, \
36         BreadcrumbList, FAQPage, LocalBusiness, Organization). \
37         Unparseable JSON or missing-required-field findings are \
38         emitted at error severity; unknown types are pass-through."
39    }
40
41    fn run(&self, site: &Site, _opts: &AuditOptions) -> Vec<Finding> {
42        let mut findings = Vec::new();
43        for path in &site.html_files {
44            let Ok(html) = site.read(path) else { continue };
45            let rel = site.rel(path);
46            for err in validate_jsonld(&html) {
47                findings.push(
48                    Finding::new(
49                        NAME,
50                        Severity::Error,
51                        format!(
52                            "[{}] missing/invalid `{}` — {}",
53                            err.schema_type, err.field, err.reason
54                        ),
55                    )
56                    .with_code(format!("JSONLD-{}", err.schema_type))
57                    .with_path(rel.clone()),
58                );
59            }
60        }
61        findings
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use std::path::PathBuf;
69
70    fn site(html: &str) -> Site {
71        let tmp = tempfile::tempdir().unwrap();
72        let path = tmp.path().join("page.html");
73        std::fs::write(&path, html).unwrap();
74        let root = tmp.path().to_path_buf();
75        std::mem::forget(tmp);
76        Site {
77            root,
78            html_files: vec![path],
79        }
80    }
81
82    #[test]
83    fn passing_jsonld_produces_no_findings() {
84        let html = r#"<html><head><script type="application/ld+json">
85            {"@context":"https://schema.org","@type":"Organization","name":"Acme","url":"https://acme.test"}
86        </script></head><body></body></html>"#;
87        let f = JsonLdGate.run(&site(html), &AuditOptions::default());
88        assert!(f.is_empty(), "got {f:?}");
89    }
90
91    #[test]
92    fn unparseable_jsonld_is_flagged() {
93        let html = r#"<html><head><script type="application/ld+json">{ not json }</script></head><body></body></html>"#;
94        let f = JsonLdGate.run(&site(html), &AuditOptions::default());
95        assert!(
96            f.iter().any(|x| matches!(x.severity, Severity::Error)),
97            "expected an error finding, got {f:?}"
98        );
99    }
100
101    #[test]
102    fn empty_site_produces_no_findings() {
103        let s = Site {
104            root: PathBuf::from("/nonexistent"),
105            html_files: Vec::new(),
106        };
107        let f = JsonLdGate.run(&s, &AuditOptions::default());
108        assert!(f.is_empty());
109    }
110
111    #[test]
112    fn metadata_methods_exposed() {
113        let g = JsonLdGate;
114        assert_eq!(g.name(), "jsonld");
115        assert!(g.explain().contains("JSON"));
116        let _copy: JsonLdGate = g;
117        let _clone = g;
118        assert!(format!("{g:?}").contains("JsonLdGate"));
119    }
120
121    #[test]
122    fn unreadable_html_file_is_skipped() {
123        let tmp = tempfile::tempdir().unwrap();
124        let bogus = tmp.path().join("ghost.html");
125        let s = Site {
126            root: tmp.path().to_path_buf(),
127            html_files: vec![bogus],
128        };
129        let f = JsonLdGate.run(&s, &AuditOptions::default());
130        std::mem::forget(tmp);
131        assert!(f.is_empty());
132    }
133
134    #[test]
135    fn missing_required_field_emits_jsonld_prefixed_code() {
136        let html = r#"<html><head><script type="application/ld+json">
137            {"@context":"https://schema.org","@type":"Article"}
138        </script></head><body></body></html>"#;
139        let f = JsonLdGate.run(&site(html), &AuditOptions::default());
140        assert!(!f.is_empty(), "expected at least one missing-field error");
141        for finding in &f {
142            assert_eq!(finding.severity, Severity::Error);
143            let code = finding.code.as_deref();
144            assert!(
145                code.is_some_and(|c| c.starts_with("JSONLD-")),
146                "code should be JSONLD-prefixed, got {code:?}"
147            );
148        }
149    }
150
151    #[test]
152    fn unknown_type_is_pass_through() {
153        let html = r#"<html><head><script type="application/ld+json">
154            {"@context":"https://schema.org","@type":"WidgetType","name":"x"}
155        </script></head><body></body></html>"#;
156        let f = JsonLdGate.run(&site(html), &AuditOptions::default());
157        assert!(f.is_empty(), "unknown types are pass-through; got {f:?}");
158    }
159
160    #[test]
161    fn multiple_html_files_aggregate_findings() {
162        let tmp = tempfile::tempdir().unwrap();
163        let a = tmp.path().join("a.html");
164        let b = tmp.path().join("b.html");
165        std::fs::write(
166            &a,
167            r#"<html><head><script type="application/ld+json">{ bad </script></head></html>"#,
168        )
169        .unwrap();
170        std::fs::write(
171            &b,
172            r#"<html><head><script type="application/ld+json">also bad</script></head></html>"#,
173        )
174        .unwrap();
175        let root = tmp.path().to_path_buf();
176        std::mem::forget(tmp);
177        let s = Site {
178            root,
179            html_files: vec![a, b],
180        };
181        let f = JsonLdGate.run(&s, &AuditOptions::default());
182        assert!(f.len() >= 2, "expected at least one per file, got {f:?}");
183    }
184
185    #[test]
186    fn no_jsonld_blocks_produces_no_findings() {
187        let html =
188            "<html><head><title>plain</title></head><body></body></html>";
189        let f = JsonLdGate.run(&site(html), &AuditOptions::default());
190        assert!(f.is_empty(), "got {f:?}");
191    }
192}