1use crate::core::escape::escape_json_script;
7use crate::core::{Attribute, Element};
8use crate::elements::{link, meta, script, title};
9
10#[must_use]
32pub fn json_ld(json: &str) -> Element {
33 script_json("application/ld+json", json)
34}
35
36#[must_use]
52pub fn script_json(mime: &str, json: &str) -> Element {
53 script()
56 .attr("type", mime)
57 .raw_text(escape_json_script(json).into_owned())
58}
59
60#[must_use]
66pub fn meta_name(name: &str, content: impl AsRef<str>) -> Element {
67 meta()
68 .add_attribute(Attribute::new_static("name", name))
69 .attr("content", content)
70}
71
72#[must_use]
74pub fn meta_property(property: &str, content: impl AsRef<str>) -> Element {
75 meta()
76 .add_attribute(Attribute::new_static("property", property))
77 .attr("content", content)
78}
79
80#[must_use]
82pub fn meta_charset(charset: &str) -> Element {
83 meta().add_attribute(Attribute::new_static("charset", charset))
84}
85
86#[must_use]
88pub fn meta_http_equiv(http_equiv: &str, content: impl AsRef<str>) -> Element {
89 meta()
90 .add_attribute(Attribute::new_static("http-equiv", http_equiv))
91 .attr("content", content)
92}
93
94#[must_use]
99pub fn open_graph(
100 page_title: &str,
101 description: &str,
102 image: &str,
103 url: &str,
104 og_type: &str,
105 site_name: Option<&str>,
106) -> Vec<Element> {
107 let mut tags = vec![
108 meta_property("og:title", page_title),
109 meta_property("og:description", description),
110 meta_property("og:image", image),
111 meta_property("og:url", url),
112 meta_property("og:type", og_type),
113 ];
114 if let Some(site_name) = site_name {
115 tags.push(meta_property("og:site_name", site_name));
116 }
117 tags
118}
119
120#[must_use]
122pub fn open_graph_article(
123 page_title: &str,
124 description: &str,
125 image: &str,
126 url: &str,
127 author: Option<&str>,
128 published_time: Option<&str>,
129 modified_time: Option<&str>,
130) -> Vec<Element> {
131 let mut tags = open_graph(page_title, description, image, url, "article", None);
132 if let Some(author) = author {
133 tags.push(meta_property("article:author", author));
134 }
135 if let Some(published) = published_time {
136 tags.push(meta_property("article:published_time", published));
137 }
138 if let Some(modified) = modified_time {
139 tags.push(meta_property("article:modified_time", modified));
140 }
141 tags
142}
143
144#[must_use]
146pub fn twitter_card(
147 page_title: &str,
148 description: &str,
149 image: &str,
150 card: &str,
151 site: Option<&str>,
152 creator: Option<&str>,
153) -> Vec<Element> {
154 let mut tags = vec![
155 meta_name("twitter:card", card),
156 meta_name("twitter:title", page_title),
157 meta_name("twitter:description", description),
158 meta_name("twitter:image", image),
159 ];
160 if let Some(site) = site {
161 tags.push(meta_name("twitter:site", site));
162 }
163 if let Some(creator) = creator {
164 tags.push(meta_name("twitter:creator", creator));
165 }
166 tags
167}
168
169#[must_use]
177pub fn common(
178 page_title: &str,
179 description: &str,
180 keywords: Option<&[&str]>,
181 author: Option<&str>,
182 viewport: &str,
183 robots: &str,
184) -> Vec<Element> {
185 let mut tags = vec![
186 meta_charset("UTF-8"),
187 meta_name("viewport", viewport),
188 meta_name("description", description),
189 meta_name("robots", robots),
190 ];
191 if let Some(keywords) = keywords.filter(|list| !list.is_empty()) {
195 tags.push(meta_name("keywords", keywords.join(", ")));
196 }
197 if let Some(author) = author {
198 tags.push(meta_name("author", author));
199 }
200 tags.push(title().text(page_title));
201 tags
202}
203
204pub const DEFAULT_VIEWPORT: &str = "width=device-width, initial-scale=1.0";
206pub const DEFAULT_ROBOTS: &str = "index, follow";
208pub const DEFAULT_TWITTER_CARD: &str = "summary_large_image";
210
211#[derive(Debug, Clone)]
230pub struct SeoBuilder {
231 title: String,
232 description: String,
233 image: Option<String>,
234 url: Option<String>,
235 site_name: Option<String>,
236 author: Option<String>,
237 keywords: Vec<String>,
238 twitter_card: String,
239 twitter_site: Option<String>,
240 twitter_creator: Option<String>,
241 viewport: String,
242 robots: String,
243 canonical: Option<String>,
244 alternates: Vec<(String, String)>,
245 locale: Option<String>,
246 locale_alternates: Vec<String>,
247 image_size: Option<(u32, u32)>,
248 image_alt: Option<String>,
249 theme_color: Option<String>,
250 color_scheme: Option<String>,
251}
252
253impl SeoBuilder {
254 pub fn new(page_title: impl Into<String>, description: impl Into<String>) -> Self {
256 Self {
257 title: page_title.into(),
258 description: description.into(),
259 image: None,
260 url: None,
261 site_name: None,
262 author: None,
263 keywords: Vec::new(),
264 twitter_card: DEFAULT_TWITTER_CARD.to_string(),
265 twitter_site: None,
266 twitter_creator: None,
267 viewport: DEFAULT_VIEWPORT.to_string(),
268 robots: DEFAULT_ROBOTS.to_string(),
269 canonical: None,
270 alternates: Vec::new(),
271 locale: None,
272 locale_alternates: Vec::new(),
273 image_size: None,
274 image_alt: None,
275 theme_color: None,
276 color_scheme: None,
277 }
278 }
279
280 #[must_use]
282 pub fn image(mut self, image_url: impl Into<String>) -> Self {
283 self.image = Some(image_url.into());
284 self
285 }
286
287 #[must_use]
291 pub fn url(mut self, page_url: impl Into<String>) -> Self {
292 self.url = Some(page_url.into());
293 self
294 }
295
296 #[must_use]
298 pub fn site_name(mut self, site_name: impl Into<String>) -> Self {
299 self.site_name = Some(site_name.into());
300 self
301 }
302
303 #[must_use]
305 pub fn author(mut self, author: impl Into<String>) -> Self {
306 self.author = Some(author.into());
307 self
308 }
309
310 #[must_use]
312 pub fn keywords<S: Into<String>>(mut self, keywords: impl IntoIterator<Item = S>) -> Self {
313 self.keywords = keywords.into_iter().map(Into::into).collect();
314 self
315 }
316
317 #[must_use]
319 pub fn twitter_card(mut self, card: impl Into<String>) -> Self {
320 self.twitter_card = card.into();
321 self
322 }
323
324 #[must_use]
326 pub fn twitter_site(mut self, site: impl Into<String>) -> Self {
327 self.twitter_site = Some(site.into());
328 self
329 }
330
331 #[must_use]
333 pub fn twitter_creator(mut self, creator: impl Into<String>) -> Self {
334 self.twitter_creator = Some(creator.into());
335 self
336 }
337
338 #[must_use]
351 pub fn robots(mut self, robots: impl Into<String>) -> Self {
352 self.robots = robots.into();
353 self
354 }
355
356 #[must_use]
358 pub fn viewport(mut self, viewport: impl Into<String>) -> Self {
359 self.viewport = viewport.into();
360 self
361 }
362
363 #[must_use]
376 pub fn canonical(mut self, url: impl Into<String>) -> Self {
377 self.canonical = Some(url.into());
378 self
379 }
380
381 #[must_use]
399 pub fn alternate(mut self, hreflang: impl Into<String>, url: impl Into<String>) -> Self {
400 self.alternates.push((hreflang.into(), url.into()));
401 self
402 }
403
404 #[must_use]
406 pub fn locale(mut self, locale: impl Into<String>) -> Self {
407 self.locale = Some(locale.into());
408 self
409 }
410
411 #[must_use]
413 pub fn locale_alternate(mut self, locale: impl Into<String>) -> Self {
414 self.locale_alternates.push(locale.into());
415 self
416 }
417
418 #[must_use]
420 pub fn image_size(mut self, width: u32, height: u32) -> Self {
421 self.image_size = Some((width, height));
422 self
423 }
424
425 #[must_use]
427 pub fn image_alt(mut self, alt: impl Into<String>) -> Self {
428 self.image_alt = Some(alt.into());
429 self
430 }
431
432 #[must_use]
434 pub fn theme_color(mut self, color: impl Into<String>) -> Self {
435 self.theme_color = Some(color.into());
436 self
437 }
438
439 #[must_use]
441 pub fn color_scheme(mut self, scheme: impl Into<String>) -> Self {
442 self.color_scheme = Some(scheme.into());
443 self
444 }
445
446 #[must_use]
459 pub fn build(&self) -> Vec<Element> {
460 let keywords: Vec<&str> = self.keywords.iter().map(String::as_str).collect();
461 let mut tags = common(
462 &self.title,
463 &self.description,
464 Some(&keywords[..]),
465 self.author.as_deref(),
466 &self.viewport,
467 &self.robots,
468 );
469
470 if let Some(canonical) = &self.canonical {
471 tags.push(link().attr("rel", "canonical").attr("href", canonical));
472 }
473 for (hreflang, url) in &self.alternates {
474 tags.push(
475 link()
476 .attr("rel", "alternate")
477 .attr("hreflang", hreflang)
478 .attr("href", url),
479 );
480 }
481 if let Some(color) = &self.theme_color {
482 tags.push(meta_name("theme-color", color));
483 }
484 if let Some(scheme) = &self.color_scheme {
485 tags.push(meta_name("color-scheme", scheme));
486 }
487
488 let image = self.image.as_deref().unwrap_or_default();
489 let url = self.url.as_deref().unwrap_or_default();
490
491 tags.extend(open_graph(
492 &self.title,
493 &self.description,
494 image,
495 url,
496 "website",
497 self.site_name.as_deref(),
498 ));
499 if let Some((width, height)) = self.image_size {
500 tags.push(meta_property("og:image:width", width.to_string()));
501 tags.push(meta_property("og:image:height", height.to_string()));
502 }
503 if let Some(alt) = &self.image_alt {
504 tags.push(meta_property("og:image:alt", alt));
505 }
506 if let Some(locale) = &self.locale {
507 tags.push(meta_property("og:locale", locale));
508 }
509 for locale in &self.locale_alternates {
510 tags.push(meta_property("og:locale:alternate", locale));
511 }
512 tags.extend(twitter_card(
513 &self.title,
514 &self.description,
515 image,
516 &self.twitter_card,
517 self.twitter_site.as_deref(),
518 self.twitter_creator.as_deref(),
519 ));
520 if let Some(alt) = &self.image_alt {
521 tags.push(meta_name("twitter:image:alt", alt));
522 }
523 tags
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 #[test]
530 fn json_ld_cannot_be_broken_out_of_by_a_value() {
531 for hostile in [
532 "</script><script>alert(1)</script>",
533 "</SCRIPT >",
534 "<!--<script>",
535 "-->",
536 "<img src=x onerror=alert(1)>",
537 ] {
538 let json = format!(r#"{{"name":"{hostile}"}}"#);
539 let html = json_ld(&json).render();
540 let inner = html
541 .strip_prefix(r#"<script type="application/ld+json">"#)
542 .and_then(|rest| rest.strip_suffix("</script>"))
543 .expect("one script element");
544 for forbidden in ['<', '>', '&'] {
545 assert!(!inner.contains(forbidden), "{hostile:?} -> {html}");
546 }
547 }
548 }
549
550 #[test]
551 fn json_ld_keeps_the_document_and_only_rewrites_what_is_dangerous() {
552 let plain = r#"{"@context":"https://schema.org","name":"Tyres","n":[1,2.5,true,null]}"#;
553 assert_eq!(
554 json_ld(plain).render(),
555 format!(r#"<script type="application/ld+json">{plain}</script>"#)
556 );
557 let json = format!(r#"{{"t":"a & b <c> {} {}"}}"#, '\u{2028}', '\u{2029}');
559 assert_eq!(
560 json_ld(&json).render(),
561 r#"<script type="application/ld+json">{"t":"a \u0026 b \u003cc\u003e \u2028 \u2029"}</script>"#
562 );
563 assert_eq!(
564 script_json("a\"b", "{}").render(),
565 r#"<script type="a"b">{}</script>"#
566 );
567 }
568
569 use super::*;
570 use crate::core::Render;
571
572 fn built(builder: &SeoBuilder) -> String {
573 builder.build().iter().map(Render::render).collect()
574 }
575
576 #[test]
577 fn a_builder_that_calls_no_new_setter_writes_exactly_what_it_always_did() {
578 let html = built(&SeoBuilder::new("T", "D").image("https://x.example/i.png"));
579 for absent in [
580 "canonical",
581 "hreflang",
582 "theme-color",
583 "color-scheme",
584 "og:locale",
585 "og:image:width",
586 "og:image:height",
587 "og:image:alt",
588 "twitter:image:alt",
589 ] {
590 assert!(!html.contains(absent), "{absent} in {html}");
591 }
592 }
593
594 #[test]
595 fn robots_and_viewport_can_be_overridden() {
596 let html = built(
597 &SeoBuilder::new("T", "D")
598 .robots("noindex")
599 .viewport("width=device-width"),
600 );
601 assert!(html.contains(r#"<meta name="robots" content="noindex">"#));
602 assert!(html.contains(r#"<meta name="viewport" content="width=device-width">"#));
603 }
604
605 #[test]
606 fn a_bilingual_page_gets_its_links_and_locales_in_a_fixed_order() {
607 let html = built(
608 &SeoBuilder::new("T", "D")
609 .image("https://x.example/i.png")
610 .site_name("Molt")
611 .canonical("https://x.example/en/")
612 .alternate("en", "https://x.example/en/")
613 .alternate("pt-BR", "https://x.example/pt/")
614 .alternate("x-default", "https://x.example/")
615 .theme_color("#15140f")
616 .color_scheme("dark light")
617 .image_size(1200, 630)
618 .image_alt("A molting crab")
619 .locale("en_US")
620 .locale_alternate("pt_BR")
621 .twitter_site("@molt"),
622 );
623 let in_order = [
624 "<title>T</title>",
625 r#"<link rel="canonical" href="https://x.example/en/">"#,
626 r#"<link rel="alternate" hreflang="en" href="https://x.example/en/">"#,
627 r#"<link rel="alternate" hreflang="pt-BR" href="https://x.example/pt/">"#,
628 r#"<link rel="alternate" hreflang="x-default" href="https://x.example/">"#,
629 r##"<meta name="theme-color" content="#15140f">"##,
630 r#"<meta name="color-scheme" content="dark light">"#,
631 r#"<meta property="og:site_name" content="Molt">"#,
632 r#"<meta property="og:image:width" content="1200">"#,
633 r#"<meta property="og:image:height" content="630">"#,
634 r#"<meta property="og:image:alt" content="A molting crab">"#,
635 r#"<meta property="og:locale" content="en_US">"#,
636 r#"<meta property="og:locale:alternate" content="pt_BR">"#,
637 r#"<meta name="twitter:card" content="summary_large_image">"#,
638 r#"<meta name="twitter:site" content="@molt">"#,
639 r#"<meta name="twitter:image:alt" content="A molting crab">"#,
640 ];
641 let mut from = 0;
642 for tag in in_order {
643 let at = html[from..]
644 .find(tag)
645 .unwrap_or_else(|| panic!("{tag} missing or out of order in {html}"));
646 from += at + tag.len();
647 }
648 }
649
650 #[test]
651 fn hostile_values_in_the_new_setters_stay_inside_their_attribute() {
652 let html = built(
653 &SeoBuilder::new("T", "D")
654 .canonical("javascript:alert(1)")
655 .alternate("en\"><script>", "https://x.example/\"><script>")
656 .theme_color("\"><script>alert(1)</script>")
657 .image_alt("\"><img src=x onerror=alert(1)>")
658 .locale("\"><b>"),
659 );
660 assert!(
661 html.contains(r##"<link rel="canonical" href="#">"##),
662 "{html}"
663 );
664 for payload in ["\"><script", "\"><img", "\"><b>"] {
667 assert!(!html.contains(payload), "{payload} in {html}");
668 }
669 }
670
671 fn rendered(tags: &[Element]) -> String {
672 tags.iter().map(Render::render).collect()
673 }
674
675 #[test]
677 fn a_hostile_meta_name_cannot_break_out_of_its_attribute() {
678 let hostile = "a\"><script>alert(1)</script><x y=\"";
679 for (tag, quotes) in [
682 (meta_name(hostile, "v"), 4),
683 (meta_property(hostile, "v"), 4),
684 (meta_charset(hostile), 2),
685 (meta_http_equiv(hostile, "v"), 4),
686 ] {
687 let html = tag.render();
688 assert_eq!(html.matches('"').count(), quotes, "{html}");
689 assert!(html.contains("""), "{html}");
690 }
691 assert_eq!(
692 meta_property("og:title", "T").render(),
693 r#"<meta property="og:title" content="T">"#
694 );
695 }
696
697 #[test]
698 fn open_graph_emits_the_five_core_properties() {
699 let tags = open_graph("T", "D", "/i.png", "https://e.com", "website", None);
700 assert_eq!(tags.len(), 5);
701 let html = rendered(&tags);
702 for property in [
703 "og:title",
704 "og:description",
705 "og:image",
706 "og:url",
707 "og:type",
708 ] {
709 assert!(html.contains(property), "{property} missing");
710 }
711 }
712
713 #[test]
714 fn og_site_name_is_only_emitted_when_supplied() {
715 assert_eq!(open_graph("T", "D", "i", "u", "website", None).len(), 5);
716 assert_eq!(
717 open_graph("T", "D", "i", "u", "website", Some("Site")).len(),
718 6
719 );
720 }
721
722 #[test]
723 fn an_article_adds_the_article_extensions_and_sets_the_type() {
724 let tags = open_graph_article("T", "D", "i", "u", Some("Ana"), Some("2026-01-01"), None);
725 let html = rendered(&tags);
726 assert!(html.contains(r#"content="article""#));
727 assert!(html.contains("article:author"));
728 assert!(html.contains("article:published_time"));
729 assert!(!html.contains("article:modified_time"));
730 }
731
732 #[test]
733 fn twitter_card_defaults_to_a_large_image_summary() {
734 let tags = twitter_card("T", "D", "i", DEFAULT_TWITTER_CARD, None, None);
735 assert!(rendered(&tags).contains(r#"content="summary_large_image""#));
736 assert_eq!(tags.len(), 4);
737 }
738
739 #[test]
740 fn common_emits_a_title() {
741 let tags = common("Page", "D", None, None, DEFAULT_VIEWPORT, DEFAULT_ROBOTS);
742 assert!(
743 rendered(&tags).contains("<title>Page</title>"),
744 "common must emit the title it is given"
745 );
746 }
747
748 #[test]
749 fn keywords_are_joined_with_a_comma_and_a_space() {
750 let tags = common(
751 "P",
752 "D",
753 Some(&["rust", "motorcycle"]),
754 None,
755 DEFAULT_VIEWPORT,
756 DEFAULT_ROBOTS,
757 );
758 assert!(rendered(&tags).contains(r#"content="rust, motorcycle""#));
759 }
760
761 #[test]
763 fn the_builder_emits_common_then_open_graph_then_twitter() {
764 let html = rendered(&SeoBuilder::new("T", "D").image("i").url("u").build());
765 let charset = html.find("charset").expect("charset");
766 let og = html.find("og:title").expect("og:title");
767 let twitter = html.find("twitter:card").expect("twitter:card");
768 assert!(charset < og && og < twitter);
769 }
770
771 #[test]
772 fn meta_values_are_escaped_but_keys_are_not() {
773 let tag = meta_property("og:title", r#"Tom & Jerry's "show""#);
774 let html = tag.render();
775 assert!(html.contains(r#"property="og:title""#));
776 assert!(html.contains("&"));
777 assert!(html.contains("""));
778 }
779
780 #[test]
781 fn an_article_carries_its_timestamps() {
782 let markup = rendered(&open_graph_article(
783 "T",
784 "D",
785 "I",
786 "U",
787 Some("Michel"),
788 Some("2026-08-11T10:00:00Z"),
789 Some("2026-08-12T10:00:00Z"),
790 ));
791
792 assert!(markup.contains(r#"<meta property="article:author" content="Michel">"#));
793 assert!(markup.contains(
794 r#"<meta property="article:published_time" content="2026-08-11T10:00:00Z">"#
795 ));
796 assert!(
797 markup.contains(
798 r#"<meta property="article:modified_time" content="2026-08-12T10:00:00Z">"#
799 )
800 );
801 }
802
803 #[test]
806 fn empty_keywords_emit_no_tag() {
807 for keywords in [None, Some(&[][..])] {
808 let markup = rendered(&common(
809 "T",
810 "D",
811 keywords,
812 None,
813 DEFAULT_VIEWPORT,
814 DEFAULT_ROBOTS,
815 ));
816 assert!(!markup.contains("keywords"), "emitted for {keywords:?}");
817 }
818 }
819
820 #[test]
821 fn meta_property_renders_a_property_attribute() {
822 assert_eq!(
823 meta_property("og:title", "Test Title").render(),
824 r#"<meta property="og:title" content="Test Title">"#
825 );
826 }
827
828 #[test]
829 fn meta_charset_renders_a_charset_attribute() {
830 assert_eq!(meta_charset("UTF-8").render(), r#"<meta charset="UTF-8">"#);
831 }
832}