Skip to main content

winged_rust/
seo.rs

1//! SEO metadata: Open Graph, Twitter Cards and the common `<meta>` block.
2//!
3//! Ports `Winged-Swift/Sources/WingedSwift/seo/SEOHelpers.swift`, and implements the
4//! [`SeoBuilder`] from `WINGED_RUST_SPEC.md` §6 on top of it — the builder is sugar over
5//! these functions, never a second implementation.
6
7use crate::core::{Attribute, Element};
8use crate::elements::{meta, title};
9
10/// A `<meta name="…" content="…">`.
11///
12/// The key is inserted unescaped, matching Winged-Swift, which builds its `Meta` keys with
13/// `escape: false` because they are literals the library controls. Values are escaped.
14#[must_use]
15pub fn meta_name(name: &str, content: impl AsRef<str>) -> Element {
16    meta()
17        .add_attribute(Attribute::raw("name", name))
18        .attr("content", content)
19}
20
21/// A `<meta property="…" content="…">`, used by Open Graph.
22#[must_use]
23pub fn meta_property(property: &str, content: impl AsRef<str>) -> Element {
24    meta()
25        .add_attribute(Attribute::raw("property", property))
26        .attr("content", content)
27}
28
29/// A `<meta charset="…">`.
30#[must_use]
31pub fn meta_charset(charset: &str) -> Element {
32    meta().add_attribute(Attribute::raw("charset", charset))
33}
34
35/// A `<meta http-equiv="…" content="…">`.
36#[must_use]
37pub fn meta_http_equiv(http_equiv: &str, content: impl AsRef<str>) -> Element {
38    meta()
39        .add_attribute(Attribute::raw("http-equiv", http_equiv))
40        .attr("content", content)
41}
42
43/// Open Graph tags for a page.
44///
45/// Emits `og:title`, `og:description`, `og:image`, `og:url`, `og:type` and, when supplied,
46/// `og:site_name` — in that order.
47#[must_use]
48pub fn open_graph(
49    page_title: &str,
50    description: &str,
51    image: &str,
52    url: &str,
53    og_type: &str,
54    site_name: Option<&str>,
55) -> Vec<Element> {
56    let mut tags = vec![
57        meta_property("og:title", page_title),
58        meta_property("og:description", description),
59        meta_property("og:image", image),
60        meta_property("og:url", url),
61        meta_property("og:type", og_type),
62    ];
63    if let Some(site_name) = site_name {
64        tags.push(meta_property("og:site_name", site_name));
65    }
66    tags
67}
68
69/// Open Graph tags for an article, with the `article:*` extensions.
70#[must_use]
71pub fn open_graph_article(
72    page_title: &str,
73    description: &str,
74    image: &str,
75    url: &str,
76    author: Option<&str>,
77    published_time: Option<&str>,
78    modified_time: Option<&str>,
79) -> Vec<Element> {
80    let mut tags = open_graph(page_title, description, image, url, "article", None);
81    if let Some(author) = author {
82        tags.push(meta_property("article:author", author));
83    }
84    if let Some(published) = published_time {
85        tags.push(meta_property("article:published_time", published));
86    }
87    if let Some(modified) = modified_time {
88        tags.push(meta_property("article:modified_time", modified));
89    }
90    tags
91}
92
93/// Twitter Card tags.
94#[must_use]
95pub fn twitter_card(
96    page_title: &str,
97    description: &str,
98    image: &str,
99    card: &str,
100    site: Option<&str>,
101    creator: Option<&str>,
102) -> Vec<Element> {
103    let mut tags = vec![
104        meta_name("twitter:card", card),
105        meta_name("twitter:title", page_title),
106        meta_name("twitter:description", description),
107        meta_name("twitter:image", image),
108    ];
109    if let Some(site) = site {
110        tags.push(meta_name("twitter:site", site));
111    }
112    if let Some(creator) = creator {
113        tags.push(meta_name("twitter:creator", creator));
114    }
115    tags
116}
117
118/// The common `<meta>` block: charset, viewport, description, robots, keywords, author —
119/// plus the `<title>`.
120///
121/// # A bug fixed in the port
122///
123/// Winged-Swift's `SEO.common(title:…)` accepts a `title` argument and never uses it — no
124/// `<title>` element is emitted. This version emits one. See `PORTING.md`.
125#[must_use]
126pub fn common(
127    page_title: &str,
128    description: &str,
129    keywords: Option<&[&str]>,
130    author: Option<&str>,
131    viewport: &str,
132    robots: &str,
133) -> Vec<Element> {
134    let mut tags = vec![
135        meta_charset("UTF-8"),
136        meta_name("viewport", viewport),
137        meta_name("description", description),
138        meta_name("robots", robots),
139    ];
140    // An empty list is the same as no list: `content=""` says nothing and Winged-Swift
141    // omits the tag. The guard lives here rather than in `SeoBuilder` so both entry points
142    // agree — it used to be in the builder only, and calling `common` directly with an
143    // empty slice emitted the empty tag.
144    if let Some(keywords) = keywords.filter(|list| !list.is_empty()) {
145        tags.push(meta_name("keywords", keywords.join(", ")));
146    }
147    if let Some(author) = author {
148        tags.push(meta_name("author", author));
149    }
150    tags.push(title().text(page_title));
151    tags
152}
153
154/// The default viewport Winged-Swift uses.
155pub const DEFAULT_VIEWPORT: &str = "width=device-width, initial-scale=1.0";
156/// The default robots directive Winged-Swift uses.
157pub const DEFAULT_ROBOTS: &str = "index, follow";
158/// The default Twitter card type Winged-Swift uses.
159pub const DEFAULT_TWITTER_CARD: &str = "summary_large_image";
160
161/// A fluent builder for a page's whole metadata block.
162///
163/// Implements `WINGED_RUST_SPEC.md` §6 on top of the functions above.
164///
165/// # Examples
166/// ```
167/// use winged_rust::prelude::*;
168/// use winged_rust::seo::SeoBuilder;
169///
170/// let tags = SeoBuilder::new("RideKeeper", "Motorcycle maintenance companion")
171///     .image("https://ridekeeper.example/og.jpg")
172///     .url("https://ridekeeper.example")
173///     .twitter_site("@micheltlutz")
174///     .build();
175///
176/// let rendered: String = tags.iter().map(Render::render).collect();
177/// assert!(rendered.contains(r#"<meta property="og:title" content="RideKeeper">"#));
178/// ```
179#[derive(Debug, Clone)]
180pub struct SeoBuilder {
181    title: String,
182    description: String,
183    image: Option<String>,
184    url: Option<String>,
185    site_name: Option<String>,
186    author: Option<String>,
187    keywords: Vec<String>,
188    twitter_card: String,
189    twitter_site: Option<String>,
190    twitter_creator: Option<String>,
191    viewport: String,
192    robots: String,
193}
194
195impl SeoBuilder {
196    /// Starts a metadata block from the two fields every page needs.
197    pub fn new(page_title: impl Into<String>, description: impl Into<String>) -> Self {
198        Self {
199            title: page_title.into(),
200            description: description.into(),
201            image: None,
202            url: None,
203            site_name: None,
204            author: None,
205            keywords: Vec::new(),
206            twitter_card: DEFAULT_TWITTER_CARD.to_string(),
207            twitter_site: None,
208            twitter_creator: None,
209            viewport: DEFAULT_VIEWPORT.to_string(),
210            robots: DEFAULT_ROBOTS.to_string(),
211        }
212    }
213
214    /// Sets the preview image used by both Open Graph and Twitter.
215    #[must_use]
216    pub fn image(mut self, image_url: impl Into<String>) -> Self {
217        self.image = Some(image_url.into());
218        self
219    }
220
221    /// Sets the canonical page URL.
222    #[must_use]
223    pub fn url(mut self, page_url: impl Into<String>) -> Self {
224        self.url = Some(page_url.into());
225        self
226    }
227
228    /// Sets `og:site_name`.
229    #[must_use]
230    pub fn site_name(mut self, site_name: impl Into<String>) -> Self {
231        self.site_name = Some(site_name.into());
232        self
233    }
234
235    /// Sets the page author.
236    #[must_use]
237    pub fn author(mut self, author: impl Into<String>) -> Self {
238        self.author = Some(author.into());
239        self
240    }
241
242    /// Sets the keyword list.
243    #[must_use]
244    pub fn keywords<S: Into<String>>(mut self, keywords: impl IntoIterator<Item = S>) -> Self {
245        self.keywords = keywords.into_iter().map(Into::into).collect();
246        self
247    }
248
249    /// Overrides the Twitter card type.
250    #[must_use]
251    pub fn twitter_card(mut self, card: impl Into<String>) -> Self {
252        self.twitter_card = card.into();
253        self
254    }
255
256    /// Sets `twitter:site`.
257    #[must_use]
258    pub fn twitter_site(mut self, site: impl Into<String>) -> Self {
259        self.twitter_site = Some(site.into());
260        self
261    }
262
263    /// Sets `twitter:creator`.
264    #[must_use]
265    pub fn twitter_creator(mut self, creator: impl Into<String>) -> Self {
266        self.twitter_creator = Some(creator.into());
267        self
268    }
269
270    /// Builds the tags: the common block, then Open Graph, then Twitter Cards.
271    ///
272    /// That order matches Winged-Swift's `SEO.complete`, and the golden fixture depends on
273    /// it.
274    #[must_use]
275    pub fn build(&self) -> Vec<Element> {
276        let keywords: Vec<&str> = self.keywords.iter().map(String::as_str).collect();
277        let mut tags = common(
278            &self.title,
279            &self.description,
280            Some(&keywords[..]),
281            self.author.as_deref(),
282            &self.viewport,
283            &self.robots,
284        );
285
286        let image = self.image.as_deref().unwrap_or_default();
287        let url = self.url.as_deref().unwrap_or_default();
288
289        tags.extend(open_graph(
290            &self.title,
291            &self.description,
292            image,
293            url,
294            "website",
295            self.site_name.as_deref(),
296        ));
297        tags.extend(twitter_card(
298            &self.title,
299            &self.description,
300            image,
301            &self.twitter_card,
302            self.twitter_site.as_deref(),
303            self.twitter_creator.as_deref(),
304        ));
305        tags
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::core::Render;
313
314    fn rendered(tags: &[Element]) -> String {
315        tags.iter().map(Render::render).collect()
316    }
317
318    /// Ports `SEOTests.testOpenGraphMetaTags`.
319    #[test]
320    fn open_graph_emits_the_five_core_properties() {
321        let tags = open_graph("T", "D", "/i.png", "https://e.com", "website", None);
322        assert_eq!(tags.len(), 5);
323        let html = rendered(&tags);
324        for property in [
325            "og:title",
326            "og:description",
327            "og:image",
328            "og:url",
329            "og:type",
330        ] {
331            assert!(html.contains(property), "{property} missing");
332        }
333    }
334
335    /// Ports `SEOTests.testOpenGraphIncludesTheSiteName`.
336    #[test]
337    fn og_site_name_is_only_emitted_when_supplied() {
338        assert_eq!(open_graph("T", "D", "i", "u", "website", None).len(), 5);
339        assert_eq!(
340            open_graph("T", "D", "i", "u", "website", Some("Site")).len(),
341            6
342        );
343    }
344
345    /// Ports `SEOTests.testOpenGraphArticle`.
346    #[test]
347    fn an_article_adds_the_article_extensions_and_sets_the_type() {
348        let tags = open_graph_article("T", "D", "i", "u", Some("Ana"), Some("2026-01-01"), None);
349        let html = rendered(&tags);
350        assert!(html.contains(r#"content="article""#));
351        assert!(html.contains("article:author"));
352        assert!(html.contains("article:published_time"));
353        assert!(!html.contains("article:modified_time"));
354    }
355
356    /// Ports `SEOTests.testTwitterCardMetaTags`.
357    #[test]
358    fn twitter_card_defaults_to_a_large_image_summary() {
359        let tags = twitter_card("T", "D", "i", DEFAULT_TWITTER_CARD, None, None);
360        assert!(rendered(&tags).contains(r#"content="summary_large_image""#));
361        assert_eq!(tags.len(), 4);
362    }
363
364    /// Ports `SEOTests.testCommonSEOTags`, with the fixed `<title>`.
365    #[test]
366    fn common_emits_a_title_unlike_the_swift_original() {
367        let tags = common("Page", "D", None, None, DEFAULT_VIEWPORT, DEFAULT_ROBOTS);
368        assert!(
369            rendered(&tags).contains("<title>Page</title>"),
370            "SEO.common accepts a title and drops it in Winged-Swift; the port emits it"
371        );
372    }
373
374    #[test]
375    fn keywords_are_joined_with_a_comma_and_a_space() {
376        let tags = common(
377            "P",
378            "D",
379            Some(&["swift", "motorcycle"]),
380            None,
381            DEFAULT_VIEWPORT,
382            DEFAULT_ROBOTS,
383        );
384        assert!(rendered(&tags).contains(r#"content="swift, motorcycle""#));
385    }
386
387    /// Ports `SEOTests.testCompleteSEOTags`. The order is what the golden fixture encodes.
388    #[test]
389    fn the_builder_emits_common_then_open_graph_then_twitter() {
390        let html = rendered(&SeoBuilder::new("T", "D").image("i").url("u").build());
391        let charset = html.find("charset").expect("charset");
392        let og = html.find("og:title").expect("og:title");
393        let twitter = html.find("twitter:card").expect("twitter:card");
394        assert!(charset < og && og < twitter);
395    }
396
397    #[test]
398    fn meta_values_are_escaped_but_keys_are_not() {
399        let tag = meta_property("og:title", r#"Tom & Jerry's "show""#);
400        let html = tag.render();
401        assert!(html.contains(r#"property="og:title""#));
402        assert!(html.contains("&amp;"));
403        assert!(html.contains("&quot;"));
404    }
405
406    /// Ports `SEOTests.testArticleCarriesItsTimestamps`.
407    #[test]
408    fn an_article_carries_its_timestamps() {
409        let markup = rendered(&open_graph_article(
410            "T",
411            "D",
412            "I",
413            "U",
414            Some("Michel"),
415            Some("2026-08-11T10:00:00Z"),
416            Some("2026-08-12T10:00:00Z"),
417        ));
418
419        assert!(markup.contains(r#"<meta property="article:author" content="Michel">"#));
420        assert!(markup.contains(
421            r#"<meta property="article:published_time" content="2026-08-11T10:00:00Z">"#
422        ));
423        assert!(
424            markup.contains(
425                r#"<meta property="article:modified_time" content="2026-08-12T10:00:00Z">"#
426            )
427        );
428    }
429
430    /// Ports `SEOTests.testCommonOmitsEmptyKeywords`.
431    ///
432    /// Swift passes an empty array and expects no tag. Rust distinguishes "no keywords" as
433    /// `None`, and an empty slice has to behave the same way — an empty `content=""` would
434    /// be worse than nothing.
435    #[test]
436    fn empty_keywords_emit_no_tag() {
437        for keywords in [None, Some(&[][..])] {
438            let markup = rendered(&common(
439                "T",
440                "D",
441                keywords,
442                None,
443                DEFAULT_VIEWPORT,
444                DEFAULT_ROBOTS,
445            ));
446            assert!(!markup.contains("keywords"), "emitted for {keywords:?}");
447        }
448    }
449
450    /// Ports `SEOTests.testMetaWithProperty`.
451    #[test]
452    fn meta_property_renders_a_property_attribute() {
453        assert_eq!(
454            meta_property("og:title", "Test Title").render(),
455            r#"<meta property="og:title" content="Test Title">"#
456        );
457    }
458
459    /// Ports `SEOTests.testMetaWithCharset`.
460    #[test]
461    fn meta_charset_renders_a_charset_attribute() {
462        assert_eq!(meta_charset("UTF-8").render(), r#"<meta charset="UTF-8">"#);
463    }
464}