1use crate::core::{Attribute, Element};
8use crate::elements::{meta, title};
9
10#[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#[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#[must_use]
31pub fn meta_charset(charset: &str) -> Element {
32 meta().add_attribute(Attribute::raw("charset", charset))
33}
34
35#[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#[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#[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#[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#[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 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
154pub const DEFAULT_VIEWPORT: &str = "width=device-width, initial-scale=1.0";
156pub const DEFAULT_ROBOTS: &str = "index, follow";
158pub const DEFAULT_TWITTER_CARD: &str = "summary_large_image";
160
161#[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 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 #[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 #[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 #[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 #[must_use]
237 pub fn author(mut self, author: impl Into<String>) -> Self {
238 self.author = Some(author.into());
239 self
240 }
241
242 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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("&"));
403 assert!(html.contains("""));
404 }
405
406 #[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 #[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 #[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 #[test]
461 fn meta_charset_renders_a_charset_attribute() {
462 assert_eq!(meta_charset("UTF-8").render(), r#"<meta charset="UTF-8">"#);
463 }
464}