Skip to main content

metadata_gen/
metatags.rs

1//! Meta tag generation and extraction module.
2//!
3//! This module provides functionality for generating HTML meta tags from metadata
4//! and extracting meta tags from HTML content.
5
6use crate::error::MetadataError;
7use quick_xml::events::Event;
8use quick_xml::reader::Reader;
9use std::{collections::HashMap, fmt};
10
11/// Holds collections of meta tags for different platforms and categories.
12///
13/// # Example
14///
15/// ```
16/// use metadata_gen::metatags::generate_metatags;
17/// use std::collections::HashMap;
18///
19/// let mut metadata = HashMap::new();
20/// metadata.insert("description".to_string(), "A sample page".to_string());
21/// metadata.insert("og:title".to_string(), "Sample".to_string());
22///
23/// let tags = generate_metatags(&metadata);
24/// assert!(tags.primary.contains("description"));
25/// assert!(tags.og.contains("og:title"));
26/// ```
27#[derive(Debug, Default, PartialEq, Eq, Hash, Clone)]
28pub struct MetaTagGroups {
29    /// The `apple` meta tags.
30    pub apple: String,
31    /// The primary meta tags.
32    pub primary: String,
33    /// The `og` meta tags.
34    pub og: String,
35    /// The `ms` meta tags.
36    pub ms: String,
37    /// The `twitter` meta tags.
38    pub twitter: String,
39}
40
41/// Represents a single meta tag.
42///
43/// # Example
44///
45/// ```
46/// use metadata_gen::metatags::MetaTag;
47///
48/// let tag = MetaTag {
49///     name: "description".to_string(),
50///     content: "A sample page".to_string(),
51/// };
52/// assert_eq!(tag.name, "description");
53/// ```
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct MetaTag {
56    /// The name or property of the meta tag.
57    pub name: String,
58    /// The content of the meta tag.
59    pub content: String,
60}
61
62impl MetaTagGroups {
63    /// Adds a custom meta tag to the appropriate group.
64    ///
65    /// # Arguments
66    ///
67    /// * `name` - The name of the meta tag.
68    /// * `content` - The content of the meta tag.
69    pub fn add_custom_tag(&mut self, name: &str, content: &str) {
70        let formatted_tag = self.format_meta_tag(name, content);
71
72        // Match based on specific prefixes for Apple, MS, OG, Twitter, etc.
73        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            // println!("Adding MS meta tag: {}", formatted_tag);  // Debugging output
79            self.ms.push_str(&formatted_tag);
80        } else if name.starts_with("og:") {
81            // println!("Adding OG meta tag: {}", formatted_tag);  // Debugging output
82            self.og.push_str(&formatted_tag);
83        } else if name.starts_with("twitter:") {
84            // println!("Adding Twitter meta tag: {}", formatted_tag);  // Debugging output
85            self.twitter.push_str(&formatted_tag);
86        } else {
87            // println!("Adding Primary meta tag: {}", formatted_tag);  // Debugging output
88            self.primary.push_str(&formatted_tag);
89        }
90    }
91
92    /// Formats a single meta tag.
93    ///
94    /// # Arguments
95    ///
96    /// * `name` - The name of the meta tag.
97    /// * `content` - The content of the meta tag.
98    ///
99    /// # Returns
100    ///
101    /// A formatted meta tag string.
102    pub fn format_meta_tag(&self, name: &str, content: &str) -> String {
103        format!(
104            r#"<meta name="{}" content="{}">"#,
105            name,
106            content.replace('"', "&quot;")
107        )
108    }
109
110    /// Generates meta tags for Apple devices.
111    ///
112    /// # Arguments
113    ///
114    /// * `metadata` - A reference to a HashMap containing the metadata.
115    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    /// Generates primary meta tags like `author`, `description`, and `keywords`.
129    ///
130    /// # Arguments
131    ///
132    /// * `metadata` - A reference to a HashMap containing the metadata.
133    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    /// Generates Open Graph (`og`) meta tags for social media.
143    ///
144    /// # Arguments
145    ///
146    /// * `metadata` - A reference to a HashMap containing the metadata.
147    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    /// Generates Microsoft-specific meta tags.
162    ///
163    /// # Arguments
164    ///
165    /// * `metadata` - A reference to a HashMap containing the metadata.
166    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    /// Generates Twitter meta tags for embedding rich media in tweets.
176    ///
177    /// # Arguments
178    ///
179    /// * `metadata` - A reference to a HashMap containing the metadata.
180    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    /// Generates meta tags based on the provided list of tag names.
195    ///
196    /// # Arguments
197    ///
198    /// * `metadata` - A reference to a `HashMap` containing the metadata.
199    /// * `tags` - A reference to an array of tag names.
200    ///
201    /// # Returns
202    ///
203    /// A string containing the generated meta tags.
204    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
220/// Implement `Display` for `MetaTagGroups`.
221impl 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
231/// Generates HTML meta tags based on the provided metadata.
232///
233/// This function takes metadata from a `HashMap` and generates meta tags for various platforms (e.g., Apple, Open Graph, Twitter).
234///
235/// # Arguments
236///
237/// * `metadata` - A reference to a `HashMap` containing the metadata.
238///
239/// # Returns
240///
241/// A `MetaTagGroups` structure with meta tags grouped by platform.
242pub 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
254/// Extracts every `<meta>` tag from an HTML document.
255///
256/// Walks the input in document order, yielding one `MetaTag` per
257/// `<meta>` element that carries both an identifying attribute (`name`,
258/// `property`, or `http-equiv`, in that fallback order) and a `content`
259/// attribute. Self-closing (`<meta … />`) and HTML-style (`<meta …>`)
260/// shapes are both accepted.
261///
262/// # Arguments
263///
264/// * `html_content` - A string slice containing the HTML content to parse.
265///
266/// # Returns
267///
268/// Returns a `Result` containing a `Vec<MetaTag>` in document order if
269/// parsing reached the end of the input, or a `MetadataError` if the
270/// underlying scanner could not recover from a malformed region.
271///
272/// # Errors
273///
274/// Returns `MetadataError::ExtractionError` only when the input is so
275/// malformed that no further events can be produced. Per-element issues
276/// (missing `content`, unknown attributes, unrecognized escape) are
277/// tolerated silently.
278///
279/// # Implementation note
280///
281/// Backed by `quick-xml` configured in HTML-tolerant mode (mismatched
282/// end tags allowed, no DTD validation). This replaces the previous
283/// `scraper` / `html5ever` dependency tree, which dragged ~30 transitive
284/// crates including `fxhash` (RUSTSEC-2025-0057) and a vulnerable
285/// `phf_generator` / `rand 0.8` path (RUSTSEC-2026-0097). See issue #22.
286pub 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    // HTML is not XML — be lenient so doctypes, unquoted attrs, and
292    // mismatched end tags don't abort the scan.
293    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            // Both Start (`<meta …>`) and Empty (`<meta … />`) shapes are
303            // produced for `<meta>` depending on author style. Treat them
304            // identically.
305            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                // Per #22 acceptance: tolerate malformed regions and
315                // return what we found so far. The remaining content may
316                // simply be the body of an HTML page that quick-xml
317                // doesn't fully understand.
318                let _ = e;
319                break;
320            }
321        }
322        buf.clear();
323    }
324
325    Ok(meta_tags)
326}
327
328/// Case-insensitive ASCII equality for element names.
329fn 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
336/// Pulls a `MetaTag` out of a `<meta>` start/empty element if it carries
337/// both an identifying attribute (`name` → fallback `property` →
338/// fallback `http-equiv`) and a `content` value.
339///
340/// HTML entities in attribute values are decoded via `quick-xml`'s
341/// `unescape_value` so `&amp;`, `&quot;`, numeric refs, etc. round-trip
342/// to the same byte sequence the previous `scraper` implementation
343/// produced.
344fn 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        // Decode as UTF-8 then unescape HTML entities. `unescape_value`
355        // was deprecated in quick-xml 0.40; the recommended replacement
356        // is to drive the static `escape::unescape` helper directly.
357        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
384/// Converts a vector of MetaTags into a HashMap for easier access.
385///
386/// # Arguments
387///
388/// * `meta_tags` - A vector of MetaTag structs.
389///
390/// # Returns
391///
392/// A HashMap where the keys are the meta tag names and the values are the contents.
393pub 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        // Issue #22 acceptance: document order must match the previous
451        // scraper-backed implementation. Three tags, deterministic order.
452        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        // XHTML-style self-closing syntax must yield the same result as
468        // HTML-style. Both shapes appear in the wild.
469        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        // Issue #22 acceptance: HTML entities in attribute values must
479        // be decoded so consumers don't see literal `&amp;` text.
480        let html =
481            r#"<meta name="title" content="Tom &amp; Jerry &lt;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        // Issue #22 acceptance: a malformed HTML fragment with an
490        // unclosed tag must not panic; whatever was already parsed is
491        // returned to the caller. We don't pin the exact count because
492        // recovery behaviour is intentionally implementation-defined.
493        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        // A <meta> with no content attr is dropped (parity with the
506        // previous scraper-based behaviour).
507        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 &quot;Value&quot;">"#
572        );
573    }
574}