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        // A `<script` quoted inside an HTML comment is prose, not a
549        // block. Without this check a head comment that documents the
550        // CSP by naming `<script type="application/ld+json">` opened a
551        // phantom block that ran to the next real `</script>`, and
552        // every page on sebastienrousseau.com audited as "Unparseable".
553        if let Some(comment_end) = comment_end_covering(&lower, abs_open) {
554            cursor = comment_end;
555            continue;
556        }
557        // Use find_tag_end equivalent: advance past `>` while
558        // skipping any `>` characters that appear inside quoted
559        // attribute values. Without this, `<script type="text/x>y">`
560        // would close prematurely at the inner `>`.
561        let tag_end = find_tag_end(&lower, abs_open);
562        let tag = &lower[abs_open..tag_end];
563        cursor = tag_end;
564
565        if !is_jsonld_script_tag(tag) {
566            continue;
567        }
568
569        let Some(close) = find_script_close_skipping_strings(&html[cursor..])
570        else {
571            break;
572        };
573        // Use the original-case slice for the JSON payload —
574        // schema.org values are case-sensitive.
575        blocks.push(html[cursor..cursor + close].trim().to_string());
576        cursor += close + "</script>".len();
577    }
578
579    blocks
580}
581
582/// If byte offset `pos` lies inside an HTML comment, returns the offset
583/// just past that comment's `-->`; otherwise `None`. An unterminated
584/// comment covers everything after its `<!--`.
585pub(crate) fn comment_end_covering(lower: &str, pos: usize) -> Option<usize> {
586    let open = lower[..pos].rfind("<!--")?;
587    match lower[open + 4..].find("-->") {
588        Some(rel) => {
589            let end = open + 4 + rel + 3;
590            (end > pos).then_some(end)
591        }
592        None => Some(lower.len()),
593    }
594}
595
596/// Returns `true` if the `<script ...>` tag declares
597/// `type="application/ld+json"` exactly (any quoting; no
598/// substring match).
599fn is_jsonld_script_tag(tag: &str) -> bool {
600    extract_attr(tag, "type")
601        .is_some_and(|v| v.eq_ignore_ascii_case("application/ld+json"))
602}
603
604/// Extracts the value of an HTML attribute from an open-tag string.
605/// Tolerant of quoting and whitespace. Returns `None` if the
606/// attribute is absent or has no value.
607fn extract_attr(tag: &str, name: &str) -> Option<String> {
608    let lower = tag.to_lowercase();
609    let needle = format!("{}=", name.to_lowercase());
610    let idx = lower.find(&needle)?;
611    // Make sure the match starts at a token boundary (preceding
612    // char is whitespace or `<` or the very start of `tag`).
613    let pre = lower.as_bytes().get(idx.wrapping_sub(1));
614    let boundary_ok = idx == 0
615        || matches!(pre, Some(b) if b.is_ascii_whitespace() || *b == b'<');
616    if !boundary_ok {
617        return None;
618    }
619    let rest = &tag[idx + needle.len()..];
620    let trimmed = rest.trim_start();
621    if let Some(s) = trimmed.strip_prefix('"') {
622        s.find('"').map(|e| s[..e].to_string())
623    } else if let Some(s) = trimmed.strip_prefix('\'') {
624        s.find('\'').map(|e| s[..e].to_string())
625    } else {
626        let end = trimmed
627            .find(|c: char| c.is_whitespace() || c == '>')
628            .unwrap_or(trimmed.len());
629        Some(trimmed[..end].to_string())
630    }
631}
632
633/// Returns the byte offset of `</script>` in `body` while ignoring
634/// occurrences that appear *inside* a JSON string literal.
635///
636/// The walker tracks two pieces of state: whether we're currently
637/// inside a `"..."` string, and whether the previous byte was the
638/// JSON escape character `\`. Scanning is done in bytes (UTF-8 is
639/// not relevant for the ASCII-only delimiters we care about).
640fn find_script_close_skipping_strings(body: &str) -> Option<usize> {
641    let bytes = body.as_bytes();
642    let needle = b"</script>";
643    let mut i = 0;
644    let mut in_string = false;
645    let mut escape = false;
646    while i < bytes.len() {
647        if in_string {
648            if escape {
649                escape = false;
650            } else if bytes[i] == b'\\' {
651                escape = true;
652            } else if bytes[i] == b'"' {
653                in_string = false;
654            }
655            i += 1;
656            continue;
657        }
658        if bytes[i] == b'"' {
659            in_string = true;
660            i += 1;
661            continue;
662        }
663        // Case-insensitive check for `</script>`.
664        if i + needle.len() <= bytes.len()
665            && bytes[i..i + needle.len()].eq_ignore_ascii_case(needle)
666        {
667            return Some(i);
668        }
669        i += 1;
670    }
671    None
672}
673
674/// Validates a single parsed JSON-LD value (object or array).
675fn validate_one(
676    value: &serde_json::Value,
677    errors: &mut Vec<JsonLdValidationError>,
678) {
679    // schema.org allows top-level @graph arrays; descend into them.
680    if let Some(graph) = value.get("@graph").and_then(|v| v.as_array()) {
681        for entry in graph {
682            validate_one(entry, errors);
683        }
684        return;
685    }
686
687    // Array at top level — validate each entry.
688    if let Some(array) = value.as_array() {
689        for entry in array {
690            validate_one(entry, errors);
691        }
692        return;
693    }
694
695    let schema_type = value
696        .get("@type")
697        .and_then(|v| v.as_str())
698        .unwrap_or("Unknown")
699        .to_string();
700
701    // Required-field sets aligned with Google's rich-results
702    // requirements (https://developers.google.com/search/docs/appearance/structured-data),
703    // not the broader schema.org vocabulary. schema.org marks many
704    // useful fields as `Recommended` rather than `Required` — this
705    // validator only fires on truly-missing fields the search
706    // engines actually penalise.
707    let required: &[&str] = match schema_type.as_str() {
708        "Article" | "NewsArticle" | "BlogPosting" => {
709            // Google requires headline + datePublished + author +
710            // image for Article rich results.
711            &["headline", "datePublished", "author", "image"]
712        }
713        // WebPage's only hard requirement is `name`. `url` and
714        // `inLanguage` are Recommended but not penalised when
715        // absent — auto-generated stub pages (taxonomy indexes,
716        // 404, offline) routinely omit them.
717        "WebPage" => &["name"],
718        "BreadcrumbList" => &["itemListElement"],
719        "FAQPage" => &["mainEntity"],
720        "LocalBusiness" | "Restaurant" | "Store" => &["name", "address"],
721        "Organization" => &["name", "url"],
722        // Unknown types: don't enforce required fields. Users may ship
723        // custom @types that are still valid schema.org extensions.
724        _ => return,
725    };
726
727    for field in required {
728        match value.get(*field) {
729            None => errors.push(JsonLdValidationError {
730                schema_type: schema_type.clone(),
731                field: (*field).to_string(),
732                reason: "field absent".to_string(),
733            }),
734            Some(serde_json::Value::Null) => {
735                errors.push(JsonLdValidationError {
736                    schema_type: schema_type.clone(),
737                    field: (*field).to_string(),
738                    reason: "field is null".to_string(),
739                });
740            }
741            Some(serde_json::Value::String(s)) if s.trim().is_empty() => {
742                errors.push(JsonLdValidationError {
743                    schema_type: schema_type.clone(),
744                    field: (*field).to_string(),
745                    reason: "field is empty string".to_string(),
746                });
747            }
748            Some(serde_json::Value::Array(a)) if a.is_empty() => {
749                errors.push(JsonLdValidationError {
750                    schema_type: schema_type.clone(),
751                    field: (*field).to_string(),
752                    reason: "array is empty".to_string(),
753                });
754            }
755            _ => {}
756        }
757    }
758
759    // BreadcrumbList: itemListElement entries should each be ListItem
760    // with a `position` and `name`. Catch the most common regression.
761    if schema_type == "BreadcrumbList" {
762        if let Some(items) =
763            value.get("itemListElement").and_then(|v| v.as_array())
764        {
765            for (idx, item) in items.iter().enumerate() {
766                if item.get("position").is_none() {
767                    errors.push(JsonLdValidationError {
768                        schema_type: schema_type.clone(),
769                        field: format!("itemListElement[{idx}].position"),
770                        reason: "ListItem missing position".to_string(),
771                    });
772                }
773                if item.get("name").is_none() && item.get("item").is_none() {
774                    errors.push(JsonLdValidationError {
775                        schema_type: schema_type.clone(),
776                        field: format!("itemListElement[{idx}].name|item"),
777                        reason: "ListItem missing name and item".to_string(),
778                    });
779                }
780            }
781        }
782    }
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788    use std::path::Path;
789    use tempfile::tempdir;
790
791    fn ctx(site: &Path) -> PluginContext {
792        PluginContext::new(
793            Path::new("content"),
794            Path::new("build"),
795            site,
796            Path::new("templates"),
797        )
798    }
799
800    fn cfg() -> JsonLdConfig {
801        JsonLdConfig {
802            base_url: "https://example.com".to_string(),
803            org_name: "Example Org".to_string(),
804            breadcrumbs: true,
805        }
806    }
807
808    #[test]
809    fn name_is_stable() {
810        let p = JsonLdPlugin::new(cfg());
811        assert_eq!(p.name(), "json-ld");
812    }
813
814    #[test]
815    fn from_site_constructs_with_breadcrumbs_enabled() {
816        let p = JsonLdPlugin::from_site("https://x.example", "X");
817        assert_eq!(p.config.base_url, "https://x.example");
818        assert_eq!(p.config.org_name, "X");
819        assert!(p.config.breadcrumbs);
820    }
821
822    // ── build_article_jsonld ───────────────────────────────────
823
824    #[test]
825    fn article_includes_author_when_provided() {
826        let v = build_article_jsonld(
827            "T",
828            "D",
829            "https://x/p",
830            "Org",
831            "Jane",
832            "",
833            None,
834            None,
835            "en",
836        );
837        assert_eq!(v["author"]["name"], "Jane");
838        assert_eq!(v["author"]["@type"], "Person");
839    }
840
841    #[test]
842    fn article_omits_author_when_empty() {
843        let v = build_article_jsonld(
844            "T",
845            "D",
846            "https://x/p",
847            "Org",
848            "",
849            "",
850            None,
851            None,
852            "en",
853        );
854        assert!(v.get("author").is_none());
855    }
856
857    #[test]
858    fn article_includes_image_when_url_present() {
859        let v = build_article_jsonld(
860            "T",
861            "D",
862            "https://x/p",
863            "Org",
864            "",
865            "https://x/img.png",
866            None,
867            None,
868            "en",
869        );
870        assert_eq!(v["image"]["@type"], "ImageObject");
871        assert_eq!(v["image"]["url"], "https://x/img.png");
872    }
873
874    #[test]
875    fn article_uses_date_published_for_date_modified_fallback() {
876        let dp = "2025-01-01".to_string();
877        let v = build_article_jsonld(
878            "T",
879            "D",
880            "https://x/p",
881            "Org",
882            "",
883            "",
884            Some(&dp),
885            None,
886            "en",
887        );
888        assert_eq!(v["datePublished"], "2025-01-01");
889        assert_eq!(
890            v["dateModified"], "2025-01-01",
891            "missing dateModified should fall back to datePublished"
892        );
893    }
894
895    #[test]
896    fn article_keeps_distinct_date_modified() {
897        let dp = "2025-01-01".to_string();
898        let dm = "2025-06-15".to_string();
899        let v = build_article_jsonld(
900            "T",
901            "D",
902            "https://x/p",
903            "Org",
904            "",
905            "",
906            Some(&dp),
907            Some(&dm),
908            "en",
909        );
910        assert_eq!(v["datePublished"], "2025-01-01");
911        assert_eq!(v["dateModified"], "2025-06-15");
912    }
913
914    #[test]
915    fn article_emits_resolved_lang_verbatim() {
916        // spec A5: the inline "en" fallback is gone — the caller
917        // passes the resolver's output and this sink echoes it.
918        let v = build_article_jsonld(
919            "T",
920            "D",
921            "https://x/p",
922            "Org",
923            "",
924            "",
925            None,
926            None,
927            "hi",
928        );
929        assert_eq!(v["inLanguage"], "hi");
930    }
931
932    // ── build_webpage_jsonld ───────────────────────────────────
933
934    #[test]
935    fn webpage_includes_author_image_date_when_present() {
936        let dp = "2025-01-01".to_string();
937        let v = build_webpage_jsonld(
938            "T",
939            "D",
940            "https://x/p",
941            "Jane",
942            "https://x/i.png",
943            Some(&dp),
944            "fr",
945        );
946        assert_eq!(v["@type"], "WebPage");
947        assert_eq!(v["author"]["name"], "Jane");
948        assert_eq!(v["image"]["url"], "https://x/i.png");
949        assert_eq!(v["datePublished"], "2025-01-01");
950        assert_eq!(v["inLanguage"], "fr");
951    }
952
953    #[test]
954    fn webpage_omits_optional_fields_when_empty() {
955        let v =
956            build_webpage_jsonld("T", "D", "https://x/p", "", "", None, "en");
957        assert!(v.get("author").is_none());
958        assert!(v.get("image").is_none());
959        assert!(v.get("datePublished").is_none());
960        assert_eq!(v["inLanguage"], "en");
961    }
962
963    // ── build_breadcrumb_jsonld ────────────────────────────────
964
965    #[test]
966    fn breadcrumb_returns_none_for_root_path() {
967        // Just `index.html` (or empty path) → no breadcrumb chain.
968        assert!(build_breadcrumb_jsonld("https://x", "/").is_none());
969        assert!(build_breadcrumb_jsonld("https://x", "index.html").is_none());
970    }
971
972    #[test]
973    fn breadcrumb_builds_chain_for_nested_path() {
974        let v = build_breadcrumb_jsonld("https://x", "blog/my-post/index.html")
975            .expect("should produce breadcrumb for nested path");
976        assert_eq!(v["@type"], "BreadcrumbList");
977        let items = v["itemListElement"].as_array().unwrap();
978        assert_eq!(items.len(), 3); // Home + blog + my-post
979        assert_eq!(items[0]["name"], "Home");
980        assert_eq!(items[1]["name"], "blog");
981        assert_eq!(items[2]["name"], "my post"); // hyphens → spaces
982    }
983
984    #[test]
985    fn breadcrumb_handles_html_extension_in_part_name() {
986        let v = build_breadcrumb_jsonld("https://x", "page.html").unwrap();
987        let items = v["itemListElement"].as_array().unwrap();
988        assert_eq!(items.len(), 2);
989        assert_eq!(items[1]["name"], "page");
990    }
991
992    // ── build_jsonld_scripts ───────────────────────────────────
993
994    #[test]
995    fn build_scripts_picks_article_when_article_tag_present() {
996        let html = r#"<html><head><title>Post</title></head>
997            <body><article>content</article></body></html>"#;
998        let scripts =
999            build_jsonld_scripts(html, "https://x", "p/", "Org", false, "en");
1000        assert_eq!(scripts[0]["@type"], "Article");
1001    }
1002
1003    #[test]
1004    fn build_scripts_picks_webpage_when_no_article_tag() {
1005        let html = "<html><head><title>P</title></head><body>x</body></html>";
1006        let scripts =
1007            build_jsonld_scripts(html, "https://x", "p/", "Org", false, "en");
1008        assert_eq!(scripts[0]["@type"], "WebPage");
1009    }
1010
1011    #[test]
1012    fn build_scripts_includes_breadcrumb_when_enabled() {
1013        let html = "<html><head><title>P</title></head><body>x</body></html>";
1014        let scripts = build_jsonld_scripts(
1015            html,
1016            "https://x",
1017            "blog/post/",
1018            "Org",
1019            true,
1020            "en",
1021        );
1022        assert!(
1023            scripts.iter().any(|s| s["@type"] == "BreadcrumbList"),
1024            "breadcrumb should be present when enabled and path nested"
1025        );
1026    }
1027
1028    #[test]
1029    fn build_scripts_skips_breadcrumb_when_disabled() {
1030        let html = "<html><head><title>P</title></head><body>x</body></html>";
1031        let scripts = build_jsonld_scripts(
1032            html,
1033            "https://x",
1034            "blog/post/",
1035            "Org",
1036            false,
1037            "en",
1038        );
1039        assert!(!scripts.iter().any(|s| s["@type"] == "BreadcrumbList"));
1040    }
1041
1042    // ── after_compile end-to-end ───────────────────────────────
1043
1044    #[test]
1045    fn after_compile_no_op_when_site_missing() {
1046        let dir = tempdir().unwrap();
1047        let nope = dir.path().join("nope");
1048        JsonLdPlugin::new(cfg()).after_compile(&ctx(&nope)).unwrap();
1049    }
1050
1051    #[test]
1052    fn transform_html_injects_jsonld() {
1053        let dir = tempdir().unwrap();
1054        let c = ctx(dir.path());
1055        let html = "<html><head><title>X</title></head><body>x</body></html>";
1056        let page_path = dir.path().join("index.html");
1057        let after = JsonLdPlugin::new(cfg())
1058            .transform_html(html, &page_path, &c)
1059            .unwrap();
1060        assert!(after.contains("application/ld+json"));
1061        assert!(after.contains("\"@type\":\"WebPage\""));
1062    }
1063
1064    #[test]
1065    fn transform_html_skips_existing_jsonld() {
1066        let dir = tempdir().unwrap();
1067        let c = ctx(dir.path());
1068        let html = r#"<html><head><script type="application/ld+json">{"@type":"X"}</script><title>X</title></head></html>"#;
1069        let page_path = dir.path().join("p.html");
1070        let after = JsonLdPlugin::new(cfg())
1071            .transform_html(html, &page_path, &c)
1072            .unwrap();
1073        // Only one JSON-LD block — no duplicate injected.
1074        assert_eq!(after.matches("application/ld+json").count(), 1);
1075        assert!(after.contains(r#"{"@type":"X"}"#));
1076    }
1077
1078    #[cfg(feature = "i18n")]
1079    #[test]
1080    fn transform_html_skips_without_head_tag() {
1081        let dir = tempdir().unwrap();
1082        let c = ctx(dir.path());
1083        let raw = "<!doctype html><html><body>only</body></html>";
1084        let page_path = dir.path().join("frag.html");
1085        let after = JsonLdPlugin::new(cfg())
1086            .transform_html(raw, &page_path, &c)
1087            .unwrap();
1088        assert_eq!(after, raw);
1089    }
1090
1091    // ── inLanguage via resolve_page_lang (spec A5, plan §2 1.5) ────
1092
1093    /// Context with a site `language` and declared `[i18n]` locales,
1094    /// rooted so that sidecars under `<dir>/build/.meta` are found.
1095    #[cfg(feature = "i18n")]
1096    fn locale_ctx(
1097        dir: &Path,
1098        language: &str,
1099        locales: &[&str],
1100    ) -> PluginContext {
1101        let mut c = PluginContext::new(
1102            Path::new("content"),
1103            &dir.join("build"),
1104            &dir.join("site"),
1105            Path::new("templates"),
1106        );
1107        c.config = Some(crate::cmd::SsgConfig {
1108            language: language.to_string(),
1109            i18n: Some(crate::i18n::I18nConfig {
1110                default_locale: locales
1111                    .first()
1112                    .map_or_else(|| "en".to_string(), |l| (*l).to_string()),
1113                locales: locales.iter().map(|l| (*l).to_string()).collect(),
1114                url_prefix: Default::default(),
1115            }),
1116            ..crate::cmd::SsgConfig::default()
1117        });
1118        c
1119    }
1120
1121    /// Extracts `inLanguage` from the first injected JSON-LD block.
1122    fn injected_in_language(html: &str) -> String {
1123        let block = extract_jsonld_blocks(html)
1124            .into_iter()
1125            .next()
1126            .expect("page should carry an injected JSON-LD block");
1127        let v: serde_json::Value = serde_json::from_str(&block).unwrap();
1128        v["inLanguage"].as_str().unwrap_or_default().to_string()
1129    }
1130
1131    #[cfg(feature = "i18n")]
1132    #[test]
1133    fn in_language_is_path_driven_on_locale_pages() {
1134        // The A5 signature bug: a /hi/… page whose template carries
1135        // the site-wide lang="en-GB" must emit inLanguage=hi.
1136        let dir = tempdir().unwrap();
1137        let c = locale_ctx(dir.path(), "en-GB", &["en", "hi", "fr"]);
1138        let html = r#"<html lang="en-GB"><head><title>नमस्ते</title></head><body>x</body></html>"#;
1139        let page = dir.path().join("site/hi/2026-06-01-post/index.html");
1140        let out = JsonLdPlugin::new(cfg())
1141            .transform_html(html, &page, &c)
1142            .unwrap();
1143        assert_eq!(injected_in_language(&out), "hi");
1144    }
1145
1146    #[cfg(feature = "i18n")]
1147    #[test]
1148    fn in_language_is_frontmatter_driven_when_sidecar_declares_language() {
1149        let dir = tempdir().unwrap();
1150        let c = locale_ctx(dir.path(), "en-GB", &["en", "hi"]);
1151        let sidecar = dir.path().join("build/.meta/hi/post/index.meta.json");
1152        std::fs::create_dir_all(sidecar.parent().unwrap()).unwrap();
1153        std::fs::write(sidecar, r#"{"language":"fr"}"#).unwrap();
1154
1155        let html = r#"<html lang="en-GB"><head><title>T</title></head><body>x</body></html>"#;
1156        let page = dir.path().join("site/hi/post/index.html");
1157        let out = JsonLdPlugin::new(cfg())
1158            .transform_html(html, &page, &c)
1159            .unwrap();
1160        assert_eq!(
1161            injected_in_language(&out),
1162            "fr",
1163            "front-matter `language` outranks the locale path prefix"
1164        );
1165    }
1166
1167    #[cfg(feature = "i18n")]
1168    #[test]
1169    fn in_language_is_default_driven_on_default_locale_pages() {
1170        // en-GB site, page outside any locale prefix, template lang
1171        // matches the site default: all sources agree on en-GB.
1172        let dir = tempdir().unwrap();
1173        let c = locale_ctx(dir.path(), "en-GB", &["en"]);
1174        let html = r#"<html lang="en-GB"><head><title>T</title></head><body>x</body></html>"#;
1175        let page = dir.path().join("site/about/index.html");
1176        let out = JsonLdPlugin::new(cfg())
1177            .transform_html(html, &page, &c)
1178            .unwrap();
1179        assert_eq!(injected_in_language(&out), "en-GB");
1180    }
1181
1182    #[test]
1183    fn in_language_en_fallback_only_when_nothing_resolves() {
1184        // No config, no sidecar, no locale prefix, no <html lang>:
1185        // only then does the resolver's final "en" constant fire.
1186        let dir = tempdir().unwrap();
1187        let c = ctx(&dir.path().join("site"));
1188        let html = "<html><head><title>T</title></head><body>x</body></html>";
1189        let page = dir.path().join("site/index.html");
1190        let out = JsonLdPlugin::new(cfg())
1191            .transform_html(html, &page, &c)
1192            .unwrap();
1193        assert_eq!(injected_in_language(&out), "en");
1194    }
1195
1196    #[cfg(feature = "i18n")]
1197    #[test]
1198    fn in_language_validation_passes_on_locale_fixtures() {
1199        // Fixture pages for en, fr, hi and en-GB must build with zero
1200        // JSON-LD validation findings (spec A5 acceptance).
1201        let dir = tempdir().unwrap();
1202        let c = locale_ctx(dir.path(), "en-GB", &["en", "fr", "hi"]);
1203        for (rel, want) in [
1204            ("en/page/index.html", "en"),
1205            ("fr/page/index.html", "fr"),
1206            ("hi/page/index.html", "hi"),
1207            ("page/index.html", "en-GB"),
1208        ] {
1209            let html = r#"<html lang="en-GB"><head><title>T</title></head><body>x</body></html>"#;
1210            let page = dir.path().join("site").join(rel);
1211            let out = JsonLdPlugin::new(cfg())
1212                .transform_html(html, &page, &c)
1213                .unwrap();
1214            assert_eq!(injected_in_language(&out), want, "page {rel}");
1215            assert!(
1216                validate_jsonld(&out).is_empty(),
1217                "page {rel} must emit zero JSON-LD validation findings"
1218            );
1219        }
1220    }
1221
1222    // ── JSON-LD validation (issue #467) ────────────────────────────
1223
1224    #[test]
1225    fn validate_extracts_block() {
1226        let html = r#"<html><head>
1227            <script type="application/ld+json">
1228            {"@context":"https://schema.org","@type":"WebPage",
1229             "name":"Hi","url":"https://x.test/","inLanguage":"en"}
1230            </script></head><body></body></html>"#;
1231        assert!(
1232            validate_jsonld(html).is_empty(),
1233            "{:?}",
1234            validate_jsonld(html)
1235        );
1236    }
1237
1238    #[test]
1239    fn validate_flags_missing_required_field_on_article() {
1240        let html = r#"<script type="application/ld+json">
1241            {"@context":"https://schema.org","@type":"Article",
1242             "headline":"H","datePublished":"2026-05-10","author":"A"}
1243        </script>"#;
1244        let errs = validate_jsonld(html);
1245        assert!(
1246            errs.iter()
1247                .any(|e| e.schema_type == "Article" && e.field == "image"),
1248            "expected Article.image violation, got {errs:?}"
1249        );
1250    }
1251
1252    #[test]
1253    fn validate_flags_empty_breadcrumb_list() {
1254        let html = r#"<script type="application/ld+json">
1255            {"@context":"https://schema.org","@type":"BreadcrumbList",
1256             "itemListElement":[]}
1257        </script>"#;
1258        let errs = validate_jsonld(html);
1259        assert!(
1260            errs.iter().any(|e| e.field == "itemListElement"),
1261            "expected itemListElement empty-array error, got {errs:?}"
1262        );
1263    }
1264
1265    #[test]
1266    fn validate_breadcrumb_listitem_missing_position() {
1267        let html = r#"<script type="application/ld+json">
1268            {"@type":"BreadcrumbList",
1269             "itemListElement":[{"name":"Home","item":"https://x/"}]}
1270        </script>"#;
1271        let errs = validate_jsonld(html);
1272        assert!(
1273            errs.iter()
1274                .any(|e| e.field == "itemListElement[0].position"),
1275            "expected position-missing error, got {errs:?}"
1276        );
1277    }
1278
1279    #[test]
1280    fn validate_unparseable_json() {
1281        let html = r#"<script type="application/ld+json">{not json}</script>"#;
1282        let errs = validate_jsonld(html);
1283        assert_eq!(errs.len(), 1);
1284        assert_eq!(errs[0].schema_type, "Unparseable");
1285    }
1286
1287    #[test]
1288    fn validate_descends_into_graph() {
1289        // Article inside @graph missing required fields exercises the
1290        // descent path. Article has 4 required fields; this provides 1.
1291        let html = r#"<script type="application/ld+json">
1292            {"@context":"https://schema.org","@graph":[
1293                {"@type":"Article","headline":"H"}
1294            ]}
1295        </script>"#;
1296        let errs = validate_jsonld(html);
1297        // Article requires headline + datePublished + author + image;
1298        // we only provided headline, so the other 3 fire.
1299        assert!(errs
1300            .iter()
1301            .any(|e| e.schema_type == "Article" && e.field == "datePublished"));
1302        assert!(errs
1303            .iter()
1304            .any(|e| e.schema_type == "Article" && e.field == "author"));
1305        assert!(errs
1306            .iter()
1307            .any(|e| e.schema_type == "Article" && e.field == "image"));
1308    }
1309
1310    #[test]
1311    fn validate_unknown_type_passes_through() {
1312        let html = r#"<script type="application/ld+json">
1313            {"@type":"CustomThing","foo":"bar"}
1314        </script>"#;
1315        assert!(
1316            validate_jsonld(html).is_empty(),
1317            "{:?}",
1318            validate_jsonld(html)
1319        );
1320    }
1321
1322    #[test]
1323    fn validate_handles_multiple_blocks() {
1324        let html = r#"
1325            <script type="application/ld+json">{"@type":"Organization","name":"O","url":"https://o/"}</script>
1326            <script type="application/ld+json">{"@type":"Article","headline":"H"}</script>
1327        "#;
1328        let errs = validate_jsonld(html);
1329        // Org passes; Article missing 3 of 4 required.
1330        assert_eq!(
1331            errs.iter()
1332                .filter(|e| e.schema_type == "Organization")
1333                .count(),
1334            0
1335        );
1336        assert!(
1337            errs.iter().filter(|e| e.schema_type == "Article").count() >= 3
1338        );
1339    }
1340
1341    // ── Strict type-attribute parsing (audit fix item #4) ──────────
1342
1343    #[test]
1344    fn validate_skips_extra_qualified_type() {
1345        // `application/ld+json/extra` must NOT be treated as JSON-LD.
1346        // Pre-fix: `tag.contains("application/ld+json")` falsely
1347        // matched this.
1348        let html = r#"<script type="application/ld+json/extra">
1349            {"@type":"Article"}
1350        </script>"#;
1351        assert!(
1352            validate_jsonld(html).is_empty(),
1353            "non-JSON-LD type must not be validated"
1354        );
1355    }
1356
1357    #[test]
1358    fn validate_recognises_type_with_single_quotes() {
1359        let html = r#"<script type='application/ld+json'>
1360            {"@type":"Organization","name":"O","url":"https://o/"}
1361        </script>"#;
1362        assert!(
1363            validate_jsonld(html).is_empty(),
1364            "{:?}",
1365            validate_jsonld(html)
1366        );
1367    }
1368
1369    #[test]
1370    fn validate_recognises_type_after_other_attrs() {
1371        let html = r#"<script id="ld1" type="application/ld+json">
1372            {"@type":"Organization","name":"O","url":"https://o/"}
1373        </script>"#;
1374        assert!(
1375            validate_jsonld(html).is_empty(),
1376            "{:?}",
1377            validate_jsonld(html)
1378        );
1379    }
1380
1381    // ── String-literal-aware </script> finder (audit fix item #5) ──
1382
1383    #[test]
1384    fn validate_handles_close_script_inside_json_string() {
1385        // The old extractor truncated at the first `</script>` inside
1386        // a string value, producing parse-failure noise. The fixed
1387        // extractor only honours `</script>` outside JSON strings.
1388        let html = r#"<script type="application/ld+json">
1389            {"@type":"Article",
1390             "headline":"H","datePublished":"2026-01-01",
1391             "author":"A","image":"https://x/i.png",
1392             "description":"this contains a </script> inside the string and is still valid JSON"}
1393        </script>"#;
1394        let errs = validate_jsonld(html);
1395        // Article has all 4 required fields. The pre-fix bug would
1396        // have produced an Unparseable error because the extractor
1397        // would close at the inner `</script>`, leaving truncated
1398        // JSON.
1399        assert!(errs.is_empty(), "no errors expected, got {errs:?}");
1400    }
1401
1402    #[test]
1403    fn extract_attr_returns_none_when_attribute_absent() {
1404        assert_eq!(extract_attr("<script src=x>", "type"), None);
1405    }
1406
1407    #[test]
1408    fn extract_attr_handles_double_quoted_value() {
1409        assert_eq!(
1410            extract_attr(r#"<script type="application/ld+json">"#, "type"),
1411            Some("application/ld+json".to_string())
1412        );
1413    }
1414
1415    #[test]
1416    fn extract_attr_rejects_substring_match_in_other_attribute() {
1417        // `data-mytype="foo"` must NOT match a `type=` query.
1418        assert_eq!(extract_attr(r#"<script data-mytype="foo">"#, "type"), None);
1419    }
1420
1421    #[test]
1422    fn extract_attr_quoting_and_boundaries() {
1423        assert_eq!(
1424            extract_attr("<script type=\"foo\"", "type"),
1425            Some("foo".to_string())
1426        );
1427        assert_eq!(
1428            extract_attr("<script type='bar'", "type"),
1429            Some("bar".to_string())
1430        );
1431        assert_eq!(
1432            extract_attr("<script type=baz", "type"),
1433            Some("baz".to_string())
1434        );
1435        // Missing close quote
1436        assert_eq!(extract_attr("<script type=\"foo", "type"), None);
1437        assert_eq!(extract_attr("<script type='bar", "type"), None);
1438        // Not a boundary
1439        assert_eq!(extract_attr("<script subtype=\"foo\"", "type"), None);
1440    }
1441
1442    #[test]
1443    fn test_find_script_close_escaped_quotes() {
1444        let body = r#"{"msg":"escaped \" quote"}</script>"#;
1445        assert_eq!(find_script_close_skipping_strings(body), Some(26));
1446    }
1447
1448    // ── Display + extractor edge shapes ─────────────────────────────
1449
1450    #[test]
1451    fn validation_error_display_includes_all_fields() {
1452        let e = JsonLdValidationError {
1453            schema_type: "Article".to_string(),
1454            field: "headline".to_string(),
1455            reason: "field absent".to_string(),
1456        };
1457        let s = e.to_string();
1458        assert!(s.contains("[Article]"));
1459        assert!(s.contains("`headline`"));
1460        assert!(s.contains("field absent"));
1461    }
1462
1463    #[test]
1464    fn validation_error_partial_eq_covers_equal_and_unequal_tail_field() {
1465        // `JsonLdValidationError` derives `PartialEq`/`Eq`, but the rest
1466        // of this test module only ever inspects individual fields
1467        // (`e.field == "..."`), never the struct as a whole — so the
1468        // derived `eq` was otherwise never exercised. Compare on a
1469        // difference in the *last* field so the generated `&&` chain
1470        // runs past the first two comparisons too.
1471        let a = JsonLdValidationError {
1472            schema_type: "Article".to_string(),
1473            field: "headline".to_string(),
1474            reason: "field absent".to_string(),
1475        };
1476        let b = a.clone();
1477        let mut c = a.clone();
1478        c.reason = "field is null".to_string();
1479        assert_eq!(a, b);
1480        assert_ne!(a, c);
1481    }
1482
1483    #[test]
1484    fn extractor_ignores_unterminated_jsonld_script() {
1485        // No closing </script> — the extractor must bail without
1486        // yielding a truncated block.
1487        let html = r#"<script type="application/ld+json">{"@type":"WebPage""#;
1488        assert!(
1489            extract_jsonld_blocks(html).is_empty(),
1490            "{:?}",
1491            extract_jsonld_blocks(html)
1492        );
1493    }
1494
1495    #[test]
1496    fn find_html_tag_end_without_closing_bracket_returns_len() {
1497        let html = "<script type=\"application/ld+json\"";
1498        assert_eq!(find_tag_end(html, 0), html.len());
1499    }
1500
1501    // ── validate_one: remaining shapes ──────────────────────────────
1502
1503    #[test]
1504    fn validator_descends_into_top_level_array() {
1505        let html = r#"<script type="application/ld+json">
1506            [{"@type":"WebPage","name":"A"},{"@type":"WebPage"}]
1507        </script>"#;
1508        let errs = validate_jsonld(html);
1509        assert_eq!(errs.len(), 1, "only the second entry is invalid: {errs:?}");
1510        assert_eq!(errs[0].field, "name");
1511    }
1512
1513    #[test]
1514    fn validator_flags_faq_page_missing_main_entity() {
1515        let html = r#"<script type="application/ld+json">
1516            {"@type":"FAQPage"}
1517        </script>"#;
1518        let errs = validate_jsonld(html);
1519        assert!(errs.iter().any(|e| e.field == "mainEntity"), "{errs:?}");
1520    }
1521
1522    #[test]
1523    fn validator_checks_restaurant_and_store_literals() {
1524        let html = r#"<script type="application/ld+json">
1525            {"@type":"Restaurant","name":"R"}
1526        </script>
1527        <script type="application/ld+json">
1528            {"@type":"Store","address":"1 Main St"}
1529        </script>"#;
1530        let errs = validate_jsonld(html);
1531        assert!(
1532            errs.iter()
1533                .any(|e| e.schema_type == "Restaurant" && e.field == "address"),
1534            "{errs:?}"
1535        );
1536        assert!(
1537            errs.iter()
1538                .any(|e| e.schema_type == "Store" && e.field == "name"),
1539            "{errs:?}"
1540        );
1541    }
1542
1543    #[test]
1544    fn validator_flags_null_required_field() {
1545        let html = r#"<script type="application/ld+json">
1546            {"@type":"WebPage","name":null}
1547        </script>"#;
1548        let errs = validate_jsonld(html);
1549        assert!(errs.iter().any(|e| e.reason == "field is null"), "{errs:?}");
1550    }
1551
1552    #[test]
1553    fn validator_flags_whitespace_only_required_field() {
1554        let html = r#"<script type="application/ld+json">
1555            {"@type":"WebPage","name":"   "}
1556        </script>"#;
1557        let errs = validate_jsonld(html);
1558        assert!(
1559            errs.iter().any(|e| e.reason == "field is empty string"),
1560            "{errs:?}"
1561        );
1562    }
1563
1564    #[test]
1565    fn validator_flags_list_item_missing_name_and_item() {
1566        let html = r#"<script type="application/ld+json">
1567            {"@type":"BreadcrumbList","itemListElement":[{"position":1}]}
1568        </script>"#;
1569        let errs = validate_jsonld(html);
1570        assert!(
1571            errs.iter()
1572                .any(|e| e.field == "itemListElement[0].name|item"),
1573            "{errs:?}"
1574        );
1575    }
1576
1577    #[test]
1578    fn validator_tolerates_non_array_item_list_element() {
1579        // `itemListElement` present but not an array — the per-item
1580        // ListItem walk is skipped without panicking.
1581        let html = r#"<script type="application/ld+json">
1582            {"@type":"BreadcrumbList","itemListElement":"oops"}
1583        </script>"#;
1584        let errs = validate_jsonld(html);
1585        assert!(
1586            !errs.iter().any(|e| e.field.starts_with("itemListElement[")),
1587            "{errs:?}"
1588        );
1589    }
1590
1591    // ── ISO 20022 sidecar opt-in (AC4) ──────────────────────────────
1592
1593    /// Writes a front-matter sidecar for the site-relative page `rel`.
1594    fn write_sidecar(dir: &Path, rel: &str, json: &str) {
1595        let sidecar = dir
1596            .join("build")
1597            .join(".meta")
1598            .join(rel)
1599            .with_extension("meta.json");
1600        std::fs::create_dir_all(sidecar.parent().unwrap()).unwrap();
1601        std::fs::write(sidecar, json).unwrap();
1602    }
1603
1604    /// Context rooted in `dir` so `<dir>/build/.meta` sidecars are
1605    /// found for pages under `<dir>/site`.
1606    fn rooted_ctx(dir: &Path) -> PluginContext {
1607        PluginContext::new(
1608            Path::new("content"),
1609            &dir.join("build"),
1610            &dir.join("site"),
1611            Path::new("templates"),
1612        )
1613    }
1614
1615    #[test]
1616    fn iso20022_object_block_is_injected() {
1617        let dir = tempdir().unwrap();
1618        write_sidecar(
1619            dir.path(),
1620            "acct/index.html",
1621            r#"{"iso20022":{"type":"BankAccount","iban":"GB29NWBK60161331926819"}}"#,
1622        );
1623        let c = rooted_ctx(dir.path());
1624        let html = "<html><head><title>T</title></head><body>x</body></html>";
1625        let page = dir.path().join("site/acct/index.html");
1626        let out = JsonLdPlugin::new(cfg())
1627            .transform_html(html, &page, &c)
1628            .unwrap();
1629        assert!(
1630            out.contains(r#""@type":"BankAccount""#),
1631            "BankAccount block should be injected: {out}"
1632        );
1633        assert!(out.contains("GB29NWBK60161331926819"));
1634    }
1635
1636    #[test]
1637    fn iso20022_array_block_skips_invalid_entries() {
1638        // Array form: one valid entity plus one entry without a type —
1639        // the invalid entry is skipped with a warning, the valid one
1640        // is still emitted.
1641        let dir = tempdir().unwrap();
1642        write_sidecar(
1643            dir.path(),
1644            "mix/index.html",
1645            r#"{"iso20022":[
1646                {"type":"PaymentInstrument","instrument_type":"card"},
1647                {"no":"type"}
1648            ]}"#,
1649        );
1650        let c = rooted_ctx(dir.path());
1651        let html = "<html><head><title>T</title></head><body>x</body></html>";
1652        let page = dir.path().join("site/mix/index.html");
1653        let out = JsonLdPlugin::new(cfg())
1654            .transform_html(html, &page, &c)
1655            .unwrap();
1656        assert!(
1657            out.contains(r#""@type":"PaymentService""#),
1658            "valid array entry should be injected: {out}"
1659        );
1660    }
1661
1662    #[test]
1663    fn iso20022_all_invalid_entries_injects_nothing_extra() {
1664        // Every entry fails dispatch → the extension contributes zero
1665        // scripts (None, not Some(vec![])).
1666        let dir = tempdir().unwrap();
1667        write_sidecar(
1668            dir.path(),
1669            "bad/index.html",
1670            r#"{"iso20022":{"type":"NotAThing"}}"#,
1671        );
1672        let c = rooted_ctx(dir.path());
1673        let html = "<html><head><title>T</title></head><body>x</body></html>";
1674        let page = dir.path().join("site/bad/index.html");
1675        let out = JsonLdPlugin::new(cfg())
1676            .transform_html(html, &page, &c)
1677            .unwrap();
1678        assert!(
1679            !out.contains("iso20022"),
1680            "invalid block must contribute nothing: {out}"
1681        );
1682    }
1683
1684    #[cfg(feature = "i18n")]
1685    #[test]
1686    fn locale_ctx_with_empty_locale_set_defaults_to_en() {
1687        // Zero declared locales: the helper's default-locale fallback
1688        // kicks in and no path prefix can qualify as a locale.
1689        let dir = tempdir().unwrap();
1690        let c = locale_ctx(dir.path(), "en", &[]);
1691        let i18n = c.config.as_ref().unwrap().i18n.as_ref().unwrap();
1692        assert_eq!(i18n.default_locale, "en");
1693        assert!(i18n.locales.is_empty(), "{:?}", i18n.locales);
1694    }
1695
1696    #[test]
1697    fn jsonld_tag_quoted_in_a_comment_is_not_a_block() {
1698        let html = r#"<html><head>
1699<!-- Strict CSP. Inline <script type="application/ld+json"> JSON-LD blocks
1700     are allowed by hash only. -->
1701<meta http-equiv="Content-Security-Policy" content="script-src 'self'">
1702<script src="/theme-init.js"></script>
1703<script type="application/ld+json">{"@context":"https://schema.org","@type":"WebPage","name":"x","url":"https://e.com/"}</script>
1704</head><body></body></html>"#;
1705        let blocks = extract_jsonld_blocks(html);
1706        assert_eq!(
1707            blocks.len(),
1708            1,
1709            "the quoted tag inside the comment must not open a block"
1710        );
1711        assert!(blocks[0].starts_with("{\"@context\""));
1712        assert!(
1713            validate_jsonld(html)
1714                .iter()
1715                .all(|e| e.schema_type != "Unparseable"),
1716            "no phantom unparseable block"
1717        );
1718        // An unterminated comment swallows the rest of the document.
1719        assert_eq!(comment_end_covering("ab<!--cd", 6), Some(8));
1720        assert_eq!(comment_end_covering("<!-- a --><script", 12), None);
1721    }
1722
1723    #[test]
1724    fn script_to_json_maps_serde_failure_to_io_error() {
1725        // JSON object keys must be strings — a tuple-keyed map fails.
1726        let bad: std::collections::BTreeMap<(u8, u8), u8> =
1727            std::iter::once(((1, 2), 3)).collect();
1728        let err = script_to_json(&bad, Path::new("page.html"))
1729            .expect_err("non-string map keys must fail serialisation");
1730        assert!(
1731            matches!(err, SsgError::Io { ref path, .. } if path == Path::new("page.html"))
1732        );
1733    }
1734}
1735
1736#[cfg(all(test, feature = "test-fault-injection"))]
1737mod fault_tests {
1738    use super::*;
1739    use serial_test::serial;
1740    use std::path::Path;
1741    use tempfile::tempdir;
1742
1743    /// RAII guard that disables a failpoint on drop.
1744    struct FailGuard<'a>(&'a str);
1745
1746    impl Drop for FailGuard<'_> {
1747        fn drop(&mut self) {
1748            let _ = fail::cfg(self.0, "off");
1749        }
1750    }
1751
1752    #[test]
1753    #[serial]
1754    fn transform_html_propagates_script_serialisation_failure() {
1755        let _guard = FailGuard("jsonld::script-to-json");
1756        fail::cfg("jsonld::script-to-json", "return").unwrap();
1757
1758        let dir = tempdir().unwrap();
1759        let c = PluginContext::new(
1760            Path::new("content"),
1761            Path::new("build"),
1762            dir.path(),
1763            Path::new("templates"),
1764        );
1765        let plugin = JsonLdPlugin::new(JsonLdConfig {
1766            base_url: "https://example.com".to_string(),
1767            org_name: "Org".to_string(),
1768            breadcrumbs: false,
1769        });
1770        let html = "<html><head><title>T</title></head><body>x</body></html>";
1771        let err = plugin
1772            .transform_html(html, Path::new("page.html"), &c)
1773            .expect_err("failpoint must abort script serialisation");
1774        assert!(
1775            err.to_string().contains("jsonld::script-to-json"),
1776            "injected error should surface: {err}"
1777        );
1778    }
1779}