breb 0.5.0

the blog/reblog library and command-line tool
Documentation
use std::{
	cmp::Reverse,
	collections::HashMap,
	ffi::OsStr,
	io::{self, Write},
	path::{Path, PathBuf},
	sync::Arc,
};

use chrono::{DateTime, Utc};
use minijinja::Value;

use crate::{
	Mime, html, raw,
	serve::{CssRule, Feed, Serve, Url},
};

/// a blog that's fully ready to serve files
///
/// a blog is made up of several [`Serve`]s.
/// each serve has a base path, under which it can serve files.
/// multiple serves can share the same base path; the first added has priority.
/// one serve's base path can also be the prefix of another's; the longer base path has priority.
///
/// in practice, to support things like autogenerated indices,
/// serves can't serve until everyone has reported what they *can* serve.
/// so a blog's not a blog -- i.e. *ready to serve* -- until that first pass is done.
/// that's why you configure it with [`raw::Config`], then `load` a directory!
#[derive(Debug)]
pub struct Blog {
	pub(crate) dir: PathBuf,
	pub(crate) metadata: Metadata,
	pub(crate) name: String,
	pub(crate) base_url: String,
	pub(crate) authors: Vec<raw::Author>,
	pub(crate) feeds: Vec<Feed>,
	pub(crate) env: minijinja::Environment<'static>,
	pub(crate) urls: HashMap<Url, (Mime, Arc<dyn Serve>)>,
}

impl Blog {
	/// start building a blog
	///
	/// you'll need to provide, at a minimum:
	/// - the name and url of your blog (placeholders are okay)
	/// - one serve (can't have a blog with nothing to show)
	/// - one author (feel free to use a pseudonym)
	pub fn builder() -> raw::Config {
		raw::Config::default()
	}

	/// load the configured blog from a directory
	///
	/// this will do all the metadata loading from each of the serves,
	/// so the resulting [`Blog`] is immediately able to serve files.
	pub fn load(mut config: raw::Config, dir: &Path) -> io::Result<Self> {
		let mut env = minijinja::Environment::<'static>::new();
		env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
		env.set_undefined_behavior(minijinja::UndefinedBehavior::Lenient);
		env.add_filter("strftime", html::filter_strftime);
		for src in config.template_srcs {
			src.add_to(dir, &mut env)?;
		}
		for (name, data) in [
			("base", include_str!("templates/base.t.html")),
			("page", include_str!("templates/page.t.html")),
			("post", include_str!("templates/post.t.html")),
		] {
			if env.get_template(name).is_err() {
				env.add_template_owned(name, data)
					.expect("adding builtin template failed, wtf?");
			}
		}

		// longest bases first, so they get priority
		// this is a stable sort, so earlier serves will also get priority
		config.serves.sort_by_key(|s| Reverse(s.base().components().count()));
		let mut metadata = Metadata::default();
		let mut urls = HashMap::new();
		for serve in config.serves {
			let scanned = serve.scan(dir).map_err(|e| {
				io::Error::other(format!("couldn't scan {:?}: {e}", serve.base()))
			})?;
			for (url, ct) in &scanned.files {
				urls.entry(url.clone()).or_insert_with(|| (ct.clone(), serve.clone()));
			}
			metadata.extend(scanned);
		}
		for posts in metadata.posts.values_mut() {
			posts.sort_by_key(|p| Reverse(p.published));
		}
		for posts in metadata.series.values_mut() {
			posts.sort_by_key(|(published, _)| *published);
		}

		Ok(Self {
			dir: dir.to_owned(),
			urls,
			metadata,
			env,
			name: config.name,
			base_url: config.url,
			authors: config.authors,
			feeds: config.feeds,
		})
	}

	/// get the [`Servable`] that handles a specific url
	pub fn servable(&self, url: Url) -> Option<Servable<'_>> {
		if let Some((ct, s)) = self.urls.get(&url) {
			return Some(Servable {
				blog: self,
				serve: &**s,
				url,
				content_type: ct.clone(),
			});
		}
		for feed in &self.feeds {
			if feed.feed_url == url {
				return Some(Servable {
					blog: self,
					serve: feed,
					url,
					content_type: feed.mime(),
				});
			}
		}
		None
	}

	/// get all the [`Servable`]s this blog will serve
	pub fn servables(&self) -> Vec<Servable<'_>> {
		self.urls
			.iter()
			.map(|(u, (ct, s))| Servable {
				blog: self,
				serve: &**s,
				url: u.clone(),
				content_type: ct.clone(),
			})
			.chain(self.feeds.iter().map(|f| Servable {
				blog: self,
				serve: f,
				url: f.feed_url.clone(),
				content_type: f.mime(),
			}))
			.collect()
	}

	/// get the stylesheets matching the provided rules
	///
	/// if no rules are provided, this will just return all known stylesheets.
	/// otherwise, it follows a bit of a complex algorithm to assemble the final list:
	///
	/// - if the first rule is an include, start with no stylesheets.
	///   otherwise, start with all stylesheets the `Blog` knows about.
	/// - for each rule (including the first):
	///   - if it's an include: append **all known** stylesheets that match
	///     and remove any prior copies of them
	///   - if it's an exclude: remove any **listed** stylesheets that don't match
	/// - if any step failed to change the list of stylesheets, error with its index
	pub fn stylesheets(&self, rules: &[CssRule]) -> Vec<Url> {
		let all_stylesheets: Vec<_> =
			self.metadata.stylesheets.iter().map(|(n, u)| (n.clone(), u.clone())).collect();

		let mut matched = if rules.is_empty() || matches!(rules[0], CssRule::Exclude(_)) {
			all_stylesheets.to_vec()
		} else {
			vec![]
		};
		for rule in rules.iter() {
			match rule {
				CssRule::Include(p) => {
					matched.retain(|(n, _)| !p.matches(n));
					let adding = all_stylesheets.iter().filter_map(|(n, u)| {
						if p.matches(n) {
							Some((n.clone(), u.clone()))
						} else {
							None
						}
					});
					matched.extend(adding);
				}
				CssRule::Exclude(p) => {
					matched.retain(|(n, _)| !p.matches(n));
				}
			}
		}
		matched.into_iter().map(|(_, u)| u).collect()
	}

	/// get the blog-wide information you should pass to a template
	///
	/// (the expectation is you'll merge this with some serve-specific things to get the final one)
	pub fn template_data(&self) -> HashMap<&'static str, Value> {
		let posts = self
			.metadata
			.posts
			.iter()
			.map(|(k, v)| {
				(
					k,
					v.iter()
						.map(|p| p.template_data().into_iter().collect::<Value>())
						.collect::<Value>(),
				)
			})
			.collect::<Value>();
		[
			(
				"attribution",
				Value::from_safe_string(
					concat!(
						"<!-- Generated by ",
						"blog/reblog v",
						env!("CARGO_PKG_VERSION"),
						" ",
						"(see ",
						env!("CARGO_PKG_HOMEPAGE"),
						")",
						" -->",
					)
					.into(),
				),
			),
			("site", Value::from(&self.name)),
			("posts", posts),
		]
		.into_iter()
		.collect()
	}
}

