breb 0.5.0

the blog/reblog library and command-line tool
Documentation
use std::{
	fs::{self},
	io::{self},
	path::Path,
};

use minijinja::Value;
use walkdir::WalkDir;

use crate::{
	Blog, Metadata, Mime, Post,
	html::{Parsed, get_preview},
};

use super::{CssRule, Dir, HasCssRules, HasNav, Mount, NavItem, Serve, Url};

/// [`Serve`]s syndicated posts
///
/// this depends on having a template named `post` available,
/// with a block named `body` for the post body to be rendered into.
/// it ships with one which it will use if none is provided,
/// which in turn depends on having a template named `base` available.
#[derive(Debug)]
pub struct Posts {
	mount: Mount,
	index: String,
	nav: Vec<NavItem>,
	rules: Vec<CssRule>,
	locale: Option<String>,
}

impl Posts {
	const PREFIX: &str = r#"{% extends "post" %}{% block body %}"#;
	const SUFFIX: &str = r#"{% endblock %}"#;

	/// create a new serve for posts, under the given url, from the given directory.
	pub fn new(url: impl Into<Url>, dir: impl Into<Dir>) -> Self {
		Self {
			mount: Mount::new(url, dir),
			index: String::new(),
			nav: vec![],
			rules: vec![],
			locale: None,
		}
	}

	/// set the default locale for posts in this serve
	pub fn locale(mut self, new: impl Into<String>) -> Self {
		self.locale = Some(new.into());
		self
	}

	/// set the index that (published) posts are added to
	pub fn index(mut self, index: impl Into<String>) -> Self {
		self.index = index.into();
		self
	}
}
impl HasNav for Posts {
	fn get_nav(&mut self) -> &mut Vec<NavItem> {
		&mut self.nav
	}
}
impl HasCssRules for Posts {
	fn get_rules(&mut self) -> &mut Vec<CssRule> {
		&mut self.rules
	}
}

impl Serve for Posts {
	fn base(&self) -> &Url {
		&self.mount.url
	}

	fn scan(&self, dir: &Path) -> io::Result<Metadata> {
		let base = dir.join(&self.mount.dir.0);
		let mut metadata = Metadata::default();
		for item in WalkDir::new(&base).follow_root_links(true).follow_links(true) {
			let item = item?;
			if !item.file_type().is_file() {
				continue;
			}
			let Some(name) = item.path().to_str() else {
				continue;
			};
			if ![".html", ".jinja"].iter().any(|ext| name.ends_with(ext)) {
				continue;
			}

			let dir = Dir(item.path().strip_prefix(dir).unwrap().to_path_buf());
			let url = self.mount.url(&dir).unwrap();
			let url = if url.ends_with("index.html") {
				url.parent().unwrap().to_owned()
			} else {
				url.with_extension("")
			};
			let url = Url(url);

			let (mut post, body): (Post, _) = Parsed::load(item.path())
				.map_err(|e| {
					io::Error::other(format!("couldn't load {:?}: {e}", item.path()))
				})?
				.into_both();
			post.url = url.clone();
			let (preview, cutoff) = get_preview(&body);
			post.preview = preview.into();
			post.cutoff = cutoff;

			metadata.files.insert(url.clone(), Mime::HTML);
			if let Some(published) = post.published {
				if let Some(series) = &post.series {
					metadata
						.series
						.entry(series.clone())
						.or_default()
						.push((published, post.url.clone()));
				}
				metadata.posts.entry(self.index.clone()).or_default().push(post);
			}
		}
		Ok(metadata)
	}

