breb 0.5.0

the blog/reblog library and command-line tool
Documentation
//! the part of the blog that can actually serve files

mod asis;
pub use asis::AsIs;
mod feed;
pub use feed::{Feed, FeedKind};
mod git;
pub use git::GitRepo;
mod page;
pub use page::Pages;
mod post;
pub use post::Posts;

use std::{
	fmt::{self, Debug},
	io::{self, Write},
	ops::Deref,
	path::{self, Path, PathBuf},
	str::FromStr,
};

use crate::{Blog, Metadata};

/// a single group of files for the server to serve
///
/// a serve can be, for example:
/// - several files together being served in the same manner
///   - e.g. an assets folder, all served as-is
/// - a single file on the path directly
///   - e.g. as is done for the feed
///
/// either way it represents some content on the webserver,
/// and no matter what it doesn't do anything on its own.
/// you need to pair it with an input directory, by building a [`Blog`],
/// to actually get the http responses you need.
///
/// no matter what, **it must not track any state internally**.
pub trait Serve: Debug + Send + Sync {
	/// the base of the urls this `Serve` will serve
	///
	/// this is used to hint at priority: longer bases will try to serve a file first.
	/// it's also an optimization; serves won't get asked to serve resources outside their base.
	fn base(&self) -> &Url;

	/// scan the directory for info about what this serve can serve
	///
	/// this will be merged with the other serves as described in [`Blog`],
	/// mostly just ensuring urls don't collide.
	fn scan(&self, dir: &Path) -> io::Result<Metadata>;

	/// render the full http response for a specific url
	fn render(&self, blog: &Blog, url: &Url, into: &mut dyn Write) -> io::Result<()>;

	/// get the body of a post at the given url
	///
	/// the default implementation returns a `ErrorKind::NotFound` unconditionally,
	/// and will suffice for anything that doesn't actually serve any posts.
	fn post_body(&self, _: &Blog, _: &Url, _: &mut dyn Write) -> io::Result<()> {
		Err(io::Error::new(io::ErrorKind::NotFound, "this serve doesn't serve any posts"))
	}
}

/// a subpath under the root url of the site
///
/// this can be used either for a single file, or a serve mountpoint.
#[derive(Clone, Default, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct Url(pub PathBuf);
impl Url {
	/// create a new url for the given path
	pub fn new(p: impl Into<PathBuf>) -> Self {
		Self(p.into())
	}

	/// make it a string
	pub fn str(&self) -> &str {
		self.to_str().unwrap()
	}
}
impl<T: AsRef<Path>> From<T> for Url {
	fn from(value: T) -> Self {
		Self(value.as_ref().to_path_buf())
	}
}
impl Deref for Url {
	type Target = Path;
	fn deref(&self) -> &Self::Target {
		&self.0
	}
}
impl fmt::Display for Url {
	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
		write!(f, "{}", self.0.to_str().unwrap())
	}
}
impl fmt::Debug for Url {
	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
		write!(f, "url:{:?}", self.0)
	}
}

/// a subpath under the input directory of the site
#[derive(Clone, Default, Hash, PartialEq, Eq)]
pub struct Dir(pub PathBuf);
impl Dir {
	/// create a new dir for the given path
	pub fn new(p: impl Into<PathBuf>) -> Self {
		Self(p.into())
	}
}
impl<T: AsRef<Path>> From<T> for Dir {
	fn from(value: T) -> Self {
		Self(value.as_ref().to_path_buf())
	}
}
impl Deref for Dir {
	type Target = Path;
	fn deref(&self) -> &Self::Target {
		&self.0
	}
}
impl fmt::Display for Dir {
	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
		write!(f, "{}", self.0.to_str().unwrap())
	}
}
impl fmt::Debug for Dir {
	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
		write!(f, "dir:{:?}", self.0)
	}
}

/// an item on a page's navigation header
#[derive(Clone, Debug, Default, serde::Serialize)]
pub struct NavItem {
	/// the text of the nav item
	pub text: String,
	/// where it links to
	pub href: String,
}
impl NavItem {
	/// create a new `NavItem`
	pub fn new(text: impl Into<String>, href: impl Into<String>) -> Self {
		Self {
			text: text.into(),
			href: href.into(),
		}
	}
}
impl From<(String, String)> for NavItem {
	fn from((text, href): (String, String)) -> Self {
		Self::new(text, href)
	}
}
/// serves whose files have navigational headers
pub trait HasNav {
	/// get the nav, for editing by the caller
	fn get_nav(&mut self) -> &mut Vec<NavItem>;

	/// add a nav item
	fn nav(mut self, text: impl Into<String>, href: impl Into<String>) -> Self
	where
		Self: Sized,
	{
		self.get_nav().push(NavItem::new(text, href));
		self
	}
	/// add several nav items at once
	fn navs(
		mut self,
		vals: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
	) -> Self
	where
		Self: Sized,
	{
		self.get_nav().extend(vals.into_iter().map(|(t, h)| NavItem::new(t, h)));
		self
	}
}

