use super::helpers::{
collect_html_files, escape_attr, extract_canonical, extract_description,
extract_existing_meta, extract_first_content_image, extract_html_lang,
extract_title, has_meta_tag,
};
use crate::plugin::{Plugin, PluginContext};
use anyhow::{Context, Result};
use rayon::prelude::*;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Copy)]
pub struct SeoPlugin;
impl Plugin for SeoPlugin {
fn name(&self) -> &'static str {
"seo"
}
fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
if !ctx.site_dir.exists() {
return Ok(());
}
let html_files = collect_html_files(&ctx.site_dir)?;
let cache = ctx.cache.as_ref();
let files: Vec<_> = html_files
.into_iter()
.filter(|p| cache.is_none_or(|c| c.has_changed(p)))
.collect();
files
.par_iter()
.try_for_each(|path| inject_seo_tags(path))?;
Ok(())
}
}
fn build_og_tags(
html: &str,
title: &str,
description: &str,
canonical: &str,
og_type: &str,
) -> Vec<String> {
let mut tags = Vec::new();
if !has_meta_tag(html, "og:title") && !title.is_empty() {
tags.push(format!(
"<meta property=\"og:title\" content=\"{}\">",
escape_attr(title)
));
}
if !has_meta_tag(html, "og:description") && !description.is_empty() {
tags.push(format!(
"<meta property=\"og:description\" content=\"{}\">",
escape_attr(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 = extract_existing_meta(html, "twitter:image");
let image = if image.is_empty() {
extract_first_content_image(html)
} else {
image
};
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 lang = extract_html_lang(html);
if !lang.is_empty() {
let locale = lang.replace('-', "_");
tags.push(format!(
"<meta property=\"og:locale\" content=\"{}\">",
escape_attr(&locale)
));
}
}
tags
}
fn build_twitter_tags(
html: &str,
title: &str,
description: &str,
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=\"{twitter_card}\">"
));
}
if !has_meta_tag(html, "twitter:title") && !title.is_empty() {
tags.push(format!(
"<meta name=\"twitter:title\" content=\"{}\">",
escape_attr(title)
));
}
if !has_meta_tag(html, "twitter:description") && !description.is_empty() {
tags.push(format!(
"<meta name=\"twitter:description\" content=\"{}\">",
escape_attr(description)
));
}
if !has_meta_tag(html, "twitter:image") {
let image = extract_existing_meta(html, "og:image");
let image = if image.is_empty() {
extract_first_content_image(html)
} else {
image
};
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(path: &Path) -> Result<()> {
let html = fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let title = extract_title(&html);
let description = extract_description(&html, 160);
let canonical = extract_canonical(&html);
let is_article = html.contains("<article");
let og_type = if is_article { "article" } else { "website" };
let twitter_card = if is_article {
"summary_large_image"
} else {
"summary"
};
let mut tags = Vec::new();
if let Some(meta_desc) = build_meta_description(&html, &description) {
tags.push(meta_desc);
}
tags.extend(build_og_tags(
&html,
&title,
&description,
&canonical,
og_type,
));
tags.extend(build_twitter_tags(
&html,
&title,
&description,
twitter_card,
));
if tags.is_empty() {
return Ok(());
}
let injection = tags.join("\n");
let result = if let Some(pos) = html.find("</head>") {
format!("{}{}\n{}", &html[..pos], injection, &html[pos..])
} else {
html
};
fs::write(path, result)
.with_context(|| format!("cannot write {}", path.display()))?;
Ok(())
}
#[cfg(test)]
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 write_html(site: &Path, name: &str, body: &str) {
fs::write(site.join(name), body).unwrap();
}
#[test]
fn name_is_stable() {
assert_eq!(SeoPlugin.name(), "seo");
}
#[test]
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,
"Hello",
"World",
"https://example.com/page",
"website",
);
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,
"Hello",
"World",
"https://example.com",
"website",
);
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, "T", "D", "", "website");
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_html_lang_dashes_to_underscores() {
let html = "<html lang=\"en-GB\"><head></head></html>";
let tags = build_og_tags(html, "T", "D", "", "website");
let joined = tags.join("\n");
assert!(
joined.contains(r#"property="og:locale" content="en_GB""#),
"lang=\"en-GB\" should produce og:locale=\"en_GB\", got: {joined}"
);
}
#[test]
fn og_tags_omits_locale_when_html_has_no_lang() {
let html = "<html><head></head></html>";
let tags = build_og_tags(html, "T", "D", "", "website");
let joined = tags.join("\n");
assert!(
!joined.contains("og:locale"),
"no html lang → no og:locale, got: {joined}"
);
}
#[test]
fn twitter_tags_includes_card_title_description() {
let html = "<html><head></head></html>";
let tags = build_twitter_tags(html, "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, "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]
fn after_compile_injects_tags_into_html_files() {
let dir = tempdir().unwrap();
write_html(
dir.path(),
"page.html",
r#"<!doctype html><html lang="en"><head><title>Hello</title></head>
<body><p>World is wide.</p></body></html>"#,
);
SeoPlugin.after_compile(&ctx(dir.path())).unwrap();
let after = fs::read_to_string(dir.path().join("page.html")).unwrap();
assert!(after.contains("og:title"));
assert!(after.contains("twitter:card"));
assert!(after.contains("name=\"description\""));
}
#[test]
fn after_compile_uses_article_type_when_article_tag_present() {
let dir = tempdir().unwrap();
write_html(
dir.path(),
"post.html",
r#"<!doctype html><html lang="en"><head><title>P</title></head>
<body><article><p>Content.</p></article></body></html>"#,
);
SeoPlugin.after_compile(&ctx(dir.path())).unwrap();
let after = fs::read_to_string(dir.path().join("post.html")).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]
fn after_compile_is_idempotent() {
let dir = tempdir().unwrap();
write_html(
dir.path(),
"x.html",
r#"<html lang="en"><head><title>Y</title></head><body>Z</body></html>"#,
);
SeoPlugin.after_compile(&ctx(dir.path())).unwrap();
let first = fs::read_to_string(dir.path().join("x.html")).unwrap();
SeoPlugin.after_compile(&ctx(dir.path())).unwrap();
let second = fs::read_to_string(dir.path().join("x.html")).unwrap();
assert_eq!(first, second, "second run must not duplicate meta tags");
}
#[test]
fn after_compile_no_op_when_no_html_files() {
let dir = tempdir().unwrap();
SeoPlugin.after_compile(&ctx(dir.path())).unwrap();
}
#[test]
fn after_compile_handles_html_without_head_tag() {
let dir = tempdir().unwrap();
let raw = "<!doctype html><html><body>only</body></html>";
write_html(dir.path(), "frag.html", raw);
SeoPlugin.after_compile(&ctx(dir.path())).unwrap();
let after = fs::read_to_string(dir.path().join("frag.html")).unwrap();
assert_eq!(after, raw);
}
}