1use serde::{Deserialize, Serialize};
2use url::Url;
3
4#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct OpenGraphTags {
11 pub title: Option<String>,
13 pub description: Option<String>,
15 pub image: Option<String>,
17 pub url: Option<String>,
19 pub r#type: Option<String>,
21 pub site_name: Option<String>,
23 pub locale: Option<String>,
25}
26
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
33pub struct TwitterTags {
34 pub card: Option<String>,
36 pub site: Option<String>,
38 pub creator: Option<String>,
40 pub title: Option<String>,
42 pub description: Option<String>,
44 pub image: Option<String>,
46 pub image_alt: Option<String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct HreflangTag {
56 pub lang: String,
58 pub url: Url,
60}
61
62#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84pub struct MetaTags {
85 pub title: Option<String>,
87 pub description: Option<String>,
89 pub canonical: Option<Url>,
91 pub robots: Option<String>,
93 pub language: Option<String>,
95 pub charset: Option<String>,
97 pub viewport: Option<String>,
99 pub og: OpenGraphTags,
101 pub twitter: TwitterTags,
103 pub hreflang: Vec<HreflangTag>,
105}
106
107impl MetaTags {
108 pub fn title_length(&self) -> Option<usize> {
110 self.title.as_ref().map(|t| t.len())
111 }
112
113 pub fn description_length(&self) -> Option<usize> {
115 self.description.as_ref().map(|d| d.len())
116 }
117
118 pub fn is_noindex(&self) -> bool {
120 self.robots
121 .as_deref()
122 .map(|r| r.split(',').any(|v| v.trim() == "noindex"))
123 .unwrap_or(false)
124 }
125
126 pub fn is_nofollow(&self) -> bool {
128 self.robots
129 .as_deref()
130 .map(|r| r.split(',').any(|v| v.trim() == "nofollow"))
131 .unwrap_or(false)
132 }
133}
134
135#[cfg(test)]
136#[allow(clippy::unwrap_used)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn test_meta_tags_defaults() {
142 let meta = MetaTags::default();
143 assert!(meta.title.is_none());
144 assert!(meta.description.is_none());
145 assert!(meta.canonical.is_none());
146 assert!(meta.og.title.is_none());
147 assert!(meta.twitter.card.is_none());
148 assert!(meta.hreflang.is_empty());
149 }
150
151 #[test]
152 fn test_title_length() {
153 let mut meta = MetaTags::default();
154 assert_eq!(meta.title_length(), None);
155
156 meta.title = Some("Hello World".into());
157 assert_eq!(meta.title_length(), Some(11));
158 }
159
160 #[test]
161 fn test_description_length() {
162 let mut meta = MetaTags::default();
163 assert_eq!(meta.description_length(), None);
164
165 meta.description = Some("A short description".into());
166 assert_eq!(meta.description_length(), Some(19));
167 }
168
169 #[test]
170 fn test_is_noindex() {
171 let mut meta = MetaTags::default();
172 assert!(!meta.is_noindex());
173
174 meta.robots = Some("noindex".into());
175 assert!(meta.is_noindex());
176
177 meta.robots = Some("index, nofollow".into());
178 assert!(!meta.is_noindex());
179
180 meta.robots = Some("noindex, nofollow".into());
181 assert!(meta.is_noindex());
182 }
183
184 #[test]
185 fn test_is_nofollow() {
186 let mut meta = MetaTags::default();
187 assert!(!meta.is_nofollow());
188
189 meta.robots = Some("nofollow".into());
190 assert!(meta.is_nofollow());
191
192 meta.robots = Some("noindex, nofollow".into());
193 assert!(meta.is_nofollow());
194 }
195
196 #[test]
197 fn test_open_graph_serialization() {
198 let og = OpenGraphTags {
199 title: Some("My Page".into()),
200 description: Some("Desc".into()),
201 image: Some("https://example.com/img.png".into()),
202 url: Some("https://example.com".into()),
203 r#type: Some("website".into()),
204 site_name: Some("Example".into()),
205 locale: Some("en_US".into()),
206 };
207 let json = serde_json::to_string(&og).unwrap();
208 let deser: OpenGraphTags = serde_json::from_str(&json).unwrap();
209 assert_eq!(og.title, deser.title);
210 assert_eq!(og.image, deser.image);
211 }
212
213 #[test]
214 fn test_twitter_tags_serialization() {
215 let tw = TwitterTags {
216 card: Some("summary_large_image".into()),
217 site: Some("@example".into()),
218 creator: Some("@author".into()),
219 title: Some("Title".into()),
220 description: Some("Desc".into()),
221 image: Some("https://example.com/tw.png".into()),
222 image_alt: Some("Alt text".into()),
223 };
224 let json = serde_json::to_string(&tw).unwrap();
225 let deser: TwitterTags = serde_json::from_str(&json).unwrap();
226 assert_eq!(tw.card, deser.card);
227 assert_eq!(tw.image_alt, deser.image_alt);
228 }
229
230 #[test]
231 fn test_hreflang_tag() {
232 let tag = HreflangTag {
233 lang: "en".into(),
234 url: Url::parse("https://example.com/en").unwrap(),
235 };
236 assert_eq!(tag.lang, "en");
237 let json = serde_json::to_string(&tag).unwrap();
238 let deser: HreflangTag = serde_json::from_str(&json).unwrap();
239 assert_eq!(tag.url, deser.url);
240 }
241}