Skip to main content

ssg/plugins/seo/jsonld/
mod.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! JSON-LD structured data injection plugin.
5
6use 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/// Configuration for the JSON-LD structured data plugin.
30#[derive(Debug, Clone)]
31pub struct JsonLdConfig {
32    /// Base URL of the site (for absolute URLs in JSON-LD).
33    pub base_url: String,
34    /// Organization name for Organization schema.
35    pub org_name: String,
36    /// Whether to generate `BreadcrumbList` for every page.
37    pub breadcrumbs: bool,
38}
39
40/// Injects JSON-LD structured data into HTML files.
41///
42/// Auto-detects schema.org types from page metadata:
43/// - Pages with `<article>` → `Article`
44/// - All other pages → `WebPage`
45/// - `BreadcrumbList` derived from URL path (opt-in)
46///
47/// Idempotent: skips files that already contain `application/ld+json`.
48#[derive(Debug, Clone)]
49pub struct JsonLdPlugin {
50    pub(crate) config: JsonLdConfig,
51}
52
53impl JsonLdPlugin {
54    /// Creates a new `JsonLdPlugin` with the given configuration.
55    ///
56    /// # Examples
57    ///
58    /// ```rust
59    /// use ssg::seo::{JsonLdConfig, JsonLdPlugin};
60    /// use ssg::plugin::Plugin;
61    ///
62    /// let cfg = JsonLdConfig {
63    ///     base_url: "https://example.com".into(),
64    ///     org_name: "Demo".into(),
65    ///     breadcrumbs: true,
66    /// };
67    /// let p = JsonLdPlugin::new(cfg);
68    /// assert_eq!(p.name(), "json-ld");
69    /// ```
70    #[must_use]
71    pub const fn new(config: JsonLdConfig) -> Self {
72        Self { config }
73    }
74
75    /// Creates a `JsonLdPlugin` from site config values.
76    ///
77    /// # Examples
78    ///
79    /// ```rust
80    /// use ssg::seo::JsonLdPlugin;
81    /// use ssg::plugin::Plugin;
82    ///
83    /// let p = JsonLdPlugin::from_site("https://example.com", "Demo");
84    /// assert_eq!(p.name(), "json-ld");
85    /// ```
86    #[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
98/// Builds an Article JSON-LD object from page metadata.
99///
100/// `lang` is the already-resolved page language from
101/// [`resolve_page_lang`] (spec A5, plan §2 1.5) — never empty, so no
102/// inline fallback is needed at this sink.
103fn 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        // spec A5: language comes from the single page-language
121        // resolver — the hard-coded "en" fallback that used to live
122        // here was the bug's signature (plan §2 1.5).
123        "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
160/// Builds a `WebPage` JSON-LD object from page metadata.
161///
162/// `lang` is the already-resolved page language from
163/// [`resolve_page_lang`] (spec A5, plan §2 1.5) — never empty, so no
164/// inline fallback is needed at this sink.
165fn 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        // spec A5: language comes from the single page-language
181        // resolver — the hard-coded "en" fallback that used to live
182        // here was the bug's signature (plan §2 1.5).
183        "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
207/// Builds a `BreadcrumbList` JSON-LD object from the URL path, if applicable.
208fn 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
248/// Builds all JSON-LD scripts for a single page.
249///
250/// `lang` is the canonical page language from [`resolve_page_lang`]
251/// (spec A5, plan §2 1.5), resolved once by the caller so every
252/// emitted block agrees.
253fn 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        // spec A5 (plan §2 1.5): resolve the page language once so
334        // every JSON-LD block agrees with the other language sinks.
335        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        // ── ISO 20022 / banking extension (opt-in via frontmatter) ──
347        //
348        // AC4: When no `iso20022` key is present in the frontmatter
349        // sidecar (or no sidecar exists at all), this branch contributes
350        // ZERO bytes to `scripts` — preserving the v0.0.43 emission
351        // byte-for-byte.
352        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
372/// Serialises one JSON-LD script blob, mapping any serialisation
373/// failure onto [`SsgError::Io`] keyed by the page path.
374///
375/// Generic over the serialisable type so the (structurally
376/// unreachable for `serde_json::Value`) error arm stays testable.
377fn 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
390/// Resolves the `.meta.json` sidecar path for a built HTML file and
391/// returns its `iso20022` block, if any. Returns `None` when there is
392/// no sidecar, when it does not parse, or when it carries no
393/// `iso20022` key — preserving AC4 (zero behavioural drift on pages
394/// that don't opt in).
395fn read_iso20022_block(
396    path: &Path,
397    ctx: &PluginContext,
398    rel_path: &str,
399) -> Option<serde_json::Value> {
400    // Sidecar lookup is shared with the page-language resolver
401    // (spec A5) — see `seo::lang::read_page_sidecar` for the
402    // three-location resolution order.
403    super::lang::read_page_sidecar(path, ctx, rel_path)?
404        .get("iso20022")
405        .cloned()
406}
407
408/// Builds ISO 20022 JSON-LD scripts for a page, given its sidecar
409/// frontmatter. Returns `None` (NOT `Some(vec![])`) when the page
410/// has not opted in — callers can extend the script list without
411/// allocation overhead.
412fn 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    // The block can be either a single object or an array of objects —
423    // sites with multiple transactions per page (statements, ledgers)
424    // benefit from the array form.
425    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// =====================================================================
455// JSON-LD validation (resolves #467)
456// =====================================================================
457
458/// A single validation failure against a JSON-LD block.
459#[derive(Debug, Clone, PartialEq, Eq)]
460pub struct JsonLdValidationError {
461    /// The schema.org `@type` of the block (or "Unknown" if absent).
462    pub schema_type: String,
463    /// Required field that was missing or had the wrong shape.
464    pub field: String,
465    /// Human-readable reason.
466    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/// Walks an HTML string, extracts every `<script type="application/ld+json">`
480/// block, parses it as JSON, and validates required fields per
481/// schema.org `@type`.
482///
483/// Supported types (with their required-field guards):
484///
485/// - **`Article`** — `headline`, `datePublished`, `author`, `image`
486/// - **`WebPage`** — `name` (Google rich-results requirement; `url`
487///   and `inLanguage` are Recommended only and not flagged here)
488/// - **`BreadcrumbList`** — `itemListElement` (non-empty array)
489/// - **`FAQPage`** — `mainEntity` (non-empty array of `Question`)
490/// - **`LocalBusiness`** — `name`, `address`
491/// - **`Organization`** — `name`, `url`
492///
493/// Returns the empty vector if every block parses and passes its
494/// required-field check. Unknown `@type` values are treated as
495/// pass-through (no required fields enforced) so user-extended
496/// schemas don't trigger false negatives.
497///
498/// # Examples
499///
500/// ```rust
501/// use ssg::seo::validate_jsonld;
502///
503/// let html = r#"<script type="application/ld+json">
504/// {"@type":"Article","headline":"x","datePublished":"2024","author":"y","image":"i"}
505/// </script>"#;
506/// assert!(validate_jsonld(html).is_empty());
507/// ```
508#[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
528/// Returns the inner JSON of every `<script type="application/ld+json">`
529/// block. Tolerant of attribute order and whitespace.
530///
531/// Resolves audit items #4 + #5:
532/// - `type` is parsed as a discrete attribute value rather than
533///   substring-matched, so `type="application/ld+json/extra"` no
534///   longer falsely qualifies.
535/// - The `</script>` close finder is JSON-string-aware: a literal
536///   `</script>` *inside* a JSON string value (e.g.
537///   `"description": "code: </script>"`) is correctly skipped over.
538///   The HTML5 spec actually forbids `</script>` inside script
539///   bodies even in strings — most authors escape as `<\/script>`
540///   — but our extractor handles either form gracefully.
541fn 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        // Use find_tag_end equivalent: advance past `>` while
549        // skipping any `>` characters that appear inside quoted
550        // attribute values. Without this, `<script type="text/x>y">`
551        // would close prematurely at the inner `>`.
552        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        // Use the original-case slice for the JSON payload —
565        // schema.org values are case-sensitive.
566        blocks.push(html[cursor..cursor + close].trim().to_string());
567        cursor += close + "</script>".len();
568    }
569
570    blocks
571}
572
573/// Returns `true` if the `<script ...>` tag declares
574/// `type="application/ld+json"` exactly (any quoting; no
575/// substring match).
576fn 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
581/// Extracts the value of an HTML attribute from an open-tag string.
582/// Tolerant of quoting and whitespace. Returns `None` if the
583/// attribute is absent or has no value.
584fn 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    // Make sure the match starts at a token boundary (preceding
589    // char is whitespace or `<` or the very start of `tag`).
590    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
610/// Returns the byte offset of `</script>` in `body` while ignoring
611/// occurrences that appear *inside* a JSON string literal.
612///
613/// The walker tracks two pieces of state: whether we're currently
614/// inside a `"..."` string, and whether the previous byte was the
615/// JSON escape character `\`. Scanning is done in bytes (UTF-8 is
616/// not relevant for the ASCII-only delimiters we care about).
617fn 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        // Case-insensitive check for `</script>`.
641        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
651/// Validates a single parsed JSON-LD value (object or array).
652fn validate_one(
653    value: &serde_json::Value,
654    errors: &mut Vec<JsonLdValidationError>,
655) {
656    // schema.org allows top-level @graph arrays; descend into them.
657    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    // Array at top level — validate each entry.
665    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    // Required-field sets aligned with Google's rich-results
679    // requirements (https://developers.google.com/search/docs/appearance/structured-data),
680    // not the broader schema.org vocabulary. schema.org marks many
681    // useful fields as `Recommended` rather than `Required` — this
682    // validator only fires on truly-missing fields the search
683    // engines actually penalise.
684    let required: &[&str] = match schema_type.as_str() {
685        "Article" | "NewsArticle" | "BlogPosting" => {
686            // Google requires headline + datePublished + author +
687            // image for Article rich results.
688            &["headline", "datePublished", "author", "image"]
689        }
690        // WebPage's only hard requirement is `name`. `url` and
691        // `inLanguage` are Recommended but not penalised when
692        // absent — auto-generated stub pages (taxonomy indexes,
693        // 404, offline) routinely omit them.
694        "WebPage" => &["name"],
695        "BreadcrumbList" => &["itemListElement"],
696        "FAQPage" => &["mainEntity"],
697        "LocalBusiness" | "Restaurant" | "Store" => &["name", "address"],
698        "Organization" => &["name", "url"],
699        // Unknown types: don't enforce required fields. Users may ship
700        // custom @types that are still valid schema.org extensions.
701        _ => 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    // BreadcrumbList: itemListElement entries should each be ListItem
737    // with a `position` and `name`. Catch the most common regression.
738    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    // ── build_article_jsonld ───────────────────────────────────
800
801    #[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        // spec A5: the inline "en" fallback is gone — the caller
894        // passes the resolver's output and this sink echoes it.
895        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    // ── build_webpage_jsonld ───────────────────────────────────
910
911    #[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    // ── build_breadcrumb_jsonld ────────────────────────────────
941
942    #[test]
943    fn breadcrumb_returns_none_for_root_path() {
944        // Just `index.html` (or empty path) → no breadcrumb chain.
945        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); // Home + blog + my-post
956        assert_eq!(items[0]["name"], "Home");
957        assert_eq!(items[1]["name"], "blog");
958        assert_eq!(items[2]["name"], "my post"); // hyphens → spaces
959    }
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    // ── build_jsonld_scripts ───────────────────────────────────
970
971    #[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    // ── after_compile end-to-end ───────────────────────────────
1020
1021    #[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        // Only one JSON-LD block — no duplicate injected.
1051        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    // ── inLanguage via resolve_page_lang (spec A5, plan §2 1.5) ────
1069
1070    /// Context with a site `language` and declared `[i18n]` locales,
1071    /// rooted so that sidecars under `<dir>/build/.meta` are found.
1072    #[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    /// Extracts `inLanguage` from the first injected JSON-LD block.
1099    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        // The A5 signature bug: a /hi/… page whose template carries
1112        // the site-wide lang="en-GB" must emit inLanguage=hi.
1113        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        // en-GB site, page outside any locale prefix, template lang
1148        // matches the site default: all sources agree on en-GB.
1149        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        // No config, no sidecar, no locale prefix, no <html lang>:
1162        // only then does the resolver's final "en" constant fire.
1163        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        // Fixture pages for en, fr, hi and en-GB must build with zero
1177        // JSON-LD validation findings (spec A5 acceptance).
1178        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    // ── JSON-LD validation (issue #467) ────────────────────────────
1200
1201    #[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        // Article inside @graph missing required fields exercises the
1263        // descent path. Article has 4 required fields; this provides 1.
1264        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        // Article requires headline + datePublished + author + image;
1271        // we only provided headline, so the other 3 fire.
1272        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        // Org passes; Article missing 3 of 4 required.
1299        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    // ── Strict type-attribute parsing (audit fix item #4) ──────────
1311
1312    #[test]
1313    fn validate_skips_extra_qualified_type() {
1314        // `application/ld+json/extra` must NOT be treated as JSON-LD.
1315        // Pre-fix: `tag.contains("application/ld+json")` falsely
1316        // matched this.
1317        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    // ── String-literal-aware </script> finder (audit fix item #5) ──
1343
1344    #[test]
1345    fn validate_handles_close_script_inside_json_string() {
1346        // The old extractor truncated at the first `</script>` inside
1347        // a string value, producing parse-failure noise. The fixed
1348        // extractor only honours `</script>` outside JSON strings.
1349        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        // Article has all 4 required fields. The pre-fix bug would
1357        // have produced an Unparseable error because the extractor
1358        // would close at the inner `</script>`, leaving truncated
1359        // JSON.
1360        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        // `data-mytype="foo"` must NOT match a `type=` query.
1379        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        // Missing close quote
1397        assert_eq!(extract_attr("<script type=\"foo", "type"), None);
1398        assert_eq!(extract_attr("<script type='bar", "type"), None);
1399        // Not a boundary
1400        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    // ── Display + extractor edge shapes ─────────────────────────────
1410
1411    #[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        // `JsonLdValidationError` derives `PartialEq`/`Eq`, but the rest
1427        // of this test module only ever inspects individual fields
1428        // (`e.field == "..."`), never the struct as a whole — so the
1429        // derived `eq` was otherwise never exercised. Compare on a
1430        // difference in the *last* field so the generated `&&` chain
1431        // runs past the first two comparisons too.
1432        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        // No closing </script> — the extractor must bail without
1447        // yielding a truncated block.
1448        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    // ── validate_one: remaining shapes ──────────────────────────────
1459
1460    #[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        // `itemListElement` present but not an array — the per-item
1537        // ListItem walk is skipped without panicking.
1538        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    // ── ISO 20022 sidecar opt-in (AC4) ──────────────────────────────
1549
1550    /// Writes a front-matter sidecar for the site-relative page `rel`.
1551    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    /// Context rooted in `dir` so `<dir>/build/.meta` sidecars are
1562    /// found for pages under `<dir>/site`.
1563    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        // Array form: one valid entity plus one entry without a type —
1596        // the invalid entry is skipped with a warning, the valid one
1597        // is still emitted.
1598        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        // Every entry fails dispatch → the extension contributes zero
1622        // scripts (None, not Some(vec![])).
1623        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        // Zero declared locales: the helper's default-locale fallback
1645        // kicks in and no path prefix can qualify as a locale.
1646        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        // JSON object keys must be strings — a tuple-keyed map fails.
1656        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    /// RAII guard that disables a failpoint on drop.
1674    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}