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 e.name().as_ref().eq_ignore_ascii_case("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 collect_meta_tag(
337 e: &quick_xml::events::BytesStart<'_>,
338) -> Option<MetaTag> {
339 let mut name: Option<String> = None;
340 let mut property: Option<String> = None;
341 let mut http_equiv: Option<String> = None;
342 let mut content: Option<String> = None;
343
344 for attr_res in e.attributes() {
345 let Ok(attr) = attr_res else { continue };
346 let raw: &str = attr.value.as_ref();
352 let value = match quick_xml::escape::unescape(raw) {
353 Ok(v) => v.into_owned(),
354 Err(_) => continue,
355 };
356 let key: &str = attr.key.as_ref();
357 if key.eq_ignore_ascii_case("name") {
358 name = Some(value);
359 } else if key.eq_ignore_ascii_case("property") {
360 property = Some(value);
361 } else if key.eq_ignore_ascii_case("http-equiv") {
362 http_equiv = Some(value);
363 } else if key.eq_ignore_ascii_case("content") {
364 content = Some(value);
365 }
366 }
367
368 let id = name.or(property).or(http_equiv)?;
369 let content = content?;
370 Some(MetaTag { name: id, content })
371}
372
373pub fn meta_tags_to_hashmap(
383 meta_tags: Vec<MetaTag>,
384) -> HashMap<String, String> {
385 meta_tags
386 .into_iter()
387 .map(|tag| (tag.name, tag.content))
388 .collect()
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn test_generate_metatags() {
397 let mut metadata = HashMap::new();
398 metadata.insert("title".to_string(), "Test Page".to_string());
399 metadata.insert(
400 "description".to_string(),
401 "A test page".to_string(),
402 );
403 metadata
404 .insert("og:title".to_string(), "OG Test Page".to_string());
405
406 let meta_tags = generate_metatags(&metadata);
407
408 assert!(meta_tags.primary.contains("description"));
409 assert!(meta_tags.og.contains("og:title"));
410 }
411
412 #[test]
413 fn test_extract_meta_tags() {
414 let html = r#"
415 <html>
416 <head>
417 <meta name="description" content="A sample page">
418 <meta property="og:title" content="Sample Title">
419 <meta http-equiv="content-type" content="text/html; charset=UTF-8">
420 </head>
421 <body>
422 <p>Some content</p>
423 </body>
424 </html>
425 "#;
426
427 let meta_tags = extract_meta_tags(html).unwrap();
428 assert_eq!(meta_tags.len(), 3);
429 assert!(meta_tags.iter().any(|tag| tag.name == "description"
430 && tag.content == "A sample page"));
431 assert!(meta_tags.iter().any(|tag| tag.name == "og:title"
432 && tag.content == "Sample Title"));
433 assert!(meta_tags.iter().any(|tag| tag.name == "content-type"
434 && tag.content == "text/html; charset=UTF-8"));
435 }
436
437 #[test]
438 fn test_extract_meta_tags_preserves_document_order() {
439 let html = r#"
442 <html><head>
443 <meta name="a" content="1">
444 <meta property="og:b" content="2">
445 <meta name="c" content="3">
446 </head><body></body></html>
447 "#;
448 let tags = extract_meta_tags(html).unwrap();
449 let names: Vec<_> =
450 tags.iter().map(|t| t.name.as_str()).collect();
451 assert_eq!(names, vec!["a", "og:b", "c"]);
452 }
453
454 #[test]
455 fn test_extract_meta_tags_handles_self_closing() {
456 let html = r#"<meta name="x" content="1" /><meta name="y" content="2">"#;
459 let tags = extract_meta_tags(html).unwrap();
460 assert_eq!(tags.len(), 2);
461 assert_eq!(tags[0].name, "x");
462 assert_eq!(tags[1].name, "y");
463 }
464
465 #[test]
466 fn test_extract_meta_tags_decodes_entities() {
467 let html =
470 r#"<meta name="title" content="Tom & Jerry <3">"#;
471 let tags = extract_meta_tags(html).unwrap();
472 assert_eq!(tags.len(), 1);
473 assert_eq!(tags[0].content, "Tom & Jerry <3");
474 }
475
476 #[test]
477 fn test_extract_meta_tags_does_not_panic_on_malformed() {
478 let html = r#"
483 <html><head>
484 <meta name="first" content="ok">
485 <meta name="broken" content="oops
486 <meta name="second" content="probably-lost">
487 </head>
488 "#;
489 let _ = extract_meta_tags(html).expect("must not panic");
490 }
491
492 #[test]
493 fn test_extract_meta_tags_ignores_meta_without_content() {
494 let html =
497 r#"<meta name="orphan"><meta name="ok" content="yes">"#;
498 let tags = extract_meta_tags(html).unwrap();
499 assert_eq!(tags.len(), 1);
500 assert_eq!(tags[0].name, "ok");
501 assert_eq!(tags[0].content, "yes");
502 }
503
504 #[test]
505 fn test_extract_meta_tags_empty_html() {
506 let html = "<html><head></head><body></body></html>";
507 let meta_tags = extract_meta_tags(html).unwrap();
508 assert_eq!(meta_tags.len(), 0);
509 }
510
511 #[test]
512 fn test_meta_tags_to_hashmap() {
513 let meta_tags = vec![
514 MetaTag {
515 name: "description".to_string(),
516 content: "A sample page".to_string(),
517 },
518 MetaTag {
519 name: "og:title".to_string(),
520 content: "Sample Title".to_string(),
521 },
522 ];
523
524 let hashmap = meta_tags_to_hashmap(meta_tags);
525 assert_eq!(hashmap.len(), 2);
526 assert_eq!(
527 hashmap.get("description"),
528 Some(&"A sample page".to_string())
529 );
530 assert_eq!(
531 hashmap.get("og:title"),
532 Some(&"Sample Title".to_string())
533 );
534 }
535
536 #[test]
537 fn test_meta_tag_groups_display() {
538 let groups = MetaTagGroups {
539 apple: "<meta name=\"apple-mobile-web-app-capable\" content=\"yes\">".to_string(),
540 primary: "<meta name=\"description\" content=\"A test page\">".to_string(),
541 og: "<meta property=\"og:title\" content=\"Test Page\">".to_string(),
542 ms: "<meta name=\"msapplication-TileColor\" content=\"#ffffff\">".to_string(),
543 twitter: "<meta name=\"twitter:card\" content=\"summary\">".to_string(),
544};
545
546 let display = groups.to_string();
547 assert!(display.contains("apple-mobile-web-app-capable"));
548 assert!(display.contains("description"));
549 assert!(display.contains("og:title"));
550 assert!(display.contains("msapplication-TileColor"));
551 assert!(display.contains("twitter:card"));
552 }
553
554 #[test]
555 fn test_format_meta_tag() {
556 let groups = MetaTagGroups::default();
557 let tag = groups.format_meta_tag("test", "Test \"Value\"");
558 assert_eq!(
559 tag,
560 r#"<meta name="test" content="Test "Value"">"#
561 );
562 }
563}