use crate::error::MetadataError;
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use std::{collections::HashMap, fmt};
#[derive(Debug, Default, PartialEq, Eq, Hash, Clone)]
pub struct MetaTagGroups {
pub apple: String,
pub primary: String,
pub og: String,
pub ms: String,
pub twitter: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MetaTag {
pub name: String,
pub content: String,
}
impl MetaTagGroups {
pub fn add_custom_tag(&mut self, name: &str, content: &str) {
let formatted_tag = self.format_meta_tag(name, content);
if name.starts_with("apple-")
|| name == "mobile-web-app-capable"
{
self.apple.push_str(&formatted_tag);
} else if name.starts_with("msapplication-") {
self.ms.push_str(&formatted_tag);
} else if name.starts_with("og:") {
self.og.push_str(&formatted_tag);
} else if name.starts_with("twitter:") {
self.twitter.push_str(&formatted_tag);
} else {
self.primary.push_str(&formatted_tag);
}
}
pub fn format_meta_tag(&self, name: &str, content: &str) -> String {
format!(
r#"<meta name="{}" content="{}">"#,
name,
content.replace('"', """)
)
}
pub fn generate_apple_meta_tags(
&mut self,
metadata: &HashMap<String, String>,
) {
const APPLE_TAGS: [&str; 4] = [
"apple-mobile-web-app-capable",
"mobile-web-app-capable",
"apple-mobile-web-app-status-bar-style",
"apple-mobile-web-app-title",
];
self.apple = self.generate_tags(metadata, &APPLE_TAGS);
}
pub fn generate_primary_meta_tags(
&mut self,
metadata: &HashMap<String, String>,
) {
const PRIMARY_TAGS: [&str; 4] =
["author", "description", "keywords", "viewport"];
self.primary = self.generate_tags(metadata, &PRIMARY_TAGS);
}
pub fn generate_og_meta_tags(
&mut self,
metadata: &HashMap<String, String>,
) {
const OG_TAGS: [&str; 5] = [
"og:title",
"og:description",
"og:image",
"og:url",
"og:type",
];
self.og = self.generate_tags(metadata, &OG_TAGS);
}
pub fn generate_ms_meta_tags(
&mut self,
metadata: &HashMap<String, String>,
) {
const MS_TAGS: [&str; 2] =
["msapplication-TileColor", "msapplication-TileImage"];
self.ms = self.generate_tags(metadata, &MS_TAGS);
}
pub fn generate_twitter_meta_tags(
&mut self,
metadata: &HashMap<String, String>,
) {
const TWITTER_TAGS: [&str; 5] = [
"twitter:card",
"twitter:site",
"twitter:title",
"twitter:description",
"twitter:image",
];
self.twitter = self.generate_tags(metadata, &TWITTER_TAGS);
}
pub fn generate_tags(
&self,
metadata: &HashMap<String, String>,
tags: &[&str],
) -> String {
tags.iter()
.filter_map(|&tag| {
metadata
.get(tag)
.map(|value| self.format_meta_tag(tag, value))
})
.collect::<Vec<_>>()
.join("\n")
}
}
impl fmt::Display for MetaTagGroups {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}\n{}\n{}\n{}\n{}",
self.apple, self.primary, self.og, self.ms, self.twitter
)
}
}
pub fn generate_metatags(
metadata: &HashMap<String, String>,
) -> MetaTagGroups {
let mut meta_tag_groups = MetaTagGroups::default();
meta_tag_groups.generate_apple_meta_tags(metadata);
meta_tag_groups.generate_primary_meta_tags(metadata);
meta_tag_groups.generate_og_meta_tags(metadata);
meta_tag_groups.generate_ms_meta_tags(metadata);
meta_tag_groups.generate_twitter_meta_tags(metadata);
meta_tag_groups
}
pub fn extract_meta_tags(
html_content: &str,
) -> Result<Vec<MetaTag>, MetadataError> {
let mut reader = Reader::from_str(html_content);
let config = reader.config_mut();
config.check_end_names = false;
config.trim_text(false);
let mut meta_tags = Vec::new();
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Eof) => break,
Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e))
if name_eq_ignore_case(e.name().as_ref(), b"meta") =>
{
if let Some(tag) = collect_meta_tag(e) {
meta_tags.push(tag);
}
}
Ok(_) => {}
Err(e) => {
let _ = e;
break;
}
}
buf.clear();
}
Ok(meta_tags)
}
fn name_eq_ignore_case(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b.iter())
.all(|(x, y)| x.eq_ignore_ascii_case(y))
}
fn collect_meta_tag(
e: &quick_xml::events::BytesStart<'_>,
) -> Option<MetaTag> {
let mut name: Option<String> = None;
let mut property: Option<String> = None;
let mut http_equiv: Option<String> = None;
let mut content: Option<String> = None;
for attr_res in e.attributes() {
let Ok(attr) = attr_res else { continue };
let Ok(raw) = std::str::from_utf8(attr.value.as_ref()) else {
continue;
};
let value = match quick_xml::escape::unescape(raw) {
Ok(v) => v.into_owned(),
Err(_) => continue,
};
match attr.key.as_ref() {
k if name_eq_ignore_case(k, b"name") => name = Some(value),
k if name_eq_ignore_case(k, b"property") => {
property = Some(value)
}
k if name_eq_ignore_case(k, b"http-equiv") => {
http_equiv = Some(value)
}
k if name_eq_ignore_case(k, b"content") => {
content = Some(value)
}
_ => {}
}
}
let id = name.or(property).or(http_equiv)?;
let content = content?;
Some(MetaTag { name: id, content })
}
pub fn meta_tags_to_hashmap(
meta_tags: Vec<MetaTag>,
) -> HashMap<String, String> {
meta_tags
.into_iter()
.map(|tag| (tag.name, tag.content))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_metatags() {
let mut metadata = HashMap::new();
metadata.insert("title".to_string(), "Test Page".to_string());
metadata.insert(
"description".to_string(),
"A test page".to_string(),
);
metadata
.insert("og:title".to_string(), "OG Test Page".to_string());
let meta_tags = generate_metatags(&metadata);
assert!(meta_tags.primary.contains("description"));
assert!(meta_tags.og.contains("og:title"));
}
#[test]
fn test_extract_meta_tags() {
let html = r#"
<html>
<head>
<meta name="description" content="A sample page">
<meta property="og:title" content="Sample Title">
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
</head>
<body>
<p>Some content</p>
</body>
</html>
"#;
let meta_tags = extract_meta_tags(html).unwrap();
assert_eq!(meta_tags.len(), 3);
assert!(meta_tags.iter().any(|tag| tag.name == "description"
&& tag.content == "A sample page"));
assert!(meta_tags.iter().any(|tag| tag.name == "og:title"
&& tag.content == "Sample Title"));
assert!(meta_tags.iter().any(|tag| tag.name == "content-type"
&& tag.content == "text/html; charset=UTF-8"));
}
#[test]
fn test_extract_meta_tags_preserves_document_order() {
let html = r#"
<html><head>
<meta name="a" content="1">
<meta property="og:b" content="2">
<meta name="c" content="3">
</head><body></body></html>
"#;
let tags = extract_meta_tags(html).unwrap();
let names: Vec<_> =
tags.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["a", "og:b", "c"]);
}
#[test]
fn test_extract_meta_tags_handles_self_closing() {
let html = r#"<meta name="x" content="1" /><meta name="y" content="2">"#;
let tags = extract_meta_tags(html).unwrap();
assert_eq!(tags.len(), 2);
assert_eq!(tags[0].name, "x");
assert_eq!(tags[1].name, "y");
}
#[test]
fn test_extract_meta_tags_decodes_entities() {
let html =
r#"<meta name="title" content="Tom & Jerry <3">"#;
let tags = extract_meta_tags(html).unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].content, "Tom & Jerry <3");
}
#[test]
fn test_extract_meta_tags_does_not_panic_on_malformed() {
let html = r#"
<html><head>
<meta name="first" content="ok">
<meta name="broken" content="oops
<meta name="second" content="probably-lost">
</head>
"#;
let _ = extract_meta_tags(html).expect("must not panic");
}
#[test]
fn test_extract_meta_tags_ignores_meta_without_content() {
let html =
r#"<meta name="orphan"><meta name="ok" content="yes">"#;
let tags = extract_meta_tags(html).unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].name, "ok");
assert_eq!(tags[0].content, "yes");
}
#[test]
fn test_extract_meta_tags_empty_html() {
let html = "<html><head></head><body></body></html>";
let meta_tags = extract_meta_tags(html).unwrap();
assert_eq!(meta_tags.len(), 0);
}
#[test]
fn test_meta_tags_to_hashmap() {
let meta_tags = vec![
MetaTag {
name: "description".to_string(),
content: "A sample page".to_string(),
},
MetaTag {
name: "og:title".to_string(),
content: "Sample Title".to_string(),
},
];
let hashmap = meta_tags_to_hashmap(meta_tags);
assert_eq!(hashmap.len(), 2);
assert_eq!(
hashmap.get("description"),
Some(&"A sample page".to_string())
);
assert_eq!(
hashmap.get("og:title"),
Some(&"Sample Title".to_string())
);
}
#[test]
fn test_meta_tag_groups_display() {
let groups = MetaTagGroups {
apple: "<meta name=\"apple-mobile-web-app-capable\" content=\"yes\">".to_string(),
primary: "<meta name=\"description\" content=\"A test page\">".to_string(),
og: "<meta property=\"og:title\" content=\"Test Page\">".to_string(),
ms: "<meta name=\"msapplication-TileColor\" content=\"#ffffff\">".to_string(),
twitter: "<meta name=\"twitter:card\" content=\"summary\">".to_string(),
};
let display = groups.to_string();
assert!(display.contains("apple-mobile-web-app-capable"));
assert!(display.contains("description"));
assert!(display.contains("og:title"));
assert!(display.contains("msapplication-TileColor"));
assert!(display.contains("twitter:card"));
}
#[test]
fn test_format_meta_tag() {
let groups = MetaTagGroups::default();
let tag = groups.format_meta_tag("test", "Test \"Value\"");
assert_eq!(
tag,
r#"<meta name="test" content="Test "Value"">"#
);
}
}