Skip to main content

use_metadata/
lib.rs

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/// Error returned by metadata primitive constructors.
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum MetadataValueError {
24    /// The supplied value was empty after trimming whitespace.
25    Empty { field: &'static str },
26    /// The URL did not look like an HTTP or HTTPS URL.
27    InvalidUrl,
28    /// Image dimensions must be non-zero.
29    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/// Page metadata title.
49#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
50pub struct MetadataTitle(String);
51
52impl MetadataTitle {
53    /// Creates a metadata title.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`MetadataValueError::Empty`] when the title is empty.
58    pub fn new(value: impl AsRef<str>) -> Result<Self, MetadataValueError> {
59        non_empty(value, "metadata title").map(Self)
60    }
61
62    /// Returns the title text.
63    #[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/// Page metadata description.
90#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
91pub struct MetadataDescription(String);
92
93impl MetadataDescription {
94    /// Creates a metadata description.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`MetadataValueError::Empty`] when the description is empty.
99    pub fn new(value: impl AsRef<str>) -> Result<Self, MetadataValueError> {
100        non_empty(value, "metadata description").map(Self)
101    }
102
103    /// Returns the description text.
104    #[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/// Open Graph type label.
123#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
124pub enum OpenGraphType {
125    /// Generic website.
126    Website,
127    /// Article surface.
128    Article,
129    /// Product surface.
130    Product,
131    /// Profile surface.
132    Profile,
133    /// Local business surface.
134    LocalBusiness,
135}
136
137impl OpenGraphType {
138    /// Returns the Open Graph type label.
139    #[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/// Twitter card kind label.
152#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
153pub enum TwitterCardKind {
154    /// Summary card.
155    Summary,
156    /// Summary card with a large image.
157    SummaryLargeImage,
158    /// App card.
159    App,
160    /// Player card.
161    Player,
162}
163
164impl TwitterCardKind {
165    /// Returns the card kind label.
166    #[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/// Open Graph image metadata.
178#[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    /// Creates an Open Graph image from a URL-like string.
188    ///
189    /// # Errors
190    ///
191    /// Returns [`MetadataValueError::InvalidUrl`] when the URL shape is unsupported.
192    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    /// Sets alternative text.
207    ///
208    /// # Errors
209    ///
210    /// Returns [`MetadataValueError::Empty`] when the alt text is empty.
211    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    /// Sets image dimensions.
217    ///
218    /// # Errors
219    ///
220    /// Returns [`MetadataValueError::InvalidImageDimensions`] when either dimension is zero.
221    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    /// Returns the image URL.
231    #[must_use]
232    pub fn url(&self) -> &str {
233        &self.url
234    }
235}
236
237/// Social preview metadata.
238#[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    /// Creates a social preview with default website/summary labels.
249    #[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    /// Sets the preview image.
261    #[must_use]
262    pub fn with_image(mut self, image: OpenGraphImage) -> Self {
263        self.image = Some(image);
264        self
265    }
266
267    /// Sets the Open Graph type.
268    #[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    /// Sets the Twitter card kind.
275    #[must_use]
276    pub const fn with_twitter_card(mut self, value: TwitterCardKind) -> Self {
277        self.twitter_card = value;
278        self
279    }
280
281    /// Returns the Open Graph type.
282    #[must_use]
283    pub const fn open_graph_type(&self) -> OpenGraphType {
284        self.open_graph_type
285    }
286}
287
288/// Page metadata for external surfaces.
289#[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    /// Creates page metadata from a title and description.
300    #[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    /// Sets a canonical URL label.
312    #[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    /// Sets robots meta content.
319    #[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    /// Sets social preview metadata.
326    #[must_use]
327    pub fn with_social_preview(mut self, preview: SocialPreview) -> Self {
328        self.social_preview = Some(preview);
329        self
330    }
331
332    /// Returns the title.
333    #[must_use]
334    pub const fn title(&self) -> &MetadataTitle {
335        &self.title
336    }
337
338    /// Returns the description.
339    #[must_use]
340    pub const fn description(&self) -> &MetadataDescription {
341        &self.description
342    }
343
344    /// Returns the canonical URL label.
345    #[must_use]
346    pub fn canonical_url(&self) -> Option<&str> {
347        self.canonical_url.as_deref()
348    }
349
350    /// Returns robots meta content.
351    #[must_use]
352    pub fn robots(&self) -> Option<&str> {
353        self.robots.as_deref()
354    }
355
356    /// Returns social preview metadata.
357    #[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}