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::{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}
244
245impl SeoBuilder {
246    /// Starts a metadata block from the two fields every page needs.
247    pub fn new(page_title: impl Into<String>, description: impl Into<String>) -> Self {
248        Self {
249            title: page_title.into(),
250            description: description.into(),
251            image: None,
252            url: None,
253            site_name: None,
254            author: None,
255            keywords: Vec::new(),
256            twitter_card: DEFAULT_TWITTER_CARD.to_string(),
257            twitter_site: None,
258            twitter_creator: None,
259            viewport: DEFAULT_VIEWPORT.to_string(),
260            robots: DEFAULT_ROBOTS.to_string(),
261        }
262    }
263
264    /// Sets the preview image used by both Open Graph and Twitter.
265    #[must_use]
266    pub fn image(mut self, image_url: impl Into<String>) -> Self {
267        self.image = Some(image_url.into());
268        self
269    }
270
271    /// Sets the canonical page URL.
272    #[must_use]
273    pub fn url(mut self, page_url: impl Into<String>) -> Self {
274        self.url = Some(page_url.into());
275        self
276    }
277
278    /// Sets `og:site_name`.
279    #[must_use]
280    pub fn site_name(mut self, site_name: impl Into<String>) -> Self {
281        self.site_name = Some(site_name.into());
282        self
283    }
284
285    /// Sets the page author.
286    #[must_use]
287    pub fn author(mut self, author: impl Into<String>) -> Self {
288        self.author = Some(author.into());
289        self
290    }
291
292    /// Sets the keyword list.
293    #[must_use]
294    pub fn keywords<S: Into<String>>(mut self, keywords: impl IntoIterator<Item = S>) -> Self {
295        self.keywords = keywords.into_iter().map(Into::into).collect();
296        self
297    }
298
299    /// Overrides the Twitter card type.
300    #[must_use]
301    pub fn twitter_card(mut self, card: impl Into<String>) -> Self {
302        self.twitter_card = card.into();
303        self
304    }
305
306    /// Sets `twitter:site`.
307    #[must_use]
308    pub fn twitter_site(mut self, site: impl Into<String>) -> Self {
309        self.twitter_site = Some(site.into());
310        self
311    }
312
313    /// Sets `twitter:creator`.
314    #[must_use]
315    pub fn twitter_creator(mut self, creator: impl Into<String>) -> Self {
316        self.twitter_creator = Some(creator.into());
317        self
318    }
319
320    /// Builds the tags: the common block, then Open Graph, then Twitter Cards.
321    ///
322    /// The order is fixed, and the golden fixture depends on it.
323    #[must_use]
324    pub fn build(&self) -> Vec<Element> {
325        let keywords: Vec<&str> = self.keywords.iter().map(String::as_str).collect();
326        let mut tags = common(
327            &self.title,
328            &self.description,
329            Some(&keywords[..]),
330            self.author.as_deref(),
331            &self.viewport,
332            &self.robots,
333        );
334
335        let image = self.image.as_deref().unwrap_or_default();
336        let url = self.url.as_deref().unwrap_or_default();
337
338        tags.extend(open_graph(
339            &self.title,
340            &self.description,
341            image,
342            url,
343            "website",
344            self.site_name.as_deref(),
345        ));
346        tags.extend(twitter_card(
347            &self.title,
348            &self.description,
349            image,
350            &self.twitter_card,
351            self.twitter_site.as_deref(),
352            self.twitter_creator.as_deref(),
353        ));
354        tags
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    #[test]
361    fn json_ld_cannot_be_broken_out_of_by_a_value() {
362        for hostile in [
363            "</script><script>alert(1)</script>",
364            "</SCRIPT >",
365            "<!--<script>",
366            "-->",
367            "<img src=x onerror=alert(1)>",
368        ] {
369            let json = format!(r#"{{"name":"{hostile}"}}"#);
370            let html = json_ld(&json).render();
371            let inner = html
372                .strip_prefix(r#"<script type="application/ld+json">"#)
373                .and_then(|rest| rest.strip_suffix("</script>"))
374                .expect("one script element");
375            for forbidden in ['<', '>', '&'] {
376                assert!(!inner.contains(forbidden), "{hostile:?} -> {html}");
377            }
378        }
379    }
380
381    #[test]
382    fn json_ld_keeps_the_document_and_only_rewrites_what_is_dangerous() {
383        let plain = r#"{"@context":"https://schema.org","name":"Tyres","n":[1,2.5,true,null]}"#;
384        assert_eq!(
385            json_ld(plain).render(),
386            format!(r#"<script type="application/ld+json">{plain}</script>"#)
387        );
388        // `&`, `<`, `>` and the two JS line terminators become JSON escapes; the rest is as given.
389        let json = format!(r#"{{"t":"a & b <c> {} {}"}}"#, '\u{2028}', '\u{2029}');
390        assert_eq!(
391            json_ld(&json).render(),
392            r#"<script type="application/ld+json">{"t":"a \u0026 b \u003cc\u003e \u2028 \u2029"}</script>"#
393        );
394        assert_eq!(
395            script_json("a\"b", "{}").render(),
396            r#"<script type="a&quot;b">{}</script>"#
397        );
398    }
399
400    use super::*;
401    use crate::core::Render;
402
403    fn rendered(tags: &[Element]) -> String {
404        tags.iter().map(Render::render).collect()
405    }
406
407    /// The names were inserted unescaped, so a name carrying a quote closed the attribute.
408    #[test]
409    fn a_hostile_meta_name_cannot_break_out_of_its_attribute() {
410        let hostile = "a\"><script>alert(1)</script><x y=\"";
411        // Inside the quotes `<` is inert (the S4 raw-text-parent case is handled by the
412        // writer); what must hold is that no `"` from the input closes a value early.
413        for (tag, quotes) in [
414            (meta_name(hostile, "v"), 4),
415            (meta_property(hostile, "v"), 4),
416            (meta_charset(hostile), 2),
417            (meta_http_equiv(hostile, "v"), 4),
418        ] {
419            let html = tag.render();
420            assert_eq!(html.matches('"').count(), quotes, "{html}");
421            assert!(html.contains("&quot;"), "{html}");
422        }
423        assert_eq!(
424            meta_property("og:title", "T").render(),
425            r#"<meta property="og:title" content="T">"#
426        );
427    }
428
429    #[test]
430    fn open_graph_emits_the_five_core_properties() {
431        let tags = open_graph("T", "D", "/i.png", "https://e.com", "website", None);
432        assert_eq!(tags.len(), 5);
433        let html = rendered(&tags);
434        for property in [
435            "og:title",
436            "og:description",
437            "og:image",
438            "og:url",
439            "og:type",
440        ] {
441            assert!(html.contains(property), "{property} missing");
442        }
443    }
444
445    #[test]
446    fn og_site_name_is_only_emitted_when_supplied() {
447        assert_eq!(open_graph("T", "D", "i", "u", "website", None).len(), 5);
448        assert_eq!(
449            open_graph("T", "D", "i", "u", "website", Some("Site")).len(),
450            6
451        );
452    }
453
454    #[test]
455    fn an_article_adds_the_article_extensions_and_sets_the_type() {
456        let tags = open_graph_article("T", "D", "i", "u", Some("Ana"), Some("2026-01-01"), None);
457        let html = rendered(&tags);
458        assert!(html.contains(r#"content="article""#));
459        assert!(html.contains("article:author"));
460        assert!(html.contains("article:published_time"));
461        assert!(!html.contains("article:modified_time"));
462    }
463
464    #[test]
465    fn twitter_card_defaults_to_a_large_image_summary() {
466        let tags = twitter_card("T", "D", "i", DEFAULT_TWITTER_CARD, None, None);
467        assert!(rendered(&tags).contains(r#"content="summary_large_image""#));
468        assert_eq!(tags.len(), 4);
469    }
470
471    #[test]
472    fn common_emits_a_title() {
473        let tags = common("Page", "D", None, None, DEFAULT_VIEWPORT, DEFAULT_ROBOTS);
474        assert!(
475            rendered(&tags).contains("<title>Page</title>"),
476            "common must emit the title it is given"
477        );
478    }
479
480    #[test]
481    fn keywords_are_joined_with_a_comma_and_a_space() {
482        let tags = common(
483            "P",
484            "D",
485            Some(&["rust", "motorcycle"]),
486            None,
487            DEFAULT_VIEWPORT,
488            DEFAULT_ROBOTS,
489        );
490        assert!(rendered(&tags).contains(r#"content="rust, motorcycle""#));
491    }
492
493    /// The order is what the golden fixture encodes.
494    #[test]
495    fn the_builder_emits_common_then_open_graph_then_twitter() {
496        let html = rendered(&SeoBuilder::new("T", "D").image("i").url("u").build());
497        let charset = html.find("charset").expect("charset");
498        let og = html.find("og:title").expect("og:title");
499        let twitter = html.find("twitter:card").expect("twitter:card");
500        assert!(charset < og && og < twitter);
501    }
502
503    #[test]
504    fn meta_values_are_escaped_but_keys_are_not() {
505        let tag = meta_property("og:title", r#"Tom & Jerry's "show""#);
506        let html = tag.render();
507        assert!(html.contains(r#"property="og:title""#));
508        assert!(html.contains("&amp;"));
509        assert!(html.contains("&quot;"));
510    }
511
512    #[test]
513    fn an_article_carries_its_timestamps() {
514        let markup = rendered(&open_graph_article(
515            "T",
516            "D",
517            "I",
518            "U",
519            Some("Michel"),
520            Some("2026-08-11T10:00:00Z"),
521            Some("2026-08-12T10:00:00Z"),
522        ));
523
524        assert!(markup.contains(r#"<meta property="article:author" content="Michel">"#));
525        assert!(markup.contains(
526            r#"<meta property="article:published_time" content="2026-08-11T10:00:00Z">"#
527        ));
528        assert!(
529            markup.contains(
530                r#"<meta property="article:modified_time" content="2026-08-12T10:00:00Z">"#
531            )
532        );
533    }
534
535    /// "No keywords" is `None`, and an empty slice has to behave the same way — an empty `content=""` would
536    /// be worse than nothing.
537    #[test]
538    fn empty_keywords_emit_no_tag() {
539        for keywords in [None, Some(&[][..])] {
540            let markup = rendered(&common(
541                "T",
542                "D",
543                keywords,
544                None,
545                DEFAULT_VIEWPORT,
546                DEFAULT_ROBOTS,
547            ));
548            assert!(!markup.contains("keywords"), "emitted for {keywords:?}");
549        }
550    }
551
552    #[test]
553    fn meta_property_renders_a_property_attribute() {
554        assert_eq!(
555            meta_property("og:title", "Test Title").render(),
556            r#"<meta property="og:title" content="Test Title">"#
557        );
558    }
559
560    #[test]
561    fn meta_charset_renders_a_charset_attribute() {
562        assert_eq!(meta_charset("UTF-8").render(), r#"<meta charset="UTF-8">"#);
563    }
564}