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 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                // 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/// Pulls a `MetaTag` out of a `<meta>` start/empty element if it carries
329/// both an identifying attribute (`name` → fallback `property` →
330/// fallback `http-equiv`) and a `content` value.
331///
332/// HTML entities in attribute values are decoded via `quick-xml`'s
333/// `unescape_value` so `&amp;`, `&quot;`, numeric refs, etc. round-trip
334/// to the same byte sequence the previous `scraper` implementation
335/// produced.
336fn 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        // quick-xml 0.42 hands attribute names and values out as `str`
347        // (its reader validates UTF-8 up front), so there is no decode
348        // step here any more: unescape HTML entities and match the name.
349        // `unescape_value` was deprecated in quick-xml 0.40; driving the
350        // static `escape::unescape` helper directly is the replacement.
351        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
373/// Converts a vector of MetaTags into a HashMap for easier access.
374///
375/// # Arguments
376///
377/// * `meta_tags` - A vector of MetaTag structs.
378///
379/// # Returns
380///
381/// A HashMap where the keys are the meta tag names and the values are the contents.
382pub 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        // Issue #22 acceptance: document order must match the previous
440        // scraper-backed implementation. Three tags, deterministic order.
441        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        // XHTML-style self-closing syntax must yield the same result as
457        // HTML-style. Both shapes appear in the wild.
458        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        // Issue #22 acceptance: HTML entities in attribute values must
468        // be decoded so consumers don't see literal `&amp;` text.
469        let html =
470            r#"<meta name="title" content="Tom &amp; Jerry &lt;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        // Issue #22 acceptance: a malformed HTML fragment with an
479        // unclosed tag must not panic; whatever was already parsed is
480        // returned to the caller. We don't pin the exact count because
481        // recovery behaviour is intentionally implementation-defined.
482        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        // A <meta> with no content attr is dropped (parity with the
495        // previous scraper-based behaviour).
496        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 &quot;Value&quot;">"#
561        );
562    }
563}