Skip to main content

molt/
seo.rs

1//! SEO metadata: Open Graph, Twitter Cards and the common `<meta>` block.
2//!
3//! The free functions here build the tags; [`SeoBuilder`] is sugar over them, never a second
4//! implementation.
5
6use crate::core::escape::escape_json_script;
7use crate::core::{Attribute, Element};
8use crate::elements::{link, meta, script, title};
9
10/// A `<script type="application/ld+json">` carrying structured data for search engines.
11///
12/// `json` is a JSON document, most often built from page fields that include user text. It
13/// is made safe to embed: `<`, `>`, `&`, U+2028 and U+2029 are written as JSON escapes, so a
14/// value containing `</script>` cannot end the element. It is **not validated**; pass valid
15/// JSON, from a serializer rather than string concatenation.
16///
17/// Before this helper the only way was `script().raw_text(json)`, which writes `json` as it is
18/// and lets a `</script>` in any field break out of the element.
19///
20/// # Examples
21/// ```
22/// use molt::prelude::*;
23/// use molt::seo::json_ld;
24///
25/// let tag = json_ld(r#"{"@type":"Article","headline":"</script><script>alert(1)</script>"}"#);
26/// assert_eq!(
27///     tag.render(),
28///     r#"<script type="application/ld+json">{"@type":"Article","headline":"\u003c/script\u003e\u003cscript\u003ealert(1)\u003c/script\u003e"}</script>"#
29/// );
30/// ```
31#[must_use]
32pub fn json_ld(json: &str) -> Element {
33    script_json("application/ld+json", json)
34}
35
36/// A `<script type="…">` holding a JSON document, made safe to embed. See [`json_ld`].
37///
38/// Use it for other data blocks a page reads with `JSON.parse`, such as
39/// `application/json`. `mime` is attribute-escaped. Do not use it for `type="module"` or for
40/// classic scripts: that content is code, not data, and no escaping makes arbitrary code safe.
41///
42/// # Examples
43/// ```
44/// use molt::prelude::*;
45/// use molt::seo::script_json;
46/// assert_eq!(
47///     script_json("application/json", r#"{"id":7}"#).render(),
48///     r#"<script type="application/json">{"id":7}</script>"#
49/// );
50/// ```
51#[must_use]
52pub fn script_json(mime: &str, json: &str) -> Element {
53    // `raw_text` because `<script>` content must not be HTML-escaped, which would write `&lt;`
54    // that JSON does not decode. It is safe here: `escape_json_script` has removed every `<`.
55    script()
56        .attr("type", mime)
57        .raw_text(escape_json_script(json).into_owned())
58}
59
60/// A `<meta name="…" content="…">`.
61///
62/// Both `name` and `content` are attribute-escaped. The name is usually a literal, but these
63/// functions are `pub` and take `&str`, so a name built from user input (`og:` plus a field, say) would break out of
64/// the quotes. Every legitimate name (`description`, `og:title`) is unchanged by escaping.
65#[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/// A `<meta property="…" content="…">`, used by Open Graph.
73#[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/// A `<meta charset="…">`.
81#[must_use]
82pub fn meta_charset(charset: &str) -> Element {
83    meta().add_attribute(Attribute::new_static("charset", charset))
84}
85
86/// A `<meta http-equiv="…" content="…">`.
87#[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/// Open Graph tags for a page.
95///
96/// Emits `og:title`, `og:description`, `og:image`, `og:url`, `og:type` and, when supplied,
97/// `og:site_name` — in that order.
98#[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/// Open Graph tags for an article, with the `article:*` extensions.
121#[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/// Twitter Card tags.
145#[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/// The common `<meta>` block: charset, viewport, description, robots, keywords, author —
170/// plus the `<title>`.
171///
172/// # A bug fixed in the port
173///
174/// Emits a `<title>` element from `title`, followed by the charset, viewport, description and
175/// robots tags.
176#[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    // An empty list is the same as no list: `content=""` says nothing, so the tag
192    // is omitted. The guard lives here rather than in `SeoBuilder` so both entry points
193    // agree.
194    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
204/// The default viewport.
205pub const DEFAULT_VIEWPORT: &str = "width=device-width, initial-scale=1.0";
206/// The default robots directive.
207pub const DEFAULT_ROBOTS: &str = "index, follow";
208/// The default Twitter card type.
209pub const DEFAULT_TWITTER_CARD: &str = "summary_large_image";
210
211/// A fluent builder for a page's whole metadata block.
212///
213/// Implements `docs/history/MOLT_SPEC.md` §6 on top of the functions above.
214///
215/// # Examples
216/// ```
217/// use molt::prelude::*;
218/// use molt::seo::SeoBuilder;
219///
220/// let tags = SeoBuilder::new("RideKeeper", "Motorcycle maintenance companion")
221///     .image("https://ridekeeper.example/og.jpg")
222///     .url("https://ridekeeper.example")
223///     .twitter_site("@micheltlutz")
224///     .build();
225///
226/// let rendered: String = tags.iter().map(Render::render).collect();
227/// assert!(rendered.contains(r#"<meta property="og:title" content="RideKeeper">"#));
228/// ```
229#[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    /// Starts a metadata block from the two fields every page needs.
255    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    /// Sets the preview image used by both Open Graph and Twitter.
281    #[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    /// Sets the page URL, written as `og:url`.
288    ///
289    /// This is not `<link rel="canonical">`; for that, see [`SeoBuilder::canonical`].
290    #[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    /// Sets `og:site_name`.
297    #[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    /// Sets the page author.
304    #[must_use]
305    pub fn author(mut self, author: impl Into<String>) -> Self {
306        self.author = Some(author.into());
307        self
308    }
309
310    /// Sets the keyword list.
311    #[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    /// Overrides the Twitter card type.
318    #[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    /// Sets `twitter:site`.
325    #[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    /// Sets `twitter:creator`.
332    #[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    /// Overrides the `robots` directive (default [`DEFAULT_ROBOTS`]).
339    ///
340    /// # Examples
341    /// ```
342    /// use molt::prelude::*;
343    /// use molt::seo::SeoBuilder;
344    /// let tags = SeoBuilder::new("T", "D")
345    ///     .robots("index, follow, max-image-preview:large")
346    ///     .build();
347    /// let html: String = tags.iter().map(Render::render).collect();
348    /// assert!(html.contains(r#"<meta name="robots" content="index, follow, max-image-preview:large">"#));
349    /// ```
350    #[must_use]
351    pub fn robots(mut self, robots: impl Into<String>) -> Self {
352        self.robots = robots.into();
353        self
354    }
355
356    /// Overrides the `viewport` directive (default [`DEFAULT_VIEWPORT`]).
357    #[must_use]
358    pub fn viewport(mut self, viewport: impl Into<String>) -> Self {
359        self.viewport = viewport.into();
360        self
361    }
362
363    /// Adds `<link rel="canonical" href="…">`. Omitted unless called.
364    ///
365    /// The `href` goes through the URL policy, like any `href`.
366    ///
367    /// # Examples
368    /// ```
369    /// use molt::prelude::*;
370    /// use molt::seo::SeoBuilder;
371    /// let tags = SeoBuilder::new("T", "D").canonical("https://example.com/en/").build();
372    /// let html: String = tags.iter().map(Render::render).collect();
373    /// assert!(html.contains(r#"<link rel="canonical" href="https://example.com/en/">"#));
374    /// ```
375    #[must_use]
376    pub fn canonical(mut self, url: impl Into<String>) -> Self {
377        self.canonical = Some(url.into());
378        self
379    }
380
381    /// Adds `<link rel="alternate" hreflang="…" href="…">`, once per call, in call order.
382    ///
383    /// `hreflang` is a language tag or `x-default`.
384    ///
385    /// # Examples
386    /// ```
387    /// use molt::prelude::*;
388    /// use molt::seo::SeoBuilder;
389    /// let tags = SeoBuilder::new("T", "D")
390    ///     .alternate("en", "https://example.com/en/")
391    ///     .alternate("x-default", "https://example.com/")
392    ///     .build();
393    /// let html: String = tags.iter().map(Render::render).collect();
394    /// assert!(html.contains(
395    ///     r#"<link rel="alternate" hreflang="x-default" href="https://example.com/">"#
396    /// ));
397    /// ```
398    #[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    /// Sets `og:locale`, such as `pt_BR`.
405    #[must_use]
406    pub fn locale(mut self, locale: impl Into<String>) -> Self {
407        self.locale = Some(locale.into());
408        self
409    }
410
411    /// Adds an `og:locale:alternate`, once per call, in call order.
412    #[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    /// Sets `og:image:width` and `og:image:height`, in pixels.
419    #[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    /// Sets `og:image:alt` and `twitter:image:alt`.
426    #[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    /// Adds `<meta name="theme-color">`. Omitted unless called.
433    #[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    /// Adds `<meta name="color-scheme">`, such as `dark light`. Omitted unless called.
440    #[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    /// Builds the tags: the common block, then Open Graph, then Twitter Cards.
447    ///
448    /// The order is fixed, and the golden fixture depends on it. A tag that needs a setter
449    /// (`canonical`, `alternate`, `theme_color`, `color_scheme`, the `og:locale` and
450    /// `og:image:*` tags, `twitter:image:alt`) is written only when that setter was called, so
451    /// a builder that never calls them produces what it always did:
452    ///
453    /// 1. the common block, ending in `<title>`;
454    /// 2. `canonical`, the `alternate` links, `theme-color`, `color-scheme`;
455    /// 3. Open Graph, then `og:image:width`, `og:image:height`, `og:image:alt`, `og:locale`,
456    ///    the `og:locale:alternate` tags;
457    /// 4. Twitter Cards, then `twitter:image:alt`.
458    #[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        // `&`, `<`, `>` and the two JS line terminators become JSON escapes; the rest is as given.
558        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&quot;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        // `<` and `>` stay as text inside a quoted value; what matters is that no raw quote
665        // lets the payload out of its attribute.
666        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    /// The names were inserted unescaped, so a name carrying a quote closed the attribute.
676    #[test]
677    fn a_hostile_meta_name_cannot_break_out_of_its_attribute() {
678        let hostile = "a\"><script>alert(1)</script><x y=\"";
679        // Inside the quotes `<` is inert (the S4 raw-text-parent case is handled by the
680        // writer); what must hold is that no `"` from the input closes a value early.
681        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("&quot;"), "{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    /// The order is what the golden fixture encodes.
762    #[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("&amp;"));
777        assert!(html.contains("&quot;"));
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    /// "No keywords" is `None`, and an empty slice has to behave the same way — an empty `content=""` would
804    /// be worse than nothing.
805    #[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}