/// information about a file that this blog can render.
pub struct Servable<'b> {
	/// the blog this comes from
	blog: &'b Blog,
	/// the serve which can serve this
	serve: &'b dyn Serve,
	/// where this file is accessible
	url: Url,
	/// the mime type of the response
	content_type: Mime,
}

impl Servable<'_> {
	/// the url that this servable is served from
	pub fn url(&self) -> &str {
		self.url.str()
	}

	/// the path on disk that this servable should be written to
	pub fn filepath(&self) -> PathBuf {
		let mut path = self.url.0.clone();
		if self.content_type == Mime::HTML && path.extension() != Some(OsStr::new("html")) {
			path.push("index.html");
		}
		path
	}

	/// write the contents of the file being served to the provided location
	pub fn render(&self, out: &mut dyn Write) -> io::Result<()> {
		self.serve.render(self.blog, &self.url, out)
	}

	/// the mime content type of the item
	pub fn content_type(&self) -> &Mime {
		&self.content_type
	}
}

/// metadata about the blog, as supplied by serves
#[derive(Clone, Default, Debug)]
pub struct Metadata {
	/// stylesheets this serve can provide
	///
	/// the urls are still from the root of the site, not the base url!
	pub stylesheets: HashMap<String, Url>,
	/// all files it's capable of handling and their mime type
	///
	/// the urls are still from the root of the site, not the base url!
	pub files: HashMap<Url, Mime>,
	/// blogposts, in specific indices
	pub posts: HashMap<String, Vec<Post>>,
	/// blogposts, in specific series(es)
	pub series: HashMap<String, Vec<(DateTime<Utc>, Url)>>,
}

impl Metadata {
	/// add the other metadata's... data to this one
	///
	/// `self` will take priority, when there's a conflict;
	/// otherwise, data will be merged.
	///
	/// in detail:
	/// - `self.files` and `self.stylesheets` will only accept new keys
	/// - `self.posts` indices will be merged; duplicate posts are *not* detected
	pub fn extend(&mut self, other: Metadata) {
		for (name, url) in other.stylesheets {
			self.stylesheets.entry(name).or_insert(url);
		}
		for (url, mime) in other.files {
			self.files.entry(url).or_insert(mime);
		}
		for (index, posts) in other.posts {
			self.posts.entry(index).or_default().extend(posts);
		}
		for (series, posts) in other.series {
			self.series.entry(series).or_default().extend(posts);
		}
	}
}

/// metadata about a syndicated blogpost.
#[derive(Clone, Debug, Default, Hash, serde::Serialize, serde::Deserialize)]
pub struct Post {
	/// the url of the post
	#[serde(skip_deserializing)]
	pub url: Url,
	/// the primary title of the blogpost
	pub title: String,
	/// secondary title or summary, if there is one
	pub subtitle: Option<String>,
	/// categories this post belongs in
	#[serde(default)]
	pub tags: Vec<String>,
	/// when this was published, if it's out yet
	pub published: Option<DateTime<Utc>>,
	/// the name of the series this post is a part of
	///
	/// this will get filled out when the template is rendered
	/// to an object shaped something like this:
	///
	/// ```rs
	/// pub struct SeriesInfo {
	///     name: String,
	///     first: Option<Url>,
	///     prev: Option<Url>,
	///     next: Option<Url>,
	///     latest: Option<Url>,
	/// }
	/// ```
	pub series: Option<String>,
	/// the preview of the post
	#[serde(skip)]
	pub preview: String,
	/// whether the preview was shortened from the full thing
	#[serde(skip)]
	pub cutoff: bool,
}

impl Post {
	/// get the blog-wide information you should pass to a template
	pub fn template_data(&self) -> HashMap<&'static str, Value> {
		let mut map: HashMap<&'static str, Value> = [
			("url", Value::from(self.url.str())),
			("title", Value::from(&self.title)),
			("tags", Value::from_serialize(&self.tags)),
			("preview", Value::from(&self.preview)),
			("cutoff", Value::from(self.cutoff)),
		]
		.into_iter()
		.collect();
		if let Some(subtitle) = &self.subtitle {
			map.insert("subtitle", Value::from(subtitle));
		}
		if let Some(published) = &self.published {
			map.insert("published", Value::from_serialize(published));
		}
		map
	}
}