ssg/audit/gates/
jsonld.rs1use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
11use crate::seo::validate_jsonld;
12
13const NAME: &str = "jsonld";
14
15#[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}