1use crate::core::escape::escape_json_script;
7use crate::core::{Attribute, Element};
8use crate::elements::{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}
244
245impl SeoBuilder {
246 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 #[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 #[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 #[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 #[must_use]
287 pub fn author(mut self, author: impl Into<String>) -> Self {
288 self.author = Some(author.into());
289 self
290 }
291
292 #[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 #[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 #[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 #[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 #[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 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"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 #[test]
409 fn a_hostile_meta_name_cannot_break_out_of_its_attribute() {
410 let hostile = "a\"><script>alert(1)</script><x y=\"";
411 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("""), "{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 #[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("&"));
509 assert!(html.contains("""));
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 #[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}