switch to handlebars

This commit is contained in:
slonkazoid 2024-08-13 15:53:18 +03:00
parent 0e97ffaeb8
commit 9d91e829c8
Signed by: slonk
SSH key fingerprint: SHA256:tbZfJX4IOvZ0LGWOWu5Ijo8jfMPi78TU7x1VoEeCIjM
20 changed files with 1330 additions and 541 deletions

1
.gitignore vendored
View file

@ -5,3 +5,4 @@
!/posts/README.md !/posts/README.md
/cache /cache
/config.toml /config.toml
/custom

939
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -38,9 +38,14 @@ comrak = { version = "0.22.0", features = [
console-subscriber = { version = "0.2.0", optional = true } console-subscriber = { version = "0.2.0", optional = true }
derive_more = "0.99.17" derive_more = "0.99.17"
fronma = "0.2.0" fronma = "0.2.0"
handlebars = "6.0.0"
include_dir = "0.7.4"
mime_guess = "2.0.5"
notify-debouncer-full = { version = "0.3.1", default-features = false }
rss = "2.0.7" rss = "2.0.7"
scc = { version = "2.1.0", features = ["serde"] } scc = { version = "2.1.0", features = ["serde"] }
serde = { version = "1.0.197", features = ["derive"] } serde = { version = "1.0.197", features = ["derive"] }
serde_json = { version = "1.0.124", features = ["preserve_order"] }
syntect = "5.2.0" syntect = "5.2.0"
thiserror = "1.0.58" thiserror = "1.0.58"
tokio = { version = "1.37.0", features = [ tokio = { version = "1.37.0", features = [

View file

@ -51,7 +51,7 @@ date_format = "RFC3339" # format string used to format dates in the backend
# so the date can be formatted by the browser. # so the date can be formatted by the browser.
# format: https://docs.rs/chrono/latest/chrono/format/strftime/index.html#specifiers # format: https://docs.rs/chrono/latest/chrono/format/strftime/index.html#specifiers
default_sort = "date" # default sorting method ("date" or "name") default_sort = "date" # default sorting method ("date" or "name")
default_color = "#f5c2e7" # default embed color, optional #default_color = "#f5c2e7" # default embed color, optional
[rss] [rss]
enable = false # serve an rss field under /feed.xml enable = false # serve an rss field under /feed.xml

18
partials/post_table.hbs Normal file
View file

@ -0,0 +1,18 @@
<div class="table">
{{#if (ne this.created_at null)}}
<div class="created">written</div>
<div class="created value">{{>span_date date_time=this.created_at}}</div>
{{/if}}
{{#if (ne this.modified_at null)}}
<div class="modified">last modified</div>
<div class="modified value">{{>span_date date_time=this.modified_at}}</div>
{{/if}}
{{#if (gt (len this.tags) 0)}}
<div class="tags">tags</div>
<div class="tags value">
{{#each this.tags}}
<a href="/?tag={{this}}" title="view all posts with this tag">{{this}}</a>
{{/each}}
</div>
{{/if}}
</div>

1
partials/span_date.hbs Normal file
View file

@ -0,0 +1 @@
<span class="date {{#if (eq df "RFC3339")}}date-rfc3339{{/if}}">{{date date_time df}}</span>

View file

@ -1,32 +1,35 @@
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use askama_axum::Template;
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::http::header::CONTENT_TYPE; use axum::http::header::CONTENT_TYPE;
use axum::http::{header, Request}; use axum::http::Request;
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::get; use axum::routing::get;
use axum::{Json, Router}; use axum::{Json, Router};
use handlebars::Handlebars;
use rss::{Category, ChannelBuilder, ItemBuilder}; use rss::{Category, ChannelBuilder, ItemBuilder};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serde_json::Map;
use tokio::sync::RwLock;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use tracing::{info, info_span, Span}; use tracing::{info, info_span, Span};
use crate::config::{Config, DateFormat, Sort}; use crate::config::{Config, DateFormat, Sort};
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::filters;
use crate::post::{MarkdownPosts, PostManager, PostMetadata, RenderStats, ReturnedPost}; use crate::post::{MarkdownPosts, PostManager, PostMetadata, RenderStats, ReturnedPost};
#[derive(Clone)] #[derive(Clone)]
#[non_exhaustive]
pub struct AppState { pub struct AppState {
pub config: Arc<Config>, pub config: Arc<Config>,
pub posts: Arc<MarkdownPosts<Arc<Config>>>, pub posts: Arc<MarkdownPosts<Arc<Config>>>,
pub reg: Arc<RwLock<Handlebars<'static>>>,
} }
#[derive(Template)] #[derive(Serialize)]
#[template(path = "index.html")]
struct IndexTemplate<'a> { struct IndexTemplate<'a> {
title: &'a str, title: &'a str,
description: &'a str, description: &'a str,
@ -36,10 +39,11 @@ struct IndexTemplate<'a> {
js: bool, js: bool,
color: Option<&'a str>, color: Option<&'a str>,
sort: Sort, sort: Sort,
tags: Map<String, serde_json::Value>,
joined_tags: String,
} }
#[derive(Template)] #[derive(Serialize)]
#[template(path = "post.html")]
struct PostTemplate<'a> { struct PostTemplate<'a> {
meta: &'a PostMetadata, meta: &'a PostMetadata,
rendered: String, rendered: String,
@ -48,6 +52,7 @@ struct PostTemplate<'a> {
df: &'a DateFormat, df: &'a DateFormat,
js: bool, js: bool,
color: Option<&'a str>, color: Option<&'a str>,
joined_tags: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -57,15 +62,63 @@ struct QueryParams {
num_posts: Option<usize>, num_posts: Option<usize>,
} }
fn collect_tags(posts: &Vec<PostMetadata>) -> Map<String, serde_json::Value> {
let mut tags = HashMap::new();
for post in posts {
for tag in &post.tags {
if let Some((existing_tag, count)) = tags.remove_entry(tag) {
tags.insert(existing_tag, count + 1);
} else {
tags.insert(tag.clone(), 1);
}
}
}
let mut tags: Vec<(String, u64)> = tags.into_iter().collect();
tags.sort_unstable_by_key(|(v, _)| v.clone());
tags.sort_by_key(|(_, v)| -(*v as i64));
let mut map = Map::new();
for tag in tags.into_iter() {
map.insert(tag.0, tag.1.into());
}
map
}
fn join_tags_for_meta(tags: &Map<String, serde_json::Value>, delim: &str) -> String {
let mut s = String::new();
let tags = tags.keys().enumerate();
let len = tags.len();
for (i, tag) in tags {
s += tag;
if i != len - 1 {
s += delim;
}
}
s
}
async fn index<'a>( async fn index<'a>(
State(AppState { config, posts }): State<AppState>, State(AppState {
config, posts, reg, ..
}): State<AppState>,
Query(query): Query<QueryParams>, Query(query): Query<QueryParams>,
) -> AppResult<Response> { ) -> AppResult<impl IntoResponse> {
let posts = posts let posts = posts
.get_max_n_post_metadata_with_optional_tag_sorted(query.num_posts, query.tag.as_ref()) .get_max_n_post_metadata_with_optional_tag_sorted(query.num_posts, query.tag.as_ref())
.await?; .await?;
Ok(IndexTemplate { let tags = collect_tags(&posts);
let joined_tags = join_tags_for_meta(&tags, ", ");
let reg = reg.read().await;
let rendered = reg.render(
"index",
&IndexTemplate {
title: &config.title, title: &config.title,
description: &config.description, description: &config.description,
posts, posts,
@ -74,8 +127,12 @@ async fn index<'a>(
js: config.js_enable, js: config.js_enable,
color: config.default_color.as_deref(), color: config.default_color.as_deref(),
sort: config.default_sort, sort: config.default_sort,
} tags,
.into_response()) joined_tags,
},
);
drop(reg);
Ok(([(CONTENT_TYPE, "text/html")], rendered?))
} }
async fn all_posts( async fn all_posts(
@ -90,7 +147,7 @@ async fn all_posts(
} }
async fn rss( async fn rss(
State(AppState { config, posts }): State<AppState>, State(AppState { config, posts, .. }): State<AppState>,
Query(query): Query<QueryParams>, Query(query): Query<QueryParams>,
) -> AppResult<Response> { ) -> AppResult<Response> {
if !config.rss.enable { if !config.rss.enable {
@ -145,15 +202,23 @@ async fn rss(
let body = channel.build().to_string(); let body = channel.build().to_string();
drop(channel); drop(channel);
Ok(([(header::CONTENT_TYPE, "text/xml")], body).into_response()) Ok(([(CONTENT_TYPE, "text/xml")], body).into_response())
} }
async fn post( async fn post(
State(AppState { config, posts }): State<AppState>, State(AppState {
config, posts, reg, ..
}): State<AppState>,
Path(name): Path<String>, Path(name): Path<String>,
) -> AppResult<Response> { ) -> AppResult<impl IntoResponse> {
match posts.get_post(&name).await? { match posts.get_post(&name).await? {
ReturnedPost::Rendered(ref meta, rendered, rendered_in) => Ok(PostTemplate { ReturnedPost::Rendered(ref meta, rendered, rendered_in) => {
let joined_tags = meta.tags.join(", ");
let reg = reg.read().await;
let rendered = reg.render(
"post",
&PostTemplate {
meta, meta,
rendered, rendered,
rendered_in, rendered_in,
@ -161,8 +226,12 @@ async fn post(
df: &config.date_format, df: &config.date_format,
js: config.js_enable, js: config.js_enable,
color: meta.color.as_deref().or(config.default_color.as_deref()), color: meta.color.as_deref().or(config.default_color.as_deref()),
joined_tags,
},
);
drop(reg);
Ok(([(CONTENT_TYPE, "text/html")], rendered?).into_response())
} }
.into_response()),
ReturnedPost::Raw(body, content_type) => { ReturnedPost::Raw(body, content_type) => {
Ok(([(CONTENT_TYPE, content_type)], body).into_response()) Ok(([(CONTENT_TYPE, content_type)], body).into_response())
} }

View file

@ -102,7 +102,7 @@ impl Default for Config {
js_enable: true, js_enable: true,
date_format: Default::default(), date_format: Default::default(),
default_sort: Default::default(), default_sort: Default::default(),
default_color: Some("#f5c2e7".into()), default_color: None,
// i have a love-hate relationship with serde // i have a love-hate relationship with serde
// it was engimatic at first, but then i started actually using it // it was engimatic at first, but then i started actually using it
// writing my own serialize and deserialize implementations.. spending // writing my own serialize and deserialize implementations.. spending

View file

@ -53,6 +53,8 @@ pub type AppResult<T> = Result<T, AppError>;
pub enum AppError { pub enum AppError {
#[error("failed to fetch post: {0}")] #[error("failed to fetch post: {0}")]
PostError(#[from] PostError), PostError(#[from] PostError),
#[error(transparent)]
HandlebarsError(#[from] handlebars::RenderError),
#[error("rss is disabled")] #[error("rss is disabled")]
RssDisabled, RssDisabled,
#[error(transparent)] #[error(transparent)]
@ -75,12 +77,9 @@ struct ErrorTemplate {
impl IntoResponse for AppError { impl IntoResponse for AppError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let status_code = match &self { let status_code = match &self {
AppError::PostError(err) => match err { AppError::PostError(PostError::NotFound(_)) => StatusCode::NOT_FOUND,
PostError::NotFound(_) => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
},
AppError::RssDisabled => StatusCode::FORBIDDEN, AppError::RssDisabled => StatusCode::FORBIDDEN,
AppError::UrlError(_) => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}; };
( (
status_code, status_code,

View file

@ -1,56 +0,0 @@
use std::collections::HashMap;
use std::fmt::Display;
use std::time::Duration;
use chrono::{DateTime, TimeZone};
use crate::config::DateFormat;
use crate::post::PostMetadata;
fn format_date<T>(date: &DateTime<T>, date_format: &DateFormat) -> String
where
T: TimeZone,
T::Offset: Display,
{
match date_format {
DateFormat::RFC3339 => date.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
DateFormat::Strftime(ref format_string) => date.format(format_string).to_string(),
}
}
pub fn date<T>(date: &DateTime<T>, date_format: &DateFormat) -> Result<String, askama::Error>
where
T: TimeZone,
T::Offset: Display,
{
Ok(format_date(date, date_format))
}
pub fn duration(duration: &&Duration) -> Result<String, askama::Error> {
Ok(format!("{:?}", duration))
}
pub fn collect_tags(posts: &Vec<PostMetadata>) -> Result<Vec<(String, u64)>, askama::Error> {
let mut tags = HashMap::new();
for post in posts {
for tag in &post.tags {
if let Some((existing_tag, count)) = tags.remove_entry(tag) {
tags.insert(existing_tag, count + 1);
} else {
tags.insert(tag.clone(), 1);
}
}
}
let mut tags: Vec<(String, u64)> = tags.into_iter().collect();
tags.sort_unstable_by_key(|(v, _)| v.clone());
tags.sort_by_key(|(_, v)| -(*v as i64));
Ok(tags)
}
pub fn join_tags_for_meta(tags: &Vec<String>) -> Result<String, askama::Error> {
Ok(tags.join(", "))
}

24
src/helpers.rs Normal file
View file

@ -0,0 +1,24 @@
use std::fmt::Display;
use std::time::Duration;
use chrono::{DateTime, TimeZone, Utc};
use handlebars::handlebars_helper;
use crate::config::DateFormat;
fn date_impl<T>(date_time: &DateTime<T>, date_format: &DateFormat) -> String
where
T: TimeZone,
T::Offset: Display,
{
match date_format {
DateFormat::RFC3339 => date_time.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
DateFormat::Strftime(ref format_string) => date_time.format(format_string).to_string(),
}
}
handlebars_helper!(date: |date_time: Option<DateTime<Utc>>, date_format: DateFormat| {
date_impl(date_time.as_ref().unwrap(), &date_format)
});
handlebars_helper!(duration: |duration_: Duration| format!("{:?}", duration_));

View file

@ -3,13 +3,14 @@
mod app; mod app;
mod config; mod config;
mod error; mod error;
mod filters;
mod hash_arc_store; mod hash_arc_store;
mod helpers;
mod markdown_render; mod markdown_render;
mod platform; mod platform;
mod post; mod post;
mod ranged_i128_visitor; mod ranged_i128_visitor;
mod systemtime_as_secs; mod systemtime_as_secs;
mod templates;
use std::future::IntoFuture; use std::future::IntoFuture;
use std::net::SocketAddr; use std::net::SocketAddr;
@ -19,31 +20,40 @@ use std::time::Duration;
use color_eyre::eyre::{self, Context}; use color_eyre::eyre::{self, Context};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::RwLock;
use tokio::task::JoinSet; use tokio::task::JoinSet;
use tokio::time::Instant;
use tokio::{select, signal}; use tokio::{select, signal};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::level_filters::LevelFilter; use tracing::level_filters::LevelFilter;
use tracing::{debug, info, warn}; use tracing::{debug, error, info, info_span, warn, Instrument};
use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::{util::SubscriberInitExt, EnvFilter}; use tracing_subscriber::{util::SubscriberInitExt, EnvFilter};
use crate::app::AppState; use crate::app::AppState;
use crate::post::{MarkdownPosts, PostManager}; use crate::post::{MarkdownPosts, PostManager};
use crate::templates::new_registry;
use crate::templates::watcher::watch_templates;
#[tokio::main] #[tokio::main]
async fn main() -> eyre::Result<()> { async fn main() -> eyre::Result<()> {
#[cfg(feature = "tokio-console")]
console_subscriber::init();
color_eyre::install()?; color_eyre::install()?;
#[cfg(not(feature = "tokio-console"))] let reg = tracing_subscriber::registry();
tracing_subscriber::registry() #[cfg(feature = "tokio-console")]
let reg = reg
.with( .with(
EnvFilter::builder()
.with_default_directive(LevelFilter::TRACE.into())
.from_env_lossy(),
)
.with(console_subscriber::spawn());
#[cfg(not(feature = "tokio-console"))]
let reg = reg.with(
EnvFilter::builder() EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into()) .with_default_directive(LevelFilter::INFO.into())
.from_env_lossy(), .from_env_lossy(),
) );
.with(tracing_subscriber::fmt::layer()) reg.with(tracing_subscriber::fmt::layer()).init();
.init();
let config = Arc::new( let config = Arc::new(
config::load() config::load()
@ -56,22 +66,41 @@ async fn main() -> eyre::Result<()> {
let mut tasks = JoinSet::new(); let mut tasks = JoinSet::new();
let cancellation_token = CancellationToken::new(); let cancellation_token = CancellationToken::new();
let start = Instant::now();
// NOTE: use tokio::task::spawn_blocking if this ever turns into a concurrent task
let mut reg =
new_registry("custom/templates").context("failed to create handlebars registry")?;
reg.register_helper("date", Box::new(helpers::date));
reg.register_helper("duration", Box::new(helpers::duration));
debug!(duration = ?start.elapsed(), "registered all templates");
let reg = Arc::new(RwLock::new(reg));
let watcher_token = cancellation_token.child_token();
let posts = Arc::new(MarkdownPosts::new(Arc::clone(&config)).await?); let posts = Arc::new(MarkdownPosts::new(Arc::clone(&config)).await?);
let state = AppState { let state = AppState {
config: Arc::clone(&config), config: Arc::clone(&config),
posts: Arc::clone(&posts), posts: Arc::clone(&posts),
reg: Arc::clone(&reg),
}; };
debug!("setting up watcher");
tasks.spawn(
watch_templates("custom/templates", watcher_token.clone(), reg)
.instrument(info_span!("custom_template_watcher")),
);
if config.cache.enable && config.cache.cleanup { if config.cache.enable && config.cache.cleanup {
if let Some(t) = config.cache.cleanup_interval { if let Some(millis) = config.cache.cleanup_interval {
let posts = Arc::clone(&posts); let posts = Arc::clone(&posts);
let token = cancellation_token.child_token(); let token = cancellation_token.child_token();
debug!("setting up cleanup task"); debug!("setting up cleanup task");
tasks.spawn(async move { tasks.spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(t)); let mut interval = tokio::time::interval(Duration::from_millis(millis));
loop { loop {
select! { select! {
_ = token.cancelled() => break, _ = token.cancelled() => break Ok(()),
_ = interval.tick() => { _ = interval.tick() => {
posts.cleanup().await posts.cleanup().await
} }
@ -122,7 +151,10 @@ async fn main() -> eyre::Result<()> {
cancellation_token.cancel(); cancellation_token.cancel();
server.await.context("failed to serve app")?; server.await.context("failed to serve app")?;
while let Some(task) = tasks.join_next().await { while let Some(task) = tasks.join_next().await {
task.context("failed to join task")?; let res = task.context("failed to join task")?;
if let Err(err) = res {
error!("task failed with error: {err}");
}
} }
drop(state); drop(state);

View file

@ -24,6 +24,7 @@ pub struct PostMetadata {
pub tags: Vec<String>, pub tags: Vec<String>,
} }
#[derive(Serialize)]
pub enum RenderStats { pub enum RenderStats {
Cached(Duration), Cached(Duration),
// format: Total, Parsed in, Rendered in // format: Total, Parsed in, Rendered in

186
src/templates/mod.rs Normal file
View file

@ -0,0 +1,186 @@
pub mod watcher;
use std::{io, path::Path};
use handlebars::{Handlebars, Template};
use include_dir::{include_dir, Dir};
use thiserror::Error;
use tracing::{debug, error, info_span, trace};
const TEMPLATES: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates");
const PARTIALS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/partials");
#[derive(Error, Debug)]
#[allow(clippy::enum_variant_names)]
pub enum TemplateError {
#[error(transparent)]
IoError(#[from] std::io::Error),
#[error("file doesn't contain valid UTF-8")]
UTF8Error,
#[error(transparent)]
TemplateError(#[from] handlebars::TemplateError),
}
fn is_ext(path: impl AsRef<Path>, ext: &str) -> bool {
match path.as_ref().extension() {
Some(path_ext) if path_ext != ext => false,
None => false,
_ => true,
}
}
pub(self) fn get_template_name<'a>(path: &'a Path) -> Option<&'a str> {
if !is_ext(path, "hbs") {
return None;
}
path.file_stem()?.to_str()
}
fn register_included_file(
file: &include_dir::File<'_>,
name: &str,
registry: &mut Handlebars,
) -> Result<(), TemplateError> {
let template = compile_included_file(file)?;
registry.register_template(name, template);
Ok(())
}
fn register_path<'a>(
path: impl AsRef<std::path::Path>,
name: &str,
registry: &mut Handlebars<'a>,
) -> Result<(), TemplateError> {
let template = compile_path(path)?;
registry.register_template(name, template);
Ok(())
}
fn register_partial(
file: &include_dir::File<'_>,
name: &str,
registry: &mut Handlebars,
) -> Result<(), TemplateError> {
registry.register_partial(name, file.contents_utf8().ok_or(TemplateError::UTF8Error)?)?;
Ok(())
}
fn compile_included_file(file: &include_dir::File<'_>) -> Result<Template, TemplateError> {
let contents = file.contents_utf8().ok_or(TemplateError::UTF8Error)?;
let template = Template::compile(contents)?;
Ok(template)
}
fn compile_path(path: impl AsRef<std::path::Path>) -> Result<Template, TemplateError> {
use std::fs::OpenOptions;
use std::io::Read;
let mut file = OpenOptions::new().read(true).open(path)?;
let mut buf = String::new();
file.read_to_string(&mut buf)?;
let template = Template::compile(&buf)?;
Ok(template)
}
pub(self) async fn compile_path_async_io(
path: impl AsRef<std::path::Path>,
) -> Result<Template, TemplateError> {
use tokio::fs::OpenOptions;
use tokio::io::AsyncReadExt;
let mut file = OpenOptions::new().read(true).open(path).await?;
let mut buf = String::new();
file.read_to_string(&mut buf).await?;
let template = Template::compile(&buf)?;
Ok(template)
}
pub fn new_registry<'a>(custom_templates_path: impl AsRef<Path>) -> io::Result<Handlebars<'a>> {
let mut reg = Handlebars::new();
for entry in TEMPLATES.entries() {
let file = match entry.as_file() {
Some(file) => file,
None => continue,
};
let span = info_span!("register_included_template", path = ?file.path());
let _handle = span.enter();
let name = match get_template_name(file.path()) {
Some(v) => v,
None => {
trace!("skipping file");
continue;
}
};
match register_included_file(file, name, &mut reg) {
Ok(()) => debug!("registered template {name:?}"),
Err(err) => error!("error while registering template: {err}"),
};
}
for entry in PARTIALS.entries() {
let file = match entry.as_file() {
Some(file) => file,
None => continue,
};
let span = info_span!("register_partial", path = ?file.path());
let _handle = span.enter();
let name = match get_template_name(file.path()) {
Some(v) => v,
None => {
trace!("skipping file");
continue;
}
};
match register_partial(file, name, &mut reg) {
Ok(()) => debug!("registered partial {name:?}"),
Err(err) => error!("error while registering partial: {err}"),
};
}
let read_dir = match std::fs::read_dir(custom_templates_path) {
Ok(v) => v,
Err(err) => match err.kind() {
io::ErrorKind::NotFound => return Ok(reg),
_ => panic!("{:?}", err),
},
};
for entry in read_dir {
let entry = entry.unwrap();
let file_type = entry.file_type()?;
if !file_type.is_file() {
continue;
}
let path = entry.path();
let span = info_span!("register_custom_template", ?path);
let _handle = span.enter();
let name = match get_template_name(&path) {
Some(v) => v,
None => {
trace!("skipping file");
continue;
}
};
match register_path(&path, name, &mut reg) {
Ok(()) => debug!("registered template {name:?}"),
Err(err) => error!("error while registering template: {err}"),
};
}
Ok(reg)
}

126
src/templates/watcher.rs Normal file
View file

@ -0,0 +1,126 @@
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use handlebars::{Handlebars, Template};
use notify_debouncer_full::notify::{self, Watcher};
use notify_debouncer_full::{new_debouncer, DebouncedEvent};
use tokio::select;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, trace, trace_span};
use crate::templates::*;
async fn process_event(
event: DebouncedEvent,
templates: &mut Vec<(String, Template)>,
) -> Result<(), Box<dyn std::error::Error>> {
match event.kind {
notify::EventKind::Create(notify::event::CreateKind::File)
| notify::EventKind::Modify(_) => {
for path in &event.paths {
let span = trace_span!("modify_event", ?path);
let _handle = span.enter();
let template_name = match get_template_name(path) {
Some(v) => v,
None => {
trace!("skipping event");
continue;
}
};
trace!("processing recompilation");
let compiled = compile_path_async_io(path).await?;
trace!("compiled template {template_name:?}");
templates.push((template_name.to_owned(), compiled));
}
}
notify::EventKind::Remove(notify::event::RemoveKind::File) => {
for path in &event.paths {
let span = trace_span!("remove_event", ?path);
let _handle = span.enter();
let (file_name, template_name) = match path
.file_name()
.and_then(|o| o.to_str())
.and_then(|file_name| {
get_template_name(Path::new(file_name))
.map(|template_name| (file_name, template_name))
}) {
Some(v) => v,
None => {
trace!("skipping event");
continue;
}
};
trace!("processing removal");
let file = TEMPLATES.get_file(file_name);
if let Some(file) = file {
let compiled = compile_included_file(file)?;
trace!("compiled template {template_name:?}");
templates.push((template_name.to_owned(), compiled));
}
}
}
_ => {}
};
Ok(())
}
pub async fn watch_templates<'a>(
path: impl AsRef<Path>,
watcher_token: CancellationToken,
reg: Arc<RwLock<Handlebars<'a>>>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
let path = path.as_ref();
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
let mut debouncer = new_debouncer(Duration::from_millis(100), None, move |events| {
tx.blocking_send(events)
.expect("failed to send message over channel")
})?;
debouncer
.watcher()
.watch(path, notify::RecursiveMode::NonRecursive)?;
'event_loop: while let Some(events) = select! {
_ = watcher_token.cancelled() => {
debug!("exiting watcher loop");
break 'event_loop;
},
events = rx.recv() => events
} {
let events = match events {
Ok(events) => events,
Err(err) => {
error!("error getting events: {err:?}");
continue;
}
};
let mut templates = Vec::new();
for event in events {
trace!("file event: {event:?}");
if let Err(err) = process_event(event, &mut templates).await {
error!("error while processing event: {err}");
}
}
let mut reg = reg.write().await;
for template in templates.into_iter() {
debug!("registered template {}", template.0);
reg.register_template(&template.0, template.1);
}
drop(reg);
info!("updated custom templates");
}
Ok(())
}

63
templates/index.hbs Normal file
View file

@ -0,0 +1,63 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="{{title}}" />
<meta property="og:title" content="{{title}}" />
<meta property="og:description" content="{{description}}" />
<meta name="keywords" content="{{joined_tags}}" />
{{#if (ne color null)}}
<meta name="theme-color" content="{{color}}" />
{{/if}}
<title>{{title}}</title>
<link rel="stylesheet" href="/static/style.css" />
{{#if rss}}
<link rel="alternate" type="application/rss+xml" title="{{title}}" href="/feed.xml" />
{{/if}}
{{#if js}}
<script src="/static/date.js" defer></script>
<script src="/static/sort.js" defer></script>
<script src="/static/main.js" defer></script>
{{/if}}
</head>
<body>
<main>
<h1>{{title}}</h1>
<p>{{description}}</p>
<h2>posts</h2>
<div>
{{#if js}}
<form id="sort" style="display: none">
sort by: {{sort}}
<br />
<input type="radio" name="sort" id="sort-date" value="date" {{#if (eq sort "date")}}checked{{/if}} />
<label for="sort-date">date</label>
<input type="radio" name="sort" id="sort-name" value="name" {{#if (eq sort "name")}}checked{{/if}} />
<label for="sort-name">name</label>
</form>
{{/if}}
{{#each posts}}
<div id="posts">
<div class="post">
<a href="/posts/{{name}}"><b>{{title}}</b></a>
<span class="post-author">- by {{author}}</span>
<br />
{{description}}<br />
{{>post_table post df=@root.df}}
</div>
</div>
{{else}} there are no posts right now. check back later! {{/each}}
</div>
{{#if (gt (len tags) 0)}}
<h2>tags</h2>
<b><a href="/">clear tags</a></b>
<br />
{{/if}}
{{#each tags}}
<a href="/?tag={{@key}}" title="view all posts with this tag">{{@key}}</a>
<span class="post-author">- {{this}} post{{#if (ne this 1)}}s{{/if}}</span><br />
{{/each}}
</main>
</body>
</html>

View file

@ -1,70 +0,0 @@
{%- import "macros.askama" as macros -%}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="{{ title }}" />
<meta property="og:title" content="{{ title }}" />
<meta property="og:description" content="{{ description }}" />
{% match color %} {% when Some with (color) %}
<meta name="theme-color" content="{{ color }}" />
{% when None %} {% endmatch %}
<title>{{ title }}</title>
<link rel="stylesheet" href="/static/style.css" />
<link rel="stylesheet" href="/static/custom/style.css" />
<link rel="stylesheet" href="/static/custom/index.css" />
{% if rss %}
<link rel="alternate" type="application/rss+xml" title="{{ title }}" href="/feed.xml" />
{% endif %}
<!-- prettier-br -->
{% if js %}
<script src="/static/date.js" defer></script>
<script src="/static/sort.js" defer></script>
<script src="/static/main.js" defer></script>
{% endif %}
</head>
<body>
<main>
<h1>{{ title }}</h1>
<p>{{ description }}</p>
<h2>posts</h2>
<div>
{% if posts.is_empty() %}<!-- prettier-br -->
there are no posts right now. check back later!<!-- prettier-br -->
{% else %}<!-- prettier-br -->
{% if js %}<!-- prettier-br -->
<form id="sort" style="display: none">
sort by: <br />
<input type="radio" name="sort" id="sort-date" value="date" {% if sort == Sort::Date %} checked {% endif %} />
<label for="sort-date">date</label>
<input type="radio" name="sort" id="sort-name" value="name" {% if sort == Sort::Name %} checked {% endif %} />
<label for="sort-name">name</label>
</form>
{% endif %}<!-- prettier-br -->
<div id="posts">
{% for post in posts %}
<div class="post">
<a href="/posts/{{ post.name }}"><b>{{ post.title }}</b></a>
<span class="post-author">- by {{ post.author }}</span>
<br />
{{ post.description }}<br />
{% call macros::table(post) %}
</div>
{% endfor %}
</div>
{% endif %}<!-- prettier-br -->
</div>
{% let tags = posts|collect_tags %}<!-- prettier-br -->
{% if !tags.is_empty() %}
<h2>tags</h2>
<b><a href="/">clear tags</a></b>
<br />
{% endif %}<!-- prettier-br -->
{% for tag in tags %}
<a href="/?tag={{ tag.0 }}" title="view all posts with this tag">{{ tag.0 }}</a>
<span class="post-author">- {{ tag.1 }} post{% if tag.1 != 1 %}s{%endif %}</span><br />
{% endfor %}
</main>
</body>
</html>

View file

@ -1,31 +0,0 @@
{% macro span_date(value) %}
<span class="{%- match df -%}
{% when DateFormat::RFC3339 %}
date-rfc3339
{% when DateFormat::Strftime(_) %}
{%- endmatch -%}">{{ value|date(df) }}</span>
{% endmacro %}
{% macro table(post) %}
<div class="table">
{% match post.created_at %}
{% when Some(created_at) %}
<div class="created">written</div>
<div class="created value">{% call span_date(created_at) %}</div>
{% when None %}
{% endmatch %}
{% match post.modified_at %}
{% when Some(modified_at) %}
<div class="modified">last modified</div>
<div class="modified value">{% call span_date(modified_at) %}</div>
{% when None %}
{% endmatch %}
{% if !post.tags.is_empty() %}
<div class="tags">tags</div>
<div class="tags value">
{% for tag in post.tags %}
<a href="/?tag={{ tag }}" title="view all posts with this tag">{{ tag }}</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}

71
templates/post.hbs Normal file
View file

@ -0,0 +1,71 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="author" content="{{meta.author}}" />
<meta name="keywords" content="{{joined_tags}}" />
<meta name="description" content="{{meta.title}}" />
<!-- you know what I really love? platforms like discord
favoring twitter embeds over the open standard. to color
your embed or have large images, you have to do _this_. lmao -->
<meta property="og:title" content="{{meta.title}}" />
<meta property="twitter:title" content="{{meta.title}}" />
<meta property="og:description" content="{{meta.description}}" />
<meta property="twitter:description" content="{{meta.description}}" />
{{#if (ne meta.icon null)}}
<meta property="og:image" content="{{meta.icon}}" />
<meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:image:src" content="{{meta.icon}}" />
{{#if (ne meta.icon_alt null)}}
<meta property="og:image:alt" content="{{meta.icon_alt}}" />
<meta property="twitter:image:alt" content="{{meta.icon_alt}}" />
{{/if}}{{/if}}
{{#if (ne color null)}}
<meta name="theme-color" content="{{meta.color}}" />
{{/if}}
<title>{{meta.title}}</title>
<link rel="stylesheet" href="/static/style.css" />
<link rel="stylesheet" href="/static/post.css" />
<link rel="stylesheet" href="/static/custom/style.css" />
<link rel="stylesheet" href="/static/custom/post.css" />
{{#if js}}
<script src="/static/date.js" defer></script>
<script src="/static/main.js" defer></script>
{{/if}}
</head>
<body>
<main>
<h1 class="post-title">
{{meta.title}}
<span class="post-author">- by {{meta.author}}</span>
</h1>
<p class="post-desc">{{meta.description}}</p>
<div class="post">
{{>post_table meta df=@root.df}}
<a href="/posts/{{meta.name}}">link</a><br />
<a href="/">back to home</a>
</div>
<hr />
{{{rendered}}}
</main>
<footer>
{{#each rendered_in}}
{{#if (eq @key "ParsedAndRendered")}}
<span class="tooltipped" title="parsing took {{duration this.[1]}}">parsed</span>
and
<span class="tooltipped" title="rendering took {{duration this.[2]}}">rendered</span>
in
{{duration this.[0]}}
{{else if (eq @key "Cached")}}
retrieved from cache in
{{duration this}}
{{/if}}
{{/each}}
{{#if markdown_access}}
-
<a href="/posts/{{meta.name}}.md">view raw</a>
{{/if}}
</footer>
</body>
</html>

View file

@ -1,71 +0,0 @@
{%- import "macros.askama" as macros -%}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="author" content="{{ meta.author }}" />
<meta name="keywords" content="{{ meta.tags|join_tags_for_meta }}" />
<meta name="description" content="{{ meta.title }}" />
<!-- you know what I really love? platforms like discord
favoring twitter embeds over the open standard. to color
your embed or have large images, you have to do _this_. lmao -->
<meta property="og:title" content="{{ meta.title }}" />
<meta property="twitter:title" content="{{ meta.title }}" />
<meta property="og:description" content="{{ meta.description }}" />
<meta property="twitter:description" content="{{ meta.description }}" />
{% match meta.icon %} {% when Some with (url) %}
<meta property="og:image" content="{{ url }}" />
<meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:image:src" content="{{ url }}" />
{% match meta.icon_alt %} {% when Some with (alt) %}
<meta property="og:image:alt" content="{{ alt }}" />
<meta property="twitter:image:alt" content="{{ alt }}" />
{% when None %} {% endmatch %}
<!-- prettier-br -->
{% when None %} {% endmatch %}
<!-- prettier is annoying -->
{% match color %} {% when Some with (color) %}
<meta name="theme-color" content="{{ color }}" />
{% when None %} {% endmatch %}
<title>{{ meta.title }}</title>
<link rel="stylesheet" href="/static/style.css" />
<link rel="stylesheet" href="/static/post.css" />
<link rel="stylesheet" href="/static/custom/style.css" />
<link rel="stylesheet" href="/static/custom/post.css" />
{% if js %}
<script src="/static/date.js" defer></script>
<script src="/static/main.js" defer></script>
{% endif %}
</head>
<body>
<main>
<h1 class="post-title">
{{ meta.title }}
<span class="post-author">- by {{ meta.author }}</span>
</h1>
<p class="post-desc">{{ meta.description }}</p>
<div class="post">
<!-- prettier-ignore -->
{% call macros::table(meta) %}
<a href="/posts/{{ meta.name }}">link</a><br />
<a href="/">back to home</a>
</div>
<hr />
{{ rendered|escape("none") }}
</main>
<!-- prettier-ignore -->
<footer>
{% match rendered_in %}
{% when RenderStats::ParsedAndRendered(total, parsing, rendering) %}
<span class="tooltipped" title="parsing took {{ parsing|duration }}">parsed</span> and
<span class="tooltipped" title="rendering took {{ rendering|duration }}">rendered</span> in {{ total|duration }}
{% when RenderStats::Cached(total) %}
retrieved from cache in {{ total|duration }}
{% endmatch %}
{% if markdown_access %}
- <a href="/posts/{{ meta.name }}.md">view raw</a>
{% endif %}
</footer>
</body>
</html>