	fn render(&self, blog: &Blog, url: &Url, into: &mut dyn io::Write) -> io::Result<()> {
		let in_dir = self
			.mount
			.dir(url)
			.ok_or(io::Error::new(io::ErrorKind::NotFound, format!("not served: {url:?}")))?;
		let in_base = blog.dir.join(&in_dir.0);
		let file: Parsed<Post> = [in_base.with_extension("html"), in_base.join("index.html")]
			.into_iter()
			.filter_map(|p| Parsed::load(&p).ok())
			.next()
			.ok_or_else(|| {
				io::Error::new(
					io::ErrorKind::NotFound,
					format!("no file matching {in_base:?}"),
				)
			})?;

		let post = file.extra();
		let mut nb =
			String::with_capacity(Self::PREFIX.len() + file.body().len() + Self::SUFFIX.len());
		nb.push_str(Self::PREFIX);
		nb.push_str(file.body());
		nb.push_str(Self::SUFFIX);

		let template = blog
			.env
			.template_from_named_str(url.str(), &nb)
			.map_err(|e| io::Error::other(format!("invalid template: {e}")))?;

		let mut value = blog.template_data();
		if let Some(locale) = &self.locale {
			value.insert("locale", Value::from(locale));
		}
		value.extend(post.template_data());
		if post.published.is_some()
			&& let Some(series) = &post.series
		{
			let mut smap = vec![("name", Value::from(series))];
			let other_posts = blog
				.metadata
				.series
				.get(series)
				.expect("blog is missing series mentioned in published post");
			let idx = other_posts
				.iter()
				.enumerate()
				.find(|(_, (_, u))| u == url)
				.expect("published post is missing from its series")
				.0;
			if idx > 0 {
				let (_, first) = other_posts.first().expect("published post has empty series");
				smap.push(("first", first.to_str().into()));
				let (_, prev) = other_posts.get(idx - 1).expect("index past end?");
				smap.push(("prev", prev.to_str().into()));
			}
			if idx < other_posts.len() - 1 {
				let (_, latest) = other_posts.last().expect("published post has empty series");
				smap.push(("latest", latest.to_str().into()));
				let (_, next) =
					other_posts.get(idx + 1).expect("index past end despite check?");
				smap.push(("next", next.to_str().into()));
			}
			value.insert("series", smap.into_iter().collect());
		}
		value.extend(file.template_data());
		let stylesheets = blog.stylesheets(&self.rules);
		value.insert("stylesheets", stylesheets.iter().flat_map(|u| u.to_str()).collect());
		value.insert("nav", self.nav.iter().map(Value::from_serialize).collect());
		let context = value.into_iter().collect::<Value>();

		template
			.render_captured_to(context, into)
			.map_err(|e| io::Error::other(format!("couldn't render: {e}")))?;

		Ok(())
	}

	fn post_body(&self, blog: &Blog, url: &Url, into: &mut dyn io::Write) -> io::Result<()> {
		let in_dir = self
			.mount
			.dir(url)
			.ok_or(io::Error::new(io::ErrorKind::NotFound, format!("not served: {url:?}")))?;
		let in_base = blog.dir.join(&in_dir.0);
		let file = [in_base.with_extension("html"), in_base.join("index.html")]
			.into_iter()
			.filter_map(|p| fs::read_to_string(p).ok())
			.next()
			.ok_or_else(|| {
				io::Error::new(
					io::ErrorKind::NotFound,
					format!("no file matching {in_base:?}"),
				)
			})?;

		let (metadata, body) = file.split_once("\n---\n").unwrap_or(("", &file));
		let metadata: Post = toml::from_str(metadata).map_err(|e| {
			io::Error::new(
				io::ErrorKind::InvalidData,
				format!("invalid metadata: {url:?}: {e}"),
			)
		})?;
		let mut nb =
			String::with_capacity(Self::PREFIX.len() + body.len() + Self::SUFFIX.len());
		nb.push_str(Self::PREFIX);
		nb.push_str(body);
		nb.push_str(Self::SUFFIX);

		let template = blog
			.env
			.template_from_named_str(url.str(), &nb)
			.map_err(|e| io::Error::other(format!("invalid template: {e}")))?;

		let mut value = blog.template_data();
		if let Some(locale) = &self.locale {
			value.insert("locale", Value::from(locale));
		}
		value.extend(metadata.template_data());
		let stylesheets = blog.stylesheets(&self.rules);
		value.insert("stylesheets", stylesheets.iter().flat_map(|u| u.to_str()).collect());
		let context = value.into_iter().collect::<Value>();

		template
			.render_captured(context)
			.map_err(|e| io::Error::other(format!("couldn't eval: {e}")))?
			.with_state_mut(|state| {
				state
					.render_block_to_write("body", into)
					.map_err(|e| io::Error::other(format!("couldn't render: {e}")))
			})?;
		Ok(())
	}
}