initial slonkmit
This commit is contained in:
commit
3e7fbea3bb
26 changed files with 6276 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
/target
|
||||||
|
/static/**/*.gz
|
||||||
|
/media/*
|
||||||
|
/posts/*
|
||||||
|
!/posts/README.md
|
||||||
|
/.slbg-cache
|
||||||
|
/config.toml
|
2639
Cargo.lock
generated
Normal file
2639
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
50
Cargo.toml
Normal file
50
Cargo.toml
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
[package]
|
||||||
|
name = "silly-blog"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
default-run = "silly-blog"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "syntect-to-css"
|
||||||
|
required-features = ["clap"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["precompression"]
|
||||||
|
tokio-console = ["dep:console-subscriber"]
|
||||||
|
clap = ["dep:clap"]
|
||||||
|
precompression = ["dep:async-compression"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "fat"
|
||||||
|
opt-level = 3
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
askama = { version = "0.12.1", features = ["with-axum"] }
|
||||||
|
askama_axum = "0.4.0"
|
||||||
|
async-compression = { version = "0.4.8", optional = true }
|
||||||
|
axum = { version = "0.7.5", features = ["macros"] }
|
||||||
|
bitcode = { version = "0.6.0", features = ["serde"] }
|
||||||
|
chrono = { version = "0.4.37", features = ["serde"] }
|
||||||
|
clap = { version = "4.5.4", features = ["derive"], optional = true }
|
||||||
|
color-eyre = "0.6.3"
|
||||||
|
comrak = { version = "0.22.0", features = ["syntect"] }
|
||||||
|
console-subscriber = { version = "0.2.0", optional = true }
|
||||||
|
fronma = { version = "0.2.0", features = ["toml"] }
|
||||||
|
futures-util = "0.3.30"
|
||||||
|
notify = "6.1.1"
|
||||||
|
scc = "2.1.0"
|
||||||
|
serde = { version = "1.0.197", features = ["derive"] }
|
||||||
|
syntect = "5.2.0"
|
||||||
|
thiserror = "1.0.58"
|
||||||
|
tokio = { version = "1.37.0", features = ["full"] }
|
||||||
|
tokio-util = "0.7.10"
|
||||||
|
toml = "0.8.12"
|
||||||
|
tower-http = { version = "0.5.2", features = [
|
||||||
|
"compression-gzip",
|
||||||
|
"fs",
|
||||||
|
"trace",
|
||||||
|
], default-features = false }
|
||||||
|
tracing = "0.1.40"
|
||||||
|
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
19
README.md
Normal file
19
README.md
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
title = "README"
|
||||||
|
description = "the README.md file of this project"
|
||||||
|
author = "slonkazoid"
|
||||||
|
---
|
||||||
|
|
||||||
|
# silly-blog
|
||||||
|
|
||||||
|
blazingly fast markdown blog software written in rust memory safe
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- [ ] finish writing this document
|
||||||
|
- [ ] document config
|
||||||
|
- [ ] extend syntect options
|
||||||
|
- [ ] general cleanup of code
|
||||||
|
- [x] be blazingly fast
|
||||||
|
- [x] 100+ MiB binary size
|
||||||
|
|
1
posts/README.md
Symbolic link
1
posts/README.md
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
../README.md
|
20
src/append_path.rs
Normal file
20
src/append_path.rs
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
use std::{
|
||||||
|
ffi::{OsStr, OsString},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
// i will kill you rust stdlib
|
||||||
|
pub trait Append<T>
|
||||||
|
where
|
||||||
|
Self: Into<OsString>,
|
||||||
|
T: From<OsString>,
|
||||||
|
{
|
||||||
|
fn append(self, ext: impl AsRef<OsStr>) -> T {
|
||||||
|
let mut buffer: OsString = self.into();
|
||||||
|
buffer.push(ext.as_ref());
|
||||||
|
T::from(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Append<PathBuf> for PathBuf {}
|
||||||
|
impl Append<PathBuf> for &Path {}
|
76
src/bin/syntect-to-css.rs
Normal file
76
src/bin/syntect-to-css.rs
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::BufReader;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use color_eyre::eyre::{self, Context, Ok, OptionExt};
|
||||||
|
use syntect::highlighting::{Theme, ThemeSet};
|
||||||
|
use syntect::html::{css_for_theme_with_class_style, ClassStyle};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(about = "generate CSS from a syntect theme")]
|
||||||
|
struct Args {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Command,
|
||||||
|
#[arg(
|
||||||
|
short,
|
||||||
|
long,
|
||||||
|
help = "prefix for generated classes",
|
||||||
|
default_value = "syntect-"
|
||||||
|
)]
|
||||||
|
prefix: String,
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
help = "don't add a prefix to generated classes",
|
||||||
|
default_value_t = false
|
||||||
|
)]
|
||||||
|
no_prefix: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
enum Command {
|
||||||
|
#[command(about = "generate CSS from a theme in the default theme set")]
|
||||||
|
Default {
|
||||||
|
#[arg(help = "name of theme (no .tmTheme)")]
|
||||||
|
theme_name: String,
|
||||||
|
},
|
||||||
|
#[command(about = "generate CSS from a .tmTheme file")]
|
||||||
|
File {
|
||||||
|
#[arg(help = "path to theme (including .tmTheme)")]
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> eyre::Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
color_eyre::install()?;
|
||||||
|
|
||||||
|
let theme = match args.command {
|
||||||
|
Command::Default { theme_name } => {
|
||||||
|
let ts = ThemeSet::load_defaults();
|
||||||
|
ts.themes
|
||||||
|
.get(&theme_name)
|
||||||
|
.ok_or_eyre(format!("theme {:?} doesn't exist", theme_name))?
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
Command::File { path } => {
|
||||||
|
let mut file = BufReader::new(
|
||||||
|
File::open(&path).with_context(|| format!("failed to open {:?}", path))?,
|
||||||
|
);
|
||||||
|
ThemeSet::load_from_reader(&mut file).with_context(|| "failed to parse theme")?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let class_style = if args.no_prefix {
|
||||||
|
ClassStyle::Spaced
|
||||||
|
} else {
|
||||||
|
ClassStyle::SpacedPrefixed {
|
||||||
|
prefix: args.prefix.leak(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let css = css_for_theme_with_class_style(&theme, class_style)
|
||||||
|
.with_context(|| "failed to generate css")?;
|
||||||
|
println!("{css}");
|
||||||
|
Ok(())
|
||||||
|
}
|
60
src/compress.rs
Normal file
60
src/compress.rs
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
// TODO: make this bearable
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
fs::{self, Metadata},
|
||||||
|
io::{self, Result},
|
||||||
|
path::Path,
|
||||||
|
process::{Child, Command},
|
||||||
|
sync::Mutex,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn compress_file(path: &Path, metadata: Metadata, handles: &Mutex<Vec<Child>>) -> Result<()> {
|
||||||
|
let compressed_file = format!("{}.gz", path.to_str().unwrap());
|
||||||
|
if match fs::metadata(compressed_file) {
|
||||||
|
Ok(existing_metadata) => metadata.modified()? > existing_metadata.modified()?,
|
||||||
|
Err(err) => match err.kind() {
|
||||||
|
io::ErrorKind::NotFound => true,
|
||||||
|
_ => return Err(err),
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
let mut handles_guard = handles.lock().unwrap();
|
||||||
|
handles_guard.push(Command::new("gzip").arg("-kf5").arg(path).spawn()?);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compress_recursively(path: &Path, handles: &Mutex<Vec<Child>>) -> Result<()> {
|
||||||
|
let metadata = fs::metadata(path)?;
|
||||||
|
|
||||||
|
if metadata.is_dir() {
|
||||||
|
for entry in fs::read_dir(path)? {
|
||||||
|
compress_recursively(&entry?.path(), handles)?
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
} else if match path.extension() {
|
||||||
|
Some(ext) => ext == "gz",
|
||||||
|
None => false,
|
||||||
|
} || metadata.is_symlink()
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
compress_file(path, metadata, handles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compress_epicly<P: AsRef<Path>>(path: P) -> Result<u64> {
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
let handles = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
compress_recursively(AsRef::<Path>::as_ref(&path), &handles)?;
|
||||||
|
|
||||||
|
let handles = handles.into_inner().unwrap();
|
||||||
|
|
||||||
|
for mut handle in handles {
|
||||||
|
assert!(handle.wait().unwrap().success());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(i)
|
||||||
|
}
|
121
src/config.rs
Normal file
121
src/config.rs
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
net::{IpAddr, Ipv4Addr},
|
||||||
|
path::PathBuf,
|
||||||
|
};
|
||||||
|
|
||||||
|
use color_eyre::eyre::{bail, Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct RenderConfig {
|
||||||
|
pub syntect_load_defaults: bool,
|
||||||
|
pub syntect_themes_dir: Option<PathBuf>,
|
||||||
|
pub syntect_theme: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "precompression")]
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct PrecompressionConfig {
|
||||||
|
pub enable: bool,
|
||||||
|
pub watch: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Config {
|
||||||
|
pub host: IpAddr,
|
||||||
|
pub port: u16,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub posts_dir: PathBuf,
|
||||||
|
pub render: RenderConfig,
|
||||||
|
#[cfg(feature = "precompression")]
|
||||||
|
pub precompression: PrecompressionConfig,
|
||||||
|
pub cache_file: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
host: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||||
|
port: 3000,
|
||||||
|
title: "silly-blog".into(),
|
||||||
|
description: "blazingly fast markdown blog software written in rust memory safe".into(),
|
||||||
|
render: Default::default(),
|
||||||
|
posts_dir: "posts".into(),
|
||||||
|
#[cfg(feature = "precompression")]
|
||||||
|
precompression: Default::default(),
|
||||||
|
cache_file: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RenderConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
syntect_load_defaults: false,
|
||||||
|
syntect_themes_dir: Some("themes".into()),
|
||||||
|
syntect_theme: Some("Catppuccin Mocha".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "precompression")]
|
||||||
|
impl Default for PrecompressionConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enable: false,
|
||||||
|
watch: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load() -> Result<Config> {
|
||||||
|
let config_file = env::var(format!("{}_CONFIG", env!("CARGO_BIN_NAME")))
|
||||||
|
.unwrap_or(String::from("config.toml"));
|
||||||
|
match tokio::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.open(&config_file)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(mut file) => {
|
||||||
|
let mut buf = String::new();
|
||||||
|
file.read_to_string(&mut buf)
|
||||||
|
.await
|
||||||
|
.with_context(|| "couldn't read configuration file")?;
|
||||||
|
toml::from_str(&buf).with_context(|| "couldn't parse configuration")
|
||||||
|
}
|
||||||
|
Err(err) => match err.kind() {
|
||||||
|
std::io::ErrorKind::NotFound => {
|
||||||
|
let config = Config::default();
|
||||||
|
info!("configuration file doesn't exist, creating");
|
||||||
|
match tokio::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(&config_file)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(mut file) => file
|
||||||
|
.write_all(
|
||||||
|
toml::to_string_pretty(&config)
|
||||||
|
.with_context(|| "couldn't serialize configuration")?
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|err| error!("couldn't write configuration: {}", err)),
|
||||||
|
Err(err) => {
|
||||||
|
error!("couldn't open file {:?} for writing: {}", &config_file, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
_ => bail!("couldn't open config file: {}", err),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
50
src/error.rs
Normal file
50
src/error.rs
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
|
use axum::{http::StatusCode, response::IntoResponse};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
// fronma is too lazy to implement std::error::Error for their own types
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[repr(transparent)]
|
||||||
|
pub struct FronmaBalls(fronma::error::Error);
|
||||||
|
|
||||||
|
impl std::error::Error for FronmaBalls {}
|
||||||
|
|
||||||
|
impl Display for FronmaBalls {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("failed to parse front matter: ")?;
|
||||||
|
match &self.0 {
|
||||||
|
fronma::error::Error::MissingBeginningLine => f.write_str("missing beginning line"),
|
||||||
|
fronma::error::Error::MissingEndingLine => f.write_str("missing ending line"),
|
||||||
|
fronma::error::Error::SerdeYaml(_) => {
|
||||||
|
unimplemented!("no yaml allowed in this household")
|
||||||
|
}
|
||||||
|
fronma::error::Error::Toml(toml_error) => write!(f, "{}", toml_error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
#[allow(clippy::enum_variant_names)]
|
||||||
|
pub enum PostError {
|
||||||
|
#[error(transparent)]
|
||||||
|
IoError(#[from] std::io::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
AskamaError(#[from] askama::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
ParseError(#[from] FronmaBalls),
|
||||||
|
#[error("post {0:?} not found")]
|
||||||
|
NotFound(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<fronma::error::Error> for PostError {
|
||||||
|
fn from(value: fronma::error::Error) -> Self {
|
||||||
|
Self::ParseError(FronmaBalls(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for PostError {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response()
|
||||||
|
}
|
||||||
|
}
|
11
src/filters.rs
Normal file
11
src/filters.rs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::{DateTime, TimeZone};
|
||||||
|
|
||||||
|
pub fn date<T: TimeZone>(date: &DateTime<T>) -> Result<String, askama::Error> {
|
||||||
|
Ok(date.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn duration(duration: &&Duration) -> Result<String, askama::Error> {
|
||||||
|
Ok(format!("{:?}", duration))
|
||||||
|
}
|
51
src/hash_arc_store.rs
Normal file
51
src/hash_arc_store.rs
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||||
|
use std::marker::PhantomData;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct HashArcStore<T, Lookup>
|
||||||
|
where
|
||||||
|
Lookup: Hash,
|
||||||
|
{
|
||||||
|
inner: Option<Arc<T>>,
|
||||||
|
hash: Option<u64>,
|
||||||
|
_phantom: PhantomData<Lookup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T, Lookup> HashArcStore<T, Lookup>
|
||||||
|
where
|
||||||
|
Lookup: Hash,
|
||||||
|
{
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: None,
|
||||||
|
hash: None,
|
||||||
|
_phantom: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*pub fn get(&self, key: &Lookup) -> Option<Arc<T>> {
|
||||||
|
self.hash.and_then(|hash| {
|
||||||
|
let mut h = DefaultHasher::new();
|
||||||
|
key.hash(&mut h);
|
||||||
|
if hash == h.finish() {
|
||||||
|
self.inner.clone()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}*/
|
||||||
|
|
||||||
|
pub fn get_or_init(&mut self, key: &Lookup, init: impl Fn(&Lookup) -> Arc<T>) -> Arc<T> {
|
||||||
|
let mut h = DefaultHasher::new();
|
||||||
|
key.hash(&mut h);
|
||||||
|
let hash = h.finish();
|
||||||
|
if !self.hash.is_some_and(|inner_hash| inner_hash == hash) {
|
||||||
|
let mut h = DefaultHasher::new();
|
||||||
|
key.hash(&mut h);
|
||||||
|
self.inner = Some(init(key));
|
||||||
|
self.hash = Some(h.finish());
|
||||||
|
}
|
||||||
|
// safety: please.
|
||||||
|
unsafe { self.inner.as_ref().unwrap_unchecked().clone() }
|
||||||
|
}
|
||||||
|
}
|
352
src/main.rs
Normal file
352
src/main.rs
Normal file
|
@ -0,0 +1,352 @@
|
||||||
|
#![feature(let_chains, stmt_expr_attributes, proc_macro_hygiene)]
|
||||||
|
|
||||||
|
mod append_path;
|
||||||
|
mod compress;
|
||||||
|
mod config;
|
||||||
|
mod error;
|
||||||
|
mod filters;
|
||||||
|
mod hash_arc_store;
|
||||||
|
mod markdown_render;
|
||||||
|
mod post;
|
||||||
|
mod watcher;
|
||||||
|
|
||||||
|
use std::future::IntoFuture;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::process::exit;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use askama_axum::Template;
|
||||||
|
use axum::extract::{MatchedPath, Path, State};
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
use axum::response::{IntoResponse, Redirect, Response};
|
||||||
|
use axum::routing::{get, Router};
|
||||||
|
use axum::Json;
|
||||||
|
use color_eyre::eyre::{self, Context};
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::signal;
|
||||||
|
use tokio::task::JoinSet;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
use tracing::level_filters::LevelFilter;
|
||||||
|
use tracing::{error, info, info_span, warn, Span};
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||||
|
|
||||||
|
use crate::compress::compress_epicly;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::error::PostError;
|
||||||
|
use crate::post::{PostManager, PostMetadata, RenderStats};
|
||||||
|
use crate::watcher::watch;
|
||||||
|
|
||||||
|
type ArcState = Arc<AppState>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
pub config: Config,
|
||||||
|
pub posts: PostManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "index.html")]
|
||||||
|
struct IndexTemplate {
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
posts: Vec<PostMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "view_post.html")]
|
||||||
|
struct ViewPostTemplate {
|
||||||
|
meta: PostMetadata,
|
||||||
|
rendered: String,
|
||||||
|
rendered_in: RenderStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppResult<T> = Result<T, AppError>;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
enum AppError {
|
||||||
|
#[error("failed to fetch post: {0}")]
|
||||||
|
PostError(#[from] PostError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "error.html")]
|
||||||
|
struct ErrorTemplate {
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let status_code = match &self {
|
||||||
|
AppError::PostError(err) => match err {
|
||||||
|
PostError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||||
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
},
|
||||||
|
//_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
};
|
||||||
|
(
|
||||||
|
status_code,
|
||||||
|
ErrorTemplate {
|
||||||
|
error: self.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index(State(state): State<ArcState>) -> AppResult<IndexTemplate> {
|
||||||
|
Ok(IndexTemplate {
|
||||||
|
title: state.config.title.clone(),
|
||||||
|
description: state.config.description.clone(),
|
||||||
|
posts: state.posts.list_posts().await?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post(State(state): State<ArcState>, Path(name): Path<String>) -> AppResult<Response> {
|
||||||
|
let post = state.posts.get_post(&name).await?;
|
||||||
|
|
||||||
|
let post = ViewPostTemplate {
|
||||||
|
meta: post.0,
|
||||||
|
rendered: post.1,
|
||||||
|
rendered_in: post.2,
|
||||||
|
}
|
||||||
|
.into_response();
|
||||||
|
|
||||||
|
Ok(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn all_posts(State(state): State<ArcState>) -> AppResult<Json<Vec<PostMetadata>>> {
|
||||||
|
let posts = state.posts.list_posts().await?;
|
||||||
|
Ok(Json(posts))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> eyre::Result<()> {
|
||||||
|
#[cfg(feature = "tokio-console")]
|
||||||
|
console_subscriber::init();
|
||||||
|
color_eyre::install()?;
|
||||||
|
#[cfg(not(feature = "tokio-console"))]
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
EnvFilter::builder()
|
||||||
|
.with_default_directive(LevelFilter::INFO.into())
|
||||||
|
.from_env_lossy(),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = config::load()
|
||||||
|
.await
|
||||||
|
.with_context(|| "couldn't load configuration")?;
|
||||||
|
|
||||||
|
let mut tasks = JoinSet::new();
|
||||||
|
let mut cancellation_tokens = Vec::new();
|
||||||
|
|
||||||
|
#[cfg(feature = "precompression")]
|
||||||
|
if config.precompression.enable {
|
||||||
|
let span = info_span!("compression");
|
||||||
|
info!(parent: span.clone(), "compressing static");
|
||||||
|
|
||||||
|
let compressed = tokio::task::spawn_blocking(|| compress_epicly("static"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.with_context(|| "couldn't compress static")?;
|
||||||
|
|
||||||
|
let _handle = span.enter();
|
||||||
|
|
||||||
|
if compressed > 0 {
|
||||||
|
info!(compressed_files=%compressed, "compressed {compressed} files");
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.precompression.watch {
|
||||||
|
info!("starting compressor task");
|
||||||
|
let span = span.clone();
|
||||||
|
let token = CancellationToken::new();
|
||||||
|
let passed_token = token.clone();
|
||||||
|
tasks.spawn(async move {
|
||||||
|
watch(span, passed_token, Default::default())
|
||||||
|
.await
|
||||||
|
.with_context(|| "failed to watch static")
|
||||||
|
.unwrap()
|
||||||
|
});
|
||||||
|
cancellation_tokens.push(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let posts = if let Some(path) = config.cache_file.as_ref()
|
||||||
|
&& tokio::fs::try_exists(&path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to check if {} exists", path.display()))?
|
||||||
|
{
|
||||||
|
info!("loading cache from file");
|
||||||
|
let load_cache = async {
|
||||||
|
let mut cache_file = tokio::fs::File::open(&path)
|
||||||
|
.await
|
||||||
|
.with_context(|| "failed to open cache file")?;
|
||||||
|
let mut serialized = Vec::with_capacity(4096);
|
||||||
|
cache_file
|
||||||
|
.read_to_end(&mut serialized)
|
||||||
|
.await
|
||||||
|
.with_context(|| "failed to read cache file")?;
|
||||||
|
let cache = bitcode::deserialize(serialized.as_slice())
|
||||||
|
.with_context(|| "failed to parse cache")?;
|
||||||
|
Ok::<PostManager, color_eyre::Report>(PostManager::new_with_cache(
|
||||||
|
config.posts_dir.clone(),
|
||||||
|
config.render.clone(),
|
||||||
|
cache,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match load_cache {
|
||||||
|
Ok(posts) => posts,
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to load cache: {}", err);
|
||||||
|
info!("using empty cache");
|
||||||
|
PostManager::new(config.posts_dir.clone(), config.render.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PostManager::new(config.posts_dir.clone(), config.render.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = Arc::new(AppState { config, posts });
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(index))
|
||||||
|
.route(
|
||||||
|
"/post/:name",
|
||||||
|
get(
|
||||||
|
|Path(name): Path<String>| async move { Redirect::to(&format!("/posts/{}", name)) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.route("/posts/:name", get(post))
|
||||||
|
.route("/posts", get(all_posts))
|
||||||
|
.nest_service("/static", ServeDir::new("static").precompressed_gzip())
|
||||||
|
.nest_service("/media", ServeDir::new("media"))
|
||||||
|
.layer(
|
||||||
|
TraceLayer::new_for_http()
|
||||||
|
.make_span_with(|request: &Request<_>| {
|
||||||
|
let matched_path = request
|
||||||
|
.extensions()
|
||||||
|
.get::<MatchedPath>()
|
||||||
|
.map(MatchedPath::as_str);
|
||||||
|
|
||||||
|
info_span!(
|
||||||
|
"request",
|
||||||
|
method = ?request.method(),
|
||||||
|
path = ?request.uri().path(),
|
||||||
|
matched_path,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.on_response(|response: &Response<_>, duration: Duration, span: &Span| {
|
||||||
|
let _ = span.enter();
|
||||||
|
let status = response.status();
|
||||||
|
info!(?status, ?duration, "response");
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.with_state(state.clone());
|
||||||
|
|
||||||
|
let listener = TcpListener::bind((state.config.host, state.config.port))
|
||||||
|
.await
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"couldn't listen on {}",
|
||||||
|
SocketAddr::new(state.config.host, state.config.port)
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let local_addr = listener
|
||||||
|
.local_addr()
|
||||||
|
.with_context(|| "couldn't get socket address")?;
|
||||||
|
info!("listening on http://{}", local_addr);
|
||||||
|
|
||||||
|
let sigint = signal::ctrl_c();
|
||||||
|
#[cfg(unix)]
|
||||||
|
let mut sigterm_handler =
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
let sigterm = sigterm_handler.recv();
|
||||||
|
#[cfg(not(unix))] // TODO: kill all windows server users
|
||||||
|
let sigterm = std::future::pending::<()>();
|
||||||
|
|
||||||
|
let axum_token = CancellationToken::new();
|
||||||
|
cancellation_tokens.push(axum_token.clone());
|
||||||
|
|
||||||
|
let mut server = axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.with_graceful_shutdown(async move { axum_token.cancelled().await })
|
||||||
|
.into_future();
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
result = &mut server => {
|
||||||
|
result.with_context(|| "failed to serve app")?;
|
||||||
|
},
|
||||||
|
_ = sigint => {
|
||||||
|
info!("received SIGINT, exiting gracefully");
|
||||||
|
},
|
||||||
|
_ = sigterm => {
|
||||||
|
info!("received SIGTERM, exiting gracefully");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let cleanup = async move {
|
||||||
|
// stop tasks
|
||||||
|
for token in cancellation_tokens {
|
||||||
|
token.cancel();
|
||||||
|
}
|
||||||
|
server.await.with_context(|| "failed to serve app")?;
|
||||||
|
while let Some(task) = tasks.join_next().await {
|
||||||
|
task.with_context(|| "failed to join task")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// write cache to file
|
||||||
|
let AppState { config, posts } = Arc::<AppState>::try_unwrap(state).unwrap_or_else(|state| {
|
||||||
|
warn!("couldn't unwrap Arc over AppState, more than one strong reference exists for Arc. cloning instead");
|
||||||
|
AppState::clone(state.as_ref())
|
||||||
|
});
|
||||||
|
if let Some(path) = config.cache_file.as_ref() {
|
||||||
|
let cache = posts.into_cache();
|
||||||
|
let mut serialized =
|
||||||
|
bitcode::serialize(&cache).with_context(|| "failed to serialize cache")?;
|
||||||
|
let mut cache_file = tokio::fs::File::create(path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to open cache at {}", path.display()))?;
|
||||||
|
cache_file
|
||||||
|
.write_all(serialized.as_mut_slice())
|
||||||
|
.await
|
||||||
|
.with_context(|| "failed to write cache to file")?;
|
||||||
|
info!("wrote cache to {}", path.display());
|
||||||
|
}
|
||||||
|
Ok::<(), color_eyre::Report>(())
|
||||||
|
};
|
||||||
|
|
||||||
|
let sigint = signal::ctrl_c();
|
||||||
|
#[cfg(unix)]
|
||||||
|
let mut sigterm_handler =
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
let sigterm = sigterm_handler.recv();
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let sigterm = std::future::pending::<()>();
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
result = cleanup => {
|
||||||
|
result.with_context(|| "cleanup failed, oh well")?;
|
||||||
|
},
|
||||||
|
_ = sigint => {
|
||||||
|
warn!("received second signal, exiting");
|
||||||
|
exit(1);
|
||||||
|
},
|
||||||
|
_ = sigterm => {
|
||||||
|
warn!("received second signal, exiting");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
63
src/markdown_render.rs
Normal file
63
src/markdown_render.rs
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
use std::sync::{Arc, OnceLock, RwLock};
|
||||||
|
|
||||||
|
use comrak::markdown_to_html_with_plugins;
|
||||||
|
use comrak::plugins::syntect::{SyntectAdapter, SyntectAdapterBuilder};
|
||||||
|
use comrak::ComrakOptions;
|
||||||
|
use comrak::Plugins;
|
||||||
|
use comrak::RenderPlugins;
|
||||||
|
use syntect::highlighting::ThemeSet;
|
||||||
|
|
||||||
|
use crate::config::RenderConfig;
|
||||||
|
use crate::hash_arc_store::HashArcStore;
|
||||||
|
|
||||||
|
fn syntect_adapter(config: &RenderConfig) -> Arc<SyntectAdapter> {
|
||||||
|
static STATE: OnceLock<RwLock<HashArcStore<SyntectAdapter, RenderConfig>>> = OnceLock::new();
|
||||||
|
let lock = STATE.get_or_init(|| RwLock::new(HashArcStore::new()));
|
||||||
|
let mut guard = lock.write().unwrap();
|
||||||
|
guard.get_or_init(config, build_syntect)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_syntect(config: &RenderConfig) -> Arc<SyntectAdapter> {
|
||||||
|
let mut theme_set = if config.syntect_load_defaults {
|
||||||
|
ThemeSet::load_defaults()
|
||||||
|
} else {
|
||||||
|
ThemeSet::new()
|
||||||
|
};
|
||||||
|
if let Some(path) = config.syntect_themes_dir.as_ref() {
|
||||||
|
theme_set.add_from_folder(path).unwrap();
|
||||||
|
}
|
||||||
|
let mut builder = SyntectAdapterBuilder::new().theme_set(theme_set);
|
||||||
|
if let Some(theme) = config.syntect_theme.as_ref() {
|
||||||
|
builder = builder.theme(theme);
|
||||||
|
}
|
||||||
|
Arc::new(builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_with_config(markdown: &str, config: &RenderConfig, front_matter: bool) -> String {
|
||||||
|
let mut options = ComrakOptions::default();
|
||||||
|
options.extension.table = true;
|
||||||
|
options.extension.autolink = true;
|
||||||
|
options.extension.tasklist = true;
|
||||||
|
options.extension.superscript = true;
|
||||||
|
options.extension.multiline_block_quotes = true;
|
||||||
|
options.extension.header_ids = Some(String::new());
|
||||||
|
if front_matter {
|
||||||
|
options.extension.front_matter_delimiter = Some(String::from("---"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut render_plugins = RenderPlugins::default();
|
||||||
|
let syntect = syntect_adapter(config);
|
||||||
|
render_plugins.codefence_syntax_highlighter = Some(syntect.as_ref());
|
||||||
|
|
||||||
|
let plugins = comrak::PluginsBuilder::default()
|
||||||
|
.render(render_plugins)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
render(markdown, &options, &plugins)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(markdown: &str, options: &ComrakOptions, plugins: &Plugins) -> String {
|
||||||
|
// TODO: post-processing
|
||||||
|
markdown_to_html_with_plugins(markdown, options, plugins)
|
||||||
|
}
|
160
src/post/cache.rs
Normal file
160
src/post/cache.rs
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||||
|
|
||||||
|
use scc::HashMap;
|
||||||
|
use serde::de::{SeqAccess, Visitor};
|
||||||
|
use serde::{ser::SerializeSeq, Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
|
||||||
|
use crate::config::RenderConfig;
|
||||||
|
use crate::post::PostMetadata;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct CacheValue {
|
||||||
|
pub metadata: PostMetadata,
|
||||||
|
pub rendered: String,
|
||||||
|
pub mtime: u64,
|
||||||
|
config_hash: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
pub struct Cache(HashMap<String, CacheValue>);
|
||||||
|
|
||||||
|
impl Cache {
|
||||||
|
pub fn from_map(cache: HashMap<String, CacheValue>) -> Self {
|
||||||
|
Self(cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn lookup(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
mtime: u64,
|
||||||
|
config: &RenderConfig,
|
||||||
|
) -> Option<CacheValue> {
|
||||||
|
match self.0.get_async(name).await {
|
||||||
|
Some(entry) => {
|
||||||
|
let cached = entry.get();
|
||||||
|
if mtime <= cached.mtime && {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
config.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
} == cached.config_hash
|
||||||
|
{
|
||||||
|
Some(cached.clone())
|
||||||
|
} else {
|
||||||
|
let _ = entry.remove();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn lookup_metadata(&self, name: &str, mtime: u64) -> Option<PostMetadata> {
|
||||||
|
match self.0.get_async(name).await {
|
||||||
|
Some(entry) => {
|
||||||
|
let cached = entry.get();
|
||||||
|
if mtime <= cached.mtime {
|
||||||
|
Some(cached.metadata.clone())
|
||||||
|
} else {
|
||||||
|
let _ = entry.remove();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(
|
||||||
|
&self,
|
||||||
|
name: String,
|
||||||
|
metadata: PostMetadata,
|
||||||
|
mtime: u64,
|
||||||
|
rendered: String,
|
||||||
|
config: &RenderConfig,
|
||||||
|
) -> Result<(), (String, (PostMetadata, String))> {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
config.hash(&mut hasher);
|
||||||
|
let hash = hasher.finish();
|
||||||
|
|
||||||
|
let value = CacheValue {
|
||||||
|
metadata,
|
||||||
|
rendered,
|
||||||
|
mtime,
|
||||||
|
config_hash: hash,
|
||||||
|
};
|
||||||
|
|
||||||
|
if self
|
||||||
|
.0
|
||||||
|
.update_async(&name, |_, _| value.clone())
|
||||||
|
.await
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
self.0
|
||||||
|
.insert_async(name, value)
|
||||||
|
.await
|
||||||
|
.map_err(|x| (x.0, (x.1.metadata, x.1.rendered)))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove(&self, name: &str) -> Option<(String, CacheValue)> {
|
||||||
|
self.0.remove_async(name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn into_inner(self) -> HashMap<String, CacheValue> {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for Cache {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
let cache = self.clone().into_inner();
|
||||||
|
let mut seq = serializer.serialize_seq(Some(cache.len()))?;
|
||||||
|
let mut entry = cache.first_entry();
|
||||||
|
while let Some(occupied) = entry {
|
||||||
|
let key = occupied.key().clone();
|
||||||
|
let value = occupied.get().clone();
|
||||||
|
seq.serialize_element(&(key, value))?;
|
||||||
|
entry = occupied.next();
|
||||||
|
}
|
||||||
|
seq.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Cache {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
struct CoolVisitor;
|
||||||
|
impl<'de> Visitor<'de> for CoolVisitor {
|
||||||
|
type Value = Cache;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
write!(formatter, "meow")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
|
||||||
|
where
|
||||||
|
A: SeqAccess<'de>,
|
||||||
|
{
|
||||||
|
let cache = match seq.size_hint() {
|
||||||
|
Some(size) => HashMap::with_capacity(size),
|
||||||
|
None => HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Some((key, value)) = seq.next_element::<(String, CacheValue)>()? {
|
||||||
|
cache.insert(key, value).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Cache::from_map(cache))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_seq(CoolVisitor)
|
||||||
|
}
|
||||||
|
}
|
229
src/post/mod.rs
Normal file
229
src/post/mod.rs
Normal file
|
@ -0,0 +1,229 @@
|
||||||
|
mod cache;
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, Instant, SystemTime};
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use fronma::engines::Toml;
|
||||||
|
use fronma::parser::{parse_with_engine, ParsedData};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::fs;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::config::RenderConfig;
|
||||||
|
use crate::markdown_render;
|
||||||
|
use crate::post::cache::Cache;
|
||||||
|
use crate::PostError;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FrontMatter {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub author: String,
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub created_at: Option<DateTime<Utc>>,
|
||||||
|
pub modified_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrontMatter {
|
||||||
|
pub fn into_full(
|
||||||
|
self,
|
||||||
|
name: String,
|
||||||
|
created: Option<SystemTime>,
|
||||||
|
modified: Option<SystemTime>,
|
||||||
|
) -> PostMetadata {
|
||||||
|
PostMetadata {
|
||||||
|
name,
|
||||||
|
title: self.title,
|
||||||
|
description: self.description,
|
||||||
|
author: self.author,
|
||||||
|
icon: self.icon,
|
||||||
|
created_at: self.created_at.or_else(|| created.map(|t| t.into())),
|
||||||
|
modified_at: self.modified_at.or_else(|| modified.map(|t| t.into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct PostMetadata {
|
||||||
|
pub name: String,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub author: String,
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub created_at: Option<DateTime<Utc>>,
|
||||||
|
pub modified_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
use crate::filters;
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "post.html")]
|
||||||
|
struct Post<'a> {
|
||||||
|
pub meta: &'a PostMetadata,
|
||||||
|
pub rendered_markdown: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// format: TOTAL OP1 OP2
|
||||||
|
#[allow(unused)]
|
||||||
|
pub enum RenderStats {
|
||||||
|
Cached(Duration),
|
||||||
|
ParsedAndRendered(Duration, Duration, Duration),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PostManager {
|
||||||
|
dir: PathBuf,
|
||||||
|
cache: Cache,
|
||||||
|
config: RenderConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostManager {
|
||||||
|
pub fn new(dir: PathBuf, config: RenderConfig) -> PostManager {
|
||||||
|
PostManager {
|
||||||
|
dir,
|
||||||
|
cache: Default::default(),
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_with_cache(dir: PathBuf, config: RenderConfig, cache: Cache) -> PostManager {
|
||||||
|
PostManager { dir, cache, config }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_and_render(
|
||||||
|
&self,
|
||||||
|
name: String,
|
||||||
|
path: impl AsRef<Path>,
|
||||||
|
) -> Result<(PostMetadata, String, (Duration, Duration)), PostError> {
|
||||||
|
let parsing_start = Instant::now();
|
||||||
|
let mut file = match tokio::fs::OpenOptions::new().read(true).open(&path).await {
|
||||||
|
Ok(val) => val,
|
||||||
|
Err(err) => match err.kind() {
|
||||||
|
io::ErrorKind::NotFound => return Err(PostError::NotFound(name)),
|
||||||
|
_ => return Err(PostError::IoError(err)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let stat = file.metadata().await?;
|
||||||
|
let modified = stat.modified()?;
|
||||||
|
let created = stat.created().ok();
|
||||||
|
|
||||||
|
let mut content = String::with_capacity(stat.len() as usize);
|
||||||
|
file.read_to_string(&mut content).await?;
|
||||||
|
|
||||||
|
let ParsedData { headers, body } = parse_with_engine::<FrontMatter, Toml>(&content)?;
|
||||||
|
let metadata = headers.into_full(name.to_owned(), created, Some(modified));
|
||||||
|
let parsing = parsing_start.elapsed();
|
||||||
|
|
||||||
|
let before_render = Instant::now();
|
||||||
|
let rendered_markdown = markdown_render::render_with_config(body, &self.config, false);
|
||||||
|
let post = Post {
|
||||||
|
meta: &metadata,
|
||||||
|
rendered_markdown,
|
||||||
|
}
|
||||||
|
.render()?;
|
||||||
|
let rendering = before_render.elapsed();
|
||||||
|
|
||||||
|
self.cache
|
||||||
|
.insert(
|
||||||
|
name.to_string(),
|
||||||
|
metadata.clone(),
|
||||||
|
modified
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs(),
|
||||||
|
post.clone(),
|
||||||
|
&self.config,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|err| warn!("failed to insert {:?} into cache", err.0));
|
||||||
|
|
||||||
|
Ok((metadata, post, (parsing, rendering)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_posts_recursive(
|
||||||
|
&self,
|
||||||
|
dir: impl AsRef<Path>,
|
||||||
|
) -> Result<Vec<PostMetadata>, PostError> {
|
||||||
|
let mut posts = Vec::new();
|
||||||
|
|
||||||
|
let mut read_dir = fs::read_dir(dir).await?;
|
||||||
|
while let Some(entry) = read_dir.next_entry().await? {
|
||||||
|
let path = entry.path();
|
||||||
|
let stat = fs::metadata(&path).await?;
|
||||||
|
|
||||||
|
if stat.is_file() && path.extension().is_some_and(|ext| ext == "md") {
|
||||||
|
let mtime = stat
|
||||||
|
.modified()?
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
let name = path
|
||||||
|
.clone()
|
||||||
|
.file_stem()
|
||||||
|
.unwrap()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if let Some(hit) = self.cache.lookup_metadata(&name, mtime).await {
|
||||||
|
posts.push(hit)
|
||||||
|
} else if let Ok((metadata, ..)) = self.parse_and_render(name, path).await {
|
||||||
|
posts.push(metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(posts)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
|
pub async fn list_posts(&self) -> Result<Vec<PostMetadata>, PostError> {
|
||||||
|
self.list_posts_recursive(&self.dir).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// third entry in the tuple is whether it got rendered and if so, how long did it take
|
||||||
|
pub async fn get_post(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<(PostMetadata, String, RenderStats), PostError> {
|
||||||
|
let start = Instant::now();
|
||||||
|
let path = self.dir.join(name.to_owned() + ".md");
|
||||||
|
|
||||||
|
let stat = match tokio::fs::metadata(&path).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => match err.kind() {
|
||||||
|
io::ErrorKind::NotFound => {
|
||||||
|
self.cache.remove(name).await;
|
||||||
|
return Err(PostError::NotFound(name.to_string()));
|
||||||
|
}
|
||||||
|
_ => return Err(PostError::IoError(err)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mtime = stat
|
||||||
|
.modified()?
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
if let Some(hit) = self.cache.lookup(name, mtime, &self.config).await {
|
||||||
|
Ok((
|
||||||
|
hit.metadata,
|
||||||
|
hit.rendered,
|
||||||
|
RenderStats::Cached(start.elapsed()),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
let (metadata, rendered, stats) = self.parse_and_render(name.to_string(), path).await?;
|
||||||
|
Ok((
|
||||||
|
metadata,
|
||||||
|
rendered,
|
||||||
|
RenderStats::ParsedAndRendered(start.elapsed(), stats.0, stats.1),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_cache(self) -> Cache {
|
||||||
|
self.cache
|
||||||
|
}
|
||||||
|
}
|
76
src/watcher.rs
Normal file
76
src/watcher.rs
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
use notify::{event::RemoveKind, Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing::{info, Span};
|
||||||
|
|
||||||
|
use crate::append_path::Append;
|
||||||
|
use crate::compress::compress_epicly;
|
||||||
|
|
||||||
|
pub async fn watch(
|
||||||
|
span: Span,
|
||||||
|
token: CancellationToken,
|
||||||
|
config: Config,
|
||||||
|
) -> Result<(), notify::Error> {
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::channel(12);
|
||||||
|
let mut watcher = RecommendedWatcher::new(
|
||||||
|
move |res| {
|
||||||
|
tx.blocking_send(res)
|
||||||
|
.expect("failed to send message over channel")
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
watcher.watch(std::path::Path::new("static"), RecursiveMode::Recursive)?;
|
||||||
|
|
||||||
|
while let Some(received) = tokio::select! {
|
||||||
|
received = rx.recv() => received,
|
||||||
|
_ = token.cancelled() => return Ok(())
|
||||||
|
} {
|
||||||
|
match received {
|
||||||
|
Ok(event) => {
|
||||||
|
if event.kind.is_create() || event.kind.is_modify() {
|
||||||
|
let cloned_span = span.clone();
|
||||||
|
let compressed =
|
||||||
|
tokio::task::spawn_blocking(move || -> std::io::Result<u64> {
|
||||||
|
let _handle = cloned_span.enter();
|
||||||
|
let mut i = 0;
|
||||||
|
for path in event.paths {
|
||||||
|
if path.extension().is_some_and(|ext| ext == "gz") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
info!("{} changed, compressing", path.display());
|
||||||
|
i += compress_epicly(&path)?;
|
||||||
|
}
|
||||||
|
Ok(i)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()?;
|
||||||
|
|
||||||
|
if compressed > 0 {
|
||||||
|
let _handle = span.enter();
|
||||||
|
info!(compressed_files=%compressed, "compressed {compressed} files");
|
||||||
|
}
|
||||||
|
} else if let EventKind::Remove(remove_event) = event.kind // UNSTABLE
|
||||||
|
&& matches!(remove_event, RemoveKind::File)
|
||||||
|
{
|
||||||
|
for path in event.paths {
|
||||||
|
if path.extension().is_some_and(|ext| ext == "gz") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let gz_path = path.clone().append(".gz");
|
||||||
|
if tokio::fs::try_exists(&gz_path).await? {
|
||||||
|
info!(
|
||||||
|
"{} removed, also removing {}",
|
||||||
|
path.display(),
|
||||||
|
gz_path.display()
|
||||||
|
);
|
||||||
|
tokio::fs::remove_file(&gz_path).await?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
29
static/post.css
Normal file
29
static/post.css
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
.anchor {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.anchor::before {
|
||||||
|
content: "§";
|
||||||
|
}
|
||||||
|
.anchor::after {
|
||||||
|
content: " ";
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: larger;
|
||||||
|
padding: 0.15em 0.4em;
|
||||||
|
|
||||||
|
background-color: var(--surface0);
|
||||||
|
color: var(--subtext1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* code blocks */
|
||||||
|
pre > code {
|
||||||
|
border: 2px solid var(--surface0);
|
||||||
|
padding: 1.25em 1.5em;
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
|
||||||
|
background-color: var(--base);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
134
static/style.css
Normal file
134
static/style.css
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
/* colors */
|
||||||
|
:root {
|
||||||
|
--base: #1e1e2e;
|
||||||
|
--text: #cdd6f4;
|
||||||
|
--crust: #11111b;
|
||||||
|
--surface0: #313244;
|
||||||
|
--subtext0: #a6adc8;
|
||||||
|
--subtext1: #bac2de;
|
||||||
|
--pink: #f5c2e7;
|
||||||
|
--rosewater: #f5e0dc;
|
||||||
|
--blue: #89b4fa;
|
||||||
|
--mauve: #cba6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--base: #eff1f5;
|
||||||
|
--text: #4c4f69;
|
||||||
|
--crust: #dce0e8;
|
||||||
|
--surface0: #ccd0da;
|
||||||
|
--subtext0: #6c6f85;
|
||||||
|
--subtext1: #5c5f77;
|
||||||
|
--pink: #ea76cb;
|
||||||
|
--rosewater: #dc8a78;
|
||||||
|
--blue: #1e66f5;
|
||||||
|
--mauve: #8839ef;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* please have one at least one good monospace font */
|
||||||
|
font-family: "Hack Nerd Font", "Hack", "JetBrains Mono",
|
||||||
|
"JetBrainsMono Nerd Font", "Ubuntu Mono", monospace, sans-serif;
|
||||||
|
|
||||||
|
background-color: var(--base);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--pink);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--rosewater);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:active {
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: var(--mauve);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 0.15em 0.4em;
|
||||||
|
|
||||||
|
background-color: var(--surface0);
|
||||||
|
color: var(--subtext0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipped {
|
||||||
|
border-bottom: 1px dotted var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
color: var(--subtext1);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
text-align: end;
|
||||||
|
font-size: small;
|
||||||
|
opacity: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-author {
|
||||||
|
font-size: smaller;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BEGIN cool effect everyone liked */
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
background-color: var(--base);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 1em;
|
||||||
|
|
||||||
|
background-color: var(--base);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > main > h1:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 956px) {
|
||||||
|
:root {
|
||||||
|
--target-ratio: 0.7; /* 669px - 1344x */
|
||||||
|
--width: min(100% * var(--target-ratio), 1920px * var(--target-ratio));
|
||||||
|
--padding: 4em;
|
||||||
|
--padded-width: calc(var(--width) - var(--padding) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding: 4em 0;
|
||||||
|
min-height: calc(100vh - 8em);
|
||||||
|
|
||||||
|
background: var(--crust);
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--base) 0%,
|
||||||
|
var(--crust) calc((100% - var(--width)) / 2),
|
||||||
|
var(--crust) calc(50% + var(--width) / 2),
|
||||||
|
var(--base) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > * {
|
||||||
|
margin: auto;
|
||||||
|
padding: var(--padding);
|
||||||
|
width: var(--padded-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > footer {
|
||||||
|
padding: initial;
|
||||||
|
width: var(--width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* END cool effect everyone liked */
|
16
templates/error.html
Normal file
16
templates/error.html
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>error</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>error</h1>
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
<a href="/">go back to home</a>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
36
templates/index.html
Normal file
36
templates/index.html
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
<!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 }}" />
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<p>{{ description }}</p>
|
||||||
|
<h2>posts</h2>
|
||||||
|
<!-- prettier-ignore -->
|
||||||
|
<div>
|
||||||
|
{% for post in posts %}
|
||||||
|
<p>
|
||||||
|
<a href="/posts/{{ post.name }}"><b>{{ post.title }}</b></a>
|
||||||
|
<span class="post-author">- by {{ post.author }}</span>
|
||||||
|
<br />
|
||||||
|
{{ post.description }}<br />
|
||||||
|
{% match post.created_at %} {% when Some(created_at) %}
|
||||||
|
written: {{ created_at|date }}<br />
|
||||||
|
{% when None %} {% endmatch %}
|
||||||
|
{% match post.modified_at %} {% when Some(modified_at) %}
|
||||||
|
last modified: {{ modified_at|date }}
|
||||||
|
{% when None %} {% endmatch %}
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
20
templates/post.html
Normal file
20
templates/post.html
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<h1 class="post-title">
|
||||||
|
{{ meta.title }}
|
||||||
|
<span class="post-author">- by {{ meta.author }}</span>
|
||||||
|
</h1>
|
||||||
|
<p class="post-desc">{{ meta.description }}</p>
|
||||||
|
<p>
|
||||||
|
<!-- prettier-ignore -->
|
||||||
|
<div>
|
||||||
|
{% match meta.created_at %} {% when Some(created_at) %}
|
||||||
|
written: {{ created_at|date }}<br />
|
||||||
|
{% when None %} {% endmatch %}
|
||||||
|
{% match meta.modified_at %} {% when Some(modified_at) %}
|
||||||
|
last modified: {{ modified_at|date }}
|
||||||
|
{% when None %} {% endmatch %}
|
||||||
|
</div>
|
||||||
|
<a href="/posts/{{ meta.name }}">link</a><br />
|
||||||
|
<a href="/">back to home</a>
|
||||||
|
</p>
|
||||||
|
<hr />
|
||||||
|
{{ rendered_markdown|escape("none") }}
|
0
templates/post_list.html
Normal file
0
templates/post_list.html
Normal file
0
templates/post_preview.html
Normal file
0
templates/post_preview.html
Normal file
35
templates/view_post.html
Normal file
35
templates/view_post.html
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0"
|
||||||
|
/>
|
||||||
|
<meta name="description" content="{{ meta.title }}" />
|
||||||
|
<meta property="og:title" content="{{ meta.title }}" />
|
||||||
|
<meta property="og:description" content="{{ meta.description }}" />
|
||||||
|
{% match meta.icon %} {% when Some with (url) %}
|
||||||
|
<meta property="og:image" content="{{ url }}" />
|
||||||
|
<link rel="shortcut icon" href="{{ url }}" />
|
||||||
|
{% when None %} {% endmatch %}
|
||||||
|
<title>{{ meta.title }}</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
<link rel="stylesheet" href="/static/post.css" />
|
||||||
|
</head>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>{{ 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 %}
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
2021
themes/Catppuccin Mocha.tmTheme
Normal file
2021
themes/Catppuccin Mocha.tmTheme
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue