#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
fn non_empty(value: impl AsRef<str>, field: &'static str) -> Result<String, MetadataValueError> {
let trimmed = value.as_ref().trim();
if trimmed.is_empty() {
Err(MetadataValueError::Empty { field })
} else {
Ok(trimmed.to_string())
}
}
fn is_http_url(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
lower.starts_with("https://") || lower.starts_with("http://")
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MetadataValueError {
Empty { field: &'static str },
InvalidUrl,
InvalidImageDimensions,
}
impl fmt::Display for MetadataValueError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty { field } => write!(formatter, "{field} cannot be empty"),
Self::InvalidUrl => {
formatter.write_str("metadata URL must start with http:// or https://")
},
Self::InvalidImageDimensions => {
formatter.write_str("image dimensions must be non-zero")
},
}
}
}
impl Error for MetadataValueError {}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct MetadataTitle(String);
impl MetadataTitle {
pub fn new(value: impl AsRef<str>) -> Result<Self, MetadataValueError> {
non_empty(value, "metadata title").map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for MetadataTitle {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for MetadataTitle {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for MetadataTitle {
type Err = MetadataValueError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct MetadataDescription(String);
impl MetadataDescription {
pub fn new(value: impl AsRef<str>) -> Result<Self, MetadataValueError> {
non_empty(value, "metadata description").map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for MetadataDescription {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for MetadataDescription {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum OpenGraphType {
Website,
Article,
Product,
Profile,
LocalBusiness,
}
impl OpenGraphType {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Website => "website",
Self::Article => "article",
Self::Product => "product",
Self::Profile => "profile",
Self::LocalBusiness => "business.business",
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum TwitterCardKind {
Summary,
SummaryLargeImage,
App,
Player,
}
impl TwitterCardKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Summary => "summary",
Self::SummaryLargeImage => "summary_large_image",
Self::App => "app",
Self::Player => "player",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OpenGraphImage {
url: String,
alt: Option<String>,
width: Option<u32>,
height: Option<u32>,
}
impl OpenGraphImage {
pub fn new(value: impl AsRef<str>) -> Result<Self, MetadataValueError> {
let url = non_empty(value, "Open Graph image URL")?;
if !is_http_url(&url) {
return Err(MetadataValueError::InvalidUrl);
}
Ok(Self {
url,
alt: None,
width: None,
height: None,
})
}
pub fn with_alt(mut self, alt: impl AsRef<str>) -> Result<Self, MetadataValueError> {
self.alt = Some(non_empty(alt, "Open Graph image alt text")?);
Ok(self)
}
pub fn with_dimensions(mut self, width: u32, height: u32) -> Result<Self, MetadataValueError> {
if width == 0 || height == 0 {
return Err(MetadataValueError::InvalidImageDimensions);
}
self.width = Some(width);
self.height = Some(height);
Ok(self)
}
#[must_use]
pub fn url(&self) -> &str {
&self.url
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SocialPreview {
title: MetadataTitle,
description: MetadataDescription,
image: Option<OpenGraphImage>,
open_graph_type: OpenGraphType,
twitter_card: TwitterCardKind,
}
impl SocialPreview {
#[must_use]
pub const fn new(title: MetadataTitle, description: MetadataDescription) -> Self {
Self {
title,
description,
image: None,
open_graph_type: OpenGraphType::Website,
twitter_card: TwitterCardKind::Summary,
}
}
#[must_use]
pub fn with_image(mut self, image: OpenGraphImage) -> Self {
self.image = Some(image);
self
}
#[must_use]
pub const fn with_open_graph_type(mut self, value: OpenGraphType) -> Self {
self.open_graph_type = value;
self
}
#[must_use]
pub const fn with_twitter_card(mut self, value: TwitterCardKind) -> Self {
self.twitter_card = value;
self
}
#[must_use]
pub const fn open_graph_type(&self) -> OpenGraphType {
self.open_graph_type
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PageMetadata {
title: MetadataTitle,
description: MetadataDescription,
canonical_url: Option<String>,
robots: Option<String>,
social_preview: Option<SocialPreview>,
}
impl PageMetadata {
#[must_use]
pub const fn new(title: MetadataTitle, description: MetadataDescription) -> Self {
Self {
title,
description,
canonical_url: None,
robots: None,
social_preview: None,
}
}
#[must_use]
pub fn with_canonical_url(mut self, url: impl Into<String>) -> Self {
self.canonical_url = Some(url.into());
self
}
#[must_use]
pub fn with_robots(mut self, content: impl Into<String>) -> Self {
self.robots = Some(content.into());
self
}
#[must_use]
pub fn with_social_preview(mut self, preview: SocialPreview) -> Self {
self.social_preview = Some(preview);
self
}
#[must_use]
pub const fn title(&self) -> &MetadataTitle {
&self.title
}
#[must_use]
pub const fn description(&self) -> &MetadataDescription {
&self.description
}
#[must_use]
pub fn canonical_url(&self) -> Option<&str> {
self.canonical_url.as_deref()
}
#[must_use]
pub fn robots(&self) -> Option<&str> {
self.robots.as_deref()
}
#[must_use]
pub const fn social_preview(&self) -> Option<&SocialPreview> {
self.social_preview.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::{
MetadataDescription, MetadataTitle, OpenGraphImage, OpenGraphType, PageMetadata,
SocialPreview, TwitterCardKind,
};
#[test]
fn validates_metadata_strings() {
assert!(MetadataTitle::new("Example").is_ok());
assert!(MetadataDescription::new(" ").is_err());
}
#[test]
fn builds_open_graph_images() {
let image = OpenGraphImage::new("https://example.com/image.png")
.unwrap()
.with_alt("Preview image")
.unwrap()
.with_dimensions(1200, 630)
.unwrap();
assert_eq!(image.url(), "https://example.com/image.png");
assert!(OpenGraphImage::new("/image.png").is_err());
}
#[test]
fn composes_page_metadata() {
let preview = SocialPreview::new(
MetadataTitle::new("Example").unwrap(),
MetadataDescription::new("Example description").unwrap(),
)
.with_open_graph_type(OpenGraphType::Article)
.with_twitter_card(TwitterCardKind::SummaryLargeImage);
let metadata = PageMetadata::new(
MetadataTitle::new("Example").unwrap(),
MetadataDescription::new("Example description").unwrap(),
)
.with_canonical_url("https://example.com/")
.with_robots("index,follow")
.with_social_preview(preview);
assert_eq!(metadata.robots(), Some("index,follow"));
assert_eq!(
metadata.social_preview().unwrap().open_graph_type(),
OpenGraphType::Article
);
}
}