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};
#[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 %}"#;
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,
}
}
pub fn locale(mut self, new: impl Into<String>) -> Self {
self.locale = Some(new.into());
self
}
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(())
}
}