breb 0.5.0

the blog/reblog library and command-line tool
Documentation
use std::{
	cmp::Reverse,
	io::{self, Write},
	path::PathBuf,
};

use atom_syndication::{
	ContentBuilder, Entry, FeedBuilder, Generator, Link, Person, TextBuilder,
};
use chrono::{DurationRound, TimeDelta, Utc};

use crate::{
	Blog, Metadata, Mime,
	serve::{Serve, Url},
};

/// defines a machine-readable feed to serve the blog's posts
#[derive(Clone, Debug)]
pub struct Feed {
	/// where the feed will be served
	pub feed_url: Url,
	/// where this feed links to as its "blog"
	pub blog_url: Url,
	/// the data format of the feed
	pub kind: FeedKind,
	/// which index to feed posts from
	pub index: String,
	/// the maximum number of posts to include, or `None` for all of them (can get large!)
	pub length: Option<usize>,
}

impl Feed {
	/// create a new [atom](FeedKind::Atom) feed
	pub fn atom(url: impl Into<Url>) -> Self {
		let mut url = url.into();
		if let Ok(without) = url.strip_prefix("/") {
			url = without.into();
		}

		Self {
			feed_url: url,
			blog_url: Url(PathBuf::new()),
			kind: FeedKind::Atom,
			index: String::new(),
			length: None,
		}
	}

	/// set the index that posts will be listed from
	pub fn index(mut self, index: impl Into<String>) -> Self {
		self.index = index.into();
		self
	}

	/// set the maximum number of posts to show in the feed
	///
	/// if there are more posts than this in the index,
	/// the most recent few will be taken.
	pub fn len(mut self, len: usize) -> Self {
		self.length = Some(len);
		self
	}

	/// set where this feed links to as its blog, relative to the blog's base url
	pub fn link(mut self, link: impl Into<PathBuf>) -> Self {
		self.blog_url = Url(link.into());
		self
	}

	/// mime type for the feed, based on its [`Self::kind`]
	pub fn mime(&self) -> Mime {
		self.kind.mime()
	}

	fn render_atom(&self, blog: &Blog, into: &mut dyn io::Write) -> io::Result<()> {
		let mut feed = FeedBuilder::default();
		let feed_abs = format!(
			"{}/{}",
			blog.base_url.trim_end_matches('/'),
			self.feed_url.to_str().unwrap_or(""),
		);
		let blog_abs = format!(
			"{}/{}",
			blog.base_url.trim_end_matches("/"),
			self.blog_url.to_str().unwrap_or(""),
		);
		let feed = feed
			.id(&blog.base_url)
			.title(TextBuilder::default().value(&blog.name).build())
			.updated(Utc::now().duration_round(TimeDelta::seconds(1)).unwrap())
			.generator(Generator {
				value: "blog/reblog".into(),
				uri: Some(env!("CARGO_PKG_HOMEPAGE").into()),
				version: Some(env!("CARGO_PKG_VERSION").into()),
			})
			.link(Link {
				rel: "self".into(),
				href: feed_abs,
				..Default::default()
			})
			.link(Link {
				rel: "alternate".into(),
				href: blog_abs,
				..Default::default()
			});
		for author in &blog.authors {
			feed.author(Person {
				name: author.name.clone(),
				email: author.email.clone(),
				uri: author.email.clone(),
				extensions: Default::default(),
			});
		}
		let posts = blog.metadata.posts.get(&self.index).cloned().unwrap_or_default();
		let mut posts: Vec<_> = posts.into_iter().collect();
		let take = self.length.unwrap_or(posts.len());
		posts.sort_by_key(|p| Reverse(p.published));
		for post in posts.into_iter().take(take) {
			let published = post.published.expect("indexed unpublished post?");
			let post_abs =
				format!("{}/{}", blog.base_url.trim_end_matches('/'), post.url.str(),);
			let serve = &blog.urls.get(&post.url).expect("post had a nonexistent url?").1;
			let mut body = vec![0u8; 0];
			if let Some(st) = &post.subtitle {
				let _ = write!(
					&mut body,
					"<p><em>{}</em></p>",
					st.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;"),
				);
			}
			serve.post_body(blog, &post.url, &mut body)?;
			let body = String::from_utf8(body).expect("non-utf8");
			feed.entry(Entry {
				id: post_abs.clone(),
				title: TextBuilder::default().value(&post.title).build(),
				updated: Utc::now().into(),
				published: Some(published.into()),
				links: vec![Link {
					rel: "alternate".into(),
					href: post_abs.clone(),
					..Default::default()
				}],
				summary: post.subtitle.map(|t| TextBuilder::default().value(&t).build()),
				content: Some(
					ContentBuilder::default()
						.content_type("html".to_string())
						.value(body)
						.build(),
				),
				..Default::default()
			});
		}
		match feed.build().write_to(into) {
			Ok(_) => Ok(()),
			Err(e) => Err(io::Error::other(format!("{e}"))),
		}
	}
}

/// the format that a feed is in, and any format-specific parameters
#[derive(Clone, Debug)]
pub enum FeedKind {
	/// [atom syndication format](https://en.wikipedia.org/wiki/Atom_(web_standard))
	Atom,
	// TODO: Rss,
	// TODO: Rss2,
}

impl FeedKind {
	fn mime(&self) -> Mime {
		match self {
			Self::Atom => Mime::ATOM,
		}
	}
}

impl Serve for Feed {
	fn base(&self) -> &Url {
		&self.feed_url
	}

	fn scan(&self, _: &std::path::Path) -> io::Result<Metadata> {
		Ok(Metadata {
			files: [(self.feed_url.clone(), self.mime())].into_iter().collect(),
			..Default::default()
		})
	}

	fn render(&self, blog: &Blog, _: &Url, into: &mut dyn io::Write) -> io::Result<()> {
		match self.kind {
			FeedKind::Atom => self.render_atom(blog, into),
		}
	}
}