1use super::helpers::{
7 extract_date_from_html, extract_description, extract_first_content_image,
8 extract_meta_author, extract_meta_date, extract_title,
9};
10use super::lang::resolve_page_lang;
11use crate::audit::gates::util::find_tag_end;
12use crate::error::SsgError;
13use crate::plugin::{Plugin, PluginContext};
14use crate::util::head_dom::inject_before_head_close;
15use std::path::Path;
16
17pub mod iso20022;
18pub use iso20022::{
19 from_frontmatter as iso20022_from_frontmatter,
20 log_first_use_pointer as iso20022_log_first_use, validate_bic,
21 validate_iban, validate_schema_org as validate_iso20022_schema_org,
22 warn_invalid_fields as iso20022_warn_invalid_fields, BankAccount,
23 DispatchError as Iso20022DispatchError, FinancialProduct,
24 FinancialTransaction, Iso20022Entity, MonetaryAmount, PaymentInstrument,
25 RegulatedFinancialInstitution, SchemaOrgError as Iso20022SchemaOrgError,
26 ValidationOutcome,
27};
28
29#[derive(Debug, Clone)]
31pub struct JsonLdConfig {
32 pub base_url: String,
34 pub org_name: String,
36 pub breadcrumbs: bool,
38}
39
40#[derive(Debug, Clone)]
49pub struct JsonLdPlugin {
50 pub(crate) config: JsonLdConfig,
51}
52
53impl JsonLdPlugin {
54 #[must_use]
71 pub const fn new(config: JsonLdConfig) -> Self {
72 Self { config }
73 }
74
75 #[must_use]
87 pub fn from_site(base_url: &str, site_name: &str) -> Self {
88 Self {
89 config: JsonLdConfig {
90 base_url: base_url.to_string(),
91 org_name: site_name.to_string(),
92 breadcrumbs: true,
93 },
94 }
95 }
96}
97
98fn build_article_jsonld(
104 title: &str,
105 description: &str,
106 page_url: &str,
107 org_name: &str,
108 author_name: &str,
109 image_url: &str,
110 date_published: Option<&String>,
111 date_modified: Option<&String>,
112 lang: &str,
113) -> serde_json::Value {
114 let mut article = serde_json::json!({
115 "@context": "https://schema.org",
116 "@type": "Article",
117 "headline": title,
118 "description": description,
119 "url": page_url,
120 "inLanguage": lang,
124 "mainEntityOfPage": {
125 "@type": "WebPage",
126 "@id": page_url
127 },
128 "publisher": {
129 "@type": "Organization",
130 "name": org_name
131 }
132 });
133
134 if !author_name.is_empty() {
135 article["author"] = serde_json::json!({
136 "@type": "Person",
137 "name": author_name
138 });
139 }
140
141 if !image_url.is_empty() {
142 article["image"] = serde_json::json!({
143 "@type": "ImageObject",
144 "url": image_url
145 });
146 }
147
148 if let Some(dp) = date_published {
149 article["datePublished"] = serde_json::json!(dp);
150 }
151 if let Some(dm) = date_modified {
152 article["dateModified"] = serde_json::json!(dm);
153 } else if let Some(dp) = date_published {
154 article["dateModified"] = serde_json::json!(dp);
155 }
156
157 article
158}
159
160fn build_webpage_jsonld(
166 title: &str,
167 description: &str,
168 page_url: &str,
169 author_name: &str,
170 image_url: &str,
171 date_published: Option<&String>,
172 lang: &str,
173) -> serde_json::Value {
174 let mut webpage = serde_json::json!({
175 "@context": "https://schema.org",
176 "@type": "WebPage",
177 "name": title,
178 "description": description,
179 "url": page_url,
180 "inLanguage": lang
184 });
185
186 if !author_name.is_empty() {
187 webpage["author"] = serde_json::json!({
188 "@type": "Person",
189 "name": author_name
190 });
191 }
192
193 if !image_url.is_empty() {
194 webpage["image"] = serde_json::json!({
195 "@type": "ImageObject",
196 "url": image_url
197 });
198 }
199
200 if let Some(dp) = date_published {
201 webpage["datePublished"] = serde_json::json!(dp);
202 }
203
204 webpage
205}
206
207fn build_breadcrumb_jsonld(
209 base: &str,
210 rel_path: &str,
211) -> Option<serde_json::Value> {
212 let parts: Vec<&str> = rel_path
213 .trim_matches('/')
214 .split('/')
215 .filter(|p| !p.is_empty() && *p != "index.html")
216 .collect();
217
218 if parts.is_empty() {
219 return None;
220 }
221
222 let mut items = vec![serde_json::json!({
223 "@type": "ListItem",
224 "position": 1,
225 "name": "Home",
226 "item": format!("{}/", base)
227 })];
228
229 let mut accumulated = String::new();
230 for (i, part) in parts.iter().enumerate() {
231 accumulated = format!("{accumulated}/{part}");
232 let name = part.trim_end_matches(".html").replace('-', " ");
233 items.push(serde_json::json!({
234 "@type": "ListItem",
235 "position": i + 2,
236 "name": name,
237 "item": format!("{}{}", base, accumulated)
238 }));
239 }
240
241 Some(serde_json::json!({
242 "@context": "https://schema.org",
243 "@type": "BreadcrumbList",
244 "itemListElement": items
245 }))
246}
247
248fn build_jsonld_scripts(
254 html: &str,
255 base: &str,
256 rel_path: &str,
257 org_name: &str,
258 breadcrumbs: bool,
259 lang: &str,
260) -> Vec<serde_json::Value> {
261 let title = extract_title(html);
262 let description = extract_description(html, 160);
263 let page_url = format!("{base}/{rel_path}");
264 let author_name = extract_meta_author(html);
265 let image_url = extract_first_content_image(html);
266 let date_published = extract_date_from_html(html, "datePublished")
267 .or_else(|| extract_meta_date(html));
268 let date_modified = extract_date_from_html(html, "dateModified");
269
270 let mut scripts = Vec::new();
271
272 if html.contains("<article") {
273 scripts.push(build_article_jsonld(
274 &title,
275 &description,
276 &page_url,
277 org_name,
278 &author_name,
279 &image_url,
280 date_published.as_ref(),
281 date_modified.as_ref(),
282 lang,
283 ));
284 } else {
285 scripts.push(build_webpage_jsonld(
286 &title,
287 &description,
288 &page_url,
289 &author_name,
290 &image_url,
291 date_published.as_ref(),
292 lang,
293 ));
294 }
295
296 if breadcrumbs {
297 if let Some(breadcrumb) = build_breadcrumb_jsonld(base, rel_path) {
298 scripts.push(breadcrumb);
299 }
300 }
301
302 scripts
303}
304
305impl Plugin for JsonLdPlugin {
306 fn name(&self) -> &'static str {
307 "json-ld"
308 }
309
310 fn has_transform(&self) -> bool {
311 true
312 }
313
314 fn transform_html(
315 &self,
316 html: &str,
317 path: &Path,
318 ctx: &PluginContext,
319 ) -> Result<String, SsgError> {
320 if html.contains("application/ld+json") {
321 return Ok(html.to_string());
322 }
323
324 let base = self.config.base_url.trim_end_matches('/');
325 let site_dir = &ctx.site_dir;
326
327 let rel_path = path
328 .strip_prefix(site_dir)
329 .unwrap_or(path)
330 .to_string_lossy()
331 .replace('\\', "/");
332
333 let lang = resolve_page_lang(html, path, ctx);
336
337 let mut scripts = build_jsonld_scripts(
338 html,
339 base,
340 &rel_path,
341 &self.config.org_name,
342 self.config.breadcrumbs,
343 &lang,
344 );
345
346 if let Some(extra) = build_iso20022_scripts(path, ctx, &rel_path) {
353 scripts.extend(extra);
354 }
355
356 let mut injection = String::new();
357 for script in &scripts {
358 let json = script_to_json(script, path)?;
359 injection.push_str(&format!(
360 "<script type=\"application/ld+json\">{json}</script>\n"
361 ));
362 }
363
364 Ok(inject_before_head_close(html, &injection))
365 }
366
367 fn after_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
368 Ok(())
369 }
370}
371
372fn script_to_json<T: serde::Serialize>(
378 script: &T,
379 path: &Path,
380) -> Result<String, SsgError> {
381 fail_point!("jsonld::script-to-json", |_| {
382 Err(SsgError::io(
383 std::io::Error::other("injected: jsonld::script-to-json"),
384 path,
385 ))
386 });
387 serde_json::to_string(script).map_err(|e| SsgError::io(e, path))
388}
389
390fn read_iso20022_block(
396 path: &Path,
397 ctx: &PluginContext,
398 rel_path: &str,
399) -> Option<serde_json::Value> {
400 super::lang::read_page_sidecar(path, ctx, rel_path)?
404 .get("iso20022")
405 .cloned()
406}
407
408fn build_iso20022_scripts(
413 path: &Path,
414 ctx: &PluginContext,
415 rel_path: &str,
416) -> Option<Vec<serde_json::Value>> {
417 let block = read_iso20022_block(path, ctx, rel_path)?;
418 iso20022_log_first_use();
419
420 let page_label = path.display().to_string();
421
422 let blocks: Vec<serde_json::Value> = if let Some(arr) = block.as_array() {
426 arr.clone()
427 } else {
428 vec![block]
429 };
430
431 let mut scripts = Vec::new();
432 for entry in blocks {
433 match iso20022_from_frontmatter(&entry) {
434 Ok(entity) => {
435 let _ = iso20022_warn_invalid_fields(&entity, &page_label);
436 scripts.push(entity.to_jsonld());
437 }
438 Err(e) => {
439 log::warn!(
440 "[json-ld/iso20022] {page_label}: skipping iso20022 \
441 block — {e}"
442 );
443 }
444 }
445 }
446
447 if scripts.is_empty() {
448 None
449 } else {
450 Some(scripts)
451 }
452}
453
454#[derive(Debug, Clone, PartialEq, Eq)]
460pub struct JsonLdValidationError {
461 pub schema_type: String,
463 pub field: String,
465 pub reason: String,
467}
468
469impl std::fmt::Display for JsonLdValidationError {
470 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471 write!(
472 f,
473 "[{}] missing/invalid `{}` — {}",
474 self.schema_type, self.field, self.reason
475 )
476 }
477}
478
479#[must_use]
509pub fn validate_jsonld(html: &str) -> Vec<JsonLdValidationError> {
510 let mut errors = Vec::new();
511
512 for block in extract_jsonld_blocks(html) {
513 match serde_json::from_str::<serde_json::Value>(&block) {
514 Ok(value) => validate_one(&value, &mut errors),
515 Err(parse_err) => {
516 errors.push(JsonLdValidationError {
517 schema_type: "Unparseable".to_string(),
518 field: "(payload)".to_string(),
519 reason: format!("invalid JSON: {parse_err}"),
520 });
521 }
522 }
523 }
524
525 errors
526}
527
528fn extract_jsonld_blocks(html: &str) -> Vec<String> {
542 let mut blocks = Vec::new();
543 let lower = html.to_lowercase();
544 let mut cursor = 0;
545
546 while let Some(rel_open) = lower[cursor..].find("<script") {
547 let abs_open = cursor + rel_open;
548 if let Some(comment_end) = comment_end_covering(&lower, abs_open) {
554 cursor = comment_end;
555 continue;
556 }
557 let tag_end = find_tag_end(&lower, abs_open);
562 let tag = &lower[abs_open..tag_end];
563 cursor = tag_end;
564
565 if !is_jsonld_script_tag(tag) {
566 continue;
567 }
568
569 let Some(close) = find_script_close_skipping_strings(&html[cursor..])
570 else {
571 break;
572 };
573 blocks.push(html[cursor..cursor + close].trim().to_string());
576 cursor += close + "</script>".len();
577 }
578
579 blocks
580}
581
582pub(crate) fn comment_end_covering(lower: &str, pos: usize) -> Option<usize> {
586 let open = lower[..pos].rfind("<!--")?;
587 match lower[open + 4..].find("-->") {
588 Some(rel) => {
589 let end = open + 4 + rel + 3;
590 (end > pos).then_some(end)
591 }
592 None => Some(lower.len()),
593 }
594}
595
596fn is_jsonld_script_tag(tag: &str) -> bool {
600 extract_attr(tag, "type")
601 .is_some_and(|v| v.eq_ignore_ascii_case("application/ld+json"))
602}
603
604fn extract_attr(tag: &str, name: &str) -> Option<String> {
608 let lower = tag.to_lowercase();
609 let needle = format!("{}=", name.to_lowercase());
610 let idx = lower.find(&needle)?;
611 let pre = lower.as_bytes().get(idx.wrapping_sub(1));
614 let boundary_ok = idx == 0
615 || matches!(pre, Some(b) if b.is_ascii_whitespace() || *b == b'<');
616 if !boundary_ok {
617 return None;
618 }
619 let rest = &tag[idx + needle.len()..];
620 let trimmed = rest.trim_start();
621 if let Some(s) = trimmed.strip_prefix('"') {
622 s.find('"').map(|e| s[..e].to_string())
623 } else if let Some(s) = trimmed.strip_prefix('\'') {
624 s.find('\'').map(|e| s[..e].to_string())
625 } else {
626 let end = trimmed
627 .find(|c: char| c.is_whitespace() || c == '>')
628 .unwrap_or(trimmed.len());
629 Some(trimmed[..end].to_string())
630 }
631}
632
633fn find_script_close_skipping_strings(body: &str) -> Option<usize> {
641 let bytes = body.as_bytes();
642 let needle = b"</script>";
643 let mut i = 0;
644 let mut in_string = false;
645 let mut escape = false;
646 while i < bytes.len() {
647 if in_string {
648 if escape {
649 escape = false;
650 } else if bytes[i] == b'\\' {
651 escape = true;
652 } else if bytes[i] == b'"' {
653 in_string = false;
654 }
655 i += 1;
656 continue;
657 }
658 if bytes[i] == b'"' {
659 in_string = true;
660 i += 1;
661 continue;
662 }
663 if i + needle.len() <= bytes.len()
665 && bytes[i..i + needle.len()].eq_ignore_ascii_case(needle)
666 {
667 return Some(i);
668 }
669 i += 1;
670 }
671 None
672}
673
674fn validate_one(
676 value: &serde_json::Value,
677 errors: &mut Vec<JsonLdValidationError>,
678) {
679 if let Some(graph) = value.get("@graph").and_then(|v| v.as_array()) {
681 for entry in graph {
682 validate_one(entry, errors);
683 }
684 return;
685 }
686
687 if let Some(array) = value.as_array() {
689 for entry in array {
690 validate_one(entry, errors);
691 }
692 return;
693 }
694
695 let schema_type = value
696 .get("@type")
697 .and_then(|v| v.as_str())
698 .unwrap_or("Unknown")
699 .to_string();
700
701 let required: &[&str] = match schema_type.as_str() {
708 "Article" | "NewsArticle" | "BlogPosting" => {
709 &["headline", "datePublished", "author", "image"]
712 }
713 "WebPage" => &["name"],
718 "BreadcrumbList" => &["itemListElement"],
719 "FAQPage" => &["mainEntity"],
720 "LocalBusiness" | "Restaurant" | "Store" => &["name", "address"],
721 "Organization" => &["name", "url"],
722 _ => return,
725 };
726
727 for field in required {
728 match value.get(*field) {
729 None => errors.push(JsonLdValidationError {
730 schema_type: schema_type.clone(),
731 field: (*field).to_string(),
732 reason: "field absent".to_string(),
733 }),
734 Some(serde_json::Value::Null) => {
735 errors.push(JsonLdValidationError {
736 schema_type: schema_type.clone(),
737 field: (*field).to_string(),
738 reason: "field is null".to_string(),
739 });
740 }
741 Some(serde_json::Value::String(s)) if s.trim().is_empty() => {
742 errors.push(JsonLdValidationError {
743 schema_type: schema_type.clone(),
744 field: (*field).to_string(),
745 reason: "field is empty string".to_string(),
746 });
747 }
748 Some(serde_json::Value::Array(a)) if a.is_empty() => {
749 errors.push(JsonLdValidationError {
750 schema_type: schema_type.clone(),
751 field: (*field).to_string(),
752 reason: "array is empty".to_string(),
753 });
754 }
755 _ => {}
756 }
757 }
758
759 if schema_type == "BreadcrumbList" {
762 if let Some(items) =
763 value.get("itemListElement").and_then(|v| v.as_array())
764 {
765 for (idx, item) in items.iter().enumerate() {
766 if item.get("position").is_none() {
767 errors.push(JsonLdValidationError {
768 schema_type: schema_type.clone(),
769 field: format!("itemListElement[{idx}].position"),
770 reason: "ListItem missing position".to_string(),
771 });
772 }
773 if item.get("name").is_none() && item.get("item").is_none() {
774 errors.push(JsonLdValidationError {
775 schema_type: schema_type.clone(),
776 field: format!("itemListElement[{idx}].name|item"),
777 reason: "ListItem missing name and item".to_string(),
778 });
779 }
780 }
781 }
782 }
783}
784
785#[cfg(test)]
786mod tests {
787 use super::*;
788 use std::path::Path;
789 use tempfile::tempdir;
790
791 fn ctx(site: &Path) -> PluginContext {
792 PluginContext::new(
793 Path::new("content"),
794 Path::new("build"),
795 site,
796 Path::new("templates"),
797 )
798 }
799
800 fn cfg() -> JsonLdConfig {
801 JsonLdConfig {
802 base_url: "https://example.com".to_string(),
803 org_name: "Example Org".to_string(),
804 breadcrumbs: true,
805 }
806 }
807
808 #[test]
809 fn name_is_stable() {
810 let p = JsonLdPlugin::new(cfg());
811 assert_eq!(p.name(), "json-ld");
812 }
813
814 #[test]
815 fn from_site_constructs_with_breadcrumbs_enabled() {
816 let p = JsonLdPlugin::from_site("https://x.example", "X");
817 assert_eq!(p.config.base_url, "https://x.example");
818 assert_eq!(p.config.org_name, "X");
819 assert!(p.config.breadcrumbs);
820 }
821
822 #[test]
825 fn article_includes_author_when_provided() {
826 let v = build_article_jsonld(
827 "T",
828 "D",
829 "https://x/p",
830 "Org",
831 "Jane",
832 "",
833 None,
834 None,
835 "en",
836 );
837 assert_eq!(v["author"]["name"], "Jane");
838 assert_eq!(v["author"]["@type"], "Person");
839 }
840
841 #[test]
842 fn article_omits_author_when_empty() {
843 let v = build_article_jsonld(
844 "T",
845 "D",
846 "https://x/p",
847 "Org",
848 "",
849 "",
850 None,
851 None,
852 "en",
853 );
854 assert!(v.get("author").is_none());
855 }
856
857 #[test]
858 fn article_includes_image_when_url_present() {
859 let v = build_article_jsonld(
860 "T",
861 "D",
862 "https://x/p",
863 "Org",
864 "",
865 "https://x/img.png",
866 None,
867 None,
868 "en",
869 );
870 assert_eq!(v["image"]["@type"], "ImageObject");
871 assert_eq!(v["image"]["url"], "https://x/img.png");
872 }
873
874 #[test]
875 fn article_uses_date_published_for_date_modified_fallback() {
876 let dp = "2025-01-01".to_string();
877 let v = build_article_jsonld(
878 "T",
879 "D",
880 "https://x/p",
881 "Org",
882 "",
883 "",
884 Some(&dp),
885 None,
886 "en",
887 );
888 assert_eq!(v["datePublished"], "2025-01-01");
889 assert_eq!(
890 v["dateModified"], "2025-01-01",
891 "missing dateModified should fall back to datePublished"
892 );
893 }
894
895 #[test]
896 fn article_keeps_distinct_date_modified() {
897 let dp = "2025-01-01".to_string();
898 let dm = "2025-06-15".to_string();
899 let v = build_article_jsonld(
900 "T",
901 "D",
902 "https://x/p",
903 "Org",
904 "",
905 "",
906 Some(&dp),
907 Some(&dm),
908 "en",
909 );
910 assert_eq!(v["datePublished"], "2025-01-01");
911 assert_eq!(v["dateModified"], "2025-06-15");
912 }
913
914 #[test]
915 fn article_emits_resolved_lang_verbatim() {
916 let v = build_article_jsonld(
919 "T",
920 "D",
921 "https://x/p",
922 "Org",
923 "",
924 "",
925 None,
926 None,
927 "hi",
928 );
929 assert_eq!(v["inLanguage"], "hi");
930 }
931
932 #[test]
935 fn webpage_includes_author_image_date_when_present() {
936 let dp = "2025-01-01".to_string();
937 let v = build_webpage_jsonld(
938 "T",
939 "D",
940 "https://x/p",
941 "Jane",
942 "https://x/i.png",
943 Some(&dp),
944 "fr",
945 );
946 assert_eq!(v["@type"], "WebPage");
947 assert_eq!(v["author"]["name"], "Jane");
948 assert_eq!(v["image"]["url"], "https://x/i.png");
949 assert_eq!(v["datePublished"], "2025-01-01");
950 assert_eq!(v["inLanguage"], "fr");
951 }
952
953 #[test]
954 fn webpage_omits_optional_fields_when_empty() {
955 let v =
956 build_webpage_jsonld("T", "D", "https://x/p", "", "", None, "en");
957 assert!(v.get("author").is_none());
958 assert!(v.get("image").is_none());
959 assert!(v.get("datePublished").is_none());
960 assert_eq!(v["inLanguage"], "en");
961 }
962
963 #[test]
966 fn breadcrumb_returns_none_for_root_path() {
967 assert!(build_breadcrumb_jsonld("https://x", "/").is_none());
969 assert!(build_breadcrumb_jsonld("https://x", "index.html").is_none());
970 }
971
972 #[test]
973 fn breadcrumb_builds_chain_for_nested_path() {
974 let v = build_breadcrumb_jsonld("https://x", "blog/my-post/index.html")
975 .expect("should produce breadcrumb for nested path");
976 assert_eq!(v["@type"], "BreadcrumbList");
977 let items = v["itemListElement"].as_array().unwrap();
978 assert_eq!(items.len(), 3); assert_eq!(items[0]["name"], "Home");
980 assert_eq!(items[1]["name"], "blog");
981 assert_eq!(items[2]["name"], "my post"); }
983
984 #[test]
985 fn breadcrumb_handles_html_extension_in_part_name() {
986 let v = build_breadcrumb_jsonld("https://x", "page.html").unwrap();
987 let items = v["itemListElement"].as_array().unwrap();
988 assert_eq!(items.len(), 2);
989 assert_eq!(items[1]["name"], "page");
990 }
991
992 #[test]
995 fn build_scripts_picks_article_when_article_tag_present() {
996 let html = r#"<html><head><title>Post</title></head>
997 <body><article>content</article></body></html>"#;
998 let scripts =
999 build_jsonld_scripts(html, "https://x", "p/", "Org", false, "en");
1000 assert_eq!(scripts[0]["@type"], "Article");
1001 }
1002
1003 #[test]
1004 fn build_scripts_picks_webpage_when_no_article_tag() {
1005 let html = "<html><head><title>P</title></head><body>x</body></html>";
1006 let scripts =
1007 build_jsonld_scripts(html, "https://x", "p/", "Org", false, "en");
1008 assert_eq!(scripts[0]["@type"], "WebPage");
1009 }
1010
1011 #[test]
1012 fn build_scripts_includes_breadcrumb_when_enabled() {
1013 let html = "<html><head><title>P</title></head><body>x</body></html>";
1014 let scripts = build_jsonld_scripts(
1015 html,
1016 "https://x",
1017 "blog/post/",
1018 "Org",
1019 true,
1020 "en",
1021 );
1022 assert!(
1023 scripts.iter().any(|s| s["@type"] == "BreadcrumbList"),
1024 "breadcrumb should be present when enabled and path nested"
1025 );
1026 }
1027
1028 #[test]
1029 fn build_scripts_skips_breadcrumb_when_disabled() {
1030 let html = "<html><head><title>P</title></head><body>x</body></html>";
1031 let scripts = build_jsonld_scripts(
1032 html,
1033 "https://x",
1034 "blog/post/",
1035 "Org",
1036 false,
1037 "en",
1038 );
1039 assert!(!scripts.iter().any(|s| s["@type"] == "BreadcrumbList"));
1040 }
1041
1042 #[test]
1045 fn after_compile_no_op_when_site_missing() {
1046 let dir = tempdir().unwrap();
1047 let nope = dir.path().join("nope");
1048 JsonLdPlugin::new(cfg()).after_compile(&ctx(&nope)).unwrap();
1049 }
1050
1051 #[test]
1052 fn transform_html_injects_jsonld() {
1053 let dir = tempdir().unwrap();
1054 let c = ctx(dir.path());
1055 let html = "<html><head><title>X</title></head><body>x</body></html>";
1056 let page_path = dir.path().join("index.html");
1057 let after = JsonLdPlugin::new(cfg())
1058 .transform_html(html, &page_path, &c)
1059 .unwrap();
1060 assert!(after.contains("application/ld+json"));
1061 assert!(after.contains("\"@type\":\"WebPage\""));
1062 }
1063
1064 #[test]
1065 fn transform_html_skips_existing_jsonld() {
1066 let dir = tempdir().unwrap();
1067 let c = ctx(dir.path());
1068 let html = r#"<html><head><script type="application/ld+json">{"@type":"X"}</script><title>X</title></head></html>"#;
1069 let page_path = dir.path().join("p.html");
1070 let after = JsonLdPlugin::new(cfg())
1071 .transform_html(html, &page_path, &c)
1072 .unwrap();
1073 assert_eq!(after.matches("application/ld+json").count(), 1);
1075 assert!(after.contains(r#"{"@type":"X"}"#));
1076 }
1077
1078 #[cfg(feature = "i18n")]
1079 #[test]
1080 fn transform_html_skips_without_head_tag() {
1081 let dir = tempdir().unwrap();
1082 let c = ctx(dir.path());
1083 let raw = "<!doctype html><html><body>only</body></html>";
1084 let page_path = dir.path().join("frag.html");
1085 let after = JsonLdPlugin::new(cfg())
1086 .transform_html(raw, &page_path, &c)
1087 .unwrap();
1088 assert_eq!(after, raw);
1089 }
1090
1091 #[cfg(feature = "i18n")]
1096 fn locale_ctx(
1097 dir: &Path,
1098 language: &str,
1099 locales: &[&str],
1100 ) -> PluginContext {
1101 let mut c = PluginContext::new(
1102 Path::new("content"),
1103 &dir.join("build"),
1104 &dir.join("site"),
1105 Path::new("templates"),
1106 );
1107 c.config = Some(crate::cmd::SsgConfig {
1108 language: language.to_string(),
1109 i18n: Some(crate::i18n::I18nConfig {
1110 default_locale: locales
1111 .first()
1112 .map_or_else(|| "en".to_string(), |l| (*l).to_string()),
1113 locales: locales.iter().map(|l| (*l).to_string()).collect(),
1114 url_prefix: Default::default(),
1115 }),
1116 ..crate::cmd::SsgConfig::default()
1117 });
1118 c
1119 }
1120
1121 fn injected_in_language(html: &str) -> String {
1123 let block = extract_jsonld_blocks(html)
1124 .into_iter()
1125 .next()
1126 .expect("page should carry an injected JSON-LD block");
1127 let v: serde_json::Value = serde_json::from_str(&block).unwrap();
1128 v["inLanguage"].as_str().unwrap_or_default().to_string()
1129 }
1130
1131 #[cfg(feature = "i18n")]
1132 #[test]
1133 fn in_language_is_path_driven_on_locale_pages() {
1134 let dir = tempdir().unwrap();
1137 let c = locale_ctx(dir.path(), "en-GB", &["en", "hi", "fr"]);
1138 let html = r#"<html lang="en-GB"><head><title>नमस्ते</title></head><body>x</body></html>"#;
1139 let page = dir.path().join("site/hi/2026-06-01-post/index.html");
1140 let out = JsonLdPlugin::new(cfg())
1141 .transform_html(html, &page, &c)
1142 .unwrap();
1143 assert_eq!(injected_in_language(&out), "hi");
1144 }
1145
1146 #[cfg(feature = "i18n")]
1147 #[test]
1148 fn in_language_is_frontmatter_driven_when_sidecar_declares_language() {
1149 let dir = tempdir().unwrap();
1150 let c = locale_ctx(dir.path(), "en-GB", &["en", "hi"]);
1151 let sidecar = dir.path().join("build/.meta/hi/post/index.meta.json");
1152 std::fs::create_dir_all(sidecar.parent().unwrap()).unwrap();
1153 std::fs::write(sidecar, r#"{"language":"fr"}"#).unwrap();
1154
1155 let html = r#"<html lang="en-GB"><head><title>T</title></head><body>x</body></html>"#;
1156 let page = dir.path().join("site/hi/post/index.html");
1157 let out = JsonLdPlugin::new(cfg())
1158 .transform_html(html, &page, &c)
1159 .unwrap();
1160 assert_eq!(
1161 injected_in_language(&out),
1162 "fr",
1163 "front-matter `language` outranks the locale path prefix"
1164 );
1165 }
1166
1167 #[cfg(feature = "i18n")]
1168 #[test]
1169 fn in_language_is_default_driven_on_default_locale_pages() {
1170 let dir = tempdir().unwrap();
1173 let c = locale_ctx(dir.path(), "en-GB", &["en"]);
1174 let html = r#"<html lang="en-GB"><head><title>T</title></head><body>x</body></html>"#;
1175 let page = dir.path().join("site/about/index.html");
1176 let out = JsonLdPlugin::new(cfg())
1177 .transform_html(html, &page, &c)
1178 .unwrap();
1179 assert_eq!(injected_in_language(&out), "en-GB");
1180 }
1181
1182 #[test]
1183 fn in_language_en_fallback_only_when_nothing_resolves() {
1184 let dir = tempdir().unwrap();
1187 let c = ctx(&dir.path().join("site"));
1188 let html = "<html><head><title>T</title></head><body>x</body></html>";
1189 let page = dir.path().join("site/index.html");
1190 let out = JsonLdPlugin::new(cfg())
1191 .transform_html(html, &page, &c)
1192 .unwrap();
1193 assert_eq!(injected_in_language(&out), "en");
1194 }
1195
1196 #[cfg(feature = "i18n")]
1197 #[test]
1198 fn in_language_validation_passes_on_locale_fixtures() {
1199 let dir = tempdir().unwrap();
1202 let c = locale_ctx(dir.path(), "en-GB", &["en", "fr", "hi"]);
1203 for (rel, want) in [
1204 ("en/page/index.html", "en"),
1205 ("fr/page/index.html", "fr"),
1206 ("hi/page/index.html", "hi"),
1207 ("page/index.html", "en-GB"),
1208 ] {
1209 let html = r#"<html lang="en-GB"><head><title>T</title></head><body>x</body></html>"#;
1210 let page = dir.path().join("site").join(rel);
1211 let out = JsonLdPlugin::new(cfg())
1212 .transform_html(html, &page, &c)
1213 .unwrap();
1214 assert_eq!(injected_in_language(&out), want, "page {rel}");
1215 assert!(
1216 validate_jsonld(&out).is_empty(),
1217 "page {rel} must emit zero JSON-LD validation findings"
1218 );
1219 }
1220 }
1221
1222 #[test]
1225 fn validate_extracts_block() {
1226 let html = r#"<html><head>
1227 <script type="application/ld+json">
1228 {"@context":"https://schema.org","@type":"WebPage",
1229 "name":"Hi","url":"https://x.test/","inLanguage":"en"}
1230 </script></head><body></body></html>"#;
1231 assert!(
1232 validate_jsonld(html).is_empty(),
1233 "{:?}",
1234 validate_jsonld(html)
1235 );
1236 }
1237
1238 #[test]
1239 fn validate_flags_missing_required_field_on_article() {
1240 let html = r#"<script type="application/ld+json">
1241 {"@context":"https://schema.org","@type":"Article",
1242 "headline":"H","datePublished":"2026-05-10","author":"A"}
1243 </script>"#;
1244 let errs = validate_jsonld(html);
1245 assert!(
1246 errs.iter()
1247 .any(|e| e.schema_type == "Article" && e.field == "image"),
1248 "expected Article.image violation, got {errs:?}"
1249 );
1250 }
1251
1252 #[test]
1253 fn validate_flags_empty_breadcrumb_list() {
1254 let html = r#"<script type="application/ld+json">
1255 {"@context":"https://schema.org","@type":"BreadcrumbList",
1256 "itemListElement":[]}
1257 </script>"#;
1258 let errs = validate_jsonld(html);
1259 assert!(
1260 errs.iter().any(|e| e.field == "itemListElement"),
1261 "expected itemListElement empty-array error, got {errs:?}"
1262 );
1263 }
1264
1265 #[test]
1266 fn validate_breadcrumb_listitem_missing_position() {
1267 let html = r#"<script type="application/ld+json">
1268 {"@type":"BreadcrumbList",
1269 "itemListElement":[{"name":"Home","item":"https://x/"}]}
1270 </script>"#;
1271 let errs = validate_jsonld(html);
1272 assert!(
1273 errs.iter()
1274 .any(|e| e.field == "itemListElement[0].position"),
1275 "expected position-missing error, got {errs:?}"
1276 );
1277 }
1278
1279 #[test]
1280 fn validate_unparseable_json() {
1281 let html = r#"<script type="application/ld+json">{not json}</script>"#;
1282 let errs = validate_jsonld(html);
1283 assert_eq!(errs.len(), 1);
1284 assert_eq!(errs[0].schema_type, "Unparseable");
1285 }
1286
1287 #[test]
1288 fn validate_descends_into_graph() {
1289 let html = r#"<script type="application/ld+json">
1292 {"@context":"https://schema.org","@graph":[
1293 {"@type":"Article","headline":"H"}
1294 ]}
1295 </script>"#;
1296 let errs = validate_jsonld(html);
1297 assert!(errs
1300 .iter()
1301 .any(|e| e.schema_type == "Article" && e.field == "datePublished"));
1302 assert!(errs
1303 .iter()
1304 .any(|e| e.schema_type == "Article" && e.field == "author"));
1305 assert!(errs
1306 .iter()
1307 .any(|e| e.schema_type == "Article" && e.field == "image"));
1308 }
1309
1310 #[test]
1311 fn validate_unknown_type_passes_through() {
1312 let html = r#"<script type="application/ld+json">
1313 {"@type":"CustomThing","foo":"bar"}
1314 </script>"#;
1315 assert!(
1316 validate_jsonld(html).is_empty(),
1317 "{:?}",
1318 validate_jsonld(html)
1319 );
1320 }
1321
1322 #[test]
1323 fn validate_handles_multiple_blocks() {
1324 let html = r#"
1325 <script type="application/ld+json">{"@type":"Organization","name":"O","url":"https://o/"}</script>
1326 <script type="application/ld+json">{"@type":"Article","headline":"H"}</script>
1327 "#;
1328 let errs = validate_jsonld(html);
1329 assert_eq!(
1331 errs.iter()
1332 .filter(|e| e.schema_type == "Organization")
1333 .count(),
1334 0
1335 );
1336 assert!(
1337 errs.iter().filter(|e| e.schema_type == "Article").count() >= 3
1338 );
1339 }
1340
1341 #[test]
1344 fn validate_skips_extra_qualified_type() {
1345 let html = r#"<script type="application/ld+json/extra">
1349 {"@type":"Article"}
1350 </script>"#;
1351 assert!(
1352 validate_jsonld(html).is_empty(),
1353 "non-JSON-LD type must not be validated"
1354 );
1355 }
1356
1357 #[test]
1358 fn validate_recognises_type_with_single_quotes() {
1359 let html = r#"<script type='application/ld+json'>
1360 {"@type":"Organization","name":"O","url":"https://o/"}
1361 </script>"#;
1362 assert!(
1363 validate_jsonld(html).is_empty(),
1364 "{:?}",
1365 validate_jsonld(html)
1366 );
1367 }
1368
1369 #[test]
1370 fn validate_recognises_type_after_other_attrs() {
1371 let html = r#"<script id="ld1" type="application/ld+json">
1372 {"@type":"Organization","name":"O","url":"https://o/"}
1373 </script>"#;
1374 assert!(
1375 validate_jsonld(html).is_empty(),
1376 "{:?}",
1377 validate_jsonld(html)
1378 );
1379 }
1380
1381 #[test]
1384 fn validate_handles_close_script_inside_json_string() {
1385 let html = r#"<script type="application/ld+json">
1389 {"@type":"Article",
1390 "headline":"H","datePublished":"2026-01-01",
1391 "author":"A","image":"https://x/i.png",
1392 "description":"this contains a </script> inside the string and is still valid JSON"}
1393 </script>"#;
1394 let errs = validate_jsonld(html);
1395 assert!(errs.is_empty(), "no errors expected, got {errs:?}");
1400 }
1401
1402 #[test]
1403 fn extract_attr_returns_none_when_attribute_absent() {
1404 assert_eq!(extract_attr("<script src=x>", "type"), None);
1405 }
1406
1407 #[test]
1408 fn extract_attr_handles_double_quoted_value() {
1409 assert_eq!(
1410 extract_attr(r#"<script type="application/ld+json">"#, "type"),
1411 Some("application/ld+json".to_string())
1412 );
1413 }
1414
1415 #[test]
1416 fn extract_attr_rejects_substring_match_in_other_attribute() {
1417 assert_eq!(extract_attr(r#"<script data-mytype="foo">"#, "type"), None);
1419 }
1420
1421 #[test]
1422 fn extract_attr_quoting_and_boundaries() {
1423 assert_eq!(
1424 extract_attr("<script type=\"foo\"", "type"),
1425 Some("foo".to_string())
1426 );
1427 assert_eq!(
1428 extract_attr("<script type='bar'", "type"),
1429 Some("bar".to_string())
1430 );
1431 assert_eq!(
1432 extract_attr("<script type=baz", "type"),
1433 Some("baz".to_string())
1434 );
1435 assert_eq!(extract_attr("<script type=\"foo", "type"), None);
1437 assert_eq!(extract_attr("<script type='bar", "type"), None);
1438 assert_eq!(extract_attr("<script subtype=\"foo\"", "type"), None);
1440 }
1441
1442 #[test]
1443 fn test_find_script_close_escaped_quotes() {
1444 let body = r#"{"msg":"escaped \" quote"}</script>"#;
1445 assert_eq!(find_script_close_skipping_strings(body), Some(26));
1446 }
1447
1448 #[test]
1451 fn validation_error_display_includes_all_fields() {
1452 let e = JsonLdValidationError {
1453 schema_type: "Article".to_string(),
1454 field: "headline".to_string(),
1455 reason: "field absent".to_string(),
1456 };
1457 let s = e.to_string();
1458 assert!(s.contains("[Article]"));
1459 assert!(s.contains("`headline`"));
1460 assert!(s.contains("field absent"));
1461 }
1462
1463 #[test]
1464 fn validation_error_partial_eq_covers_equal_and_unequal_tail_field() {
1465 let a = JsonLdValidationError {
1472 schema_type: "Article".to_string(),
1473 field: "headline".to_string(),
1474 reason: "field absent".to_string(),
1475 };
1476 let b = a.clone();
1477 let mut c = a.clone();
1478 c.reason = "field is null".to_string();
1479 assert_eq!(a, b);
1480 assert_ne!(a, c);
1481 }
1482
1483 #[test]
1484 fn extractor_ignores_unterminated_jsonld_script() {
1485 let html = r#"<script type="application/ld+json">{"@type":"WebPage""#;
1488 assert!(
1489 extract_jsonld_blocks(html).is_empty(),
1490 "{:?}",
1491 extract_jsonld_blocks(html)
1492 );
1493 }
1494
1495 #[test]
1496 fn find_html_tag_end_without_closing_bracket_returns_len() {
1497 let html = "<script type=\"application/ld+json\"";
1498 assert_eq!(find_tag_end(html, 0), html.len());
1499 }
1500
1501 #[test]
1504 fn validator_descends_into_top_level_array() {
1505 let html = r#"<script type="application/ld+json">
1506 [{"@type":"WebPage","name":"A"},{"@type":"WebPage"}]
1507 </script>"#;
1508 let errs = validate_jsonld(html);
1509 assert_eq!(errs.len(), 1, "only the second entry is invalid: {errs:?}");
1510 assert_eq!(errs[0].field, "name");
1511 }
1512
1513 #[test]
1514 fn validator_flags_faq_page_missing_main_entity() {
1515 let html = r#"<script type="application/ld+json">
1516 {"@type":"FAQPage"}
1517 </script>"#;
1518 let errs = validate_jsonld(html);
1519 assert!(errs.iter().any(|e| e.field == "mainEntity"), "{errs:?}");
1520 }
1521
1522 #[test]
1523 fn validator_checks_restaurant_and_store_literals() {
1524 let html = r#"<script type="application/ld+json">
1525 {"@type":"Restaurant","name":"R"}
1526 </script>
1527 <script type="application/ld+json">
1528 {"@type":"Store","address":"1 Main St"}
1529 </script>"#;
1530 let errs = validate_jsonld(html);
1531 assert!(
1532 errs.iter()
1533 .any(|e| e.schema_type == "Restaurant" && e.field == "address"),
1534 "{errs:?}"
1535 );
1536 assert!(
1537 errs.iter()
1538 .any(|e| e.schema_type == "Store" && e.field == "name"),
1539 "{errs:?}"
1540 );
1541 }
1542
1543 #[test]
1544 fn validator_flags_null_required_field() {
1545 let html = r#"<script type="application/ld+json">
1546 {"@type":"WebPage","name":null}
1547 </script>"#;
1548 let errs = validate_jsonld(html);
1549 assert!(errs.iter().any(|e| e.reason == "field is null"), "{errs:?}");
1550 }
1551
1552 #[test]
1553 fn validator_flags_whitespace_only_required_field() {
1554 let html = r#"<script type="application/ld+json">
1555 {"@type":"WebPage","name":" "}
1556 </script>"#;
1557 let errs = validate_jsonld(html);
1558 assert!(
1559 errs.iter().any(|e| e.reason == "field is empty string"),
1560 "{errs:?}"
1561 );
1562 }
1563
1564 #[test]
1565 fn validator_flags_list_item_missing_name_and_item() {
1566 let html = r#"<script type="application/ld+json">
1567 {"@type":"BreadcrumbList","itemListElement":[{"position":1}]}
1568 </script>"#;
1569 let errs = validate_jsonld(html);
1570 assert!(
1571 errs.iter()
1572 .any(|e| e.field == "itemListElement[0].name|item"),
1573 "{errs:?}"
1574 );
1575 }
1576
1577 #[test]
1578 fn validator_tolerates_non_array_item_list_element() {
1579 let html = r#"<script type="application/ld+json">
1582 {"@type":"BreadcrumbList","itemListElement":"oops"}
1583 </script>"#;
1584 let errs = validate_jsonld(html);
1585 assert!(
1586 !errs.iter().any(|e| e.field.starts_with("itemListElement[")),
1587 "{errs:?}"
1588 );
1589 }
1590
1591 fn write_sidecar(dir: &Path, rel: &str, json: &str) {
1595 let sidecar = dir
1596 .join("build")
1597 .join(".meta")
1598 .join(rel)
1599 .with_extension("meta.json");
1600 std::fs::create_dir_all(sidecar.parent().unwrap()).unwrap();
1601 std::fs::write(sidecar, json).unwrap();
1602 }
1603
1604 fn rooted_ctx(dir: &Path) -> PluginContext {
1607 PluginContext::new(
1608 Path::new("content"),
1609 &dir.join("build"),
1610 &dir.join("site"),
1611 Path::new("templates"),
1612 )
1613 }
1614
1615 #[test]
1616 fn iso20022_object_block_is_injected() {
1617 let dir = tempdir().unwrap();
1618 write_sidecar(
1619 dir.path(),
1620 "acct/index.html",
1621 r#"{"iso20022":{"type":"BankAccount","iban":"GB29NWBK60161331926819"}}"#,
1622 );
1623 let c = rooted_ctx(dir.path());
1624 let html = "<html><head><title>T</title></head><body>x</body></html>";
1625 let page = dir.path().join("site/acct/index.html");
1626 let out = JsonLdPlugin::new(cfg())
1627 .transform_html(html, &page, &c)
1628 .unwrap();
1629 assert!(
1630 out.contains(r#""@type":"BankAccount""#),
1631 "BankAccount block should be injected: {out}"
1632 );
1633 assert!(out.contains("GB29NWBK60161331926819"));
1634 }
1635
1636 #[test]
1637 fn iso20022_array_block_skips_invalid_entries() {
1638 let dir = tempdir().unwrap();
1642 write_sidecar(
1643 dir.path(),
1644 "mix/index.html",
1645 r#"{"iso20022":[
1646 {"type":"PaymentInstrument","instrument_type":"card"},
1647 {"no":"type"}
1648 ]}"#,
1649 );
1650 let c = rooted_ctx(dir.path());
1651 let html = "<html><head><title>T</title></head><body>x</body></html>";
1652 let page = dir.path().join("site/mix/index.html");
1653 let out = JsonLdPlugin::new(cfg())
1654 .transform_html(html, &page, &c)
1655 .unwrap();
1656 assert!(
1657 out.contains(r#""@type":"PaymentService""#),
1658 "valid array entry should be injected: {out}"
1659 );
1660 }
1661
1662 #[test]
1663 fn iso20022_all_invalid_entries_injects_nothing_extra() {
1664 let dir = tempdir().unwrap();
1667 write_sidecar(
1668 dir.path(),
1669 "bad/index.html",
1670 r#"{"iso20022":{"type":"NotAThing"}}"#,
1671 );
1672 let c = rooted_ctx(dir.path());
1673 let html = "<html><head><title>T</title></head><body>x</body></html>";
1674 let page = dir.path().join("site/bad/index.html");
1675 let out = JsonLdPlugin::new(cfg())
1676 .transform_html(html, &page, &c)
1677 .unwrap();
1678 assert!(
1679 !out.contains("iso20022"),
1680 "invalid block must contribute nothing: {out}"
1681 );
1682 }
1683
1684 #[cfg(feature = "i18n")]
1685 #[test]
1686 fn locale_ctx_with_empty_locale_set_defaults_to_en() {
1687 let dir = tempdir().unwrap();
1690 let c = locale_ctx(dir.path(), "en", &[]);
1691 let i18n = c.config.as_ref().unwrap().i18n.as_ref().unwrap();
1692 assert_eq!(i18n.default_locale, "en");
1693 assert!(i18n.locales.is_empty(), "{:?}", i18n.locales);
1694 }
1695
1696 #[test]
1697 fn jsonld_tag_quoted_in_a_comment_is_not_a_block() {
1698 let html = r#"<html><head>
1699<!-- Strict CSP. Inline <script type="application/ld+json"> JSON-LD blocks
1700 are allowed by hash only. -->
1701<meta http-equiv="Content-Security-Policy" content="script-src 'self'">
1702<script src="/theme-init.js"></script>
1703<script type="application/ld+json">{"@context":"https://schema.org","@type":"WebPage","name":"x","url":"https://e.com/"}</script>
1704</head><body></body></html>"#;
1705 let blocks = extract_jsonld_blocks(html);
1706 assert_eq!(
1707 blocks.len(),
1708 1,
1709 "the quoted tag inside the comment must not open a block"
1710 );
1711 assert!(blocks[0].starts_with("{\"@context\""));
1712 assert!(
1713 validate_jsonld(html)
1714 .iter()
1715 .all(|e| e.schema_type != "Unparseable"),
1716 "no phantom unparseable block"
1717 );
1718 assert_eq!(comment_end_covering("ab<!--cd", 6), Some(8));
1720 assert_eq!(comment_end_covering("<!-- a --><script", 12), None);
1721 }
1722
1723 #[test]
1724 fn script_to_json_maps_serde_failure_to_io_error() {
1725 let bad: std::collections::BTreeMap<(u8, u8), u8> =
1727 std::iter::once(((1, 2), 3)).collect();
1728 let err = script_to_json(&bad, Path::new("page.html"))
1729 .expect_err("non-string map keys must fail serialisation");
1730 assert!(
1731 matches!(err, SsgError::Io { ref path, .. } if path == Path::new("page.html"))
1732 );
1733 }
1734}
1735
1736#[cfg(all(test, feature = "test-fault-injection"))]
1737mod fault_tests {
1738 use super::*;
1739 use serial_test::serial;
1740 use std::path::Path;
1741 use tempfile::tempdir;
1742
1743 struct FailGuard<'a>(&'a str);
1745
1746 impl Drop for FailGuard<'_> {
1747 fn drop(&mut self) {
1748 let _ = fail::cfg(self.0, "off");
1749 }
1750 }
1751
1752 #[test]
1753 #[serial]
1754 fn transform_html_propagates_script_serialisation_failure() {
1755 let _guard = FailGuard("jsonld::script-to-json");
1756 fail::cfg("jsonld::script-to-json", "return").unwrap();
1757
1758 let dir = tempdir().unwrap();
1759 let c = PluginContext::new(
1760 Path::new("content"),
1761 Path::new("build"),
1762 dir.path(),
1763 Path::new("templates"),
1764 );
1765 let plugin = JsonLdPlugin::new(JsonLdConfig {
1766 base_url: "https://example.com".to_string(),
1767 org_name: "Org".to_string(),
1768 breadcrumbs: false,
1769 });
1770 let html = "<html><head><title>T</title></head><body>x</body></html>";
1771 let err = plugin
1772 .transform_html(html, Path::new("page.html"), &c)
1773 .expect_err("failpoint must abort script serialisation");
1774 assert!(
1775 err.to_string().contains("jsonld::script-to-json"),
1776 "injected error should surface: {err}"
1777 );
1778 }
1779}