use super::helpers::{
extract_date_from_html, extract_description, extract_first_content_image,
extract_meta_author, extract_meta_date, extract_title,
};
use super::lang::resolve_page_lang;
use crate::error::SsgError;
use crate::plugin::{Plugin, PluginContext};
use crate::util::head_dom::inject_before_head_close;
use std::path::Path;
pub mod iso20022;
pub use iso20022::{
from_frontmatter as iso20022_from_frontmatter,
log_first_use_pointer as iso20022_log_first_use, validate_bic,
validate_iban, validate_schema_org as validate_iso20022_schema_org,
warn_invalid_fields as iso20022_warn_invalid_fields, BankAccount,
DispatchError as Iso20022DispatchError, FinancialProduct,
FinancialTransaction, Iso20022Entity, MonetaryAmount, PaymentInstrument,
RegulatedFinancialInstitution, SchemaOrgError as Iso20022SchemaOrgError,
ValidationOutcome,
};
#[derive(Debug, Clone)]
pub struct JsonLdConfig {
pub base_url: String,
pub org_name: String,
pub breadcrumbs: bool,
}
#[derive(Debug, Clone)]
pub struct JsonLdPlugin {
pub(crate) config: JsonLdConfig,
}
impl JsonLdPlugin {
#[must_use]
pub const fn new(config: JsonLdConfig) -> Self {
Self { config }
}
#[must_use]
pub fn from_site(base_url: &str, site_name: &str) -> Self {
Self {
config: JsonLdConfig {
base_url: base_url.to_string(),
org_name: site_name.to_string(),
breadcrumbs: true,
},
}
}
}
fn build_article_jsonld(
title: &str,
description: &str,
page_url: &str,
org_name: &str,
author_name: &str,
image_url: &str,
date_published: Option<&String>,
date_modified: Option<&String>,
lang: &str,
) -> serde_json::Value {
let mut article = serde_json::json!({
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
"description": description,
"url": page_url,
"inLanguage": lang,
"mainEntityOfPage": {
"@type": "WebPage",
"@id": page_url
},
"publisher": {
"@type": "Organization",
"name": org_name
}
});
if !author_name.is_empty() {
article["author"] = serde_json::json!({
"@type": "Person",
"name": author_name
});
}
if !image_url.is_empty() {
article["image"] = serde_json::json!({
"@type": "ImageObject",
"url": image_url
});
}
if let Some(dp) = date_published {
article["datePublished"] = serde_json::json!(dp);
}
if let Some(dm) = date_modified {
article["dateModified"] = serde_json::json!(dm);
} else if let Some(dp) = date_published {
article["dateModified"] = serde_json::json!(dp);
}
article
}
fn build_webpage_jsonld(
title: &str,
description: &str,
page_url: &str,
author_name: &str,
image_url: &str,
date_published: Option<&String>,
lang: &str,
) -> serde_json::Value {
let mut webpage = serde_json::json!({
"@context": "https://schema.org",
"@type": "WebPage",
"name": title,
"description": description,
"url": page_url,
"inLanguage": lang
});
if !author_name.is_empty() {
webpage["author"] = serde_json::json!({
"@type": "Person",
"name": author_name
});
}
if !image_url.is_empty() {
webpage["image"] = serde_json::json!({
"@type": "ImageObject",
"url": image_url
});
}
if let Some(dp) = date_published {
webpage["datePublished"] = serde_json::json!(dp);
}
webpage
}
fn build_breadcrumb_jsonld(
base: &str,
rel_path: &str,
) -> Option<serde_json::Value> {
let parts: Vec<&str> = rel_path
.trim_matches('/')
.split('/')
.filter(|p| !p.is_empty() && *p != "index.html")
.collect();
if parts.is_empty() {
return None;
}
let mut items = vec![serde_json::json!({
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": format!("{}/", base)
})];
let mut accumulated = String::new();
for (i, part) in parts.iter().enumerate() {
accumulated = format!("{accumulated}/{part}");
let name = part.trim_end_matches(".html").replace('-', " ");
items.push(serde_json::json!({
"@type": "ListItem",
"position": i + 2,
"name": name,
"item": format!("{}{}", base, accumulated)
}));
}
Some(serde_json::json!({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": items
}))
}
fn build_jsonld_scripts(
html: &str,
base: &str,
rel_path: &str,
org_name: &str,
breadcrumbs: bool,
lang: &str,
) -> Vec<serde_json::Value> {
let title = extract_title(html);
let description = extract_description(html, 160);
let page_url = format!("{base}/{rel_path}");
let author_name = extract_meta_author(html);
let image_url = extract_first_content_image(html);
let date_published = extract_date_from_html(html, "datePublished")
.or_else(|| extract_meta_date(html));
let date_modified = extract_date_from_html(html, "dateModified");
let mut scripts = Vec::new();
if html.contains("<article") {
scripts.push(build_article_jsonld(
&title,
&description,
&page_url,
org_name,
&author_name,
&image_url,
date_published.as_ref(),
date_modified.as_ref(),
lang,
));
} else {
scripts.push(build_webpage_jsonld(
&title,
&description,
&page_url,
&author_name,
&image_url,
date_published.as_ref(),
lang,
));
}
if breadcrumbs {
if let Some(breadcrumb) = build_breadcrumb_jsonld(base, rel_path) {
scripts.push(breadcrumb);
}
}
scripts
}
impl Plugin for JsonLdPlugin {
fn name(&self) -> &'static str {
"json-ld"
}
fn has_transform(&self) -> bool {
true
}
fn transform_html(
&self,
html: &str,
path: &Path,
ctx: &PluginContext,
) -> Result<String, SsgError> {
if html.contains("application/ld+json") {
return Ok(html.to_string());
}
let base = self.config.base_url.trim_end_matches('/');
let site_dir = &ctx.site_dir;
let rel_path = path
.strip_prefix(site_dir)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
let lang = resolve_page_lang(html, path, ctx);
let mut scripts = build_jsonld_scripts(
html,
base,
&rel_path,
&self.config.org_name,
self.config.breadcrumbs,
&lang,
);
if let Some(extra) = build_iso20022_scripts(path, ctx, &rel_path) {
scripts.extend(extra);
}
let mut injection = String::new();
for script in &scripts {
let json = script_to_json(script, path)?;
injection.push_str(&format!(
"<script type=\"application/ld+json\">{json}</script>\n"
));
}
Ok(inject_before_head_close(html, &injection))
}
fn after_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
Ok(())
}
}
fn script_to_json<T: serde::Serialize>(
script: &T,
path: &Path,
) -> Result<String, SsgError> {
fail_point!("jsonld::script-to-json", |_| {
Err(SsgError::io(
std::io::Error::other("injected: jsonld::script-to-json"),
path,
))
});
serde_json::to_string(script).map_err(|e| SsgError::io(e, path))
}
fn read_iso20022_block(
path: &Path,
ctx: &PluginContext,
rel_path: &str,
) -> Option<serde_json::Value> {
super::lang::read_page_sidecar(path, ctx, rel_path)?
.get("iso20022")
.cloned()
}
fn build_iso20022_scripts(
path: &Path,
ctx: &PluginContext,
rel_path: &str,
) -> Option<Vec<serde_json::Value>> {
let block = read_iso20022_block(path, ctx, rel_path)?;
iso20022_log_first_use();
let page_label = path.display().to_string();
let blocks: Vec<serde_json::Value> = if let Some(arr) = block.as_array() {
arr.clone()
} else {
vec![block]
};
let mut scripts = Vec::new();
for entry in blocks {
match iso20022_from_frontmatter(&entry) {
Ok(entity) => {
let _ = iso20022_warn_invalid_fields(&entity, &page_label);
scripts.push(entity.to_jsonld());
}
Err(e) => {
log::warn!(
"[json-ld/iso20022] {page_label}: skipping iso20022 \
block — {e}"
);
}
}
}
if scripts.is_empty() {
None
} else {
Some(scripts)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JsonLdValidationError {
pub schema_type: String,
pub field: String,
pub reason: String,
}
impl std::fmt::Display for JsonLdValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"[{}] missing/invalid `{}` — {}",
self.schema_type, self.field, self.reason
)
}
}
#[must_use]
pub fn validate_jsonld(html: &str) -> Vec<JsonLdValidationError> {
let mut errors = Vec::new();
for block in extract_jsonld_blocks(html) {
match serde_json::from_str::<serde_json::Value>(&block) {
Ok(value) => validate_one(&value, &mut errors),
Err(parse_err) => {
errors.push(JsonLdValidationError {
schema_type: "Unparseable".to_string(),
field: "(payload)".to_string(),
reason: format!("invalid JSON: {parse_err}"),
});
}
}
}
errors
}
fn extract_jsonld_blocks(html: &str) -> Vec<String> {
let mut blocks = Vec::new();
let lower = html.to_lowercase();
let mut cursor = 0;
while let Some(rel_open) = lower[cursor..].find("<script") {
let abs_open = cursor + rel_open;
let tag_end = find_html_tag_end(&lower, abs_open);
let tag = &lower[abs_open..tag_end];
cursor = tag_end;
if !is_jsonld_script_tag(tag) {
continue;
}
let Some(close) = find_script_close_skipping_strings(&html[cursor..])
else {
break;
};
blocks.push(html[cursor..cursor + close].trim().to_string());
cursor += close + "</script>".len();
}
blocks
}
fn is_jsonld_script_tag(tag: &str) -> bool {
extract_attr(tag, "type")
.is_some_and(|v| v.eq_ignore_ascii_case("application/ld+json"))
}
fn extract_attr(tag: &str, name: &str) -> Option<String> {
let lower = tag.to_lowercase();
let needle = format!("{}=", name.to_lowercase());
let idx = lower.find(&needle)?;
let pre = lower.as_bytes().get(idx.wrapping_sub(1));
let boundary_ok = idx == 0
|| matches!(pre, Some(b) if b.is_ascii_whitespace() || *b == b'<');
if !boundary_ok {
return None;
}
let rest = &tag[idx + needle.len()..];
let trimmed = rest.trim_start();
if let Some(s) = trimmed.strip_prefix('"') {
s.find('"').map(|e| s[..e].to_string())
} else if let Some(s) = trimmed.strip_prefix('\'') {
s.find('\'').map(|e| s[..e].to_string())
} else {
let end = trimmed
.find(|c: char| c.is_whitespace() || c == '>')
.unwrap_or(trimmed.len());
Some(trimmed[..end].to_string())
}
}
fn find_script_close_skipping_strings(body: &str) -> Option<usize> {
let bytes = body.as_bytes();
let needle = b"</script>";
let mut i = 0;
let mut in_string = false;
let mut escape = false;
while i < bytes.len() {
if in_string {
if escape {
escape = false;
} else if bytes[i] == b'\\' {
escape = true;
} else if bytes[i] == b'"' {
in_string = false;
}
i += 1;
continue;
}
if bytes[i] == b'"' {
in_string = true;
i += 1;
continue;
}
if i + needle.len() <= bytes.len()
&& bytes[i..i + needle.len()].eq_ignore_ascii_case(needle)
{
return Some(i);
}
i += 1;
}
None
}
fn find_html_tag_end(html: &str, tag_start: usize) -> usize {
let bytes = html.as_bytes();
let mut i = tag_start;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
match quote {
Some(q) if b == q => quote = None,
Some(_) => {}
None => match b {
b'"' | b'\'' => quote = Some(b),
b'>' => return i + 1,
_ => {}
},
}
i += 1;
}
bytes.len()
}
fn validate_one(
value: &serde_json::Value,
errors: &mut Vec<JsonLdValidationError>,
) {
if let Some(graph) = value.get("@graph").and_then(|v| v.as_array()) {
for entry in graph {
validate_one(entry, errors);
}
return;
}
if let Some(array) = value.as_array() {
for entry in array {
validate_one(entry, errors);
}
return;
}
let schema_type = value
.get("@type")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let required: &[&str] = match schema_type.as_str() {
"Article" | "NewsArticle" | "BlogPosting" => {
&["headline", "datePublished", "author", "image"]
}
"WebPage" => &["name"],
"BreadcrumbList" => &["itemListElement"],
"FAQPage" => &["mainEntity"],
"LocalBusiness" | "Restaurant" | "Store" => &["name", "address"],
"Organization" => &["name", "url"],
_ => return,
};
for field in required {
match value.get(*field) {
None => errors.push(JsonLdValidationError {
schema_type: schema_type.clone(),
field: (*field).to_string(),
reason: "field absent".to_string(),
}),
Some(serde_json::Value::Null) => {
errors.push(JsonLdValidationError {
schema_type: schema_type.clone(),
field: (*field).to_string(),
reason: "field is null".to_string(),
});
}
Some(serde_json::Value::String(s)) if s.trim().is_empty() => {
errors.push(JsonLdValidationError {
schema_type: schema_type.clone(),
field: (*field).to_string(),
reason: "field is empty string".to_string(),
});
}
Some(serde_json::Value::Array(a)) if a.is_empty() => {
errors.push(JsonLdValidationError {
schema_type: schema_type.clone(),
field: (*field).to_string(),
reason: "array is empty".to_string(),
});
}
_ => {}
}
}
if schema_type == "BreadcrumbList" {
if let Some(items) =
value.get("itemListElement").and_then(|v| v.as_array())
{
for (idx, item) in items.iter().enumerate() {
if item.get("position").is_none() {
errors.push(JsonLdValidationError {
schema_type: schema_type.clone(),
field: format!("itemListElement[{idx}].position"),
reason: "ListItem missing position".to_string(),
});
}
if item.get("name").is_none() && item.get("item").is_none() {
errors.push(JsonLdValidationError {
schema_type: schema_type.clone(),
field: format!("itemListElement[{idx}].name|item"),
reason: "ListItem missing name and item".to_string(),
});
}
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::path::Path;
use tempfile::tempdir;
fn ctx(site: &Path) -> PluginContext {
PluginContext::new(
Path::new("content"),
Path::new("build"),
site,
Path::new("templates"),
)
}
fn cfg() -> JsonLdConfig {
JsonLdConfig {
base_url: "https://example.com".to_string(),
org_name: "Example Org".to_string(),
breadcrumbs: true,
}
}
#[test]
fn name_is_stable() {
let p = JsonLdPlugin::new(cfg());
assert_eq!(p.name(), "json-ld");
}
#[test]
fn from_site_constructs_with_breadcrumbs_enabled() {
let p = JsonLdPlugin::from_site("https://x.example", "X");
assert_eq!(p.config.base_url, "https://x.example");
assert_eq!(p.config.org_name, "X");
assert!(p.config.breadcrumbs);
}
#[test]
fn article_includes_author_when_provided() {
let v = build_article_jsonld(
"T",
"D",
"https://x/p",
"Org",
"Jane",
"",
None,
None,
"en",
);
assert_eq!(v["author"]["name"], "Jane");
assert_eq!(v["author"]["@type"], "Person");
}
#[test]
fn article_omits_author_when_empty() {
let v = build_article_jsonld(
"T",
"D",
"https://x/p",
"Org",
"",
"",
None,
None,
"en",
);
assert!(v.get("author").is_none());
}
#[test]
fn article_includes_image_when_url_present() {
let v = build_article_jsonld(
"T",
"D",
"https://x/p",
"Org",
"",
"https://x/img.png",
None,
None,
"en",
);
assert_eq!(v["image"]["@type"], "ImageObject");
assert_eq!(v["image"]["url"], "https://x/img.png");
}
#[test]
fn article_uses_date_published_for_date_modified_fallback() {
let dp = "2025-01-01".to_string();
let v = build_article_jsonld(
"T",
"D",
"https://x/p",
"Org",
"",
"",
Some(&dp),
None,
"en",
);
assert_eq!(v["datePublished"], "2025-01-01");
assert_eq!(
v["dateModified"], "2025-01-01",
"missing dateModified should fall back to datePublished"
);
}
#[test]
fn article_keeps_distinct_date_modified() {
let dp = "2025-01-01".to_string();
let dm = "2025-06-15".to_string();
let v = build_article_jsonld(
"T",
"D",
"https://x/p",
"Org",
"",
"",
Some(&dp),
Some(&dm),
"en",
);
assert_eq!(v["datePublished"], "2025-01-01");
assert_eq!(v["dateModified"], "2025-06-15");
}
#[test]
fn article_emits_resolved_lang_verbatim() {
let v = build_article_jsonld(
"T",
"D",
"https://x/p",
"Org",
"",
"",
None,
None,
"hi",
);
assert_eq!(v["inLanguage"], "hi");
}
#[test]
fn webpage_includes_author_image_date_when_present() {
let dp = "2025-01-01".to_string();
let v = build_webpage_jsonld(
"T",
"D",
"https://x/p",
"Jane",
"https://x/i.png",
Some(&dp),
"fr",
);
assert_eq!(v["@type"], "WebPage");
assert_eq!(v["author"]["name"], "Jane");
assert_eq!(v["image"]["url"], "https://x/i.png");
assert_eq!(v["datePublished"], "2025-01-01");
assert_eq!(v["inLanguage"], "fr");
}
#[test]
fn webpage_omits_optional_fields_when_empty() {
let v =
build_webpage_jsonld("T", "D", "https://x/p", "", "", None, "en");
assert!(v.get("author").is_none());
assert!(v.get("image").is_none());
assert!(v.get("datePublished").is_none());
assert_eq!(v["inLanguage"], "en");
}
#[test]
fn breadcrumb_returns_none_for_root_path() {
assert!(build_breadcrumb_jsonld("https://x", "/").is_none());
assert!(build_breadcrumb_jsonld("https://x", "index.html").is_none());
}
#[test]
fn breadcrumb_builds_chain_for_nested_path() {
let v = build_breadcrumb_jsonld("https://x", "blog/my-post/index.html")
.expect("should produce breadcrumb for nested path");
assert_eq!(v["@type"], "BreadcrumbList");
let items = v["itemListElement"].as_array().unwrap();
assert_eq!(items.len(), 3); assert_eq!(items[0]["name"], "Home");
assert_eq!(items[1]["name"], "blog");
assert_eq!(items[2]["name"], "my post"); }
#[test]
fn breadcrumb_handles_html_extension_in_part_name() {
let v = build_breadcrumb_jsonld("https://x", "page.html").unwrap();
let items = v["itemListElement"].as_array().unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[1]["name"], "page");
}
#[test]
fn build_scripts_picks_article_when_article_tag_present() {
let html = r#"<html><head><title>Post</title></head>
<body><article>content</article></body></html>"#;
let scripts =
build_jsonld_scripts(html, "https://x", "p/", "Org", false, "en");
assert_eq!(scripts[0]["@type"], "Article");
}
#[test]
fn build_scripts_picks_webpage_when_no_article_tag() {
let html = "<html><head><title>P</title></head><body>x</body></html>";
let scripts =
build_jsonld_scripts(html, "https://x", "p/", "Org", false, "en");
assert_eq!(scripts[0]["@type"], "WebPage");
}
#[test]
fn build_scripts_includes_breadcrumb_when_enabled() {
let html = "<html><head><title>P</title></head><body>x</body></html>";
let scripts = build_jsonld_scripts(
html,
"https://x",
"blog/post/",
"Org",
true,
"en",
);
assert!(
scripts.iter().any(|s| s["@type"] == "BreadcrumbList"),
"breadcrumb should be present when enabled and path nested"
);
}
#[test]
fn build_scripts_skips_breadcrumb_when_disabled() {
let html = "<html><head><title>P</title></head><body>x</body></html>";
let scripts = build_jsonld_scripts(
html,
"https://x",
"blog/post/",
"Org",
false,
"en",
);
assert!(!scripts.iter().any(|s| s["@type"] == "BreadcrumbList"));
}
#[test]
fn after_compile_no_op_when_site_missing() {
let dir = tempdir().unwrap();
let nope = dir.path().join("nope");
JsonLdPlugin::new(cfg()).after_compile(&ctx(&nope)).unwrap();
}
#[test]
fn transform_html_injects_jsonld() {
let dir = tempdir().unwrap();
let c = ctx(dir.path());
let html = "<html><head><title>X</title></head><body>x</body></html>";
let page_path = dir.path().join("index.html");
let after = JsonLdPlugin::new(cfg())
.transform_html(html, &page_path, &c)
.unwrap();
assert!(after.contains("application/ld+json"));
assert!(after.contains("\"@type\":\"WebPage\""));
}
#[test]
fn transform_html_skips_existing_jsonld() {
let dir = tempdir().unwrap();
let c = ctx(dir.path());
let html = r#"<html><head><script type="application/ld+json">{"@type":"X"}</script><title>X</title></head></html>"#;
let page_path = dir.path().join("p.html");
let after = JsonLdPlugin::new(cfg())
.transform_html(html, &page_path, &c)
.unwrap();
assert_eq!(after.matches("application/ld+json").count(), 1);
assert!(after.contains(r#"{"@type":"X"}"#));
}
#[test]
fn transform_html_skips_without_head_tag() {
let dir = tempdir().unwrap();
let c = ctx(dir.path());
let raw = "<!doctype html><html><body>only</body></html>";
let page_path = dir.path().join("frag.html");
let after = JsonLdPlugin::new(cfg())
.transform_html(raw, &page_path, &c)
.unwrap();
assert_eq!(after, raw);
}
fn locale_ctx(
dir: &Path,
language: &str,
locales: &[&str],
) -> PluginContext {
let mut c = PluginContext::new(
Path::new("content"),
&dir.join("build"),
&dir.join("site"),
Path::new("templates"),
);
c.config = Some(crate::cmd::SsgConfig {
language: language.to_string(),
i18n: Some(crate::i18n::I18nConfig {
default_locale: locales
.first()
.map_or_else(|| "en".to_string(), |l| (*l).to_string()),
locales: locales.iter().map(|l| (*l).to_string()).collect(),
url_prefix: Default::default(),
}),
..crate::cmd::SsgConfig::default()
});
c
}
fn injected_in_language(html: &str) -> String {
let block = extract_jsonld_blocks(html)
.into_iter()
.next()
.expect("page should carry an injected JSON-LD block");
let v: serde_json::Value = serde_json::from_str(&block).unwrap();
v["inLanguage"].as_str().unwrap_or_default().to_string()
}
#[test]
fn in_language_is_path_driven_on_locale_pages() {
let dir = tempdir().unwrap();
let c = locale_ctx(dir.path(), "en-GB", &["en", "hi", "fr"]);
let html = r#"<html lang="en-GB"><head><title>नमस्ते</title></head><body>x</body></html>"#;
let page = dir.path().join("site/hi/2026-06-01-post/index.html");
let out = JsonLdPlugin::new(cfg())
.transform_html(html, &page, &c)
.unwrap();
assert_eq!(injected_in_language(&out), "hi");
}
#[test]
fn in_language_is_frontmatter_driven_when_sidecar_declares_language() {
let dir = tempdir().unwrap();
let c = locale_ctx(dir.path(), "en-GB", &["en", "hi"]);
let sidecar = dir.path().join("build/.meta/hi/post/index.meta.json");
std::fs::create_dir_all(sidecar.parent().unwrap()).unwrap();
std::fs::write(sidecar, r#"{"language":"fr"}"#).unwrap();
let html = r#"<html lang="en-GB"><head><title>T</title></head><body>x</body></html>"#;
let page = dir.path().join("site/hi/post/index.html");
let out = JsonLdPlugin::new(cfg())
.transform_html(html, &page, &c)
.unwrap();
assert_eq!(
injected_in_language(&out),
"fr",
"front-matter `language` outranks the locale path prefix"
);
}
#[test]
fn in_language_is_default_driven_on_default_locale_pages() {
let dir = tempdir().unwrap();
let c = locale_ctx(dir.path(), "en-GB", &["en"]);
let html = r#"<html lang="en-GB"><head><title>T</title></head><body>x</body></html>"#;
let page = dir.path().join("site/about/index.html");
let out = JsonLdPlugin::new(cfg())
.transform_html(html, &page, &c)
.unwrap();
assert_eq!(injected_in_language(&out), "en-GB");
}
#[test]
fn in_language_en_fallback_only_when_nothing_resolves() {
let dir = tempdir().unwrap();
let c = ctx(&dir.path().join("site"));
let html = "<html><head><title>T</title></head><body>x</body></html>";
let page = dir.path().join("site/index.html");
let out = JsonLdPlugin::new(cfg())
.transform_html(html, &page, &c)
.unwrap();
assert_eq!(injected_in_language(&out), "en");
}
#[test]
fn in_language_validation_passes_on_locale_fixtures() {
let dir = tempdir().unwrap();
let c = locale_ctx(dir.path(), "en-GB", &["en", "fr", "hi"]);
for (rel, want) in [
("en/page/index.html", "en"),
("fr/page/index.html", "fr"),
("hi/page/index.html", "hi"),
("page/index.html", "en-GB"),
] {
let html = r#"<html lang="en-GB"><head><title>T</title></head><body>x</body></html>"#;
let page = dir.path().join("site").join(rel);
let out = JsonLdPlugin::new(cfg())
.transform_html(html, &page, &c)
.unwrap();
assert_eq!(injected_in_language(&out), want, "page {rel}");
assert!(
validate_jsonld(&out).is_empty(),
"page {rel} must emit zero JSON-LD validation findings"
);
}
}
#[test]
fn validate_extracts_block() {
let html = r#"<html><head>
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"WebPage",
"name":"Hi","url":"https://x.test/","inLanguage":"en"}
</script></head><body></body></html>"#;
assert!(validate_jsonld(html).is_empty());
}
#[test]
fn validate_flags_missing_required_field_on_article() {
let html = r#"<script type="application/ld+json">
{"@context":"https://schema.org","@type":"Article",
"headline":"H","datePublished":"2026-05-10","author":"A"}
</script>"#;
let errs = validate_jsonld(html);
assert!(
errs.iter()
.any(|e| e.schema_type == "Article" && e.field == "image"),
"expected Article.image violation, got {errs:?}"
);
}
#[test]
fn validate_flags_empty_breadcrumb_list() {
let html = r#"<script type="application/ld+json">
{"@context":"https://schema.org","@type":"BreadcrumbList",
"itemListElement":[]}
</script>"#;
let errs = validate_jsonld(html);
assert!(
errs.iter().any(|e| e.field == "itemListElement"),
"expected itemListElement empty-array error, got {errs:?}"
);
}
#[test]
fn validate_breadcrumb_listitem_missing_position() {
let html = r#"<script type="application/ld+json">
{"@type":"BreadcrumbList",
"itemListElement":[{"name":"Home","item":"https://x/"}]}
</script>"#;
let errs = validate_jsonld(html);
assert!(
errs.iter()
.any(|e| e.field == "itemListElement[0].position"),
"expected position-missing error, got {errs:?}"
);
}
#[test]
fn validate_unparseable_json() {
let html = r#"<script type="application/ld+json">{not json}</script>"#;
let errs = validate_jsonld(html);
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].schema_type, "Unparseable");
}
#[test]
fn validate_descends_into_graph() {
let html = r#"<script type="application/ld+json">
{"@context":"https://schema.org","@graph":[
{"@type":"Article","headline":"H"}
]}
</script>"#;
let errs = validate_jsonld(html);
assert!(errs
.iter()
.any(|e| e.schema_type == "Article" && e.field == "datePublished"));
assert!(errs
.iter()
.any(|e| e.schema_type == "Article" && e.field == "author"));
assert!(errs
.iter()
.any(|e| e.schema_type == "Article" && e.field == "image"));
}
#[test]
fn validate_unknown_type_passes_through() {
let html = r#"<script type="application/ld+json">
{"@type":"CustomThing","foo":"bar"}
</script>"#;
assert!(validate_jsonld(html).is_empty());
}
#[test]
fn validate_handles_multiple_blocks() {
let html = r#"
<script type="application/ld+json">{"@type":"Organization","name":"O","url":"https://o/"}</script>
<script type="application/ld+json">{"@type":"Article","headline":"H"}</script>
"#;
let errs = validate_jsonld(html);
assert_eq!(
errs.iter()
.filter(|e| e.schema_type == "Organization")
.count(),
0
);
assert!(
errs.iter().filter(|e| e.schema_type == "Article").count() >= 3
);
}
#[test]
fn validate_skips_extra_qualified_type() {
let html = r#"<script type="application/ld+json/extra">
{"@type":"Article"}
</script>"#;
assert!(
validate_jsonld(html).is_empty(),
"non-JSON-LD type must not be validated"
);
}
#[test]
fn validate_recognises_type_with_single_quotes() {
let html = r#"<script type='application/ld+json'>
{"@type":"Organization","name":"O","url":"https://o/"}
</script>"#;
assert!(validate_jsonld(html).is_empty());
}
#[test]
fn validate_recognises_type_after_other_attrs() {
let html = r#"<script id="ld1" type="application/ld+json">
{"@type":"Organization","name":"O","url":"https://o/"}
</script>"#;
assert!(validate_jsonld(html).is_empty());
}
#[test]
fn validate_handles_close_script_inside_json_string() {
let html = r#"<script type="application/ld+json">
{"@type":"Article",
"headline":"H","datePublished":"2026-01-01",
"author":"A","image":"https://x/i.png",
"description":"this contains a </script> inside the string and is still valid JSON"}
</script>"#;
let errs = validate_jsonld(html);
assert!(errs.is_empty(), "no errors expected, got {errs:?}");
}
#[test]
fn extract_attr_returns_none_when_attribute_absent() {
assert_eq!(extract_attr("<script src=x>", "type"), None);
}
#[test]
fn extract_attr_handles_double_quoted_value() {
assert_eq!(
extract_attr(r#"<script type="application/ld+json">"#, "type"),
Some("application/ld+json".to_string())
);
}
#[test]
fn extract_attr_rejects_substring_match_in_other_attribute() {
assert_eq!(extract_attr(r#"<script data-mytype="foo">"#, "type"), None);
}
#[test]
fn extract_attr_quoting_and_boundaries() {
assert_eq!(
extract_attr("<script type=\"foo\"", "type"),
Some("foo".to_string())
);
assert_eq!(
extract_attr("<script type='bar'", "type"),
Some("bar".to_string())
);
assert_eq!(
extract_attr("<script type=baz", "type"),
Some("baz".to_string())
);
assert_eq!(extract_attr("<script type=\"foo", "type"), None);
assert_eq!(extract_attr("<script type='bar", "type"), None);
assert_eq!(extract_attr("<script subtype=\"foo\"", "type"), None);
}
#[test]
fn test_find_script_close_escaped_quotes() {
let body = r#"{"msg":"escaped \" quote"}</script>"#;
assert_eq!(find_script_close_skipping_strings(body), Some(26));
}
#[test]
fn validation_error_display_includes_all_fields() {
let e = JsonLdValidationError {
schema_type: "Article".to_string(),
field: "headline".to_string(),
reason: "field absent".to_string(),
};
let s = e.to_string();
assert!(s.contains("[Article]"));
assert!(s.contains("`headline`"));
assert!(s.contains("field absent"));
}
#[test]
fn validation_error_partial_eq_covers_equal_and_unequal_tail_field() {
let a = JsonLdValidationError {
schema_type: "Article".to_string(),
field: "headline".to_string(),
reason: "field absent".to_string(),
};
let b = a.clone();
let mut c = a.clone();
c.reason = "field is null".to_string();
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn extractor_ignores_unterminated_jsonld_script() {
let html = r#"<script type="application/ld+json">{"@type":"WebPage""#;
assert!(extract_jsonld_blocks(html).is_empty());
}
#[test]
fn find_html_tag_end_without_closing_bracket_returns_len() {
let html = "<script type=\"application/ld+json\"";
assert_eq!(find_html_tag_end(html, 0), html.len());
}
#[test]
fn validator_descends_into_top_level_array() {
let html = r#"<script type="application/ld+json">
[{"@type":"WebPage","name":"A"},{"@type":"WebPage"}]
</script>"#;
let errs = validate_jsonld(html);
assert_eq!(errs.len(), 1, "only the second entry is invalid: {errs:?}");
assert_eq!(errs[0].field, "name");
}
#[test]
fn validator_flags_faq_page_missing_main_entity() {
let html = r#"<script type="application/ld+json">
{"@type":"FAQPage"}
</script>"#;
let errs = validate_jsonld(html);
assert!(errs.iter().any(|e| e.field == "mainEntity"), "{errs:?}");
}
#[test]
fn validator_checks_restaurant_and_store_literals() {
let html = r#"<script type="application/ld+json">
{"@type":"Restaurant","name":"R"}
</script>
<script type="application/ld+json">
{"@type":"Store","address":"1 Main St"}
</script>"#;
let errs = validate_jsonld(html);
assert!(
errs.iter()
.any(|e| e.schema_type == "Restaurant" && e.field == "address"),
"{errs:?}"
);
assert!(
errs.iter()
.any(|e| e.schema_type == "Store" && e.field == "name"),
"{errs:?}"
);
}
#[test]
fn validator_flags_null_required_field() {
let html = r#"<script type="application/ld+json">
{"@type":"WebPage","name":null}
</script>"#;
let errs = validate_jsonld(html);
assert!(errs.iter().any(|e| e.reason == "field is null"), "{errs:?}");
}
#[test]
fn validator_flags_whitespace_only_required_field() {
let html = r#"<script type="application/ld+json">
{"@type":"WebPage","name":" "}
</script>"#;
let errs = validate_jsonld(html);
assert!(
errs.iter().any(|e| e.reason == "field is empty string"),
"{errs:?}"
);
}
#[test]
fn validator_flags_list_item_missing_name_and_item() {
let html = r#"<script type="application/ld+json">
{"@type":"BreadcrumbList","itemListElement":[{"position":1}]}
</script>"#;
let errs = validate_jsonld(html);
assert!(
errs.iter()
.any(|e| e.field == "itemListElement[0].name|item"),
"{errs:?}"
);
}
#[test]
fn validator_tolerates_non_array_item_list_element() {
let html = r#"<script type="application/ld+json">
{"@type":"BreadcrumbList","itemListElement":"oops"}
</script>"#;
let errs = validate_jsonld(html);
assert!(
!errs.iter().any(|e| e.field.starts_with("itemListElement[")),
"{errs:?}"
);
}
fn write_sidecar(dir: &Path, rel: &str, json: &str) {
let sidecar = dir
.join("build")
.join(".meta")
.join(rel)
.with_extension("meta.json");
std::fs::create_dir_all(sidecar.parent().unwrap()).unwrap();
std::fs::write(sidecar, json).unwrap();
}
fn rooted_ctx(dir: &Path) -> PluginContext {
PluginContext::new(
Path::new("content"),
&dir.join("build"),
&dir.join("site"),
Path::new("templates"),
)
}
#[test]
fn iso20022_object_block_is_injected() {
let dir = tempdir().unwrap();
write_sidecar(
dir.path(),
"acct/index.html",
r#"{"iso20022":{"type":"BankAccount","iban":"GB29NWBK60161331926819"}}"#,
);
let c = rooted_ctx(dir.path());
let html = "<html><head><title>T</title></head><body>x</body></html>";
let page = dir.path().join("site/acct/index.html");
let out = JsonLdPlugin::new(cfg())
.transform_html(html, &page, &c)
.unwrap();
assert!(
out.contains(r#""@type":"BankAccount""#),
"BankAccount block should be injected: {out}"
);
assert!(out.contains("GB29NWBK60161331926819"));
}
#[test]
fn iso20022_array_block_skips_invalid_entries() {
let dir = tempdir().unwrap();
write_sidecar(
dir.path(),
"mix/index.html",
r#"{"iso20022":[
{"type":"PaymentInstrument","instrument_type":"card"},
{"no":"type"}
]}"#,
);
let c = rooted_ctx(dir.path());
let html = "<html><head><title>T</title></head><body>x</body></html>";
let page = dir.path().join("site/mix/index.html");
let out = JsonLdPlugin::new(cfg())
.transform_html(html, &page, &c)
.unwrap();
assert!(
out.contains(r#""@type":"PaymentService""#),
"valid array entry should be injected: {out}"
);
}
#[test]
fn iso20022_all_invalid_entries_injects_nothing_extra() {
let dir = tempdir().unwrap();
write_sidecar(
dir.path(),
"bad/index.html",
r#"{"iso20022":{"type":"NotAThing"}}"#,
);
let c = rooted_ctx(dir.path());
let html = "<html><head><title>T</title></head><body>x</body></html>";
let page = dir.path().join("site/bad/index.html");
let out = JsonLdPlugin::new(cfg())
.transform_html(html, &page, &c)
.unwrap();
assert!(
!out.contains("iso20022"),
"invalid block must contribute nothing: {out}"
);
}
#[test]
fn locale_ctx_with_empty_locale_set_defaults_to_en() {
let dir = tempdir().unwrap();
let c = locale_ctx(dir.path(), "en", &[]);
let i18n = c.config.as_ref().unwrap().i18n.as_ref().unwrap();
assert_eq!(i18n.default_locale, "en");
assert!(i18n.locales.is_empty());
}
#[test]
fn script_to_json_maps_serde_failure_to_io_error() {
let bad: std::collections::BTreeMap<(u8, u8), u8> =
std::iter::once(((1, 2), 3)).collect();
let err = script_to_json(&bad, Path::new("page.html"))
.expect_err("non-string map keys must fail serialisation");
assert!(
matches!(err, SsgError::Io { ref path, .. } if path == Path::new("page.html"))
);
}
}
#[cfg(all(test, feature = "test-fault-injection"))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod fault_tests {
use super::*;
use serial_test::serial;
use std::path::Path;
use tempfile::tempdir;
struct FailGuard<'a>(&'a str);
impl Drop for FailGuard<'_> {
fn drop(&mut self) {
let _ = fail::cfg(self.0, "off");
}
}
#[test]
#[serial]
fn transform_html_propagates_script_serialisation_failure() {
let _guard = FailGuard("jsonld::script-to-json");
fail::cfg("jsonld::script-to-json", "return").unwrap();
let dir = tempdir().unwrap();
let c = PluginContext::new(
Path::new("content"),
Path::new("build"),
dir.path(),
Path::new("templates"),
);
let plugin = JsonLdPlugin::new(JsonLdConfig {
base_url: "https://example.com".to_string(),
org_name: "Org".to_string(),
breadcrumbs: false,
});
let html = "<html><head><title>T</title></head><body>x</body></html>";
let err = plugin
.transform_html(html, Path::new("page.html"), &c)
.expect_err("failpoint must abort script serialisation");
assert!(
err.to_string().contains("jsonld::script-to-json"),
"injected error should surface: {err}"
);
}
}