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},
};
#[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 {
pub fn builder() -> raw::Config {
raw::Config::default()
}
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?");
}
}
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,
})
}
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
}
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()
}
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()
}
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()
}
}
pub struct Servable<'b> {
blog: &'b Blog,
serve: &'b dyn Serve,
url: Url,
content_type: Mime,
}
impl Servable<'_> {
pub fn url(&self) -> &str {
self.url.str()
}
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
}
pub fn render(&self, out: &mut dyn Write) -> io::Result<()> {
self.serve.render(self.blog, &self.url, out)
}
pub fn content_type(&self) -> &Mime {
&self.content_type
}
}
#[derive(Clone, Default, Debug)]
pub struct Metadata {
pub stylesheets: HashMap<String, Url>,
pub files: HashMap<Url, Mime>,
pub posts: HashMap<String, Vec<Post>>,
pub series: HashMap<String, Vec<(DateTime<Utc>, Url)>>,
}
impl Metadata {
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);
}
}
}
#[derive(Clone, Debug, Default, Hash, serde::Serialize, serde::Deserialize)]
pub struct Post {
#[serde(skip_deserializing)]
pub url: Url,
pub title: String,
pub subtitle: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
pub published: Option<DateTime<Utc>>,
pub series: Option<String>,
#[serde(skip)]
pub preview: String,
#[serde(skip)]
pub cutoff: bool,
}
impl Post {
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
}
}