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 let tag_end = find_tag_end(&lower, abs_open);
553 let tag = &lower[abs_open..tag_end];
554 cursor = tag_end;
555
556 if !is_jsonld_script_tag(tag) {
557 continue;
558 }
559
560 let Some(close) = find_script_close_skipping_strings(&html[cursor..])
561 else {
562 break;
563 };
564 blocks.push(html[cursor..cursor + close].trim().to_string());
567 cursor += close + "</script>".len();
568 }
569
570 blocks
571}
572
573fn is_jsonld_script_tag(tag: &str) -> bool {
577 extract_attr(tag, "type")
578 .is_some_and(|v| v.eq_ignore_ascii_case("application/ld+json"))
579}
580
581fn extract_attr(tag: &str, name: &str) -> Option<String> {
585 let lower = tag.to_lowercase();
586 let needle = format!("{}=", name.to_lowercase());
587 let idx = lower.find(&needle)?;
588 let pre = lower.as_bytes().get(idx.wrapping_sub(1));
591 let boundary_ok = idx == 0
592 || matches!(pre, Some(b) if b.is_ascii_whitespace() || *b == b'<');
593 if !boundary_ok {
594 return None;
595 }
596 let rest = &tag[idx + needle.len()..];
597 let trimmed = rest.trim_start();
598 if let Some(s) = trimmed.strip_prefix('"') {
599 s.find('"').map(|e| s[..e].to_string())
600 } else if let Some(s) = trimmed.strip_prefix('\'') {
601 s.find('\'').map(|e| s[..e].to_string())
602 } else {
603 let end = trimmed
604 .find(|c: char| c.is_whitespace() || c == '>')
605 .unwrap_or(trimmed.len());
606 Some(trimmed[..end].to_string())
607 }
608}
609
610fn find_script_close_skipping_strings(body: &str) -> Option<usize> {
618 let bytes = body.as_bytes();
619 let needle = b"</script>";
620 let mut i = 0;
621 let mut in_string = false;
622 let mut escape = false;
623 while i < bytes.len() {
624 if in_string {
625 if escape {
626 escape = false;
627 } else if bytes[i] == b'\\' {
628 escape = true;
629 } else if bytes[i] == b'"' {
630 in_string = false;
631 }
632 i += 1;
633 continue;
634 }
635 if bytes[i] == b'"' {
636 in_string = true;
637 i += 1;
638 continue;
639 }
640 if i + needle.len() <= bytes.len()
642 && bytes[i..i + needle.len()].eq_ignore_ascii_case(needle)
643 {
644 return Some(i);
645 }
646 i += 1;
647 }
648 None
649}
650
651fn validate_one(
653 value: &serde_json::Value,
654 errors: &mut Vec<JsonLdValidationError>,
655) {
656 if let Some(graph) = value.get("@graph").and_then(|v| v.as_array()) {
658 for entry in graph {
659 validate_one(entry, errors);
660 }
661 return;
662 }
663
664 if let Some(array) = value.as_array() {
666 for entry in array {
667 validate_one(entry, errors);
668 }
669 return;
670 }
671
672 let schema_type = value
673 .get("@type")
674 .and_then(|v| v.as_str())
675 .unwrap_or("Unknown")
676 .to_string();
677
678 let required: &[&str] = match schema_type.as_str() {
685 "Article" | "NewsArticle" | "BlogPosting" => {
686 &["headline", "datePublished", "author", "image"]
689 }
690 "WebPage" => &["name"],
695 "BreadcrumbList" => &["itemListElement"],
696 "FAQPage" => &["mainEntity"],
697 "LocalBusiness" | "Restaurant" | "Store" => &["name", "address"],
698 "Organization" => &["name", "url"],
699 _ => return,
702 };
703
704 for field in required {
705 match value.get(*field) {
706 None => errors.push(JsonLdValidationError {
707 schema_type: schema_type.clone(),
708 field: (*field).to_string(),
709 reason: "field absent".to_string(),
710 }),
711 Some(serde_json::Value::Null) => {
712 errors.push(JsonLdValidationError {
713 schema_type: schema_type.clone(),
714 field: (*field).to_string(),
715 reason: "field is null".to_string(),
716 });
717 }
718 Some(serde_json::Value::String(s)) if s.trim().is_empty() => {
719 errors.push(JsonLdValidationError {
720 schema_type: schema_type.clone(),
721 field: (*field).to_string(),
722 reason: "field is empty string".to_string(),
723 });
724 }
725 Some(serde_json::Value::Array(a)) if a.is_empty() => {
726 errors.push(JsonLdValidationError {
727 schema_type: schema_type.clone(),
728 field: (*field).to_string(),
729 reason: "array is empty".to_string(),
730 });
731 }
732 _ => {}
733 }
734 }
735
736 if schema_type == "BreadcrumbList" {
739 if let Some(items) =
740 value.get("itemListElement").and_then(|v| v.as_array())
741 {
742 for (idx, item) in items.iter().enumerate() {
743 if item.get("position").is_none() {
744 errors.push(JsonLdValidationError {
745 schema_type: schema_type.clone(),
746 field: format!("itemListElement[{idx}].position"),
747 reason: "ListItem missing position".to_string(),
748 });
749 }
750 if item.get("name").is_none() && item.get("item").is_none() {
751 errors.push(JsonLdValidationError {
752 schema_type: schema_type.clone(),
753 field: format!("itemListElement[{idx}].name|item"),
754 reason: "ListItem missing name and item".to_string(),
755 });
756 }
757 }
758 }
759 }
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765 use std::path::Path;
766 use tempfile::tempdir;
767
768 fn ctx(site: &Path) -> PluginContext {
769 PluginContext::new(
770 Path::new("content"),
771 Path::new("build"),
772 site,
773 Path::new("templates"),
774 )
775 }
776
777 fn cfg() -> JsonLdConfig {
778 JsonLdConfig {
779 base_url: "https://example.com".to_string(),
780 org_name: "Example Org".to_string(),
781 breadcrumbs: true,
782 }
783 }
784
785 #[test]
786 fn name_is_stable() {
787 let p = JsonLdPlugin::new(cfg());
788 assert_eq!(p.name(), "json-ld");
789 }
790
791 #[test]
792 fn from_site_constructs_with_breadcrumbs_enabled() {
793 let p = JsonLdPlugin::from_site("https://x.example", "X");
794 assert_eq!(p.config.base_url, "https://x.example");
795 assert_eq!(p.config.org_name, "X");
796 assert!(p.config.breadcrumbs);
797 }
798
799 #[test]
802 fn article_includes_author_when_provided() {
803 let v = build_article_jsonld(
804 "T",
805 "D",
806 "https://x/p",
807 "Org",
808 "Jane",
809 "",
810 None,
811 None,
812 "en",
813 );
814 assert_eq!(v["author"]["name"], "Jane");
815 assert_eq!(v["author"]["@type"], "Person");
816 }
817
818 #[test]
819 fn article_omits_author_when_empty() {
820 let v = build_article_jsonld(
821 "T",
822 "D",
823 "https://x/p",
824 "Org",
825 "",
826 "",
827 None,
828 None,
829 "en",
830 );
831 assert!(v.get("author").is_none());
832 }
833
834 #[test]
835 fn article_includes_image_when_url_present() {
836 let v = build_article_jsonld(
837 "T",
838 "D",
839 "https://x/p",
840 "Org",
841 "",
842 "https://x/img.png",
843 None,
844 None,
845 "en",
846 );
847 assert_eq!(v["image"]["@type"], "ImageObject");
848 assert_eq!(v["image"]["url"], "https://x/img.png");
849 }
850
851 #[test]
852 fn article_uses_date_published_for_date_modified_fallback() {
853 let dp = "2025-01-01".to_string();
854 let v = build_article_jsonld(
855 "T",
856 "D",
857 "https://x/p",
858 "Org",
859 "",
860 "",
861 Some(&dp),
862 None,
863 "en",
864 );
865 assert_eq!(v["datePublished"], "2025-01-01");
866 assert_eq!(
867 v["dateModified"], "2025-01-01",
868 "missing dateModified should fall back to datePublished"
869 );
870 }
871
872 #[test]
873 fn article_keeps_distinct_date_modified() {
874 let dp = "2025-01-01".to_string();
875 let dm = "2025-06-15".to_string();
876 let v = build_article_jsonld(
877 "T",
878 "D",
879 "https://x/p",
880 "Org",
881 "",
882 "",
883 Some(&dp),
884 Some(&dm),
885 "en",
886 );
887 assert_eq!(v["datePublished"], "2025-01-01");
888 assert_eq!(v["dateModified"], "2025-06-15");
889 }
890
891 #[test]
892 fn article_emits_resolved_lang_verbatim() {
893 let v = build_article_jsonld(
896 "T",
897 "D",
898 "https://x/p",
899 "Org",
900 "",
901 "",
902 None,
903 None,
904 "hi",
905 );
906 assert_eq!(v["inLanguage"], "hi");
907 }
908
909 #[test]
912 fn webpage_includes_author_image_date_when_present() {
913 let dp = "2025-01-01".to_string();
914 let v = build_webpage_jsonld(
915 "T",
916 "D",
917 "https://x/p",
918 "Jane",
919 "https://x/i.png",
920 Some(&dp),
921 "fr",
922 );
923 assert_eq!(v["@type"], "WebPage");
924 assert_eq!(v["author"]["name"], "Jane");
925 assert_eq!(v["image"]["url"], "https://x/i.png");
926 assert_eq!(v["datePublished"], "2025-01-01");
927 assert_eq!(v["inLanguage"], "fr");
928 }
929
930 #[test]
931 fn webpage_omits_optional_fields_when_empty() {
932 let v =
933 build_webpage_jsonld("T", "D", "https://x/p", "", "", None, "en");
934 assert!(v.get("author").is_none());
935 assert!(v.get("image").is_none());
936 assert!(v.get("datePublished").is_none());
937 assert_eq!(v["inLanguage"], "en");
938 }
939
940 #[test]
943 fn breadcrumb_returns_none_for_root_path() {
944 assert!(build_breadcrumb_jsonld("https://x", "/").is_none());
946 assert!(build_breadcrumb_jsonld("https://x", "index.html").is_none());
947 }
948
949 #[test]
950 fn breadcrumb_builds_chain_for_nested_path() {
951 let v = build_breadcrumb_jsonld("https://x", "blog/my-post/index.html")
952 .expect("should produce breadcrumb for nested path");
953 assert_eq!(v["@type"], "BreadcrumbList");
954 let items = v["itemListElement"].as_array().unwrap();
955 assert_eq!(items.len(), 3); assert_eq!(items[0]["name"], "Home");
957 assert_eq!(items[1]["name"], "blog");
958 assert_eq!(items[2]["name"], "my post"); }
960
961 #[test]
962 fn breadcrumb_handles_html_extension_in_part_name() {
963 let v = build_breadcrumb_jsonld("https://x", "page.html").unwrap();
964 let items = v["itemListElement"].as_array().unwrap();
965 assert_eq!(items.len(), 2);
966 assert_eq!(items[1]["name"], "page");
967 }
968
969 #[test]
972 fn build_scripts_picks_article_when_article_tag_present() {
973 let html = r#"<html><head><title>Post</title></head>
974 <body><article>content</article></body></html>"#;
975 let scripts =
976 build_jsonld_scripts(html, "https://x", "p/", "Org", false, "en");
977 assert_eq!(scripts[0]["@type"], "Article");
978 }
979
980 #[test]
981 fn build_scripts_picks_webpage_when_no_article_tag() {
982 let html = "<html><head><title>P</title></head><body>x</body></html>";
983 let scripts =
984 build_jsonld_scripts(html, "https://x", "p/", "Org", false, "en");
985 assert_eq!(scripts[0]["@type"], "WebPage");
986 }
987
988 #[test]
989 fn build_scripts_includes_breadcrumb_when_enabled() {
990 let html = "<html><head><title>P</title></head><body>x</body></html>";
991 let scripts = build_jsonld_scripts(
992 html,
993 "https://x",
994 "blog/post/",
995 "Org",
996 true,
997 "en",
998 );
999 assert!(
1000 scripts.iter().any(|s| s["@type"] == "BreadcrumbList"),
1001 "breadcrumb should be present when enabled and path nested"
1002 );
1003 }
1004
1005 #[test]
1006 fn build_scripts_skips_breadcrumb_when_disabled() {
1007 let html = "<html><head><title>P</title></head><body>x</body></html>";
1008 let scripts = build_jsonld_scripts(
1009 html,
1010 "https://x",
1011 "blog/post/",
1012 "Org",
1013 false,
1014 "en",
1015 );
1016 assert!(!scripts.iter().any(|s| s["@type"] == "BreadcrumbList"));
1017 }
1018
1019 #[test]
1022 fn after_compile_no_op_when_site_missing() {
1023 let dir = tempdir().unwrap();
1024 let nope = dir.path().join("nope");
1025 JsonLdPlugin::new(cfg()).after_compile(&ctx(&nope)).unwrap();
1026 }
1027
1028 #[test]
1029 fn transform_html_injects_jsonld() {
1030 let dir = tempdir().unwrap();
1031 let c = ctx(dir.path());
1032 let html = "<html><head><title>X</title></head><body>x</body></html>";
1033 let page_path = dir.path().join("index.html");
1034 let after = JsonLdPlugin::new(cfg())
1035 .transform_html(html, &page_path, &c)
1036 .unwrap();
1037 assert!(after.contains("application/ld+json"));
1038 assert!(after.contains("\"@type\":\"WebPage\""));
1039 }
1040
1041 #[test]
1042 fn transform_html_skips_existing_jsonld() {
1043 let dir = tempdir().unwrap();
1044 let c = ctx(dir.path());
1045 let html = r#"<html><head><script type="application/ld+json">{"@type":"X"}</script><title>X</title></head></html>"#;
1046 let page_path = dir.path().join("p.html");
1047 let after = JsonLdPlugin::new(cfg())
1048 .transform_html(html, &page_path, &c)
1049 .unwrap();
1050 assert_eq!(after.matches("application/ld+json").count(), 1);
1052 assert!(after.contains(r#"{"@type":"X"}"#));
1053 }
1054
1055 #[cfg(feature = "i18n")]
1056 #[test]
1057 fn transform_html_skips_without_head_tag() {
1058 let dir = tempdir().unwrap();
1059 let c = ctx(dir.path());
1060 let raw = "<!doctype html><html><body>only</body></html>";
1061 let page_path = dir.path().join("frag.html");
1062 let after = JsonLdPlugin::new(cfg())
1063 .transform_html(raw, &page_path, &c)
1064 .unwrap();
1065 assert_eq!(after, raw);
1066 }
1067
1068 #[cfg(feature = "i18n")]
1073 fn locale_ctx(
1074 dir: &Path,
1075 language: &str,
1076 locales: &[&str],
1077 ) -> PluginContext {
1078 let mut c = PluginContext::new(
1079 Path::new("content"),
1080 &dir.join("build"),
1081 &dir.join("site"),
1082 Path::new("templates"),
1083 );
1084 c.config = Some(crate::cmd::SsgConfig {
1085 language: language.to_string(),
1086 i18n: Some(crate::i18n::I18nConfig {
1087 default_locale: locales
1088 .first()
1089 .map_or_else(|| "en".to_string(), |l| (*l).to_string()),
1090 locales: locales.iter().map(|l| (*l).to_string()).collect(),
1091 url_prefix: Default::default(),
1092 }),
1093 ..crate::cmd::SsgConfig::default()
1094 });
1095 c
1096 }
1097
1098 fn injected_in_language(html: &str) -> String {
1100 let block = extract_jsonld_blocks(html)
1101 .into_iter()
1102 .next()
1103 .expect("page should carry an injected JSON-LD block");
1104 let v: serde_json::Value = serde_json::from_str(&block).unwrap();
1105 v["inLanguage"].as_str().unwrap_or_default().to_string()
1106 }
1107
1108 #[cfg(feature = "i18n")]
1109 #[test]
1110 fn in_language_is_path_driven_on_locale_pages() {
1111 let dir = tempdir().unwrap();
1114 let c = locale_ctx(dir.path(), "en-GB", &["en", "hi", "fr"]);
1115 let html = r#"<html lang="en-GB"><head><title>नमस्ते</title></head><body>x</body></html>"#;
1116 let page = dir.path().join("site/hi/2026-06-01-post/index.html");
1117 let out = JsonLdPlugin::new(cfg())
1118 .transform_html(html, &page, &c)
1119 .unwrap();
1120 assert_eq!(injected_in_language(&out), "hi");
1121 }
1122
1123 #[cfg(feature = "i18n")]
1124 #[test]
1125 fn in_language_is_frontmatter_driven_when_sidecar_declares_language() {
1126 let dir = tempdir().unwrap();
1127 let c = locale_ctx(dir.path(), "en-GB", &["en", "hi"]);
1128 let sidecar = dir.path().join("build/.meta/hi/post/index.meta.json");
1129 std::fs::create_dir_all(sidecar.parent().unwrap()).unwrap();
1130 std::fs::write(sidecar, r#"{"language":"fr"}"#).unwrap();
1131
1132 let html = r#"<html lang="en-GB"><head><title>T</title></head><body>x</body></html>"#;
1133 let page = dir.path().join("site/hi/post/index.html");
1134 let out = JsonLdPlugin::new(cfg())
1135 .transform_html(html, &page, &c)
1136 .unwrap();
1137 assert_eq!(
1138 injected_in_language(&out),
1139 "fr",
1140 "front-matter `language` outranks the locale path prefix"
1141 );
1142 }
1143
1144 #[cfg(feature = "i18n")]
1145 #[test]
1146 fn in_language_is_default_driven_on_default_locale_pages() {
1147 let dir = tempdir().unwrap();
1150 let c = locale_ctx(dir.path(), "en-GB", &["en"]);
1151 let html = r#"<html lang="en-GB"><head><title>T</title></head><body>x</body></html>"#;
1152 let page = dir.path().join("site/about/index.html");
1153 let out = JsonLdPlugin::new(cfg())
1154 .transform_html(html, &page, &c)
1155 .unwrap();
1156 assert_eq!(injected_in_language(&out), "en-GB");
1157 }
1158
1159 #[test]
1160 fn in_language_en_fallback_only_when_nothing_resolves() {
1161 let dir = tempdir().unwrap();
1164 let c = ctx(&dir.path().join("site"));
1165 let html = "<html><head><title>T</title></head><body>x</body></html>";
1166 let page = dir.path().join("site/index.html");
1167 let out = JsonLdPlugin::new(cfg())
1168 .transform_html(html, &page, &c)
1169 .unwrap();
1170 assert_eq!(injected_in_language(&out), "en");
1171 }
1172
1173 #[cfg(feature = "i18n")]
1174 #[test]
1175 fn in_language_validation_passes_on_locale_fixtures() {
1176 let dir = tempdir().unwrap();
1179 let c = locale_ctx(dir.path(), "en-GB", &["en", "fr", "hi"]);
1180 for (rel, want) in [
1181 ("en/page/index.html", "en"),
1182 ("fr/page/index.html", "fr"),
1183 ("hi/page/index.html", "hi"),
1184 ("page/index.html", "en-GB"),
1185 ] {
1186 let html = r#"<html lang="en-GB"><head><title>T</title></head><body>x</body></html>"#;
1187 let page = dir.path().join("site").join(rel);
1188 let out = JsonLdPlugin::new(cfg())
1189 .transform_html(html, &page, &c)
1190 .unwrap();
1191 assert_eq!(injected_in_language(&out), want, "page {rel}");
1192 assert!(
1193 validate_jsonld(&out).is_empty(),
1194 "page {rel} must emit zero JSON-LD validation findings"
1195 );
1196 }
1197 }
1198
1199 #[test]
1202 fn validate_extracts_block() {
1203 let html = r#"<html><head>
1204 <script type="application/ld+json">
1205 {"@context":"https://schema.org","@type":"WebPage",
1206 "name":"Hi","url":"https://x.test/","inLanguage":"en"}
1207 </script></head><body></body></html>"#;
1208 assert!(validate_jsonld(html).is_empty());
1209 }
1210
1211 #[test]
1212 fn validate_flags_missing_required_field_on_article() {
1213 let html = r#"<script type="application/ld+json">
1214 {"@context":"https://schema.org","@type":"Article",
1215 "headline":"H","datePublished":"2026-05-10","author":"A"}
1216 </script>"#;
1217 let errs = validate_jsonld(html);
1218 assert!(
1219 errs.iter()
1220 .any(|e| e.schema_type == "Article" && e.field == "image"),
1221 "expected Article.image violation, got {errs:?}"
1222 );
1223 }
1224
1225 #[test]
1226 fn validate_flags_empty_breadcrumb_list() {
1227 let html = r#"<script type="application/ld+json">
1228 {"@context":"https://schema.org","@type":"BreadcrumbList",
1229 "itemListElement":[]}
1230 </script>"#;
1231 let errs = validate_jsonld(html);
1232 assert!(
1233 errs.iter().any(|e| e.field == "itemListElement"),
1234 "expected itemListElement empty-array error, got {errs:?}"
1235 );
1236 }
1237
1238 #[test]
1239 fn validate_breadcrumb_listitem_missing_position() {
1240 let html = r#"<script type="application/ld+json">
1241 {"@type":"BreadcrumbList",
1242 "itemListElement":[{"name":"Home","item":"https://x/"}]}
1243 </script>"#;
1244 let errs = validate_jsonld(html);
1245 assert!(
1246 errs.iter()
1247 .any(|e| e.field == "itemListElement[0].position"),
1248 "expected position-missing error, got {errs:?}"
1249 );
1250 }
1251
1252 #[test]
1253 fn validate_unparseable_json() {
1254 let html = r#"<script type="application/ld+json">{not json}</script>"#;
1255 let errs = validate_jsonld(html);
1256 assert_eq!(errs.len(), 1);
1257 assert_eq!(errs[0].schema_type, "Unparseable");
1258 }
1259
1260 #[test]
1261 fn validate_descends_into_graph() {
1262 let html = r#"<script type="application/ld+json">
1265 {"@context":"https://schema.org","@graph":[
1266 {"@type":"Article","headline":"H"}
1267 ]}
1268 </script>"#;
1269 let errs = validate_jsonld(html);
1270 assert!(errs
1273 .iter()
1274 .any(|e| e.schema_type == "Article" && e.field == "datePublished"));
1275 assert!(errs
1276 .iter()
1277 .any(|e| e.schema_type == "Article" && e.field == "author"));
1278 assert!(errs
1279 .iter()
1280 .any(|e| e.schema_type == "Article" && e.field == "image"));
1281 }
1282
1283 #[test]
1284 fn validate_unknown_type_passes_through() {
1285 let html = r#"<script type="application/ld+json">
1286 {"@type":"CustomThing","foo":"bar"}
1287 </script>"#;
1288 assert!(validate_jsonld(html).is_empty());
1289 }
1290
1291 #[test]
1292 fn validate_handles_multiple_blocks() {
1293 let html = r#"
1294 <script type="application/ld+json">{"@type":"Organization","name":"O","url":"https://o/"}</script>
1295 <script type="application/ld+json">{"@type":"Article","headline":"H"}</script>
1296 "#;
1297 let errs = validate_jsonld(html);
1298 assert_eq!(
1300 errs.iter()
1301 .filter(|e| e.schema_type == "Organization")
1302 .count(),
1303 0
1304 );
1305 assert!(
1306 errs.iter().filter(|e| e.schema_type == "Article").count() >= 3
1307 );
1308 }
1309
1310 #[test]
1313 fn validate_skips_extra_qualified_type() {
1314 let html = r#"<script type="application/ld+json/extra">
1318 {"@type":"Article"}
1319 </script>"#;
1320 assert!(
1321 validate_jsonld(html).is_empty(),
1322 "non-JSON-LD type must not be validated"
1323 );
1324 }
1325
1326 #[test]
1327 fn validate_recognises_type_with_single_quotes() {
1328 let html = r#"<script type='application/ld+json'>
1329 {"@type":"Organization","name":"O","url":"https://o/"}
1330 </script>"#;
1331 assert!(validate_jsonld(html).is_empty());
1332 }
1333
1334 #[test]
1335 fn validate_recognises_type_after_other_attrs() {
1336 let html = r#"<script id="ld1" type="application/ld+json">
1337 {"@type":"Organization","name":"O","url":"https://o/"}
1338 </script>"#;
1339 assert!(validate_jsonld(html).is_empty());
1340 }
1341
1342 #[test]
1345 fn validate_handles_close_script_inside_json_string() {
1346 let html = r#"<script type="application/ld+json">
1350 {"@type":"Article",
1351 "headline":"H","datePublished":"2026-01-01",
1352 "author":"A","image":"https://x/i.png",
1353 "description":"this contains a </script> inside the string and is still valid JSON"}
1354 </script>"#;
1355 let errs = validate_jsonld(html);
1356 assert!(errs.is_empty(), "no errors expected, got {errs:?}");
1361 }
1362
1363 #[test]
1364 fn extract_attr_returns_none_when_attribute_absent() {
1365 assert_eq!(extract_attr("<script src=x>", "type"), None);
1366 }
1367
1368 #[test]
1369 fn extract_attr_handles_double_quoted_value() {
1370 assert_eq!(
1371 extract_attr(r#"<script type="application/ld+json">"#, "type"),
1372 Some("application/ld+json".to_string())
1373 );
1374 }
1375
1376 #[test]
1377 fn extract_attr_rejects_substring_match_in_other_attribute() {
1378 assert_eq!(extract_attr(r#"<script data-mytype="foo">"#, "type"), None);
1380 }
1381
1382 #[test]
1383 fn extract_attr_quoting_and_boundaries() {
1384 assert_eq!(
1385 extract_attr("<script type=\"foo\"", "type"),
1386 Some("foo".to_string())
1387 );
1388 assert_eq!(
1389 extract_attr("<script type='bar'", "type"),
1390 Some("bar".to_string())
1391 );
1392 assert_eq!(
1393 extract_attr("<script type=baz", "type"),
1394 Some("baz".to_string())
1395 );
1396 assert_eq!(extract_attr("<script type=\"foo", "type"), None);
1398 assert_eq!(extract_attr("<script type='bar", "type"), None);
1399 assert_eq!(extract_attr("<script subtype=\"foo\"", "type"), None);
1401 }
1402
1403 #[test]
1404 fn test_find_script_close_escaped_quotes() {
1405 let body = r#"{"msg":"escaped \" quote"}</script>"#;
1406 assert_eq!(find_script_close_skipping_strings(body), Some(26));
1407 }
1408
1409 #[test]
1412 fn validation_error_display_includes_all_fields() {
1413 let e = JsonLdValidationError {
1414 schema_type: "Article".to_string(),
1415 field: "headline".to_string(),
1416 reason: "field absent".to_string(),
1417 };
1418 let s = e.to_string();
1419 assert!(s.contains("[Article]"));
1420 assert!(s.contains("`headline`"));
1421 assert!(s.contains("field absent"));
1422 }
1423
1424 #[test]
1425 fn validation_error_partial_eq_covers_equal_and_unequal_tail_field() {
1426 let a = JsonLdValidationError {
1433 schema_type: "Article".to_string(),
1434 field: "headline".to_string(),
1435 reason: "field absent".to_string(),
1436 };
1437 let b = a.clone();
1438 let mut c = a.clone();
1439 c.reason = "field is null".to_string();
1440 assert_eq!(a, b);
1441 assert_ne!(a, c);
1442 }
1443
1444 #[test]
1445 fn extractor_ignores_unterminated_jsonld_script() {
1446 let html = r#"<script type="application/ld+json">{"@type":"WebPage""#;
1449 assert!(extract_jsonld_blocks(html).is_empty());
1450 }
1451
1452 #[test]
1453 fn find_html_tag_end_without_closing_bracket_returns_len() {
1454 let html = "<script type=\"application/ld+json\"";
1455 assert_eq!(find_tag_end(html, 0), html.len());
1456 }
1457
1458 #[test]
1461 fn validator_descends_into_top_level_array() {
1462 let html = r#"<script type="application/ld+json">
1463 [{"@type":"WebPage","name":"A"},{"@type":"WebPage"}]
1464 </script>"#;
1465 let errs = validate_jsonld(html);
1466 assert_eq!(errs.len(), 1, "only the second entry is invalid: {errs:?}");
1467 assert_eq!(errs[0].field, "name");
1468 }
1469
1470 #[test]
1471 fn validator_flags_faq_page_missing_main_entity() {
1472 let html = r#"<script type="application/ld+json">
1473 {"@type":"FAQPage"}
1474 </script>"#;
1475 let errs = validate_jsonld(html);
1476 assert!(errs.iter().any(|e| e.field == "mainEntity"), "{errs:?}");
1477 }
1478
1479 #[test]
1480 fn validator_checks_restaurant_and_store_literals() {
1481 let html = r#"<script type="application/ld+json">
1482 {"@type":"Restaurant","name":"R"}
1483 </script>
1484 <script type="application/ld+json">
1485 {"@type":"Store","address":"1 Main St"}
1486 </script>"#;
1487 let errs = validate_jsonld(html);
1488 assert!(
1489 errs.iter()
1490 .any(|e| e.schema_type == "Restaurant" && e.field == "address"),
1491 "{errs:?}"
1492 );
1493 assert!(
1494 errs.iter()
1495 .any(|e| e.schema_type == "Store" && e.field == "name"),
1496 "{errs:?}"
1497 );
1498 }
1499
1500 #[test]
1501 fn validator_flags_null_required_field() {
1502 let html = r#"<script type="application/ld+json">
1503 {"@type":"WebPage","name":null}
1504 </script>"#;
1505 let errs = validate_jsonld(html);
1506 assert!(errs.iter().any(|e| e.reason == "field is null"), "{errs:?}");
1507 }
1508
1509 #[test]
1510 fn validator_flags_whitespace_only_required_field() {
1511 let html = r#"<script type="application/ld+json">
1512 {"@type":"WebPage","name":" "}
1513 </script>"#;
1514 let errs = validate_jsonld(html);
1515 assert!(
1516 errs.iter().any(|e| e.reason == "field is empty string"),
1517 "{errs:?}"
1518 );
1519 }
1520
1521 #[test]
1522 fn validator_flags_list_item_missing_name_and_item() {
1523 let html = r#"<script type="application/ld+json">
1524 {"@type":"BreadcrumbList","itemListElement":[{"position":1}]}
1525 </script>"#;
1526 let errs = validate_jsonld(html);
1527 assert!(
1528 errs.iter()
1529 .any(|e| e.field == "itemListElement[0].name|item"),
1530 "{errs:?}"
1531 );
1532 }
1533
1534 #[test]
1535 fn validator_tolerates_non_array_item_list_element() {
1536 let html = r#"<script type="application/ld+json">
1539 {"@type":"BreadcrumbList","itemListElement":"oops"}
1540 </script>"#;
1541 let errs = validate_jsonld(html);
1542 assert!(
1543 !errs.iter().any(|e| e.field.starts_with("itemListElement[")),
1544 "{errs:?}"
1545 );
1546 }
1547
1548 fn write_sidecar(dir: &Path, rel: &str, json: &str) {
1552 let sidecar = dir
1553 .join("build")
1554 .join(".meta")
1555 .join(rel)
1556 .with_extension("meta.json");
1557 std::fs::create_dir_all(sidecar.parent().unwrap()).unwrap();
1558 std::fs::write(sidecar, json).unwrap();
1559 }
1560
1561 fn rooted_ctx(dir: &Path) -> PluginContext {
1564 PluginContext::new(
1565 Path::new("content"),
1566 &dir.join("build"),
1567 &dir.join("site"),
1568 Path::new("templates"),
1569 )
1570 }
1571
1572 #[test]
1573 fn iso20022_object_block_is_injected() {
1574 let dir = tempdir().unwrap();
1575 write_sidecar(
1576 dir.path(),
1577 "acct/index.html",
1578 r#"{"iso20022":{"type":"BankAccount","iban":"GB29NWBK60161331926819"}}"#,
1579 );
1580 let c = rooted_ctx(dir.path());
1581 let html = "<html><head><title>T</title></head><body>x</body></html>";
1582 let page = dir.path().join("site/acct/index.html");
1583 let out = JsonLdPlugin::new(cfg())
1584 .transform_html(html, &page, &c)
1585 .unwrap();
1586 assert!(
1587 out.contains(r#""@type":"BankAccount""#),
1588 "BankAccount block should be injected: {out}"
1589 );
1590 assert!(out.contains("GB29NWBK60161331926819"));
1591 }
1592
1593 #[test]
1594 fn iso20022_array_block_skips_invalid_entries() {
1595 let dir = tempdir().unwrap();
1599 write_sidecar(
1600 dir.path(),
1601 "mix/index.html",
1602 r#"{"iso20022":[
1603 {"type":"PaymentInstrument","instrument_type":"card"},
1604 {"no":"type"}
1605 ]}"#,
1606 );
1607 let c = rooted_ctx(dir.path());
1608 let html = "<html><head><title>T</title></head><body>x</body></html>";
1609 let page = dir.path().join("site/mix/index.html");
1610 let out = JsonLdPlugin::new(cfg())
1611 .transform_html(html, &page, &c)
1612 .unwrap();
1613 assert!(
1614 out.contains(r#""@type":"PaymentService""#),
1615 "valid array entry should be injected: {out}"
1616 );
1617 }
1618
1619 #[test]
1620 fn iso20022_all_invalid_entries_injects_nothing_extra() {
1621 let dir = tempdir().unwrap();
1624 write_sidecar(
1625 dir.path(),
1626 "bad/index.html",
1627 r#"{"iso20022":{"type":"NotAThing"}}"#,
1628 );
1629 let c = rooted_ctx(dir.path());
1630 let html = "<html><head><title>T</title></head><body>x</body></html>";
1631 let page = dir.path().join("site/bad/index.html");
1632 let out = JsonLdPlugin::new(cfg())
1633 .transform_html(html, &page, &c)
1634 .unwrap();
1635 assert!(
1636 !out.contains("iso20022"),
1637 "invalid block must contribute nothing: {out}"
1638 );
1639 }
1640
1641 #[cfg(feature = "i18n")]
1642 #[test]
1643 fn locale_ctx_with_empty_locale_set_defaults_to_en() {
1644 let dir = tempdir().unwrap();
1647 let c = locale_ctx(dir.path(), "en", &[]);
1648 let i18n = c.config.as_ref().unwrap().i18n.as_ref().unwrap();
1649 assert_eq!(i18n.default_locale, "en");
1650 assert!(i18n.locales.is_empty());
1651 }
1652
1653 #[test]
1654 fn script_to_json_maps_serde_failure_to_io_error() {
1655 let bad: std::collections::BTreeMap<(u8, u8), u8> =
1657 std::iter::once(((1, 2), 3)).collect();
1658 let err = script_to_json(&bad, Path::new("page.html"))
1659 .expect_err("non-string map keys must fail serialisation");
1660 assert!(
1661 matches!(err, SsgError::Io { ref path, .. } if path == Path::new("page.html"))
1662 );
1663 }
1664}
1665
1666#[cfg(all(test, feature = "test-fault-injection"))]
1667mod fault_tests {
1668 use super::*;
1669 use serial_test::serial;
1670 use std::path::Path;
1671 use tempfile::tempdir;
1672
1673 struct FailGuard<'a>(&'a str);
1675
1676 impl Drop for FailGuard<'_> {
1677 fn drop(&mut self) {
1678 let _ = fail::cfg(self.0, "off");
1679 }
1680 }
1681
1682 #[test]
1683 #[serial]
1684 fn transform_html_propagates_script_serialisation_failure() {
1685 let _guard = FailGuard("jsonld::script-to-json");
1686 fail::cfg("jsonld::script-to-json", "return").unwrap();
1687
1688 let dir = tempdir().unwrap();
1689 let c = PluginContext::new(
1690 Path::new("content"),
1691 Path::new("build"),
1692 dir.path(),
1693 Path::new("templates"),
1694 );
1695 let plugin = JsonLdPlugin::new(JsonLdConfig {
1696 base_url: "https://example.com".to_string(),
1697 org_name: "Org".to_string(),
1698 breadcrumbs: false,
1699 });
1700 let html = "<html><head><title>T</title></head><body>x</body></html>";
1701 let err = plugin
1702 .transform_html(html, Path::new("page.html"), &c)
1703 .expect_err("failpoint must abort script serialisation");
1704 assert!(
1705 err.to_string().contains("jsonld::script-to-json"),
1706 "injected error should surface: {err}"
1707 );
1708 }
1709}