/// a glob matching stylesheets to include or exclude
///
/// most of the time, this enum is part of [a vec](HasCssRules),
/// defining the list of stylesheets to apply to a given file or serve.
/// the way that works is somewhat simple:
///
/// specifically:
/// - if the first rule is an include, start with an empty list.
///   if it's an exclude, start with every stylesheet b/rb knows about.
/// - for each rule:
///   - if it's an include, add all stylesheets b/rb knows which match that glob
///   - if it's an exclude, remove all stylesheets match that glob
/// - if any rule doesn't change the list, error out
#[derive(Clone)]
pub enum CssRule {
	/// include all stylesheets matching this pattern
	Include(glob::Pattern),
	/// exclude all stylesheets matching this pattern
	Exclude(glob::Pattern),
}
impl FromStr for CssRule {
	type Err = glob::PatternError;
	fn from_str(s: &str) -> Result<Self, Self::Err> {
		let (excl, s) = match s.strip_prefix('!') {
			Some(s) => (true, s),
			None => (false, s),
		};
		let pat = s.parse()?;
		if excl {
			Ok(Self::Exclude(pat))
		} else {
			Ok(Self::Include(pat))
		}
	}
}
// technically, shouldn't do this...
// ...but it makes my code so much easier to write!
impl<T: AsRef<str>> From<T> for CssRule {
	fn from(value: T) -> Self {
		value.as_ref().parse().unwrap()
	}
}
impl fmt::Display for CssRule {
	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
		match self {
			Self::Include(p) => write!(f, "{p}"),
			Self::Exclude(p) => write!(f, "!{p}"),
		}
	}
}
impl fmt::Debug for CssRule {
	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
		match self {
			Self::Include(p) => write!(f, "CssRule({:?})", p.as_str()),
			Self::Exclude(p) => write!(f, "CssRule(!{:?})", p.as_str()),
		}
	}
}
/// serves whose files include stylesheets
pub trait HasCssRules {
	/// get the stylesheet rules, for editing by the caller
	fn get_rules(&mut self) -> &mut Vec<CssRule>;

	/// add a stylesheet rule
	fn add_rule(mut self, rule: CssRule) -> Self
	where
		Self: Sized,
	{
		self.get_rules().push(rule);
		self
	}
	/// add multiple stylesheet rules
	fn add_rules(mut self, rules: impl IntoIterator<Item = impl Into<CssRule>>) -> Self
	where
		Self: Sized,
	{
		self.get_rules().extend(rules.into_iter().map(Into::into));
		self
	}
	/// add a rule to include stylesheets by a pattern
	///
	/// this will panic if the pattern cannot be parsed.
	fn include_css(mut self, rule: impl AsRef<str>) -> Self
	where
		Self: Sized,
	{
		let pat = rule.as_ref().parse().unwrap();
		self.get_rules().push(CssRule::Include(pat));
		self
	}
	/// add a rule to exclude stylesheets by a pattern
	///
	/// this will panic if the pattern cannot be parsed.
	fn exclude_css(mut self, rule: impl AsRef<str>) -> Self
	where
		Self: Sized,
	{
		let pat = rule.as_ref().parse().unwrap();
		self.get_rules().push(CssRule::Exclude(pat));
		self
	}
}

/// maps [`Url`]s to [`Dir`]s and vice versa
///
/// [`Serve`]s frequently get their contents from directories that don't match the url subpaths
/// they live under. this does the (typesafe) mapping back and forth, since all the `Serve` inputs
/// and outputs have to be relative to the url.
///
/// if you're not implementing [`Serve`], you likely only need to worry about [`Mount::new`].
#[derive(Debug, Default)]
pub struct Mount {
	url: Url,
	dir: Dir,
}

impl Mount {
	fn relify(path: PathBuf) -> PathBuf {
		if !path.is_absolute() {
			return path;
		}
		let mut new = PathBuf::new();
		for component in path.components() {
			match component {
				path::Component::Normal(n) => new.push(n),
				path::Component::ParentDir => {
					new.pop();
				}
				_ => (), // everything else is filtered out
			}
		}
		new
	}

	/// create a new [`Mount`] that maps urls under `url` into directories under `dir`.
	pub fn new(url: impl Into<Url>, dir: impl Into<Dir>) -> Self {
		Self {
			url: Url(Self::relify(url.into().0)),
			dir: Dir(Self::relify(dir.into().0)),
		}
	}

	/// get the respective [`Dir`] for a given [`Url`]
	///
	/// returns `None` if `from` isn't actually under the url subpath.
	pub fn dir(&self, from: &Url) -> Option<Dir> {
		from.0.strip_prefix(&self.url.0).ok().map(|rel| Dir::new(self.dir.0.join(rel)))
	}

	/// get the respective [`Url`] for a given [`Dir`]
	///
	/// returns `None` if `from` isn't actually under the input subdirectory.
	pub fn url(&self, from: &Dir) -> Option<Url> {
		from.0.strip_prefix(&self.dir.0).ok().map(|rel| Url::new(self.url.0.join(rel)))
	}
}