1use crate::error::MetadataError;
7use quick_xml::events::Event;
8use quick_xml::reader::Reader;
9use std::{collections::HashMap, fmt};
10
11#[derive(Debug, Default, PartialEq, Eq, Hash, Clone)]
28pub struct MetaTagGroups {
29 pub apple: String,
31 pub primary: String,
33 pub og: String,
35 pub ms: String,
37 pub twitter: String,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct MetaTag {
56 pub name: String,
58 pub content: String,
60}
61
62impl MetaTagGroups {
63 pub fn add_custom_tag(&mut self, name: &str, content: &str) {
70 let formatted_tag = self.format_meta_tag(name, content);
71
72 if name.starts_with("apple-")
74 || name == "mobile-web-app-capable"
75 {
76 self.apple.push_str(&formatted_tag);
77 } else if name.starts_with("msapplication-") {
78 self.ms.push_str(&formatted_tag);
80 } else if name.starts_with("og:") {
81 self.og.push_str(&formatted_tag);
83 } else if name.starts_with("twitter:") {
84 self.twitter.push_str(&formatted_tag);
86 } else {
87 self.primary.push_str(&formatted_tag);
89 }
90 }
91
92 pub fn format_meta_tag(&self, name: &str, content: &str) -> String {
103 format!(
104 r#"<meta name="{}" content="{}">"#,
105 name,
106 content.replace('"', """)
107 )
108 }
109
110 pub fn generate_apple_meta_tags(
116 &mut self,
117 metadata: &HashMap<String, String>,
118 ) {
119 const APPLE_TAGS: [&str; 4] = [
120 "apple-mobile-web-app-capable",
121 "mobile-web-app-capable",
122 "apple-mobile-web-app-status-bar-style",
123 "apple-mobile-web-app-title",
124 ];
125 self.apple = self.generate_tags(metadata, &APPLE_TAGS);
126 }
127
128 pub fn generate_primary_meta_tags(
134 &mut self,
135 metadata: &HashMap<String, String>,
136 ) {
137 const PRIMARY_TAGS: [&str; 4] =
138 ["author", "description", "keywords", "viewport"];
139 self.primary = self.generate_tags(metadata, &PRIMARY_TAGS);
140 }
141
142 pub fn generate_og_meta_tags(
148 &mut self,
149 metadata: &HashMap<String, String>,
150 ) {
151 const OG_TAGS: [&str; 5] = [
152 "og:title",
153 "og:description",
154 "og:image",
155 "og:url",
156 "og:type",
157 ];
158 self.og = self.generate_tags(metadata, &OG_TAGS);
159 }
160
161 pub fn generate_ms_meta_tags(
167 &mut self,
168 metadata: &HashMap<String, String>,
169 ) {
170 const MS_TAGS: [&str; 2] =
171 ["msapplication-TileColor", "msapplication-TileImage"];
172 self.ms = self.generate_tags(metadata, &MS_TAGS);
173 }
174
175 pub fn generate_twitter_meta_tags(
181 &mut self,
182 metadata: &HashMap<String, String>,
183 ) {
184 const TWITTER_TAGS: [&str; 5] = [
185 "twitter:card",
186 "twitter:site",
187 "twitter:title",
188 "twitter:description",
189 "twitter:image",
190 ];
191 self.twitter = self.generate_tags(metadata, &TWITTER_TAGS);
192 }
193
194 pub fn generate_tags(
205 &self,
206 metadata: &HashMap<String, String>,
207 tags: &[&str],
208 ) -> String {
209 tags.iter()
210 .filter_map(|&tag| {
211 metadata
212 .get(tag)
213 .map(|value| self.format_meta_tag(tag, value))
214 })
215 .collect::<Vec<_>>()
216 .join("\n")
217 }
218}
219
220impl fmt::Display for MetaTagGroups {
222 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223 write!(
224 f,
225 "{}\n{}\n{}\n{}\n{}",
226 self.apple, self.primary, self.og, self.ms, self.twitter
227 )
228 }
229}
230
231pub fn generate_metatags(
243 metadata: &HashMap<String, String>,
244) -> MetaTagGroups {
245 let mut meta_tag_groups = MetaTagGroups::default();
246 meta_tag_groups.generate_apple_meta_tags(metadata);
247 meta_tag_groups.generate_primary_meta_tags(metadata);
248 meta_tag_groups.generate_og_meta_tags(metadata);
249 meta_tag_groups.generate_ms_meta_tags(metadata);
250 meta_tag_groups.generate_twitter_meta_tags(metadata);
251 meta_tag_groups
252}
253
254pub fn extract_meta_tags(
287 html_content: &str,
288) -> Result<Vec<MetaTag>, MetadataError> {
289 let mut reader = Reader::from_str(html_content);
290 let config = reader.config_mut();
291 config.check_end_names = false;
294 config.trim_text(false);
295
296 let mut meta_tags = Vec::new();
297 let mut buf = Vec::new();
298
299 loop {
300 match reader.read_event_into(&mut buf) {
301 Ok(Event::Eof) => break,
302 Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e))
306 if name_eq_ignore_case(e.name().as_ref(), b"meta") =>
307 {
308 if let Some(tag) = collect_meta_tag(e) {
309 meta_tags.push(tag);
310 }
311 }
312 Ok(_) => {}
313 Err(e) => {
314 let _ = e;
319 break;
320 }
321 }
322 buf.clear();
323 }
324
325 Ok(meta_tags)
326}
327
328fn name_eq_ignore_case(a: &[u8], b: &[u8]) -> bool {
330 a.len() == b.len()
331 && a.iter()
332 .zip(b.iter())
333 .all(|(x, y)| x.eq_ignore_ascii_case(y))
334}
335
336fn collect_meta_tag(
345 e: &quick_xml::events::BytesStart<'_>,
346) -> Option<MetaTag> {
347 let mut name: Option<String> = None;
348 let mut property: Option<String> = None;
349 let mut http_equiv: Option<String> = None;
350 let mut content: Option<String> = None;
351
352 for attr_res in e.attributes() {
353 let Ok(attr) = attr_res else { continue };
354 let Ok(raw) = std::str::from_utf8(attr.value.as_ref()) else {
358 continue;
359 };
360 let value = match quick_xml::escape::unescape(raw) {
361 Ok(v) => v.into_owned(),
362 Err(_) => continue,
363 };
364 match attr.key.as_ref() {
365 k if name_eq_ignore_case(k, b"name") => name = Some(value),
366 k if name_eq_ignore_case(k, b"property") => {
367 property = Some(value)
368 }
369 k if name_eq_ignore_case(k, b"http-equiv") => {
370 http_equiv = Some(value)
371 }
372 k if name_eq_ignore_case(k, b"content") => {
373 content = Some(value)
374 }
375 _ => {}
376 }
377 }
378
379 let id = name.or(property).or(http_equiv)?;
380 let content = content?;
381 Some(MetaTag { name: id, content })
382}
383
384pub fn meta_tags_to_hashmap(
394 meta_tags: Vec<MetaTag>,
395) -> HashMap<String, String> {
396 meta_tags
397 .into_iter()
398 .map(|tag| (tag.name, tag.content))
399 .collect()
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn test_generate_metatags() {
408 let mut metadata = HashMap::new();
409 metadata.insert("title".to_string(), "Test Page".to_string());
410 metadata.insert(
411 "description".to_string(),
412 "A test page".to_string(),
413 );
414 metadata
415 .insert("og:title".to_string(), "OG Test Page".to_string());
416
417 let meta_tags = generate_metatags(&metadata);
418
419 assert!(meta_tags.primary.contains("description"));
420 assert!(meta_tags.og.contains("og:title"));
421 }
422
423 #[test]
424 fn test_extract_meta_tags() {
425 let html = r#"
426 <html>
427 <head>
428 <meta name="description" content="A sample page">
429 <meta property="og:title" content="Sample Title">
430 <meta http-equiv="content-type" content="text/html; charset=UTF-8">
431 </head>
432 <body>
433 <p>Some content</p>
434 </body>
435 </html>
436 "#;
437
438 let meta_tags = extract_meta_tags(html).unwrap();
439 assert_eq!(meta_tags.len(), 3);
440 assert!(meta_tags.iter().any(|tag| tag.name == "description"
441 && tag.content == "A sample page"));
442 assert!(meta_tags.iter().any(|tag| tag.name == "og:title"
443 && tag.content == "Sample Title"));
444 assert!(meta_tags.iter().any(|tag| tag.name == "content-type"
445 && tag.content == "text/html; charset=UTF-8"));
446 }
447
448 #[test]
449 fn test_extract_meta_tags_preserves_document_order() {
450 let html = r#"
453 <html><head>
454 <meta name="a" content="1">
455 <meta property="og:b" content="2">
456 <meta name="c" content="3">
457 </head><body></body></html>
458 "#;
459 let tags = extract_meta_tags(html).unwrap();
460 let names: Vec<_> =
461 tags.iter().map(|t| t.name.as_str()).collect();
462 assert_eq!(names, vec!["a", "og:b", "c"]);
463 }
464
465 #[test]
466 fn test_extract_meta_tags_handles_self_closing() {
467 let html = r#"<meta name="x" content="1" /><meta name="y" content="2">"#;
470 let tags = extract_meta_tags(html).unwrap();
471 assert_eq!(tags.len(), 2);
472 assert_eq!(tags[0].name, "x");
473 assert_eq!(tags[1].name, "y");
474 }
475
476 #[test]
477 fn test_extract_meta_tags_decodes_entities() {
478 let html =
481 r#"<meta name="title" content="Tom & Jerry <3">"#;
482 let tags = extract_meta_tags(html).unwrap();
483 assert_eq!(tags.len(), 1);
484 assert_eq!(tags[0].content, "Tom & Jerry <3");
485 }
486
487 #[test]
488 fn test_extract_meta_tags_does_not_panic_on_malformed() {
489 let html = r#"
494 <html><head>
495 <meta name="first" content="ok">
496 <meta name="broken" content="oops
497 <meta name="second" content="probably-lost">
498 </head>
499 "#;
500 let _ = extract_meta_tags(html).expect("must not panic");
501 }
502
503 #[test]
504 fn test_extract_meta_tags_ignores_meta_without_content() {
505 let html =
508 r#"<meta name="orphan"><meta name="ok" content="yes">"#;
509 let tags = extract_meta_tags(html).unwrap();
510 assert_eq!(tags.len(), 1);
511 assert_eq!(tags[0].name, "ok");
512 assert_eq!(tags[0].content, "yes");
513 }
514
515 #[test]
516 fn test_extract_meta_tags_empty_html() {
517 let html = "<html><head></head><body></body></html>";
518 let meta_tags = extract_meta_tags(html).unwrap();
519 assert_eq!(meta_tags.len(), 0);
520 }
521
522 #[test]
523 fn test_meta_tags_to_hashmap() {
524 let meta_tags = vec![
525 MetaTag {
526 name: "description".to_string(),
527 content: "A sample page".to_string(),
528 },
529 MetaTag {
530 name: "og:title".to_string(),
531 content: "Sample Title".to_string(),
532 },
533 ];
534
535 let hashmap = meta_tags_to_hashmap(meta_tags);
536 assert_eq!(hashmap.len(), 2);
537 assert_eq!(
538 hashmap.get("description"),
539 Some(&"A sample page".to_string())
540 );
541 assert_eq!(
542 hashmap.get("og:title"),
543 Some(&"Sample Title".to_string())
544 );
545 }
546
547 #[test]
548 fn test_meta_tag_groups_display() {
549 let groups = MetaTagGroups {
550 apple: "<meta name=\"apple-mobile-web-app-capable\" content=\"yes\">".to_string(),
551 primary: "<meta name=\"description\" content=\"A test page\">".to_string(),
552 og: "<meta property=\"og:title\" content=\"Test Page\">".to_string(),
553 ms: "<meta name=\"msapplication-TileColor\" content=\"#ffffff\">".to_string(),
554 twitter: "<meta name=\"twitter:card\" content=\"summary\">".to_string(),
555};
556
557 let display = groups.to_string();
558 assert!(display.contains("apple-mobile-web-app-capable"));
559 assert!(display.contains("description"));
560 assert!(display.contains("og:title"));
561 assert!(display.contains("msapplication-TileColor"));
562 assert!(display.contains("twitter:card"));
563 }
564
565 #[test]
566 fn test_format_meta_tag() {
567 let groups = MetaTagGroups::default();
568 let tag = groups.format_meta_tag("test", "Test \"Value\"");
569 assert_eq!(
570 tag,
571 r#"<meta name="test" content="Test "Value"">"#
572 );
573 }
574}