1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7fn non_empty(value: impl AsRef<str>, field: &'static str) -> Result<String, MetadataValueError> {
8 let trimmed = value.as_ref().trim();
9 if trimmed.is_empty() {
10 Err(MetadataValueError::Empty { field })
11 } else {
12 Ok(trimmed.to_string())
13 }
14}
15
16fn is_http_url(value: &str) -> bool {
17 let lower = value.to_ascii_lowercase();
18 lower.starts_with("https://") || lower.starts_with("http://")
19}
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum MetadataValueError {
24 Empty { field: &'static str },
26 InvalidUrl,
28 InvalidImageDimensions,
30}
31
32impl fmt::Display for MetadataValueError {
33 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34 match self {
35 Self::Empty { field } => write!(formatter, "{field} cannot be empty"),
36 Self::InvalidUrl => {
37 formatter.write_str("metadata URL must start with http:// or https://")
38 },
39 Self::InvalidImageDimensions => {
40 formatter.write_str("image dimensions must be non-zero")
41 },
42 }
43 }
44}
45
46impl Error for MetadataValueError {}
47
48#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
50pub struct MetadataTitle(String);
51
52impl MetadataTitle {
53 pub fn new(value: impl AsRef<str>) -> Result<Self, MetadataValueError> {
59 non_empty(value, "metadata title").map(Self)
60 }
61
62 #[must_use]
64 pub fn as_str(&self) -> &str {
65 &self.0
66 }
67}
68
69impl AsRef<str> for MetadataTitle {
70 fn as_ref(&self) -> &str {
71 self.as_str()
72 }
73}
74
75impl fmt::Display for MetadataTitle {
76 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
77 formatter.write_str(self.as_str())
78 }
79}
80
81impl FromStr for MetadataTitle {
82 type Err = MetadataValueError;
83
84 fn from_str(value: &str) -> Result<Self, Self::Err> {
85 Self::new(value)
86 }
87}
88
89#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
91pub struct MetadataDescription(String);
92
93impl MetadataDescription {
94 pub fn new(value: impl AsRef<str>) -> Result<Self, MetadataValueError> {
100 non_empty(value, "metadata description").map(Self)
101 }
102
103 #[must_use]
105 pub fn as_str(&self) -> &str {
106 &self.0
107 }
108}
109
110impl AsRef<str> for MetadataDescription {
111 fn as_ref(&self) -> &str {
112 self.as_str()
113 }
114}
115
116impl fmt::Display for MetadataDescription {
117 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
118 formatter.write_str(self.as_str())
119 }
120}
121
122#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
124pub enum OpenGraphType {
125 Website,
127 Article,
129 Product,
131 Profile,
133 LocalBusiness,
135}
136
137impl OpenGraphType {
138 #[must_use]
140 pub const fn as_str(self) -> &'static str {
141 match self {
142 Self::Website => "website",
143 Self::Article => "article",
144 Self::Product => "product",
145 Self::Profile => "profile",
146 Self::LocalBusiness => "business.business",
147 }
148 }
149}
150
151#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
153pub enum TwitterCardKind {
154 Summary,
156 SummaryLargeImage,
158 App,
160 Player,
162}
163
164impl TwitterCardKind {
165 #[must_use]
167 pub const fn as_str(self) -> &'static str {
168 match self {
169 Self::Summary => "summary",
170 Self::SummaryLargeImage => "summary_large_image",
171 Self::App => "app",
172 Self::Player => "player",
173 }
174 }
175}
176
177#[derive(Clone, Debug, Eq, PartialEq)]
179pub struct OpenGraphImage {
180 url: String,
181 alt: Option<String>,
182 width: Option<u32>,
183 height: Option<u32>,
184}
185
186impl OpenGraphImage {
187 pub fn new(value: impl AsRef<str>) -> Result<Self, MetadataValueError> {
193 let url = non_empty(value, "Open Graph image URL")?;
194 if !is_http_url(&url) {
195 return Err(MetadataValueError::InvalidUrl);
196 }
197
198 Ok(Self {
199 url,
200 alt: None,
201 width: None,
202 height: None,
203 })
204 }
205
206 pub fn with_alt(mut self, alt: impl AsRef<str>) -> Result<Self, MetadataValueError> {
212 self.alt = Some(non_empty(alt, "Open Graph image alt text")?);
213 Ok(self)
214 }
215
216 pub fn with_dimensions(mut self, width: u32, height: u32) -> Result<Self, MetadataValueError> {
222 if width == 0 || height == 0 {
223 return Err(MetadataValueError::InvalidImageDimensions);
224 }
225 self.width = Some(width);
226 self.height = Some(height);
227 Ok(self)
228 }
229
230 #[must_use]
232 pub fn url(&self) -> &str {
233 &self.url
234 }
235}
236
237#[derive(Clone, Debug, Eq, PartialEq)]
239pub struct SocialPreview {
240 title: MetadataTitle,
241 description: MetadataDescription,
242 image: Option<OpenGraphImage>,
243 open_graph_type: OpenGraphType,
244 twitter_card: TwitterCardKind,
245}
246
247impl SocialPreview {
248 #[must_use]
250 pub const fn new(title: MetadataTitle, description: MetadataDescription) -> Self {
251 Self {
252 title,
253 description,
254 image: None,
255 open_graph_type: OpenGraphType::Website,
256 twitter_card: TwitterCardKind::Summary,
257 }
258 }
259
260 #[must_use]
262 pub fn with_image(mut self, image: OpenGraphImage) -> Self {
263 self.image = Some(image);
264 self
265 }
266
267 #[must_use]
269 pub const fn with_open_graph_type(mut self, value: OpenGraphType) -> Self {
270 self.open_graph_type = value;
271 self
272 }
273
274 #[must_use]
276 pub const fn with_twitter_card(mut self, value: TwitterCardKind) -> Self {
277 self.twitter_card = value;
278 self
279 }
280
281 #[must_use]
283 pub const fn open_graph_type(&self) -> OpenGraphType {
284 self.open_graph_type
285 }
286}
287
288#[derive(Clone, Debug, Eq, PartialEq)]
290pub struct PageMetadata {
291 title: MetadataTitle,
292 description: MetadataDescription,
293 canonical_url: Option<String>,
294 robots: Option<String>,
295 social_preview: Option<SocialPreview>,
296}
297
298impl PageMetadata {
299 #[must_use]
301 pub const fn new(title: MetadataTitle, description: MetadataDescription) -> Self {
302 Self {
303 title,
304 description,
305 canonical_url: None,
306 robots: None,
307 social_preview: None,
308 }
309 }
310
311 #[must_use]
313 pub fn with_canonical_url(mut self, url: impl Into<String>) -> Self {
314 self.canonical_url = Some(url.into());
315 self
316 }
317
318 #[must_use]
320 pub fn with_robots(mut self, content: impl Into<String>) -> Self {
321 self.robots = Some(content.into());
322 self
323 }
324
325 #[must_use]
327 pub fn with_social_preview(mut self, preview: SocialPreview) -> Self {
328 self.social_preview = Some(preview);
329 self
330 }
331
332 #[must_use]
334 pub const fn title(&self) -> &MetadataTitle {
335 &self.title
336 }
337
338 #[must_use]
340 pub const fn description(&self) -> &MetadataDescription {
341 &self.description
342 }
343
344 #[must_use]
346 pub fn canonical_url(&self) -> Option<&str> {
347 self.canonical_url.as_deref()
348 }
349
350 #[must_use]
352 pub fn robots(&self) -> Option<&str> {
353 self.robots.as_deref()
354 }
355
356 #[must_use]
358 pub const fn social_preview(&self) -> Option<&SocialPreview> {
359 self.social_preview.as_ref()
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::{
366 MetadataDescription, MetadataTitle, OpenGraphImage, OpenGraphType, PageMetadata,
367 SocialPreview, TwitterCardKind,
368 };
369
370 #[test]
371 fn validates_metadata_strings() {
372 assert!(MetadataTitle::new("Example").is_ok());
373 assert!(MetadataDescription::new(" ").is_err());
374 }
375
376 #[test]
377 fn builds_open_graph_images() {
378 let image = OpenGraphImage::new("https://example.com/image.png")
379 .unwrap()
380 .with_alt("Preview image")
381 .unwrap()
382 .with_dimensions(1200, 630)
383 .unwrap();
384
385 assert_eq!(image.url(), "https://example.com/image.png");
386 assert!(OpenGraphImage::new("/image.png").is_err());
387 }
388
389 #[test]
390 fn composes_page_metadata() {
391 let preview = SocialPreview::new(
392 MetadataTitle::new("Example").unwrap(),
393 MetadataDescription::new("Example description").unwrap(),
394 )
395 .with_open_graph_type(OpenGraphType::Article)
396 .with_twitter_card(TwitterCardKind::SummaryLargeImage);
397 let metadata = PageMetadata::new(
398 MetadataTitle::new("Example").unwrap(),
399 MetadataDescription::new("Example description").unwrap(),
400 )
401 .with_canonical_url("https://example.com/")
402 .with_robots("index,follow")
403 .with_social_preview(preview);
404
405 assert_eq!(metadata.robots(), Some("index,follow"));
406 assert_eq!(
407 metadata.social_preview().unwrap().open_graph_type(),
408 OpenGraphType::Article
409 );
410 }
411}