breb 0.5.0

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

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

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

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

/// metadata about a non-syndicated page
#[derive(Debug, Default, Hash, serde::Serialize, serde::Deserialize)]
pub struct Page {
	/// the primary title of the page
	pub title: Option<String>,
	/// the subtitle of the page
	pub subtitle: Option<String>,
}

impl Page {
	fn template_data(&self) -> HashMap<&'static str, Value> {
		let mut res = HashMap::new();
		if let Some(title) = &self.title {
			res.insert("title", title.into());
		}
		if let Some(subtitle) = &self.subtitle {
			res.insert("subtitle", subtitle.into());
		}
		res
	}
}

/// [`Serve`]s unsyndicated pages
///
/// this depends on having a template named `page` available,
/// with a block named `body` for the page 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 Pages {
	mount: Mount,
	nav: Vec<NavItem>,
	rules: Vec<CssRule>,
	locale: Option<String>,
}

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

	/// create a new serve for pages, 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),
			nav: vec![],
			rules: vec![],
			locale: None,
		}
	}

	/// set the default locale for pages in this serve
	pub fn locale(mut self, new: impl Into<String>) -> Self {
		self.locale = Some(new.into());
		self
	}
}
impl HasNav for Pages {
	fn get_nav(&mut self) -> &mut Vec<NavItem> {
		&mut self.nav
	}
}
impl HasCssRules for Pages {
	fn get_rules(&mut self) -> &mut Vec<CssRule> {
		&mut self.rules
	}
}

impl Serve for Pages {
	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 suffixless = if url.ends_with("index.html") {
				url.parent().unwrap().to_owned()
			} else {
				url.with_extension("")
			};
			metadata.files.insert(Url(suffixless), Mime::HTML);
		}
		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<Page> = [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 page = 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(page.template_data());
		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(())
	}
}