1use 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#[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 let lang = resolve_page_lang(html, path, ctx);
58 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#[derive(Debug, Clone, Default)]
97struct SocialMeta {
98 og_title: String,
100 twitter_title: String,
102 description: String,
104 og_description: String,
106 twitter_description: String,
108 og_image: Option<String>,
111 twitter_image: Option<String>,
114 twitter_card: Option<String>,
116}
117
118fn 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 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
168fn 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 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 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 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
256fn 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 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
315fn 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
327fn 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 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 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 #[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 assert!(out.contains("content="));
481 assert!(!out.contains(r#"content="X & ""#));
482 }
483
484 #[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 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 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 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 #[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 #[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 #[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 #[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 #[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 SeoPlugin.after_compile(&ctx(dir.path())).unwrap();
703 }
704
705 #[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 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 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 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 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 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 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 assert_eq!(meta_content(&out, "twitter:card"), "summary_large_image");
834 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 assert_eq!(meta_content(&out, "twitter:title"), "Tw Title");
862 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 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 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 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 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 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 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 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 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}