1use crate::error::MetadataError;
7use scraper::{Html, Selector};
8use std::{collections::HashMap, fmt};
9
10#[derive(Debug, Default, PartialEq, Eq, Hash, Clone)]
27pub struct MetaTagGroups {
28 pub apple: String,
30 pub primary: String,
32 pub og: String,
34 pub ms: String,
36 pub twitter: String,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct MetaTag {
55 pub name: String,
57 pub content: String,
59}
60
61impl MetaTagGroups {
62 pub fn add_custom_tag(&mut self, name: &str, content: &str) {
69 let formatted_tag = self.format_meta_tag(name, content);
70
71 if name.starts_with("apple-")
73 || name == "mobile-web-app-capable"
74 {
75 self.apple.push_str(&formatted_tag);
76 } else if name.starts_with("msapplication-") {
77 self.ms.push_str(&formatted_tag);
79 } else if name.starts_with("og:") {
80 self.og.push_str(&formatted_tag);
82 } else if name.starts_with("twitter:") {
83 self.twitter.push_str(&formatted_tag);
85 } else {
86 self.primary.push_str(&formatted_tag);
88 }
89 }
90
91 pub fn format_meta_tag(&self, name: &str, content: &str) -> String {
102 format!(
103 r#"<meta name="{}" content="{}">"#,
104 name,
105 content.replace('"', """)
106 )
107 }
108
109 pub fn generate_apple_meta_tags(
115 &mut self,
116 metadata: &HashMap<String, String>,
117 ) {
118 const APPLE_TAGS: [&str; 4] = [
119 "apple-mobile-web-app-capable",
120 "mobile-web-app-capable",
121 "apple-mobile-web-app-status-bar-style",
122 "apple-mobile-web-app-title",
123 ];
124 self.apple = self.generate_tags(metadata, &APPLE_TAGS);
125 }
126
127 pub fn generate_primary_meta_tags(
133 &mut self,
134 metadata: &HashMap<String, String>,
135 ) {
136 const PRIMARY_TAGS: [&str; 4] =
137 ["author", "description", "keywords", "viewport"];
138 self.primary = self.generate_tags(metadata, &PRIMARY_TAGS);
139 }
140
141 pub fn generate_og_meta_tags(
147 &mut self,
148 metadata: &HashMap<String, String>,
149 ) {
150 const OG_TAGS: [&str; 5] = [
151 "og:title",
152 "og:description",
153 "og:image",
154 "og:url",
155 "og:type",
156 ];
157 self.og = self.generate_tags(metadata, &OG_TAGS);
158 }
159
160 pub fn generate_ms_meta_tags(
166 &mut self,
167 metadata: &HashMap<String, String>,
168 ) {
169 const MS_TAGS: [&str; 2] =
170 ["msapplication-TileColor", "msapplication-TileImage"];
171 self.ms = self.generate_tags(metadata, &MS_TAGS);
172 }
173
174 pub fn generate_twitter_meta_tags(
180 &mut self,
181 metadata: &HashMap<String, String>,
182 ) {
183 const TWITTER_TAGS: [&str; 5] = [
184 "twitter:card",
185 "twitter:site",
186 "twitter:title",
187 "twitter:description",
188 "twitter:image",
189 ];
190 self.twitter = self.generate_tags(metadata, &TWITTER_TAGS);
191 }
192
193 pub fn generate_tags(
204 &self,
205 metadata: &HashMap<String, String>,
206 tags: &[&str],
207 ) -> String {
208 tags.iter()
209 .filter_map(|&tag| {
210 metadata
211 .get(tag)
212 .map(|value| self.format_meta_tag(tag, value))
213 })
214 .collect::<Vec<_>>()
215 .join("\n")
216 }
217}
218
219impl fmt::Display for MetaTagGroups {
221 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222 write!(
223 f,
224 "{}\n{}\n{}\n{}\n{}",
225 self.apple, self.primary, self.og, self.ms, self.twitter
226 )
227 }
228}
229
230pub fn generate_metatags(
242 metadata: &HashMap<String, String>,
243) -> MetaTagGroups {
244 let mut meta_tag_groups = MetaTagGroups::default();
245 meta_tag_groups.generate_apple_meta_tags(metadata);
246 meta_tag_groups.generate_primary_meta_tags(metadata);
247 meta_tag_groups.generate_og_meta_tags(metadata);
248 meta_tag_groups.generate_ms_meta_tags(metadata);
249 meta_tag_groups.generate_twitter_meta_tags(metadata);
250 meta_tag_groups
251}
252
253pub fn extract_meta_tags(
272 html_content: &str,
273) -> Result<Vec<MetaTag>, MetadataError> {
274 let document = Html::parse_document(html_content);
275
276 let meta_selector = Selector::parse("meta").map_err(|e| {
277 MetadataError::ExtractionError {
278 message: format!(
279 "Failed to create meta tag selector: {}",
280 e
281 ),
282 }
283 })?;
284
285 let mut meta_tags = Vec::new();
286
287 for element in document.select(&meta_selector) {
288 let name = element
289 .value()
290 .attr("name")
291 .or_else(|| element.value().attr("property"))
292 .or_else(|| element.value().attr("http-equiv"));
293
294 let content = element.value().attr("content");
295
296 if let (Some(name), Some(content)) = (name, content) {
297 meta_tags.push(MetaTag {
298 name: name.to_string(),
299 content: content.to_string(),
300 });
301 }
302 }
303
304 Ok(meta_tags)
305}
306
307pub fn meta_tags_to_hashmap(
317 meta_tags: Vec<MetaTag>,
318) -> HashMap<String, String> {
319 meta_tags
320 .into_iter()
321 .map(|tag| (tag.name, tag.content))
322 .collect()
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
330 fn test_generate_metatags() {
331 let mut metadata = HashMap::new();
332 metadata.insert("title".to_string(), "Test Page".to_string());
333 metadata.insert(
334 "description".to_string(),
335 "A test page".to_string(),
336 );
337 metadata
338 .insert("og:title".to_string(), "OG Test Page".to_string());
339
340 let meta_tags = generate_metatags(&metadata);
341
342 assert!(meta_tags.primary.contains("description"));
343 assert!(meta_tags.og.contains("og:title"));
344 }
345
346 #[test]
347 fn test_extract_meta_tags() {
348 let html = r#"
349 <html>
350 <head>
351 <meta name="description" content="A sample page">
352 <meta property="og:title" content="Sample Title">
353 <meta http-equiv="content-type" content="text/html; charset=UTF-8">
354 </head>
355 <body>
356 <p>Some content</p>
357 </body>
358 </html>
359 "#;
360
361 let meta_tags = extract_meta_tags(html).unwrap();
362 assert_eq!(meta_tags.len(), 3);
363 assert!(meta_tags.iter().any(|tag| tag.name == "description"
364 && tag.content == "A sample page"));
365 assert!(meta_tags.iter().any(|tag| tag.name == "og:title"
366 && tag.content == "Sample Title"));
367 assert!(meta_tags.iter().any(|tag| tag.name == "content-type"
368 && tag.content == "text/html; charset=UTF-8"));
369 }
370
371 #[test]
372 fn test_extract_meta_tags_empty_html() {
373 let html = "<html><head></head><body></body></html>";
374 let meta_tags = extract_meta_tags(html).unwrap();
375 assert_eq!(meta_tags.len(), 0);
376 }
377
378 #[test]
379 fn test_meta_tags_to_hashmap() {
380 let meta_tags = vec![
381 MetaTag {
382 name: "description".to_string(),
383 content: "A sample page".to_string(),
384 },
385 MetaTag {
386 name: "og:title".to_string(),
387 content: "Sample Title".to_string(),
388 },
389 ];
390
391 let hashmap = meta_tags_to_hashmap(meta_tags);
392 assert_eq!(hashmap.len(), 2);
393 assert_eq!(
394 hashmap.get("description"),
395 Some(&"A sample page".to_string())
396 );
397 assert_eq!(
398 hashmap.get("og:title"),
399 Some(&"Sample Title".to_string())
400 );
401 }
402
403 #[test]
404 fn test_meta_tag_groups_display() {
405 let groups = MetaTagGroups {
406 apple: "<meta name=\"apple-mobile-web-app-capable\" content=\"yes\">".to_string(),
407 primary: "<meta name=\"description\" content=\"A test page\">".to_string(),
408 og: "<meta property=\"og:title\" content=\"Test Page\">".to_string(),
409 ms: "<meta name=\"msapplication-TileColor\" content=\"#ffffff\">".to_string(),
410 twitter: "<meta name=\"twitter:card\" content=\"summary\">".to_string(),
411};
412
413 let display = groups.to_string();
414 assert!(display.contains("apple-mobile-web-app-capable"));
415 assert!(display.contains("description"));
416 assert!(display.contains("og:title"));
417 assert!(display.contains("msapplication-TileColor"));
418 assert!(display.contains("twitter:card"));
419 }
420
421 #[test]
422 fn test_format_meta_tag() {
423 let groups = MetaTagGroups::default();
424 let tag = groups.format_meta_tag("test", "Test \"Value\"");
425 assert_eq!(
426 tag,
427 r#"<meta name="test" content="Test "Value"">"#
428 );
429 }
430}