Skip to main content

ssg/plugins/seo/
seo_plugin.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! SEO meta tag injection plugin.
5
6use super::helpers::{
7    escape_attr, extract_canonical, extract_description, extract_existing_meta,
8    extract_first_content_image, extract_title, has_meta_tag,
9};
10use super::lang::resolve_page_lang;
11use crate::plugin::{Plugin, PluginContext};
12use crate::util::head_dom::inject_before_head_close;
13use crate::util::html_rewriter::rewrite_html;
14use anyhow::Result;
15use lol_html::element;
16use std::path::Path;
17
18/// Injects missing SEO meta tags into HTML files.
19///
20/// After compilation, this plugin scans all HTML files in the site
21/// directory and adds any missing meta tags for description, Open Graph
22/// (title, description, type), and Twitter Card.
23///
24/// The plugin is idempotent — it checks for existing tags before
25/// injecting and will not duplicate them.
26///
27/// # Example
28///
29/// ```rust
30/// use ssg::plugin::PluginManager;
31/// use ssg::seo::SeoPlugin;
32///
33/// let mut pm = PluginManager::new();
34/// pm.register(SeoPlugin);
35/// ```
36#[derive(Debug, Clone, Copy)]
37pub struct SeoPlugin;
38
39impl Plugin for SeoPlugin {
40    fn name(&self) -> &'static str {
41        "seo"
42    }
43
44    fn has_transform(&self) -> bool {
45        true
46    }
47
48    fn transform_html(
49        &self,
50        html: &str,
51        path: &Path,
52        ctx: &PluginContext,
53    ) -> std::result::Result<String, crate::error::SsgError> {
54        // spec A5 (plan §2 1.5): resolve the page language once so
55        // `og:locale` agrees with JSON-LD `inLanguage` and the other
56        // language sinks.
57        let lang = resolve_page_lang(html, path, ctx);
58        // spec B8: resolve the social-meta derivation cascade from
59        // THIS page's front matter (never global config) before
60        // falling back to what the rendered HTML provides.
61        let social = resolve_social_meta(html, path, ctx);
62        let injected = inject_seo_tags_html(html, &lang, &social)
63            .map_err(|e| crate::error::SsgError::io(e, path))?;
64        Ok(apply_text_direction(&injected, &lang))
65    }
66
67    fn after_compile(
68        &self,
69        _ctx: &PluginContext,
70    ) -> std::result::Result<(), crate::error::SsgError> {
71        Ok(())
72    }
73}
74
75/// Per-page social metadata resolved through the spec-B8 derivation
76/// cascade.
77///
78/// Every og/twitter field derives from the page's own front matter
79/// (via its `.meta.json` sidecar) when not explicitly set:
80///
81/// - `og:title` / `twitter:title` ⇐ `og_title`/`twitter_title` ⇐
82///   `seo_title` ⇐ `title` ⇐ rendered `<title>`
83/// - `og:description` / `twitter:description` ⇐
84///   `og_description`/`twitter_description` ⇐ `description` ⇐
85///   extracted page text
86/// - `og:image` / `twitter:image` ⇐ `og_image`/`twitter_image` ⇐
87///   `banner` ⇐ `image` ⇐ existing meta / first content `<img>`
88/// - `twitter:card` ⇐ `twitter_card` ⇐ sensible default
89///   (`summary_large_image` when an image exists, else `summary`)
90///
91/// Explicit per-field front matter always wins over the derived
92/// value. All values come from THIS page's sidecar — never from
93/// global config — which kills the stale-`twitter_title` bug class
94/// (spec B8): two pages differing only in `title` must never share a
95/// social title.
96#[derive(Debug, Clone, Default)]
97struct SocialMeta {
98    /// Resolved `og:title` text.
99    og_title: String,
100    /// Resolved `twitter:title` text.
101    twitter_title: String,
102    /// Resolved plain `<meta name="description">` text.
103    description: String,
104    /// Resolved `og:description` text.
105    og_description: String,
106    /// Resolved `twitter:description` text.
107    twitter_description: String,
108    /// Front-matter-derived `og:image`, when any of
109    /// `og_image`/`banner`/`image` is set on the page.
110    og_image: Option<String>,
111    /// Front-matter-derived `twitter:image`, when any of
112    /// `twitter_image`/`banner`/`image` is set on the page.
113    twitter_image: Option<String>,
114    /// Explicit front-matter `twitter_card`, when set.
115    twitter_card: Option<String>,
116}
117
118/// Resolves the spec-B8 social-meta cascade for one page.
119///
120/// Front-matter values come from the page's own `.meta.json` sidecar
121/// (the same per-page source the A5 language resolver uses), so the
122/// derivation can never bleed one page's values into another or fall
123/// back to site-wide config strings.
124fn resolve_social_meta(
125    html: &str,
126    path: &Path,
127    ctx: &PluginContext,
128) -> SocialMeta {
129    let rel_path = path
130        .strip_prefix(&ctx.site_dir)
131        .unwrap_or(path)
132        .to_string_lossy()
133        .replace('\\', "/");
134    let meta = super::lang::read_page_sidecar(path, ctx, &rel_path);
135
136    // First non-empty string among `keys`, in cascade order.
137    let fm = |keys: &[&str]| -> Option<String> {
138        let m = meta.as_ref()?;
139        keys.iter().find_map(|key| {
140            m.get(*key)
141                .and_then(serde_json::Value::as_str)
142                .map(str::trim)
143                .filter(|s| !s.is_empty())
144                .map(str::to_string)
145        })
146    };
147
148    let html_title = extract_title(html);
149    let html_description = extract_description(html, 160);
150
151    SocialMeta {
152        og_title: fm(&["og_title", "seo_title", "title"])
153            .unwrap_or_else(|| html_title.clone()),
154        twitter_title: fm(&["twitter_title", "seo_title", "title"])
155            .unwrap_or(html_title),
156        description: fm(&["description"])
157            .unwrap_or_else(|| html_description.clone()),
158        og_description: fm(&["og_description", "description"])
159            .unwrap_or_else(|| html_description.clone()),
160        twitter_description: fm(&["twitter_description", "description"])
161            .unwrap_or(html_description),
162        og_image: fm(&["og_image", "banner", "image"]),
163        twitter_image: fm(&["twitter_image", "banner", "image"]),
164        twitter_card: fm(&["twitter_card"]),
165    }
166}
167
168/// Builds Open Graph meta tags that are missing from the HTML.
169///
170/// `lang` is the already-resolved page language from
171/// [`resolve_page_lang`] (spec A5, plan §2 1.5) in canonical BCP-47
172/// hyphen form; the Open Graph underscore spelling (`en_GB`) is
173/// produced at this sink only. Titles, descriptions, and images come
174/// from the resolved [`SocialMeta`] cascade (spec B8).
175fn build_og_tags(
176    html: &str,
177    social: &SocialMeta,
178    canonical: &str,
179    og_type: &str,
180    lang: &str,
181) -> Vec<String> {
182    let mut tags = Vec::new();
183
184    if !has_meta_tag(html, "og:title") && !social.og_title.is_empty() {
185        tags.push(format!(
186            "<meta property=\"og:title\" content=\"{}\">",
187            escape_attr(&social.og_title)
188        ));
189    }
190
191    if !has_meta_tag(html, "og:description")
192        && !social.og_description.is_empty()
193    {
194        tags.push(format!(
195            "<meta property=\"og:description\" content=\"{}\">",
196            escape_attr(&social.og_description)
197        ));
198    }
199
200    if !has_meta_tag(html, "og:type") {
201        tags.push(format!("<meta property=\"og:type\" content=\"{og_type}\">"));
202    }
203
204    if !has_meta_tag(html, "og:url") && !canonical.is_empty() {
205        tags.push(format!(
206            "<meta property=\"og:url\" content=\"{}\">",
207            escape_attr(canonical)
208        ));
209    }
210
211    // OG image (spec B8): front matter (og_image ⇐ banner ⇐ image)
212    // first, then existing meta, then first <img> in content.
213    if !has_meta_tag(html, "og:image") {
214        let image = social.og_image.clone().unwrap_or_else(|| {
215            let existing = extract_existing_meta(html, "twitter:image");
216            if existing.is_empty() {
217                extract_first_content_image(html)
218            } else {
219                existing
220            }
221        });
222        if !image.is_empty() {
223            tags.push(format!(
224                "<meta property=\"og:image\" content=\"{}\">",
225                escape_attr(&image)
226            ));
227            // Social platforms render cards faster with explicit dimensions
228            if !has_meta_tag(html, "og:image:width") {
229                tags.push(
230                    "<meta property=\"og:image:width\" content=\"1200\">"
231                        .to_string(),
232                );
233                tags.push(
234                    "<meta property=\"og:image:height\" content=\"630\">"
235                        .to_string(),
236                );
237            }
238        }
239    }
240
241    // OG locale — always emitted from the resolved page language
242    // (spec A5): the resolver never returns an empty value, and the
243    // hyphen→underscore conversion happens only at this sink so the
244    // canonical form stays BCP-47 everywhere else.
245    if !has_meta_tag(html, "og:locale") {
246        let locale = lang.replace('-', "_");
247        tags.push(format!(
248            "<meta property=\"og:locale\" content=\"{}\">",
249            escape_attr(&locale)
250        ));
251    }
252
253    tags
254}
255
256/// Builds Twitter Card meta tags that are missing from the HTML.
257///
258/// Titles, descriptions, and images come from the resolved
259/// [`SocialMeta`] cascade (spec B8); `twitter_card` is the
260/// already-resolved card type (explicit front matter beats the
261/// derived default).
262fn build_twitter_tags(
263    html: &str,
264    social: &SocialMeta,
265    twitter_card: &str,
266) -> Vec<String> {
267    let mut tags = Vec::new();
268
269    if !has_meta_tag(html, "twitter:card") {
270        tags.push(format!(
271            "<meta name=\"twitter:card\" content=\"{}\">",
272            escape_attr(twitter_card)
273        ));
274    }
275
276    if !has_meta_tag(html, "twitter:title") && !social.twitter_title.is_empty()
277    {
278        tags.push(format!(
279            "<meta name=\"twitter:title\" content=\"{}\">",
280            escape_attr(&social.twitter_title)
281        ));
282    }
283
284    if !has_meta_tag(html, "twitter:description")
285        && !social.twitter_description.is_empty()
286    {
287        tags.push(format!(
288            "<meta name=\"twitter:description\" content=\"{}\">",
289            escape_attr(&social.twitter_description)
290        ));
291    }
292
293    // Twitter image (spec B8): front matter (twitter_image ⇐ banner
294    // ⇐ image) first, then existing meta, then first content <img>.
295    if !has_meta_tag(html, "twitter:image") {
296        let image = social.twitter_image.clone().unwrap_or_else(|| {
297            let existing = extract_existing_meta(html, "og:image");
298            if existing.is_empty() {
299                extract_first_content_image(html)
300            } else {
301                existing
302            }
303        });
304        if !image.is_empty() {
305            tags.push(format!(
306                "<meta name=\"twitter:image\" content=\"{}\">",
307                escape_attr(&image)
308            ));
309        }
310    }
311
312    tags
313}
314
315/// Builds the meta description tag if missing from the HTML.
316fn build_meta_description(html: &str, description: &str) -> Option<String> {
317    if !has_meta_tag(html, "description") && !description.is_empty() {
318        Some(format!(
319            "<meta name=\"description\" content=\"{}\">",
320            escape_attr(description)
321        ))
322    } else {
323        None
324    }
325}
326
327/// Inject missing SEO meta tags into an HTML string, returning the
328/// modified HTML. `lang` is the resolved page language (spec A5);
329/// `social` is the page's resolved front-matter cascade (spec B8).
330/// Marks a right-to-left page as such on `<html>`.
331///
332/// `lang` alone does not set direction. A browser given
333/// `<html lang="ar">` still lays the page out left-to-right, so an
334/// Arabic or Hebrew site renders mirrored unless something says
335/// otherwise — and nothing in a build log reports it.
336///
337/// Two deliberate choices:
338///
339/// - **Only `rtl` is written.** Left-to-right is the HTML default, so
340///   stamping `dir="ltr"` onto every page of every site would add bytes
341///   to each one to say what was already true.
342/// - **`html:not([dir])`**, so an author who set `dir` themselves keeps
343///   it. Same rule as `th:not([scope])` in the HTML fixer.
344///
345/// This runs here rather than in a template variable because the
346/// bundled templates are rendered by `staticdatagen`, which substitutes
347/// `{{language}}` but knows nothing of direction — a `{{direction}}`
348/// placeholder there renders as `dir=""`, which is worse than absent.
349fn apply_text_direction(html: &str, lang: &str) -> String {
350    if crate::core_group::lang::text_direction(lang) != "rtl" {
351        return html.to_string();
352    }
353    rewrite_html(
354        html,
355        vec![element!("html:not([dir])", |el| {
356            el.set_attribute("dir", "rtl")?;
357            Ok(())
358        })],
359    )
360    .unwrap_or_else(|_| html.to_string())
361}
362
363fn inject_seo_tags_html(
364    html: &str,
365    lang: &str,
366    social: &SocialMeta,
367) -> Result<String> {
368    fail_point!("seo::inject-tags", |_| {
369        Err(anyhow::anyhow!("injected: seo::inject-tags"))
370    });
371
372    let canonical = extract_canonical(html);
373
374    let is_article = html.contains("<article");
375    let og_type = if is_article { "article" } else { "website" };
376
377    // spec B8: explicit front-matter `twitter_card` wins; otherwise
378    // `summary_large_image` when the page resolves an image (from
379    // front matter, existing meta, or content) or is an article, and
380    // `summary` as the final default.
381    let has_image = social.og_image.is_some()
382        || social.twitter_image.is_some()
383        || !extract_existing_meta(html, "og:image").is_empty()
384        || !extract_existing_meta(html, "twitter:image").is_empty()
385        || !extract_first_content_image(html).is_empty();
386    let derived_card = if has_image || is_article {
387        "summary_large_image"
388    } else {
389        "summary"
390    };
391    let twitter_card = social.twitter_card.as_deref().unwrap_or(derived_card);
392
393    let mut tags = Vec::new();
394
395    if let Some(meta_desc) = build_meta_description(html, &social.description) {
396        tags.push(meta_desc);
397    }
398    tags.extend(build_og_tags(html, social, &canonical, og_type, lang));
399    tags.extend(build_twitter_tags(html, social, twitter_card));
400
401    if tags.is_empty() {
402        return Ok(html.to_string());
403    }
404
405    let injection = format!("{}\n", tags.join("\n"));
406    Ok(inject_before_head_close(html, &injection))
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use std::path::Path;
413    use tempfile::tempdir;
414
415    fn ctx(site: &Path) -> PluginContext {
416        PluginContext::new(
417            Path::new("content"),
418            Path::new("build"),
419            site,
420            Path::new("templates"),
421        )
422    }
423
424    /// A `SocialMeta` with identical og/twitter title + description,
425    /// as the cascade produces when only base fields exist.
426    fn social(title: &str, desc: &str) -> SocialMeta {
427        SocialMeta {
428            og_title: title.to_string(),
429            twitter_title: title.to_string(),
430            description: desc.to_string(),
431            og_description: desc.to_string(),
432            twitter_description: desc.to_string(),
433            ..SocialMeta::default()
434        }
435    }
436
437    #[test]
438    fn name_is_stable() {
439        assert_eq!(SeoPlugin.name(), "seo");
440    }
441
442    #[test]
443    #[serial_test::parallel]
444    fn no_op_when_site_dir_missing() {
445        let dir = tempdir().unwrap();
446        SeoPlugin
447            .after_compile(&ctx(&dir.path().join("nope")))
448            .unwrap();
449    }
450
451    // ── build_meta_description ──────────────────────────────────
452
453    #[test]
454    fn meta_description_built_when_missing_and_text_provided() {
455        let html = r#"<html><head><title>X</title></head><body></body></html>"#;
456        let out = build_meta_description(html, "A cool page");
457        assert_eq!(
458            out.as_deref(),
459            Some(r#"<meta name="description" content="A cool page">"#)
460        );
461    }
462
463    #[test]
464    fn meta_description_skipped_when_empty_text() {
465        let html = "<html><head></head></html>";
466        assert!(build_meta_description(html, "").is_none());
467    }
468
469    #[test]
470    fn meta_description_skipped_when_already_present() {
471        let html = r#"<html><head><meta name="description" content="X"></head></html>"#;
472        assert!(build_meta_description(html, "Override?").is_none());
473    }
474
475    #[test]
476    fn meta_description_escapes_attribute_value() {
477        let html = "<html><head></head></html>";
478        let out = build_meta_description(html, r#"X & "Y" <Z>"#).unwrap();
479        // No raw `&`, raw `"` between content="...", or raw `<` in attribute.
480        assert!(out.contains("content="));
481        assert!(!out.contains(r#"content="X & ""#));
482    }
483
484    // ── build_og_tags ───────────────────────────────────────────
485
486    #[test]
487    fn og_tags_includes_title_description_type_url() {
488        let html = "<html lang=\"en\"><head></head></html>";
489        let tags = build_og_tags(
490            html,
491            &social("Hello", "World"),
492            "https://example.com/page",
493            "website",
494            "en",
495        );
496        let joined = tags.join("\n");
497        assert!(joined.contains(r#"property="og:title" content="Hello""#));
498        assert!(joined.contains(r#"property="og:description" content="World""#));
499        assert!(joined.contains(r#"property="og:type" content="website""#));
500        assert!(joined.contains(
501            r#"property="og:url" content="https://example.com/page""#
502        ));
503        assert!(joined.contains(r#"property="og:locale" content="en""#));
504    }
505
506    #[test]
507    fn og_tags_skips_existing_tags() {
508        let html = r#"<html lang="en"><head>
509            <meta property="og:title" content="Existing">
510            <meta property="og:type" content="article">
511        </head></html>"#;
512        let tags = build_og_tags(
513            html,
514            &social("Hello", "World"),
515            "https://example.com",
516            "website",
517            "en",
518        );
519        let joined = tags.join("\n");
520        assert!(
521            !joined.contains(r#"property="og:title""#),
522            "should not duplicate og:title: {joined}"
523        );
524        assert!(
525            !joined.contains(r#"property="og:type""#),
526            "should not duplicate og:type"
527        );
528    }
529
530    #[test]
531    fn og_tags_falls_back_from_twitter_image_when_og_image_missing() {
532        let html = r#"<html><head>
533            <meta name="twitter:image" content="/twit.png">
534        </head></html>"#;
535        let tags = build_og_tags(html, &social("T", "D"), "", "website", "en");
536        let joined = tags.join("\n");
537        assert!(
538            joined.contains(r#"property="og:image" content="/twit.png""#),
539            "should reuse twitter:image when og:image absent: {joined}"
540        );
541        // and emit explicit dimensions for fast social card render
542        assert!(joined.contains(r#"property="og:image:width" content="1200""#));
543        assert!(joined.contains(r#"property="og:image:height" content="630""#));
544    }
545
546    #[test]
547    fn og_tags_locale_translates_resolved_lang_dashes_to_underscores() {
548        // spec A5: og:locale is the resolver's BCP-47 output with the
549        // hyphen→underscore conversion applied at this sink only.
550        let html = "<html lang=\"en-GB\"><head></head></html>";
551        let tags =
552            build_og_tags(html, &social("T", "D"), "", "website", "en-GB");
553        let joined = tags.join("\n");
554        assert!(
555            joined.contains(r#"property="og:locale" content="en_GB""#),
556            "resolved en-GB should produce og:locale=\"en_GB\", got: {joined}"
557        );
558    }
559
560    #[test]
561    fn og_tags_always_emits_resolved_locale() {
562        // Pre-A5 behaviour omitted og:locale when <html lang> was
563        // missing; the resolver always produces a value now, so the
564        // tag is always present and agrees with JSON-LD inLanguage.
565        let html = "<html><head></head></html>";
566        let tags = build_og_tags(html, &social("T", "D"), "", "website", "hi");
567        let joined = tags.join("\n");
568        assert!(
569            joined.contains(r#"property="og:locale" content="hi""#),
570            "resolved lang must always be emitted, got: {joined}"
571        );
572    }
573
574    // ── build_twitter_tags ──────────────────────────────────────
575
576    #[test]
577    fn twitter_tags_includes_card_title_description() {
578        let html = "<html><head></head></html>";
579        let tags = build_twitter_tags(html, &social("T", "D"), "summary");
580        let joined = tags.join("\n");
581        assert!(joined.contains(r#"name="twitter:card" content="summary""#));
582        assert!(joined.contains(r#"name="twitter:title" content="T""#));
583        assert!(joined.contains(r#"name="twitter:description" content="D""#));
584    }
585
586    #[test]
587    fn twitter_tags_falls_back_to_og_image_when_twitter_image_missing() {
588        let html = r#"<html><head>
589            <meta property="og:image" content="/og.png">
590        </head></html>"#;
591        let tags = build_twitter_tags(html, &social("T", "D"), "summary");
592        let joined = tags.join("\n");
593        assert!(
594            joined.contains(r#"name="twitter:image" content="/og.png""#),
595            "should reuse og:image when twitter:image absent: {joined}"
596        );
597    }
598
599    // ── text direction ─────────────────────────────────────────
600
601    /// `lang` alone does not set direction: a browser given
602    /// `<html lang="ar">` still lays the page out left-to-right.
603    #[test]
604    fn a_right_to_left_language_marks_the_html_element() {
605        let out = apply_text_direction(
606            r#"<html lang="ar"><head></head><body>x</body></html>"#,
607            "ar",
608        );
609        assert!(out.contains(r#"dir="rtl""#), "{out}");
610    }
611
612    #[test]
613    fn hebrew_with_a_region_subtag_is_still_right_to_left() {
614        let out =
615            apply_text_direction(r#"<html lang="he-IL"></html>"#, "he-IL");
616        assert!(out.contains(r#"dir="rtl""#), "{out}");
617    }
618
619    /// Left-to-right is the HTML default, so saying so on every page of
620    /// every site would add bytes to each one to state what was already
621    /// true.
622    #[test]
623    fn a_left_to_right_language_adds_nothing() {
624        let html = r#"<html lang="ja"><head></head><body>x</body></html>"#;
625        assert_eq!(apply_text_direction(html, "ja"), html);
626    }
627
628    /// An author who set `dir` themselves keeps it — the same rule the
629    /// HTML fixer applies to `th[scope]`.
630    #[test]
631    fn an_author_supplied_direction_is_never_overwritten() {
632        let out =
633            apply_text_direction(r#"<html lang="ar" dir="ltr"></html>"#, "ar");
634        assert!(out.contains(r#"dir="ltr""#), "{out}");
635        assert!(!out.contains(r#"dir="rtl""#), "{out}");
636    }
637
638    // ── inject_seo_tags integration via after_compile ───────────
639
640    #[test]
641    #[serial_test::parallel]
642    fn transform_html_injects_tags() {
643        let dir = tempdir().unwrap();
644        let c = ctx(dir.path());
645
646        let html = r#"<!doctype html><html lang="en"><head><title>Hello</title></head>
647            <body><p>World is wide.</p></body></html>"#;
648
649        let after = SeoPlugin
650            .transform_html(html, Path::new("page.html"), &c)
651            .unwrap();
652        assert!(after.contains("og:title"));
653        assert!(after.contains("twitter:card"));
654        assert!(after.contains("name=\"description\""));
655    }
656
657    #[test]
658    #[serial_test::parallel]
659    fn transform_html_uses_article_type_when_article_tag_present() {
660        let dir = tempdir().unwrap();
661        let c = ctx(dir.path());
662
663        let html = r#"<!doctype html><html lang="en"><head><title>P</title></head>
664            <body><article><p>Content.</p></article></body></html>"#;
665
666        let after = SeoPlugin
667            .transform_html(html, Path::new("post.html"), &c)
668            .unwrap();
669        assert!(
670            after.contains(r#"og:type" content="article""#),
671            "presence of <article> should set og:type=article: {after}"
672        );
673        assert!(
674            after.contains(r#"twitter:card" content="summary_large_image""#),
675            "article should use summary_large_image twitter card: {after}"
676        );
677    }
678
679    #[test]
680    #[serial_test::parallel]
681    fn transform_html_is_idempotent() {
682        let dir = tempdir().unwrap();
683        let c = ctx(dir.path());
684
685        let html = r#"<html lang="en"><head><title>Y</title></head><body>Z</body></html>"#;
686
687        let first = SeoPlugin
688            .transform_html(html, Path::new("x.html"), &c)
689            .unwrap();
690        let second = SeoPlugin
691            .transform_html(&first, Path::new("x.html"), &c)
692            .unwrap();
693        assert_eq!(first, second, "second run must not duplicate meta tags");
694    }
695
696    #[cfg(feature = "i18n")]
697    #[test]
698    #[serial_test::parallel]
699    fn after_compile_no_op_when_no_html_files() {
700        let dir = tempdir().unwrap();
701        // Site dir exists but is empty.
702        SeoPlugin.after_compile(&ctx(dir.path())).unwrap();
703    }
704
705    // ── og:locale via resolve_page_lang (spec A5, plan §2 1.5) ──
706
707    /// Context with a site `language` and declared `[i18n]` locales.
708    #[cfg(feature = "i18n")]
709    fn locale_ctx(
710        site: &Path,
711        language: &str,
712        locales: &[&str],
713    ) -> PluginContext {
714        let mut c = ctx(site);
715        c.config = Some(crate::cmd::SsgConfig {
716            language: language.to_string(),
717            i18n: Some(crate::i18n::I18nConfig {
718                default_locale: locales
719                    .first()
720                    .map_or_else(|| "en".to_string(), |l| (*l).to_string()),
721                locales: locales.iter().map(|l| (*l).to_string()).collect(),
722                url_prefix: Default::default(),
723            }),
724            ..crate::cmd::SsgConfig::default()
725        });
726        c
727    }
728
729    #[cfg(feature = "i18n")]
730    #[test]
731    #[serial_test::parallel]
732    fn og_locale_is_path_driven_on_locale_pages() {
733        // The A5 signature bug: a /hi/… page carrying the site-wide
734        // lang="en-GB" must emit og:locale=hi, not en_GB.
735        let dir = tempdir().unwrap();
736        let c = locale_ctx(dir.path(), "en-GB", &["en", "hi"]);
737        let html = r#"<html lang="en-GB"><head><title>T</title></head><body>x</body></html>"#;
738        let page = dir.path().join("hi/2026-06-01-post/index.html");
739        let out = SeoPlugin.transform_html(html, &page, &c).unwrap();
740        assert!(
741            out.contains(r#"property="og:locale" content="hi""#),
742            "expected path-driven og:locale=hi, got: {out}"
743        );
744    }
745
746    #[cfg(feature = "i18n")]
747    #[test]
748    #[serial_test::parallel]
749    fn og_locale_is_default_driven_with_underscore_form() {
750        // en-GB default: og:locale uses the underscore spelling while
751        // the resolver stays canonical BCP-47 (en-GB).
752        let dir = tempdir().unwrap();
753        let c = locale_ctx(dir.path(), "en-GB", &["en"]);
754        let html = "<html><head><title>T</title></head><body>x</body></html>";
755        let page = dir.path().join("about/index.html");
756        let out = SeoPlugin.transform_html(html, &page, &c).unwrap();
757        assert!(
758            out.contains(r#"property="og:locale" content="en_GB""#),
759            "expected default-driven og:locale=en_GB, got: {out}"
760        );
761    }
762
763    #[test]
764    #[serial_test::parallel]
765    fn og_locale_en_fallback_only_when_nothing_resolves() {
766        let dir = tempdir().unwrap();
767        // No config, no sidecar, no locale prefix, no <html lang>.
768        let c = ctx(dir.path());
769        let html = "<html><head><title>T</title></head><body>x</body></html>";
770        let page = dir.path().join("index.html");
771        let out = SeoPlugin.transform_html(html, &page, &c).unwrap();
772        assert!(
773            out.contains(r#"property="og:locale" content="en""#),
774            "expected final-constant og:locale=en, got: {out}"
775        );
776    }
777
778    // ── spec B8: social-meta derivation cascade ─────────────────
779
780    /// Context rooted in `dir` so `<dir>/build/.meta` sidecars are
781    /// found for pages under `<dir>/site`.
782    fn ctx_rooted(dir: &Path) -> PluginContext {
783        PluginContext::new(
784            Path::new("content"),
785            &dir.join("build"),
786            &dir.join("site"),
787            Path::new("templates"),
788        )
789    }
790
791    /// Writes a front-matter sidecar for the site-relative page `rel`.
792    fn write_sidecar(dir: &Path, rel: &str, json: &str) {
793        let sidecar = dir
794            .join("build")
795            .join(".meta")
796            .join(rel)
797            .with_extension("meta.json");
798        std::fs::create_dir_all(sidecar.parent().unwrap()).unwrap();
799        std::fs::write(sidecar, json).unwrap();
800    }
801
802    fn meta_content(html: &str, attr: &str) -> String {
803        extract_existing_meta(html, attr)
804    }
805
806    #[test]
807    #[serial_test::parallel]
808    fn b8_title_description_banner_yield_complete_consistent_social_set() {
809        // Acceptance (spec B8): a post with ONLY title + description
810        // + banner gets complete, mutually consistent og:*/twitter:*.
811        let dir = tempdir().unwrap();
812        write_sidecar(
813            dir.path(),
814            "post/index.html",
815            r#"{"title":"My Post","description":"A fine description","banner":"/img/banner.webp"}"#,
816        );
817        let c = ctx_rooted(dir.path());
818        let html = "<html lang=\"en\"><head><title>My Post</title></head><body><p>text</p></body></html>";
819        let page = dir.path().join("site/post/index.html");
820        let out = SeoPlugin.transform_html(html, &page, &c).unwrap();
821
822        assert_eq!(meta_content(&out, "og:title"), "My Post");
823        assert_eq!(meta_content(&out, "twitter:title"), "My Post");
824        assert_eq!(meta_content(&out, "og:description"), "A fine description");
825        assert_eq!(
826            meta_content(&out, "twitter:description"),
827            "A fine description"
828        );
829        assert_eq!(meta_content(&out, "description"), "A fine description");
830        assert_eq!(meta_content(&out, "og:image"), "/img/banner.webp");
831        assert_eq!(meta_content(&out, "twitter:image"), "/img/banner.webp");
832        // Image present ⇒ summary_large_image.
833        assert_eq!(meta_content(&out, "twitter:card"), "summary_large_image");
834        // Mutual consistency: og and twitter agree everywhere.
835        assert_eq!(
836            meta_content(&out, "og:title"),
837            meta_content(&out, "twitter:title")
838        );
839        assert_eq!(
840            meta_content(&out, "og:image"),
841            meta_content(&out, "twitter:image")
842        );
843    }
844
845    #[test]
846    #[serial_test::parallel]
847    fn b8_seo_title_beats_title_and_explicit_fields_beat_seo_title() {
848        let dir = tempdir().unwrap();
849        write_sidecar(
850            dir.path(),
851            "p/index.html",
852            r#"{"title":"Base","seo_title":"Seo Title","twitter_title":"Tw Title"}"#,
853        );
854        let c = ctx_rooted(dir.path());
855        let html =
856            "<html><head><title>Base</title></head><body>x</body></html>";
857        let page = dir.path().join("site/p/index.html");
858        let out = SeoPlugin.transform_html(html, &page, &c).unwrap();
859
860        // twitter_title (explicit) ⇐ seo_title ⇐ title
861        assert_eq!(meta_content(&out, "twitter:title"), "Tw Title");
862        // og_title unset ⇒ seo_title wins over title.
863        assert_eq!(meta_content(&out, "og:title"), "Seo Title");
864    }
865
866    #[test]
867    #[serial_test::parallel]
868    fn b8_banner_beats_image_and_og_image_beats_banner() {
869        let dir = tempdir().unwrap();
870        write_sidecar(
871            dir.path(),
872            "a/index.html",
873            r#"{"title":"T","image":"/i.png"}"#,
874        );
875        write_sidecar(
876            dir.path(),
877            "b/index.html",
878            r#"{"title":"T","image":"/i.png","banner":"/b.png"}"#,
879        );
880        write_sidecar(
881            dir.path(),
882            "c/index.html",
883            r#"{"title":"T","banner":"/b.png","og_image":"/og.png"}"#,
884        );
885        let c = ctx_rooted(dir.path());
886        let html = "<html><head><title>T</title></head><body>x</body></html>";
887
888        let out_a = SeoPlugin
889            .transform_html(html, &dir.path().join("site/a/index.html"), &c)
890            .unwrap();
891        assert_eq!(meta_content(&out_a, "og:image"), "/i.png");
892        assert_eq!(meta_content(&out_a, "twitter:image"), "/i.png");
893
894        let out_b = SeoPlugin
895            .transform_html(html, &dir.path().join("site/b/index.html"), &c)
896            .unwrap();
897        assert_eq!(meta_content(&out_b, "og:image"), "/b.png");
898
899        let out_c = SeoPlugin
900            .transform_html(html, &dir.path().join("site/c/index.html"), &c)
901            .unwrap();
902        assert_eq!(meta_content(&out_c, "og:image"), "/og.png");
903        // twitter_image not explicitly set ⇒ banner still wins there.
904        assert_eq!(meta_content(&out_c, "twitter:image"), "/b.png");
905    }
906
907    #[test]
908    #[serial_test::parallel]
909    fn b8_explicit_twitter_card_wins_over_derived_default() {
910        let dir = tempdir().unwrap();
911        write_sidecar(
912            dir.path(),
913            "p/index.html",
914            r#"{"title":"T","banner":"/b.png","twitter_card":"summary"}"#,
915        );
916        let c = ctx_rooted(dir.path());
917        let html = "<html><head><title>T</title></head><body>x</body></html>";
918        let out = SeoPlugin
919            .transform_html(html, &dir.path().join("site/p/index.html"), &c)
920            .unwrap();
921        // Image present would derive summary_large_image, but the
922        // explicit front-matter field always wins (spec B8).
923        assert_eq!(meta_content(&out, "twitter:card"), "summary");
924    }
925
926    #[test]
927    #[serial_test::parallel]
928    fn b8_card_defaults_to_summary_without_image_or_article() {
929        let dir = tempdir().unwrap();
930        let c = ctx_rooted(dir.path());
931        let html = "<html><head><title>T</title></head><body>x</body></html>";
932        let out = SeoPlugin
933            .transform_html(html, &dir.path().join("site/p/index.html"), &c)
934            .unwrap();
935        assert_eq!(meta_content(&out, "twitter:card"), "summary");
936    }
937
938    #[test]
939    #[serial_test::parallel]
940    fn b8_no_bleed_between_pages_differing_only_in_title() {
941        // The stale-field bug class (spec B8): derived values must
942        // come from THIS page's front matter, never another page's or
943        // global config.
944        let dir = tempdir().unwrap();
945        write_sidecar(
946            dir.path(),
947            "alpha/index.html",
948            r#"{"title":"Alpha Page","description":"same","banner":"/same.png"}"#,
949        );
950        write_sidecar(
951            dir.path(),
952            "beta/index.html",
953            r#"{"title":"Beta Page","description":"same","banner":"/same.png"}"#,
954        );
955        let mut c = ctx_rooted(dir.path());
956        // Global config with a site name that must never leak into
957        // per-page social titles.
958        c.config = Some(crate::cmd::SsgConfig {
959            site_name: "Global Site Name".to_string(),
960            ..crate::cmd::SsgConfig::default()
961        });
962        let html = "<html><head><title>t</title></head><body>x</body></html>";
963
964        let out_a = SeoPlugin
965            .transform_html(html, &dir.path().join("site/alpha/index.html"), &c)
966            .unwrap();
967        let out_b = SeoPlugin
968            .transform_html(html, &dir.path().join("site/beta/index.html"), &c)
969            .unwrap();
970
971        assert_eq!(meta_content(&out_a, "og:title"), "Alpha Page");
972        assert_eq!(meta_content(&out_b, "og:title"), "Beta Page");
973        assert_eq!(meta_content(&out_a, "twitter:title"), "Alpha Page");
974        assert_eq!(meta_content(&out_b, "twitter:title"), "Beta Page");
975        assert!(!out_a.contains("Beta Page"), "page A leaked page B's title");
976        assert!(
977            !out_b.contains("Alpha Page"),
978            "page B leaked page A's title"
979        );
980        assert!(
981            !out_a.contains("Global Site Name"),
982            "global config bled into page meta"
983        );
984    }
985
986    #[test]
987    #[serial_test::parallel]
988    fn transform_html_handles_html_without_head_tag() {
989        let dir = tempdir().unwrap();
990        let c = ctx(dir.path());
991        let raw = "<!doctype html><html><body>only</body></html>";
992        let after = SeoPlugin
993            .transform_html(raw, Path::new("frag.html"), &c)
994            .unwrap();
995        assert_eq!(after, raw);
996    }
997
998    #[test]
999    fn og_tags_skips_image_block_when_og_image_present() {
1000        // An existing og:image means the whole image block (image +
1001        // width/height) is left alone.
1002        let html = r#"<html><head>
1003            <meta property="og:image" content="/have.png">
1004        </head></html>"#;
1005        let tags = build_og_tags(html, &social("T", "D"), "", "website", "en");
1006        let joined = tags.join("\n");
1007        assert!(
1008            !joined.contains("og:image"),
1009            "existing og:image must suppress image emission: {joined}"
1010        );
1011    }
1012
1013    #[test]
1014    fn og_tags_skips_dimensions_when_width_already_present() {
1015        // og:image is missing (and derivable from twitter:image), but
1016        // explicit dimensions already exist — only og:image is added.
1017        let html = r#"<html><head>
1018            <meta name="twitter:image" content="/twit.png">
1019            <meta property="og:image:width" content="800">
1020            <meta property="og:image:height" content="420">
1021        </head></html>"#;
1022        let tags = build_og_tags(html, &social("T", "D"), "", "website", "en");
1023        let joined = tags.join("\n");
1024        assert!(joined.contains(r#"property="og:image" content="/twit.png""#));
1025        assert!(
1026            !joined.contains(r#"content="1200""#),
1027            "must not re-emit default dimensions: {joined}"
1028        );
1029    }
1030
1031    #[test]
1032    fn twitter_tags_skips_image_when_twitter_image_present() {
1033        let html = r#"<html><head>
1034            <meta name="twitter:image" content="/have.png">
1035        </head></html>"#;
1036        let tags = build_twitter_tags(html, &social("T", "D"), "summary");
1037        let joined = tags.join("\n");
1038        assert!(
1039            !joined.contains("twitter:image"),
1040            "existing twitter:image must suppress emission: {joined}"
1041        );
1042    }
1043
1044    #[cfg(feature = "i18n")]
1045    #[test]
1046    #[serial_test::parallel]
1047    fn og_locale_with_empty_declared_locale_set_uses_site_language() {
1048        // Zero declared locales: the helper's default-locale fallback
1049        // kicks in and the site language drives og:locale.
1050        let dir = tempdir().unwrap();
1051        let c = locale_ctx(dir.path(), "en-GB", &[]);
1052        let html = "<html><head><title>T</title></head><body>x</body></html>";
1053        let page = dir.path().join("about/index.html");
1054        let out = SeoPlugin.transform_html(html, &page, &c).unwrap();
1055        assert!(
1056            out.contains(r#"property="og:locale" content="en_GB""#),
1057            "expected og:locale=en_GB from site language, got: {out}"
1058        );
1059    }
1060}
1061
1062#[cfg(all(test, feature = "test-fault-injection"))]
1063mod fault_tests {
1064    use super::*;
1065    use serial_test::serial;
1066    use std::path::Path;
1067    use tempfile::tempdir;
1068
1069    /// RAII guard that disables a failpoint on drop.
1070    struct FailGuard<'a>(&'a str);
1071
1072    impl Drop for FailGuard<'_> {
1073        fn drop(&mut self) {
1074            let _ = fail::cfg(self.0, "off");
1075        }
1076    }
1077
1078    #[test]
1079    #[serial]
1080    fn transform_html_maps_injection_failure_to_io_error() {
1081        let _guard = FailGuard("seo::inject-tags");
1082        fail::cfg("seo::inject-tags", "return").unwrap();
1083
1084        let dir = tempdir().unwrap();
1085        let c = PluginContext::new(
1086            Path::new("content"),
1087            Path::new("build"),
1088            dir.path(),
1089            Path::new("templates"),
1090        );
1091        let html = "<html><head><title>T</title></head><body>x</body></html>";
1092        let err = SeoPlugin
1093            .transform_html(html, Path::new("page.html"), &c)
1094            .expect_err("failpoint must abort tag injection");
1095        assert!(
1096            err.to_string().contains("seo::inject-tags"),
1097            "injected error should surface with its failpoint name: {err}"
1098        );
1099    }
1100}