use super::helpers::{
escape_attr, extract_canonical, extract_description, extract_existing_meta,
extract_first_content_image, extract_title, has_meta_tag,
};
use super::lang::resolve_page_lang;
use crate::plugin::{Plugin, PluginContext};
use crate::util::head_dom::inject_before_head_close;
use anyhow::Result;
use std::path::Path;
#[derive(Debug, Clone, Copy)]
pub struct SeoPlugin;
impl Plugin for SeoPlugin {
fn name(&self) -> &'static str {
"seo"
}
fn has_transform(&self) -> bool {
true
}
fn transform_html(
&self,
html: &str,
path: &Path,
ctx: &PluginContext,
) -> std::result::Result<String, crate::error::SsgError> {
let lang = resolve_page_lang(html, path, ctx);
let social = resolve_social_meta(html, path, ctx);
inject_seo_tags_html(html, &lang, &social)
.map_err(|e| crate::error::SsgError::io(e, path))
}
fn after_compile(
&self,
_ctx: &PluginContext,
) -> std::result::Result<(), crate::error::SsgError> {
Ok(())
}
}
#[derive(Debug, Clone, Default)]
struct SocialMeta {
og_title: String,
twitter_title: String,
description: String,
og_description: String,
twitter_description: String,
og_image: Option<String>,
twitter_image: Option<String>,
twitter_card: Option<String>,
}
fn resolve_social_meta(
html: &str,
path: &Path,
ctx: &PluginContext,
) -> SocialMeta {
let rel_path = path
.strip_prefix(&ctx.site_dir)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
let meta = super::lang::read_page_sidecar(path, ctx, &rel_path);
let fm = |keys: &[&str]| -> Option<String> {
let m = meta.as_ref()?;
keys.iter().find_map(|key| {
m.get(*key)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
})
};
let html_title = extract_title(html);
let html_description = extract_description(html, 160);
SocialMeta {
og_title: fm(&["og_title", "seo_title", "title"])
.unwrap_or_else(|| html_title.clone()),
twitter_title: fm(&["twitter_title", "seo_title", "title"])
.unwrap_or(html_title),
description: fm(&["description"])
.unwrap_or_else(|| html_description.clone()),
og_description: fm(&["og_description", "description"])
.unwrap_or_else(|| html_description.clone()),
twitter_description: fm(&["twitter_description", "description"])
.unwrap_or(html_description),
og_image: fm(&["og_image", "banner", "image"]),
twitter_image: fm(&["twitter_image", "banner", "image"]),
twitter_card: fm(&["twitter_card"]),
}
}
fn build_og_tags(
html: &str,
social: &SocialMeta,
canonical: &str,
og_type: &str,
lang: &str,
) -> Vec<String> {
let mut tags = Vec::new();
if !has_meta_tag(html, "og:title") && !social.og_title.is_empty() {
tags.push(format!(
"<meta property=\"og:title\" content=\"{}\">",
escape_attr(&social.og_title)
));
}
if !has_meta_tag(html, "og:description")
&& !social.og_description.is_empty()
{
tags.push(format!(
"<meta property=\"og:description\" content=\"{}\">",
escape_attr(&social.og_description)
));
}
if !has_meta_tag(html, "og:type") {
tags.push(format!("<meta property=\"og:type\" content=\"{og_type}\">"));
}
if !has_meta_tag(html, "og:url") && !canonical.is_empty() {
tags.push(format!(
"<meta property=\"og:url\" content=\"{}\">",
escape_attr(canonical)
));
}
if !has_meta_tag(html, "og:image") {
let image = social.og_image.clone().unwrap_or_else(|| {
let existing = extract_existing_meta(html, "twitter:image");
if existing.is_empty() {
extract_first_content_image(html)
} else {
existing
}
});
if !image.is_empty() {
tags.push(format!(
"<meta property=\"og:image\" content=\"{}\">",
escape_attr(&image)
));
if !has_meta_tag(html, "og:image:width") {
tags.push(
"<meta property=\"og:image:width\" content=\"1200\">"
.to_string(),
);
tags.push(
"<meta property=\"og:image:height\" content=\"630\">"
.to_string(),
);
}
}
}
if !has_meta_tag(html, "og:locale") {
let locale = lang.replace('-', "_");
tags.push(format!(
"<meta property=\"og:locale\" content=\"{}\">",
escape_attr(&locale)
));
}
tags
}
fn build_twitter_tags(
html: &str,
social: &SocialMeta,
twitter_card: &str,
) -> Vec<String> {
let mut tags = Vec::new();
if !has_meta_tag(html, "twitter:card") {
tags.push(format!(
"<meta name=\"twitter:card\" content=\"{}\">",
escape_attr(twitter_card)
));
}
if !has_meta_tag(html, "twitter:title") && !social.twitter_title.is_empty()
{
tags.push(format!(
"<meta name=\"twitter:title\" content=\"{}\">",
escape_attr(&social.twitter_title)
));
}
if !has_meta_tag(html, "twitter:description")
&& !social.twitter_description.is_empty()
{
tags.push(format!(
"<meta name=\"twitter:description\" content=\"{}\">",
escape_attr(&social.twitter_description)
));
}
if !has_meta_tag(html, "twitter:image") {
let image = social.twitter_image.clone().unwrap_or_else(|| {
let existing = extract_existing_meta(html, "og:image");
if existing.is_empty() {
extract_first_content_image(html)
} else {
existing
}
});
if !image.is_empty() {
tags.push(format!(
"<meta name=\"twitter:image\" content=\"{}\">",
escape_attr(&image)
));
}
}
tags
}
fn build_meta_description(html: &str, description: &str) -> Option<String> {
if !has_meta_tag(html, "description") && !description.is_empty() {
Some(format!(
"<meta name=\"description\" content=\"{}\">",
escape_attr(description)
))
} else {
None
}
}
fn inject_seo_tags_html(
html: &str,
lang: &str,
social: &SocialMeta,
) -> Result<String> {
fail_point!("seo::inject-tags", |_| {
Err(anyhow::anyhow!("injected: seo::inject-tags"))
});
let canonical = extract_canonical(html);
let is_article = html.contains("<article");
let og_type = if is_article { "article" } else { "website" };
let has_image = social.og_image.is_some()
|| social.twitter_image.is_some()
|| !extract_existing_meta(html, "og:image").is_empty()
|| !extract_existing_meta(html, "twitter:image").is_empty()
|| !extract_first_content_image(html).is_empty();
let derived_card = if has_image || is_article {
"summary_large_image"
} else {
"summary"
};
let twitter_card = social.twitter_card.as_deref().unwrap_or(derived_card);
let mut tags = Vec::new();
if let Some(meta_desc) = build_meta_description(html, &social.description) {
tags.push(meta_desc);
}
tags.extend(build_og_tags(html, social, &canonical, og_type, lang));
tags.extend(build_twitter_tags(html, social, twitter_card));
if tags.is_empty() {
return Ok(html.to_string());
}
let injection = format!("{}\n", tags.join("\n"));
Ok(inject_before_head_close(html, &injection))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::path::Path;
use tempfile::tempdir;
fn ctx(site: &Path) -> PluginContext {
PluginContext::new(
Path::new("content"),
Path::new("build"),
site,
Path::new("templates"),
)
}
fn social(title: &str, desc: &str) -> SocialMeta {
SocialMeta {
og_title: title.to_string(),
twitter_title: title.to_string(),
description: desc.to_string(),
og_description: desc.to_string(),
twitter_description: desc.to_string(),
..SocialMeta::default()
}
}
#[test]
fn name_is_stable() {
assert_eq!(SeoPlugin.name(), "seo");
}
#[test]
#[serial_test::parallel]
fn no_op_when_site_dir_missing() {
let dir = tempdir().unwrap();
SeoPlugin
.after_compile(&ctx(&dir.path().join("nope")))
.unwrap();
}
#[test]
fn meta_description_built_when_missing_and_text_provided() {
let html = r#"<html><head><title>X</title></head><body></body></html>"#;
let out = build_meta_description(html, "A cool page");
assert_eq!(
out.as_deref(),
Some(r#"<meta name="description" content="A cool page">"#)
);
}
#[test]
fn meta_description_skipped_when_empty_text() {
let html = "<html><head></head></html>";
assert!(build_meta_description(html, "").is_none());
}
#[test]
fn meta_description_skipped_when_already_present() {
let html = r#"<html><head><meta name="description" content="X"></head></html>"#;
assert!(build_meta_description(html, "Override?").is_none());
}
#[test]
fn meta_description_escapes_attribute_value() {
let html = "<html><head></head></html>";
let out = build_meta_description(html, r#"X & "Y" <Z>"#).unwrap();
assert!(out.contains("content="));
assert!(!out.contains(r#"content="X & ""#));
}
#[test]
fn og_tags_includes_title_description_type_url() {
let html = "<html lang=\"en\"><head></head></html>";
let tags = build_og_tags(
html,
&social("Hello", "World"),
"https://example.com/page",
"website",
"en",
);
let joined = tags.join("\n");
assert!(joined.contains(r#"property="og:title" content="Hello""#));
assert!(joined.contains(r#"property="og:description" content="World""#));
assert!(joined.contains(r#"property="og:type" content="website""#));
assert!(joined.contains(
r#"property="og:url" content="https://example.com/page""#
));
assert!(joined.contains(r#"property="og:locale" content="en""#));
}
#[test]
fn og_tags_skips_existing_tags() {
let html = r#"<html lang="en"><head>
<meta property="og:title" content="Existing">
<meta property="og:type" content="article">
</head></html>"#;
let tags = build_og_tags(
html,
&social("Hello", "World"),
"https://example.com",
"website",
"en",
);
let joined = tags.join("\n");
assert!(
!joined.contains(r#"property="og:title""#),
"should not duplicate og:title: {joined}"
);
assert!(
!joined.contains(r#"property="og:type""#),
"should not duplicate og:type"
);
}
#[test]
fn og_tags_falls_back_from_twitter_image_when_og_image_missing() {
let html = r#"<html><head>
<meta name="twitter:image" content="/twit.png">
</head></html>"#;
let tags = build_og_tags(html, &social("T", "D"), "", "website", "en");
let joined = tags.join("\n");
assert!(
joined.contains(r#"property="og:image" content="/twit.png""#),
"should reuse twitter:image when og:image absent: {joined}"
);
assert!(joined.contains(r#"property="og:image:width" content="1200""#));
assert!(joined.contains(r#"property="og:image:height" content="630""#));
}
#[test]
fn og_tags_locale_translates_resolved_lang_dashes_to_underscores() {
let html = "<html lang=\"en-GB\"><head></head></html>";
let tags =
build_og_tags(html, &social("T", "D"), "", "website", "en-GB");
let joined = tags.join("\n");
assert!(
joined.contains(r#"property="og:locale" content="en_GB""#),
"resolved en-GB should produce og:locale=\"en_GB\", got: {joined}"
);
}
#[test]
fn og_tags_always_emits_resolved_locale() {
let html = "<html><head></head></html>";
let tags = build_og_tags(html, &social("T", "D"), "", "website", "hi");
let joined = tags.join("\n");
assert!(
joined.contains(r#"property="og:locale" content="hi""#),
"resolved lang must always be emitted, got: {joined}"
);
}
#[test]
fn twitter_tags_includes_card_title_description() {
let html = "<html><head></head></html>";
let tags = build_twitter_tags(html, &social("T", "D"), "summary");
let joined = tags.join("\n");
assert!(joined.contains(r#"name="twitter:card" content="summary""#));
assert!(joined.contains(r#"name="twitter:title" content="T""#));
assert!(joined.contains(r#"name="twitter:description" content="D""#));
}
#[test]
fn twitter_tags_falls_back_to_og_image_when_twitter_image_missing() {
let html = r#"<html><head>
<meta property="og:image" content="/og.png">
</head></html>"#;
let tags = build_twitter_tags(html, &social("T", "D"), "summary");
let joined = tags.join("\n");
assert!(
joined.contains(r#"name="twitter:image" content="/og.png""#),
"should reuse og:image when twitter:image absent: {joined}"
);
}
#[test]
#[serial_test::parallel]
fn transform_html_injects_tags() {
let dir = tempdir().unwrap();
let c = ctx(dir.path());
let html = r#"<!doctype html><html lang="en"><head><title>Hello</title></head>
<body><p>World is wide.</p></body></html>"#;
let after = SeoPlugin
.transform_html(html, Path::new("page.html"), &c)
.unwrap();
assert!(after.contains("og:title"));
assert!(after.contains("twitter:card"));
assert!(after.contains("name=\"description\""));
}
#[test]
#[serial_test::parallel]
fn transform_html_uses_article_type_when_article_tag_present() {
let dir = tempdir().unwrap();
let c = ctx(dir.path());
let html = r#"<!doctype html><html lang="en"><head><title>P</title></head>
<body><article><p>Content.</p></article></body></html>"#;
let after = SeoPlugin
.transform_html(html, Path::new("post.html"), &c)
.unwrap();
assert!(
after.contains(r#"og:type" content="article""#),
"presence of <article> should set og:type=article: {after}"
);
assert!(
after.contains(r#"twitter:card" content="summary_large_image""#),
"article should use summary_large_image twitter card: {after}"
);
}
#[test]
#[serial_test::parallel]
fn transform_html_is_idempotent() {
let dir = tempdir().unwrap();
let c = ctx(dir.path());
let html = r#"<html lang="en"><head><title>Y</title></head><body>Z</body></html>"#;
let first = SeoPlugin
.transform_html(html, Path::new("x.html"), &c)
.unwrap();
let second = SeoPlugin
.transform_html(&first, Path::new("x.html"), &c)
.unwrap();
assert_eq!(first, second, "second run must not duplicate meta tags");
}
#[test]
#[serial_test::parallel]
fn after_compile_no_op_when_no_html_files() {
let dir = tempdir().unwrap();
SeoPlugin.after_compile(&ctx(dir.path())).unwrap();
}
fn locale_ctx(
site: &Path,
language: &str,
locales: &[&str],
) -> PluginContext {
let mut c = ctx(site);
c.config = Some(crate::cmd::SsgConfig {
language: language.to_string(),
i18n: Some(crate::i18n::I18nConfig {
default_locale: locales
.first()
.map_or_else(|| "en".to_string(), |l| (*l).to_string()),
locales: locales.iter().map(|l| (*l).to_string()).collect(),
url_prefix: Default::default(),
}),
..crate::cmd::SsgConfig::default()
});
c
}
#[test]
#[serial_test::parallel]
fn og_locale_is_path_driven_on_locale_pages() {
let dir = tempdir().unwrap();
let c = locale_ctx(dir.path(), "en-GB", &["en", "hi"]);
let html = r#"<html lang="en-GB"><head><title>T</title></head><body>x</body></html>"#;
let page = dir.path().join("hi/2026-06-01-post/index.html");
let out = SeoPlugin.transform_html(html, &page, &c).unwrap();
assert!(
out.contains(r#"property="og:locale" content="hi""#),
"expected path-driven og:locale=hi, got: {out}"
);
}
#[test]
#[serial_test::parallel]
fn og_locale_is_default_driven_with_underscore_form() {
let dir = tempdir().unwrap();
let c = locale_ctx(dir.path(), "en-GB", &["en"]);
let html = "<html><head><title>T</title></head><body>x</body></html>";
let page = dir.path().join("about/index.html");
let out = SeoPlugin.transform_html(html, &page, &c).unwrap();
assert!(
out.contains(r#"property="og:locale" content="en_GB""#),
"expected default-driven og:locale=en_GB, got: {out}"
);
}
#[test]
#[serial_test::parallel]
fn og_locale_en_fallback_only_when_nothing_resolves() {
let dir = tempdir().unwrap();
let c = ctx(dir.path());
let html = "<html><head><title>T</title></head><body>x</body></html>";
let page = dir.path().join("index.html");
let out = SeoPlugin.transform_html(html, &page, &c).unwrap();
assert!(
out.contains(r#"property="og:locale" content="en""#),
"expected final-constant og:locale=en, got: {out}"
);
}
fn ctx_rooted(dir: &Path) -> PluginContext {
PluginContext::new(
Path::new("content"),
&dir.join("build"),
&dir.join("site"),
Path::new("templates"),
)
}
fn write_sidecar(dir: &Path, rel: &str, json: &str) {
let sidecar = dir
.join("build")
.join(".meta")
.join(rel)
.with_extension("meta.json");
std::fs::create_dir_all(sidecar.parent().unwrap()).unwrap();
std::fs::write(sidecar, json).unwrap();
}
fn meta_content(html: &str, attr: &str) -> String {
extract_existing_meta(html, attr)
}
#[test]
#[serial_test::parallel]
fn b8_title_description_banner_yield_complete_consistent_social_set() {
let dir = tempdir().unwrap();
write_sidecar(
dir.path(),
"post/index.html",
r#"{"title":"My Post","description":"A fine description","banner":"/img/banner.webp"}"#,
);
let c = ctx_rooted(dir.path());
let html = "<html lang=\"en\"><head><title>My Post</title></head><body><p>text</p></body></html>";
let page = dir.path().join("site/post/index.html");
let out = SeoPlugin.transform_html(html, &page, &c).unwrap();
assert_eq!(meta_content(&out, "og:title"), "My Post");
assert_eq!(meta_content(&out, "twitter:title"), "My Post");
assert_eq!(meta_content(&out, "og:description"), "A fine description");
assert_eq!(
meta_content(&out, "twitter:description"),
"A fine description"
);
assert_eq!(meta_content(&out, "description"), "A fine description");
assert_eq!(meta_content(&out, "og:image"), "/img/banner.webp");
assert_eq!(meta_content(&out, "twitter:image"), "/img/banner.webp");
assert_eq!(meta_content(&out, "twitter:card"), "summary_large_image");
assert_eq!(
meta_content(&out, "og:title"),
meta_content(&out, "twitter:title")
);
assert_eq!(
meta_content(&out, "og:image"),
meta_content(&out, "twitter:image")
);
}
#[test]
#[serial_test::parallel]
fn b8_seo_title_beats_title_and_explicit_fields_beat_seo_title() {
let dir = tempdir().unwrap();
write_sidecar(
dir.path(),
"p/index.html",
r#"{"title":"Base","seo_title":"Seo Title","twitter_title":"Tw Title"}"#,
);
let c = ctx_rooted(dir.path());
let html =
"<html><head><title>Base</title></head><body>x</body></html>";
let page = dir.path().join("site/p/index.html");
let out = SeoPlugin.transform_html(html, &page, &c).unwrap();
assert_eq!(meta_content(&out, "twitter:title"), "Tw Title");
assert_eq!(meta_content(&out, "og:title"), "Seo Title");
}
#[test]
#[serial_test::parallel]
fn b8_banner_beats_image_and_og_image_beats_banner() {
let dir = tempdir().unwrap();
write_sidecar(
dir.path(),
"a/index.html",
r#"{"title":"T","image":"/i.png"}"#,
);
write_sidecar(
dir.path(),
"b/index.html",
r#"{"title":"T","image":"/i.png","banner":"/b.png"}"#,
);
write_sidecar(
dir.path(),
"c/index.html",
r#"{"title":"T","banner":"/b.png","og_image":"/og.png"}"#,
);
let c = ctx_rooted(dir.path());
let html = "<html><head><title>T</title></head><body>x</body></html>";
let out_a = SeoPlugin
.transform_html(html, &dir.path().join("site/a/index.html"), &c)
.unwrap();
assert_eq!(meta_content(&out_a, "og:image"), "/i.png");
assert_eq!(meta_content(&out_a, "twitter:image"), "/i.png");
let out_b = SeoPlugin
.transform_html(html, &dir.path().join("site/b/index.html"), &c)
.unwrap();
assert_eq!(meta_content(&out_b, "og:image"), "/b.png");
let out_c = SeoPlugin
.transform_html(html, &dir.path().join("site/c/index.html"), &c)
.unwrap();
assert_eq!(meta_content(&out_c, "og:image"), "/og.png");
assert_eq!(meta_content(&out_c, "twitter:image"), "/b.png");
}
#[test]
#[serial_test::parallel]
fn b8_explicit_twitter_card_wins_over_derived_default() {
let dir = tempdir().unwrap();
write_sidecar(
dir.path(),
"p/index.html",
r#"{"title":"T","banner":"/b.png","twitter_card":"summary"}"#,
);
let c = ctx_rooted(dir.path());
let html = "<html><head><title>T</title></head><body>x</body></html>";
let out = SeoPlugin
.transform_html(html, &dir.path().join("site/p/index.html"), &c)
.unwrap();
assert_eq!(meta_content(&out, "twitter:card"), "summary");
}
#[test]
#[serial_test::parallel]
fn b8_card_defaults_to_summary_without_image_or_article() {
let dir = tempdir().unwrap();
let c = ctx_rooted(dir.path());
let html = "<html><head><title>T</title></head><body>x</body></html>";
let out = SeoPlugin
.transform_html(html, &dir.path().join("site/p/index.html"), &c)
.unwrap();
assert_eq!(meta_content(&out, "twitter:card"), "summary");
}
#[test]
#[serial_test::parallel]
fn b8_no_bleed_between_pages_differing_only_in_title() {
let dir = tempdir().unwrap();
write_sidecar(
dir.path(),
"alpha/index.html",
r#"{"title":"Alpha Page","description":"same","banner":"/same.png"}"#,
);
write_sidecar(
dir.path(),
"beta/index.html",
r#"{"title":"Beta Page","description":"same","banner":"/same.png"}"#,
);
let mut c = ctx_rooted(dir.path());
c.config = Some(crate::cmd::SsgConfig {
site_name: "Global Site Name".to_string(),
..crate::cmd::SsgConfig::default()
});
let html = "<html><head><title>t</title></head><body>x</body></html>";
let out_a = SeoPlugin
.transform_html(html, &dir.path().join("site/alpha/index.html"), &c)
.unwrap();
let out_b = SeoPlugin
.transform_html(html, &dir.path().join("site/beta/index.html"), &c)
.unwrap();
assert_eq!(meta_content(&out_a, "og:title"), "Alpha Page");
assert_eq!(meta_content(&out_b, "og:title"), "Beta Page");
assert_eq!(meta_content(&out_a, "twitter:title"), "Alpha Page");
assert_eq!(meta_content(&out_b, "twitter:title"), "Beta Page");
assert!(!out_a.contains("Beta Page"), "page A leaked page B's title");
assert!(
!out_b.contains("Alpha Page"),
"page B leaked page A's title"
);
assert!(
!out_a.contains("Global Site Name"),
"global config bled into page meta"
);
}
#[test]
#[serial_test::parallel]
fn transform_html_handles_html_without_head_tag() {
let dir = tempdir().unwrap();
let c = ctx(dir.path());
let raw = "<!doctype html><html><body>only</body></html>";
let after = SeoPlugin
.transform_html(raw, Path::new("frag.html"), &c)
.unwrap();
assert_eq!(after, raw);
}
#[test]
fn og_tags_skips_image_block_when_og_image_present() {
let html = r#"<html><head>
<meta property="og:image" content="/have.png">
</head></html>"#;
let tags = build_og_tags(html, &social("T", "D"), "", "website", "en");
let joined = tags.join("\n");
assert!(
!joined.contains("og:image"),
"existing og:image must suppress image emission: {joined}"
);
}
#[test]
fn og_tags_skips_dimensions_when_width_already_present() {
let html = r#"<html><head>
<meta name="twitter:image" content="/twit.png">
<meta property="og:image:width" content="800">
<meta property="og:image:height" content="420">
</head></html>"#;
let tags = build_og_tags(html, &social("T", "D"), "", "website", "en");
let joined = tags.join("\n");
assert!(joined.contains(r#"property="og:image" content="/twit.png""#));
assert!(
!joined.contains(r#"content="1200""#),
"must not re-emit default dimensions: {joined}"
);
}
#[test]
fn twitter_tags_skips_image_when_twitter_image_present() {
let html = r#"<html><head>
<meta name="twitter:image" content="/have.png">
</head></html>"#;
let tags = build_twitter_tags(html, &social("T", "D"), "summary");
let joined = tags.join("\n");
assert!(
!joined.contains("twitter:image"),
"existing twitter:image must suppress emission: {joined}"
);
}
#[test]
#[serial_test::parallel]
fn og_locale_with_empty_declared_locale_set_uses_site_language() {
let dir = tempdir().unwrap();
let c = locale_ctx(dir.path(), "en-GB", &[]);
let html = "<html><head><title>T</title></head><body>x</body></html>";
let page = dir.path().join("about/index.html");
let out = SeoPlugin.transform_html(html, &page, &c).unwrap();
assert!(
out.contains(r#"property="og:locale" content="en_GB""#),
"expected og:locale=en_GB from site language, got: {out}"
);
}
}
#[cfg(all(test, feature = "test-fault-injection"))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod fault_tests {
use super::*;
use serial_test::serial;
use std::path::Path;
use tempfile::tempdir;
struct FailGuard<'a>(&'a str);
impl Drop for FailGuard<'_> {
fn drop(&mut self) {
let _ = fail::cfg(self.0, "off");
}
}
#[test]
#[serial]
fn transform_html_maps_injection_failure_to_io_error() {
let _guard = FailGuard("seo::inject-tags");
fail::cfg("seo::inject-tags", "return").unwrap();
let dir = tempdir().unwrap();
let c = PluginContext::new(
Path::new("content"),
Path::new("build"),
dir.path(),
Path::new("templates"),
);
let html = "<html><head><title>T</title></head><body>x</body></html>";
let err = SeoPlugin
.transform_html(html, Path::new("page.html"), &c)
.expect_err("failpoint must abort tag injection");
assert!(
err.to_string().contains("seo::inject-tags"),
"injected error should surface with its failpoint name: {err}"
);
}
}