Restructure project
This commit is contained in:
1 parent
0c158652c9
commit
14726ca7d4
28 files changed
+381
-357
No files matched your search
@@ -0,0 +1,35 @@
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::mpsc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use miette::{IntoDiagnostic, WrapErr};
|
||||
use notify::{
|
||||
DebouncedEvent,
|
||||
RecommendedWatcher,
|
||||
RecursiveMode,
|
||||
Watcher,
|
||||
watcher,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
pub fn start_watching(
|
||||
tx: mpsc::Sender<DebouncedEvent>,
|
||||
watch_path: &Path
|
||||
) -> miette::Result<RecommendedWatcher>
|
||||
{
|
||||
let mut watcher = watcher(tx, Duration::from_secs(2))
|
||||
.into_diagnostic()
|
||||
.wrap_err("Failed to create filesystem watcher")?;
|
||||
|
||||
// Watch the path in non-recursive mode, so events are not generated for nodes in
|
||||
// sub-directories.
|
||||
watcher.watch(watch_path, RecursiveMode::NonRecursive)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("Failed to watch directory {}", watch_path.to_string_lossy()))?;
|
||||
|
||||
info!(path = %watch_path.to_string_lossy(), "Watching directory");
|
||||
|
||||
Ok(watcher)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
mod fs_watcher;
|
||||
mod render;
|
||||
mod service;
|
||||
mod template;
|
||||
|
||||
use std::{env, fs, path::PathBuf, sync::Arc, thread};
|
||||
|
||||
use axum::Server;
|
||||
use miette::{IntoDiagnostic, Context};
|
||||
use tracing::info;
|
||||
|
||||
use blog::{
|
||||
codeblock::CodeBlockRenderer,
|
||||
db::ConcurrentPostsStore,
|
||||
uuid,
|
||||
};
|
||||
|
||||
use render::Renderer;
|
||||
|
||||
#[derive(knuffel::Decode, Clone, Debug)]
|
||||
pub struct Config {
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
bind: String,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
concurrency_limit: usize,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
static_dir: PathBuf,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
favicon_dir: PathBuf,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
robots_path: PathBuf,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
posts_dir: PathBuf,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
post_media_dir: PathBuf,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
namespace_uuid: uuid::Uuid,
|
||||
#[knuffel(child)]
|
||||
self_ref: SelfRefConfig,
|
||||
#[knuffel(child)]
|
||||
rss: RssConfig,
|
||||
#[knuffel(child)]
|
||||
atom: AtomConfig,
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode, Clone, Debug)]
|
||||
pub struct SelfRefConfig {
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
protocol: String,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
domain: String,
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode, Clone, Debug)]
|
||||
pub struct RssConfig {
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
num_posts: usize,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
title: String,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
ttl: u32,
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode, Clone, Debug)]
|
||||
pub struct AtomConfig {
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
num_posts: usize,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
title: String,
|
||||
}
|
||||
|
||||
fn main() -> miette::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
// Load the configuration from the KDL config file specified by the first command-line
|
||||
// argument.
|
||||
let config = Arc::new({
|
||||
let config_path = env::args().nth(1)
|
||||
.ok_or_else(|| miette::Error::msg("No config file specified"))?;
|
||||
|
||||
info!(path = %config_path, "Loading config");
|
||||
|
||||
let contents = fs::read_to_string(&config_path)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("Failed to read config file {}", config_path))?;
|
||||
|
||||
knuffel::parse::<Config>(&config_path, &contents)
|
||||
.wrap_err_with(|| format!("Failed to parse config file {}", config_path))?
|
||||
});
|
||||
|
||||
// Create the data structure used to store the rendered posts. This uses an `Arc` internally,
|
||||
// so clones will point to the same underlying data.
|
||||
let posts_store = ConcurrentPostsStore::new();
|
||||
|
||||
let code_renderer = CodeBlockRenderer::new();
|
||||
|
||||
// Create the post renderer and the mpsc channel that will be used to communicate with it.
|
||||
let (renderer, tx) = Renderer::new(
|
||||
config.clone(),
|
||||
posts_store.clone(),
|
||||
code_renderer,
|
||||
config.posts_dir.clone()
|
||||
);
|
||||
|
||||
// Dropping the watcher stops its thread, so keep it alive until `main` returns.
|
||||
let _watcher = fs_watcher::start_watching(tx, &config.posts_dir)?;
|
||||
|
||||
thread::spawn(move || {
|
||||
renderer.handle_events();
|
||||
});
|
||||
|
||||
info!("Started renderer thread");
|
||||
|
||||
// To run the web server, we need to be in an async context, so create a new Tokio runtime and
|
||||
// pass control to it.
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.into_diagnostic()
|
||||
.wrap_err("Failed to create async runtime")?
|
||||
.block_on(run(config, posts_store))
|
||||
}
|
||||
|
||||
async fn run(
|
||||
config: Arc<Config>,
|
||||
posts_store: ConcurrentPostsStore,
|
||||
) -> miette::Result<()>
|
||||
{
|
||||
let bind_address = &config.bind
|
||||
.parse()
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("Failed to parse socket address \"{}\"", config.bind))?;
|
||||
|
||||
let service = service::site_service(config, posts_store);
|
||||
|
||||
info!(address = %bind_address, "Starting server");
|
||||
|
||||
Server::try_bind(bind_address)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| format!("Failed to bind {}", bind_address))?
|
||||
.serve(service.into_make_service())
|
||||
.await
|
||||
.into_diagnostic()
|
||||
.wrap_err("Fatal error while running the server")
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
use std::{
|
||||
fmt,
|
||||
fs,
|
||||
io::{self, Read},
|
||||
path::PathBuf,
|
||||
sync::{Arc, mpsc},
|
||||
};
|
||||
|
||||
use chrono::DateTime;
|
||||
use notify::DebouncedEvent;
|
||||
use tracing::{info, warn, error};
|
||||
|
||||
use blog::{
|
||||
codeblock::CodeBlockRenderer,
|
||||
post::{parse as parse_post, ParseError, Post, PostId},
|
||||
db::ConcurrentPostsStore,
|
||||
time::unix_epoch,
|
||||
};
|
||||
|
||||
use crate::Config;
|
||||
|
||||
pub struct Renderer {
|
||||
config: Arc<Config>,
|
||||
posts: ConcurrentPostsStore,
|
||||
code_renderer: CodeBlockRenderer,
|
||||
posts_dir_path: PathBuf,
|
||||
rx: mpsc::Receiver<DebouncedEvent>,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
pub fn new(
|
||||
config: Arc<Config>,
|
||||
posts: ConcurrentPostsStore,
|
||||
code_renderer: CodeBlockRenderer,
|
||||
posts_dir_path: PathBuf,
|
||||
) -> (Self, mpsc::Sender<DebouncedEvent>)
|
||||
{
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
// Buffer a rescan event here so that it will be the first event received when
|
||||
// `handle_events` is called. This will cause the `Renderer` to perform an "initial scan"
|
||||
// of the post files.
|
||||
tx.send(DebouncedEvent::Rescan).unwrap();
|
||||
|
||||
(Self {
|
||||
config,
|
||||
posts,
|
||||
code_renderer,
|
||||
posts_dir_path,
|
||||
rx,
|
||||
}, tx)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub fn handle_events(self) {
|
||||
while let Ok(notify_event) = self.rx.recv() {
|
||||
let fs_event = match notify_event {
|
||||
// Convert create & write events for valid post file names to update events.
|
||||
DebouncedEvent::Create(path) | DebouncedEvent::Write(path) => {
|
||||
EventTarget::from_path(path)
|
||||
.map(Event::Update)
|
||||
},
|
||||
|
||||
// Convert remove events for valid post file names.
|
||||
DebouncedEvent::Remove(path) => {
|
||||
EventTarget::from_path(path)
|
||||
.map(Event::Remove)
|
||||
},
|
||||
|
||||
// Convert rename events depending on whether the old / new paths are valid post
|
||||
// file names.
|
||||
DebouncedEvent::Rename(old_path, new_path) => {
|
||||
match (EventTarget::from_path(old_path), EventTarget::from_path(new_path)) {
|
||||
(Some(old_target), Some(new_target)) => Some(Event::Rename(old_target, new_target)),
|
||||
(None, Some(new_target)) => Some(Event::Update(new_target)),
|
||||
(Some(old_target), None) => Some(Event::Remove(old_target)),
|
||||
(None, None) => None,
|
||||
}
|
||||
},
|
||||
|
||||
// Convert rescan events, where it is necessary to read the directory's contents.
|
||||
DebouncedEvent::Rescan => Some(Event::Scan),
|
||||
|
||||
// Ignore all other events.
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(fs_event) = fs_event {
|
||||
self.handle_event(&fs_event);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Filesystem events channel closed, exiting");
|
||||
}
|
||||
|
||||
fn handle_event(&self, event: &Event) {
|
||||
info!(event = ?event);
|
||||
match event {
|
||||
Event::Update(target) => self.update(target),
|
||||
Event::Rename(old_target, new_target) => self.rename(old_target, new_target),
|
||||
Event::Remove(target) => self.remove(target),
|
||||
Event::Scan => self.scan(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
fn update(&self, target: &EventTarget) {
|
||||
match self.parse_post_from_target(target) {
|
||||
Ok(post) => {
|
||||
let mut guard = self.posts.write_blocking();
|
||||
guard.insert(post);
|
||||
},
|
||||
Err(err) => {
|
||||
err.log();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
fn rename(&self, old_target: &EventTarget, new_target: &EventTarget) {
|
||||
let post_res = self.parse_post_from_target(new_target);
|
||||
let mut guard = self.posts.write_blocking();
|
||||
guard.remove(&old_target.id);
|
||||
match post_res {
|
||||
Ok(post) => {
|
||||
guard.insert(post);
|
||||
},
|
||||
Err(err) => {
|
||||
err.log();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
fn remove(&self, target: &EventTarget) {
|
||||
let mut guard = self.posts.write_blocking();
|
||||
guard.remove(&target.id);
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
fn scan(&self) {
|
||||
let posts_dir = match fs::read_dir(&self.posts_dir_path) {
|
||||
Ok(posts_dir) => posts_dir,
|
||||
Err(err) => {
|
||||
Error::Io(Box::new(err)).log();
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
let mut posts = Vec::new();
|
||||
|
||||
for dir_entry in posts_dir {
|
||||
let dir_entry = match dir_entry {
|
||||
Ok(dir_entry) => dir_entry,
|
||||
Err(err) => {
|
||||
Error::Io(Box::new(err)).log();
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(target) = EventTarget::from_path(dir_entry.path()) {
|
||||
posts.push(match self.parse_post_from_target(&target) {
|
||||
Ok(post) => post,
|
||||
Err(err) => {
|
||||
err.log();
|
||||
continue;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut guard = self.posts.write_blocking();
|
||||
guard.clear();
|
||||
for post in posts {
|
||||
guard.insert(post);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_post_from_target(&self, target: &EventTarget) -> Result<Post, Error> {
|
||||
let mut fd = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.open(&target.path)
|
||||
.map_err(|err| Error::Io(Box::new(err)))?;
|
||||
|
||||
let metadata = fd.metadata()
|
||||
.map_err(|err| Error::Io(Box::new(err)))?;
|
||||
|
||||
if !metadata.file_type().is_file() {
|
||||
return Err(Error::NotAFile);
|
||||
}
|
||||
|
||||
let (created, updated) = metadata.created()
|
||||
.and_then(|created| metadata.modified()
|
||||
.map(|modified| (DateTime::from(created), DateTime::from(modified))))
|
||||
// If created / modified metadata is not available, default to the UNIX epoch.
|
||||
.unwrap_or_else(|_| {
|
||||
let epoch = unix_epoch();
|
||||
(epoch, epoch)
|
||||
});
|
||||
|
||||
let contents = {
|
||||
let mut buf = String::new();
|
||||
fd.read_to_string(&mut buf)
|
||||
.map_err(|err| Error::Io(Box::new(err)))?;
|
||||
buf
|
||||
};
|
||||
|
||||
drop(fd);
|
||||
|
||||
parse_post(
|
||||
&self.code_renderer,
|
||||
*self.config.namespace_uuid,
|
||||
target.id.clone(),
|
||||
&target.path.to_string_lossy(),
|
||||
created,
|
||||
updated,
|
||||
&contents
|
||||
).map_err(|err| Error::Parsing(Box::new(err)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Event {
|
||||
Update(EventTarget),
|
||||
Rename(EventTarget, EventTarget),
|
||||
Remove(EventTarget),
|
||||
Scan,
|
||||
}
|
||||
|
||||
struct EventTarget {
|
||||
pub path: PathBuf,
|
||||
pub id: PostId,
|
||||
}
|
||||
|
||||
impl fmt::Debug for EventTarget {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}@{}", self.id, self.path.to_string_lossy())
|
||||
}
|
||||
}
|
||||
|
||||
impl EventTarget {
|
||||
pub fn from_path(path: PathBuf) -> Option<Self> {
|
||||
path.file_name()
|
||||
.and_then(|file_name| file_name.to_str())
|
||||
.and_then(PostId::from_file_name)
|
||||
.map(|id| Self {
|
||||
path,
|
||||
id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Error {
|
||||
Io(Box<io::Error>),
|
||||
NotAFile,
|
||||
Parsing(Box<ParseError>),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
fn log(&self) {
|
||||
match self {
|
||||
Error::Io(err) => {
|
||||
error!(error = %err, "IO error while processing event");
|
||||
},
|
||||
Error::NotAFile => {
|
||||
warn!("Event target is not a regular file");
|
||||
},
|
||||
Error::Parsing(err) => {
|
||||
warn!(error = %err, "Parsing error while processing event");
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use atom_syndication as atom;
|
||||
use axum::{body::Bytes, extract::Extension};
|
||||
|
||||
use blog::{db::ConcurrentPostsStore, time::unix_epoch};
|
||||
|
||||
use crate::Config;
|
||||
|
||||
use super::response::Atom;
|
||||
|
||||
pub async fn handle(
|
||||
Extension(config): Extension<Arc<Config>>,
|
||||
Extension(posts): Extension<ConcurrentPostsStore>,
|
||||
) -> Atom<Bytes> {
|
||||
let (atom_entries, updated) = {
|
||||
let guard = posts.read().await;
|
||||
|
||||
let atom_entries = guard
|
||||
.iter_by_created()
|
||||
.take(config.atom.num_posts)
|
||||
.map(|post| {
|
||||
atom::EntryBuilder::default()
|
||||
.id(format!("urn:uuid:{}", post.uuid()))
|
||||
.title(post.title().to_owned())
|
||||
.updated(post.updated())
|
||||
.links(vec![atom::LinkBuilder::default()
|
||||
.href(format!(
|
||||
"{}://{}/articles/{}",
|
||||
config.self_ref.protocol,
|
||||
config.self_ref.domain,
|
||||
post.id()
|
||||
))
|
||||
.rel("alternate".to_owned())
|
||||
.mime_type(Some("text/html".to_owned()))
|
||||
.build()])
|
||||
.author(
|
||||
atom::PersonBuilder::default()
|
||||
.name(post.author().to_owned())
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
})
|
||||
.collect::<Vec<atom::Entry>>();
|
||||
|
||||
let updated = guard.last_updated().unwrap_or_else(unix_epoch);
|
||||
|
||||
(atom_entries, updated)
|
||||
};
|
||||
|
||||
Atom(
|
||||
atom::FeedBuilder::default()
|
||||
.id(format!("urn:uuid:{}", *config.namespace_uuid))
|
||||
.title(config.atom.title.clone())
|
||||
.updated(updated)
|
||||
.links(vec![
|
||||
atom::LinkBuilder::default()
|
||||
.href(format!(
|
||||
"{}://{}/atom.xml",
|
||||
config.self_ref.protocol, config.self_ref.domain
|
||||
))
|
||||
.rel("self".to_owned())
|
||||
.build(),
|
||||
atom::LinkBuilder::default()
|
||||
.href(format!(
|
||||
"{}://{}/articles/",
|
||||
config.self_ref.protocol, config.self_ref.domain
|
||||
))
|
||||
.rel("alternate".to_owned())
|
||||
.mime_type(Some("text/html".to_owned()))
|
||||
.build(),
|
||||
])
|
||||
.entries(atom_entries)
|
||||
.build()
|
||||
.to_string()
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use maud::html;
|
||||
|
||||
use crate::template;
|
||||
|
||||
use super::response::Html;
|
||||
|
||||
pub async fn handle() -> Html {
|
||||
Html::new()
|
||||
.with_title_static("Contact")
|
||||
.with_crawler_permissive()
|
||||
.with_head(html! {
|
||||
link href="/static/styles/main.css" rel="stylesheet";
|
||||
})
|
||||
.with_body(template::main_page(html! {
|
||||
section .content_section {
|
||||
h1 { "Contact" }
|
||||
p {
|
||||
"If you want to contact me, you can find me at:"
|
||||
}
|
||||
ul {
|
||||
li {
|
||||
"Twitter: "
|
||||
a href="https://twitter.com/pantonshire" { "@pantonshire" }
|
||||
}
|
||||
li {
|
||||
"Mastodon: "
|
||||
a href="https://tech.lgbt/web/@pantonshire#" { "@pantonshire@tech.lgbt" }
|
||||
}
|
||||
li {
|
||||
"Discord: pantonshire#2076"
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use axum::extract::Extension;
|
||||
use maud::html;
|
||||
|
||||
use blog::db::ConcurrentPostsStore;
|
||||
|
||||
use crate::template;
|
||||
|
||||
use super::response::Html;
|
||||
|
||||
pub async fn handle(Extension(posts): Extension<ConcurrentPostsStore>) -> Html {
|
||||
Html::new()
|
||||
.with_title_static("Pantonshire")
|
||||
.with_crawler_permissive()
|
||||
.with_head(html! {
|
||||
link href="/static/styles/main.css" rel="stylesheet";
|
||||
link rel="alternate" type="application/atom+xml" href="/atom.xml";
|
||||
link rel="alternate" type="application/rss+xml" href="/rss.xml";
|
||||
})
|
||||
.with_body(template::main_page(html! {
|
||||
section .content_section {
|
||||
p {
|
||||
"Hi! I'm Tom, a computer science student and hobbyist programmer, and \
|
||||
definitely not an egg-shaped robot."
|
||||
}
|
||||
figure {
|
||||
img src="/static/images/tombot_450.png"
|
||||
alt="A drawing by @smolrobots (on Twitter) of me as a robot. It has an \
|
||||
egg-shaped body and a smiling face, and is doing a little dance next \
|
||||
to a Raspberry Pi."
|
||||
width="256";
|
||||
figcaption .quiet {
|
||||
"Drawn by "
|
||||
a href="https://twitter.com/smolrobots" { "@smolrobots" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section .content_section {
|
||||
h2 { "Some things I've made" }
|
||||
ul {
|
||||
li { a href="https://github.com/pantonshire/goldcrest" { "Goldcrest" } ", a proxy for the Twitter v1 API using gRPC. I made it to address the problem of centralised rate-limit tracking to avoid exceeding to Twitter's quotas." }
|
||||
li { a href="https://github.com/pantonshire/smolbotbot" { "Smolbotbot" } ", an art archival project for the drawings of " a href="https://twitter.com/smolrobots" { "@smolrobots" } "! A Twitter bot continuously checks for new small robots, and the data it collects is made available at " a href="https://smolbotbot.com" { "smolbotbot.com" } "." }
|
||||
li { a href="https://github.com/pantonshire/enumscribe" { "Enumscribe" } ", a Rust procedural macro library for generating conversion methods between enum types and strings, with support for " a href="https://serde.rs" { "Serde" } "." }
|
||||
li { a href="https://github.com/pantonshire/tasque" { "Tasque" } ", a very in-progress cron-like job scheduler that I'm having a lot of fun working on." }
|
||||
}
|
||||
}
|
||||
|
||||
section .content_section {
|
||||
h2 { "Articles" }
|
||||
p { "Some recent ones:" }
|
||||
ul .articles_list {
|
||||
@for post in posts.read().await.iter_by_created().rev().take(3) {
|
||||
li {
|
||||
h3 { a href={"/articles/" (post.id())} { (post.title()) } }
|
||||
@if let Some(subtitle) = post.subtitle() {
|
||||
p .article_list_subtitle { (subtitle) }
|
||||
}
|
||||
p .article_list_published_date {
|
||||
"Published " (post.created().format("%Y/%m/%d"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
p {
|
||||
a href="/articles" { "See the rest" }
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mod atom;
|
||||
mod contact;
|
||||
mod index;
|
||||
mod post;
|
||||
mod posts_list;
|
||||
mod response;
|
||||
mod rss;
|
||||
mod site;
|
||||
mod static_content;
|
||||
|
||||
pub use site::service as site_service;
|
||||
@@ -0,0 +1,40 @@
|
||||
use axum::extract::{Extension, Path};
|
||||
use maud::html;
|
||||
|
||||
use blog::db::ConcurrentPostsStore;
|
||||
|
||||
use crate::template;
|
||||
|
||||
use super::response::{Error, Html};
|
||||
|
||||
pub async fn handle(
|
||||
Path(post_id): Path<String>,
|
||||
Extension(posts): Extension<ConcurrentPostsStore>
|
||||
) -> Result<Html, Error>
|
||||
{
|
||||
let post = posts.get(&post_id)
|
||||
.await
|
||||
.ok_or(Error::PostNotFound)?;
|
||||
|
||||
Ok(Html::new()
|
||||
.with_crawler_permissive()
|
||||
.with_title_owned(post.title().to_owned())
|
||||
.with_head(html! {
|
||||
link href="/static/styles/main.css" rel="stylesheet";
|
||||
link href="/static/styles/code.css" rel="stylesheet";
|
||||
link rel="alternate" type="application/atom+xml" href="/atom.xml";
|
||||
link rel="alternate" type="application/rss+xml" href="/rss.xml";
|
||||
})
|
||||
.with_body(template::main_page(html! {
|
||||
section .article_header {
|
||||
h1 .article_title { (post.title()) }
|
||||
@if let Some(subtitle) = post.subtitle() {
|
||||
p .article_subtitle { (subtitle) }
|
||||
}
|
||||
p .article_published_date { "Published " (post.created().format("%Y/%m/%d")) }
|
||||
}
|
||||
article .article_content {
|
||||
(post.html())
|
||||
}
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use axum::extract::Extension;
|
||||
use maud::html;
|
||||
|
||||
use blog::db::ConcurrentPostsStore;
|
||||
|
||||
use crate::template;
|
||||
|
||||
use super::response::Html;
|
||||
|
||||
pub async fn handle(Extension(posts): Extension<ConcurrentPostsStore>) -> Html {
|
||||
Html::new()
|
||||
.with_title_static("Articles")
|
||||
.with_crawler_permissive()
|
||||
.with_head(html! {
|
||||
link href="/static/styles/main.css" rel="stylesheet";
|
||||
link rel="alternate" type="application/atom+xml" href="/atom.xml";
|
||||
link rel="alternate" type="application/rss+xml" href="/rss.xml";
|
||||
})
|
||||
.with_body(template::main_page(html! {
|
||||
section .content_section {
|
||||
h1 { "Articles" }
|
||||
p {
|
||||
"A collection of words I have written, against my better judgement."
|
||||
}
|
||||
ul .articles_list {
|
||||
@for post in posts.read().await.iter_by_created().rev() {
|
||||
li {
|
||||
h3 { a href={"/articles/" (post.id())} { (post.title()) } }
|
||||
@if let Some(subtitle) = post.subtitle() {
|
||||
p .article_list_subtitle { (subtitle) }
|
||||
}
|
||||
p .article_list_published_date {
|
||||
"Published " (post.created().format("%Y/%m/%d"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
fmt::{self, Write},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::{Bytes, Full},
|
||||
http::{header::{self, HeaderValue}, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use maud::{html, Markup, Render, Escaper, DOCTYPE};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
Internal,
|
||||
PostNotFound,
|
||||
StaticResourceNotFound,
|
||||
RouteNotFound,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Error::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Error::PostNotFound => StatusCode::NOT_FOUND,
|
||||
Error::StaticResourceNotFound => StatusCode::NOT_FOUND,
|
||||
Error::RouteNotFound => StatusCode::NOT_FOUND,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> Response {
|
||||
let status_code = self.status_code();
|
||||
|
||||
// Create a string buffer containing the full error text, e.g. "404 Not Found".
|
||||
let status_text = {
|
||||
let status_code_str = status_code.as_str();
|
||||
let reason = status_code.canonical_reason();
|
||||
|
||||
// Allocate a buffer with enough capacity to store the full error text.
|
||||
let mut buf = String::with_capacity(
|
||||
status_code_str.len() + reason.map(|reason| reason.len() + 1).unwrap_or(0));
|
||||
|
||||
// Push the numerical code string first, then a space, then the error reason string.
|
||||
buf.push_str(status_code_str);
|
||||
if let Some(reason) = reason {
|
||||
buf.push(' ');
|
||||
buf.push_str(reason);
|
||||
}
|
||||
|
||||
buf
|
||||
};
|
||||
|
||||
Html::new()
|
||||
.with_status(status_code)
|
||||
.with_body(html! {
|
||||
p { (status_text) }
|
||||
})
|
||||
.with_title_owned(status_text)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Html {
|
||||
status: StatusCode,
|
||||
title: Cow<'static, str>,
|
||||
head: Option<Markup>,
|
||||
body: Option<Markup>,
|
||||
crawler_hints: CrawlerHints,
|
||||
}
|
||||
|
||||
impl Html {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
status: StatusCode::OK,
|
||||
title: Cow::Borrowed("untitled"),
|
||||
head: None,
|
||||
body: None,
|
||||
crawler_hints: CrawlerHints::restrictive(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_status(self, status: StatusCode) -> Self {
|
||||
Self { status, ..self }
|
||||
}
|
||||
|
||||
pub fn with_title(self, title: Cow<'static, str>) -> Self {
|
||||
Self { title, ..self }
|
||||
}
|
||||
|
||||
pub fn with_title_static(self, title: &'static str) -> Self {
|
||||
self.with_title(Cow::Borrowed(title))
|
||||
}
|
||||
|
||||
pub fn with_title_owned(self, title: String) -> Self {
|
||||
self.with_title(Cow::Owned(title))
|
||||
}
|
||||
|
||||
pub fn with_head(self, head: Markup) -> Self {
|
||||
Self { head: Some(head), ..self }
|
||||
}
|
||||
|
||||
pub fn with_body(self, body: Markup) -> Self {
|
||||
Self { body: Some(body), ..self }
|
||||
}
|
||||
|
||||
pub fn with_crawler_hints(self, crawler_hints: CrawlerHints) -> Self {
|
||||
Self { crawler_hints, ..self }
|
||||
}
|
||||
|
||||
pub fn with_crawler_restrictive(self) -> Self {
|
||||
self.with_crawler_hints(CrawlerHints::restrictive())
|
||||
}
|
||||
|
||||
pub fn with_crawler_permissive(self) -> Self {
|
||||
self.with_crawler_hints(CrawlerHints::permissive())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Html {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for Html {
|
||||
fn into_response(self) -> Response {
|
||||
let html_doc = html! {
|
||||
(DOCTYPE)
|
||||
html {
|
||||
head {
|
||||
meta charset="utf-8";
|
||||
|
||||
meta name="robots" content=(self.crawler_hints);
|
||||
meta name="viewport" content="width=device-width, initial-scale=1";
|
||||
|
||||
title { (self.title) }
|
||||
|
||||
link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png";
|
||||
link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png";
|
||||
link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png";
|
||||
link rel="manifest" href="/site.webmanifest";
|
||||
|
||||
@if let Some(head) = self.head {
|
||||
(head)
|
||||
}
|
||||
}
|
||||
body {
|
||||
@if let Some(body) = self.body {
|
||||
(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
(self.status, axum::response::Html(html_doc.into_string()))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct CrawlerHints {
|
||||
index: bool,
|
||||
follow: bool,
|
||||
archive: bool,
|
||||
snippet: bool,
|
||||
image_index: bool,
|
||||
}
|
||||
|
||||
impl CrawlerHints {
|
||||
pub const fn restrictive() -> Self {
|
||||
Self {
|
||||
index: false,
|
||||
follow: false,
|
||||
archive: false,
|
||||
snippet: false,
|
||||
image_index: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn permissive() -> Self {
|
||||
Self {
|
||||
index: true,
|
||||
follow: true,
|
||||
archive: true,
|
||||
snippet: true,
|
||||
image_index: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn with_index(self, index: bool) -> Self {
|
||||
Self { index, ..self }
|
||||
}
|
||||
|
||||
pub const fn with_follow(self, follow: bool) -> Self {
|
||||
Self { follow, ..self }
|
||||
}
|
||||
|
||||
pub const fn with_archive(self, archive: bool) -> Self {
|
||||
Self { archive, ..self }
|
||||
}
|
||||
|
||||
pub const fn with_snippet(self, snippet: bool) -> Self {
|
||||
Self { snippet, ..self }
|
||||
}
|
||||
|
||||
pub const fn with_image_index(self, image_index: bool) -> Self {
|
||||
Self { image_index, ..self }
|
||||
}
|
||||
|
||||
fn index_str(self) -> &'static str {
|
||||
if self.index {
|
||||
"index"
|
||||
} else {
|
||||
"noindex"
|
||||
}
|
||||
}
|
||||
|
||||
fn follow_str(self) -> &'static str {
|
||||
if self.follow {
|
||||
"follow"
|
||||
} else {
|
||||
"nofollow"
|
||||
}
|
||||
}
|
||||
|
||||
fn archive_strs(self) -> Option<[&'static str; 2]> {
|
||||
if self.archive {
|
||||
None
|
||||
} else {
|
||||
Some(["noarchive", "nocache"])
|
||||
}
|
||||
}
|
||||
|
||||
fn snippet_str(self) -> Option<&'static str> {
|
||||
if self.snippet {
|
||||
None
|
||||
} else {
|
||||
Some("nosnippet")
|
||||
}
|
||||
}
|
||||
|
||||
fn image_index_str(self) -> Option<&'static str> {
|
||||
if self.image_index {
|
||||
None
|
||||
} else {
|
||||
Some("noimageindex")
|
||||
}
|
||||
}
|
||||
|
||||
fn write_meta_list_to<W: Write>(self, mut buf: W) -> fmt::Result {
|
||||
write!(buf, "{},{}", self.index_str(), self.follow_str())?;
|
||||
if let Some([archive_str, cache_str]) = self.archive_strs() {
|
||||
write!(buf, ",{},{}", archive_str, cache_str)?;
|
||||
}
|
||||
if let Some(snippet_str) = self.snippet_str() {
|
||||
write!(buf, ",{}", snippet_str)?;
|
||||
}
|
||||
if let Some(image_index_str) = self.image_index_str() {
|
||||
write!(buf, ",{}", image_index_str)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CrawlerHints {
|
||||
fn render_to(&self, buf: &mut String) {
|
||||
let escaper = Escaper::new(buf);
|
||||
let _result = self.write_meta_list_to(escaper);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Rss<T>(pub T);
|
||||
|
||||
impl<T: Into<Full<Bytes>>> IntoResponse for Rss<T> {
|
||||
fn into_response(self) -> Response {
|
||||
let headers = [
|
||||
(header::CONTENT_TYPE, HeaderValue::from_static("application/rss+xml")),
|
||||
];
|
||||
|
||||
(headers, self.0.into())
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Atom<T>(pub T);
|
||||
|
||||
impl<T: Into<Full<Bytes>>> IntoResponse for Atom<T> {
|
||||
fn into_response(self) -> Response {
|
||||
let headers = [
|
||||
(header::CONTENT_TYPE, HeaderValue::from_static("application/atom+xml")),
|
||||
];
|
||||
|
||||
(headers, self.0.into())
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::Extension,
|
||||
};
|
||||
|
||||
use blog::{
|
||||
db::ConcurrentPostsStore,
|
||||
time::unix_epoch,
|
||||
};
|
||||
|
||||
use crate::Config;
|
||||
|
||||
use super::response::Rss;
|
||||
|
||||
pub async fn handle(
|
||||
Extension(config): Extension<Arc<Config>>,
|
||||
Extension(posts): Extension<ConcurrentPostsStore>,
|
||||
) -> Rss<Bytes> {
|
||||
let (rss_items, updated) = {
|
||||
let guard = posts.read().await;
|
||||
|
||||
let rss_items = guard.iter_by_created()
|
||||
.take(config.rss.num_posts)
|
||||
.map(|post| {
|
||||
rss::ItemBuilder::default()
|
||||
.title(Some(post.title().to_owned()))
|
||||
.guid(Some(rss::GuidBuilder::default()
|
||||
.value(post.uuid().to_string())
|
||||
.permalink(false)
|
||||
.build()))
|
||||
.link(Some(format!(
|
||||
"{}://{}/articles/{}",
|
||||
config.self_ref.protocol,
|
||||
config.self_ref.domain,
|
||||
post.id()
|
||||
)))
|
||||
.pub_date(Some(post.created().to_rfc2822()))
|
||||
.build()
|
||||
})
|
||||
.collect::<Vec<rss::Item>>();
|
||||
|
||||
let updated = guard.last_updated()
|
||||
.unwrap_or_else(unix_epoch);
|
||||
|
||||
(rss_items, updated)
|
||||
};
|
||||
|
||||
Rss(rss::ChannelBuilder::default()
|
||||
.title(config.rss.title.clone())
|
||||
.link(format!(
|
||||
"{}://{}",
|
||||
config.self_ref.protocol, config.self_ref.domain
|
||||
))
|
||||
.ttl(Some(config.rss.ttl.to_string()))
|
||||
.last_build_date(Some(updated.to_rfc2822()))
|
||||
.items(rss_items)
|
||||
.build()
|
||||
.to_string()
|
||||
.into())
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
handler::Handler,
|
||||
http::Uri,
|
||||
extract::Extension,
|
||||
Router,
|
||||
routing::get,
|
||||
};
|
||||
use tower::limit::ConcurrencyLimitLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::info;
|
||||
|
||||
use blog::db::ConcurrentPostsStore;
|
||||
|
||||
use crate::Config;
|
||||
|
||||
use super::{
|
||||
atom,
|
||||
contact,
|
||||
index,
|
||||
post,
|
||||
posts_list,
|
||||
response::Error,
|
||||
rss,
|
||||
static_content,
|
||||
};
|
||||
|
||||
pub fn service(
|
||||
config: Arc<Config>,
|
||||
posts_store: ConcurrentPostsStore,
|
||||
) -> Router
|
||||
{
|
||||
Router::new()
|
||||
.route("/", get(index::handle))
|
||||
.route("/contact", get(contact::handle))
|
||||
.route("/articles", get(posts_list::handle))
|
||||
.route("/rss.xml", get(rss::handle))
|
||||
.route("/atom.xml", get(atom::handle))
|
||||
.route("/articles/:post_id", get(post::handle))
|
||||
.route("/robots.txt", static_content::file_service(&config.robots_path, None))
|
||||
.route("/favicon.ico", static_content::file_service(&config.favicon_dir.join("favicon.ico"), None))
|
||||
.route("/favicon-16x16.png", static_content::file_service(&config.favicon_dir.join("favicon-16x16.png"), None))
|
||||
.route("/favicon-32x32.png", static_content::file_service(&config.favicon_dir.join("favicon-32x32.png"), None))
|
||||
.route("/apple-touch-icon.png", static_content::file_service(&config.favicon_dir.join("apple-touch-icon.png"), None))
|
||||
.route("/android-chrome-192x192.png", static_content::file_service(&config.favicon_dir.join("android-chrome-192x192.png"), None))
|
||||
.route("/android-chrome-512x512.png", static_content::file_service(&config.favicon_dir.join("android-chrome-512x512.png"), None))
|
||||
.route("/site.webmanifest", static_content::file_service(&config.favicon_dir.join("site.webmanifest"), None))
|
||||
.nest("/static", static_content::dir_service(&config.static_dir))
|
||||
.nest("/article_media", static_content::dir_service(&config.post_media_dir))
|
||||
.fallback(handle_fallback.into_service())
|
||||
.layer(ConcurrencyLimitLayer::new(config.concurrency_limit))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(Extension(config))
|
||||
.layer(Extension(posts_store))
|
||||
}
|
||||
|
||||
pub async fn handle_fallback(uri: Uri) -> Error {
|
||||
info!(path = %uri.path(), "Requested resource not found");
|
||||
Error::RouteNotFound
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
io,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
handler::Handler,
|
||||
http::Uri,
|
||||
routing::{get_service, MethodRouter},
|
||||
};
|
||||
use libshire::convert::Empty;
|
||||
use mime::Mime;
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tracing::{info, error};
|
||||
|
||||
use super::response::Error;
|
||||
|
||||
pub fn file_service(file_path: &Path, mime: Option<&Mime>) -> MethodRouter<Body, Infallible> {
|
||||
let serve_file = match mime {
|
||||
Some(mime) => ServeFile::new_with_mime(file_path, mime),
|
||||
None => ServeFile::new(file_path),
|
||||
};
|
||||
|
||||
get_service(serve_file)
|
||||
.handle_error(handle_error)
|
||||
}
|
||||
|
||||
pub fn dir_service(dir_path: &Path) -> MethodRouter<Body, Infallible> {
|
||||
let fallback_service = handle_fallback
|
||||
.into_service()
|
||||
.map_err(Empty::elim::<io::Error>);
|
||||
|
||||
let serve_dir = ServeDir::new(dir_path)
|
||||
.fallback(fallback_service);
|
||||
|
||||
get_service(serve_dir)
|
||||
.handle_error(handle_error)
|
||||
}
|
||||
|
||||
pub async fn handle_fallback(uri: Uri) -> Error {
|
||||
info!(path = %uri.path(), "Requested static file not found");
|
||||
Error::StaticResourceNotFound
|
||||
}
|
||||
|
||||
pub async fn handle_error(uri: Uri, err: io::Error) -> Error {
|
||||
error!(path = %uri.path(), err = %err, "IO error");
|
||||
Error::Internal
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use maud::{html, Markup};
|
||||
|
||||
pub fn main_page(content: Markup) -> Markup {
|
||||
html! {
|
||||
header #page_header {
|
||||
nav #page_nav {
|
||||
#title_box {
|
||||
a href="/" { "Pantonshire" }
|
||||
}
|
||||
#right_nav_box {
|
||||
ul {
|
||||
li { a href="/articles" { "Articles" } }
|
||||
li { a href="/atom.xml" { "Atom" } }
|
||||
li { a href="/rss.xml" { "RSS" } }
|
||||
li { a href="https://github.com/pantonshire" { "GitHub" } }
|
||||
li { a href="/contact" { "Contact" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main #page_main {
|
||||
#content {
|
||||
(content)
|
||||
}
|
||||
}
|
||||
|
||||
footer #page_footer {
|
||||
#page_footer_content {
|
||||
ul {
|
||||
li {
|
||||
"\"What is a footer? A miserable little pile of secrets.\""
|
||||
}
|
||||
li {
|
||||
a href="https://github.com/pantonshire/blog_server" {
|
||||
"Source code for this site"
|
||||
}
|
||||
}
|
||||
li {
|
||||
a rel="me" href="https://tech.lgbt/@pantonshire" {
|
||||
"Backlink for Mastodon"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use std::{env, process};
|
||||
|
||||
use syntect::{
|
||||
highlighting::ThemeSet,
|
||||
html::css_for_theme_with_class_style,
|
||||
};
|
||||
|
||||
use blog::codeblock::CLASS_STYLE;
|
||||
|
||||
fn main() {
|
||||
let theme_set = ThemeSet::load_defaults();
|
||||
let theme_name = env::args().nth(1).unwrap_or_else(|| {
|
||||
eprintln!("No theme specified");
|
||||
eprint_available_themes(&theme_set);
|
||||
process::exit(1)
|
||||
});
|
||||
let theme = theme_set.themes.get(&theme_name).unwrap_or_else(|| {
|
||||
eprintln!("Theme not found: {}", theme_name);
|
||||
eprint_available_themes(&theme_set);
|
||||
process::exit(1)
|
||||
});
|
||||
let css = css_for_theme_with_class_style(theme, CLASS_STYLE);
|
||||
println!("{}", css);
|
||||
}
|
||||
|
||||
fn eprint_available_themes(theme_set: &ThemeSet) {
|
||||
eprintln!("Available themes:");
|
||||
for key in theme_set.themes.keys() {
|
||||
eprintln!(" {}", key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use maud::{html, Markup, PreEscaped};
|
||||
use syntect::html::{ClassedHTMLGenerator, ClassStyle};
|
||||
use syntect::parsing::SyntaxSet;
|
||||
use syntect::util::LinesWithEndings;
|
||||
|
||||
pub const CLASS_STYLE: ClassStyle = ClassStyle::SpacedPrefixed { prefix: "cb_" };
|
||||
|
||||
pub struct CodeBlockRenderer {
|
||||
syntax_set: SyntaxSet,
|
||||
}
|
||||
|
||||
impl CodeBlockRenderer {
|
||||
pub fn new() -> Self {
|
||||
// Load Syntect's default syntax set from Sublime syntax definitions embedded in the
|
||||
// binary.
|
||||
let default_syntax_set = SyntaxSet::load_defaults_newlines();
|
||||
Self::new_with_syntax_set(default_syntax_set)
|
||||
}
|
||||
|
||||
pub fn new_with_syntax_set(syntax_set: SyntaxSet) -> Self {
|
||||
Self {
|
||||
syntax_set,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self, lang: &str, source: &str) -> Markup {
|
||||
const CONTEXT_DELIM: &str = "@@";
|
||||
|
||||
// Grab the optional context information between @@s from the first line of the code block.
|
||||
let (context, source) = source.split_once('\n')
|
||||
.and_then(|(context, source)| context
|
||||
.trim()
|
||||
.strip_prefix(CONTEXT_DELIM)
|
||||
.and_then(|context| context.strip_suffix(CONTEXT_DELIM))
|
||||
.map(|context| (Some(context.trim()), source)))
|
||||
.unwrap_or((None, source));
|
||||
|
||||
// Search the syntax set for the syntax definition for the language specified for the code
|
||||
// block (after the triple backtick), and default to plaintext if no syntax definition is
|
||||
// found.
|
||||
let syntax = self.syntax_set
|
||||
.find_syntax_by_token(lang)
|
||||
.unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
|
||||
|
||||
let mut html_gen = ClassedHTMLGenerator::new_with_class_style(
|
||||
syntax,
|
||||
&self.syntax_set,
|
||||
CLASS_STYLE
|
||||
);
|
||||
|
||||
for line in LinesWithEndings::from(source) {
|
||||
html_gen.parse_html_for_line_which_includes_newline(line);
|
||||
}
|
||||
|
||||
let html_out = html_gen.finalize();
|
||||
|
||||
html! {
|
||||
.codeblock {
|
||||
@if context.is_some() || !lang.is_empty() {
|
||||
.codeblock_banner {
|
||||
span .codeblock_language { (lang) }
|
||||
span .codeblock_context { (context.unwrap_or("")) }
|
||||
}
|
||||
}
|
||||
pre .codeblock_code {
|
||||
code {
|
||||
(PreEscaped(html_out))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CodeBlockRenderer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
use std::{
|
||||
collections::{BTreeSet, hash_map, HashMap, HashSet},
|
||||
iter::FusedIterator,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use libshire::strings::ShString22;
|
||||
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||
|
||||
use crate::post::{Post, PostId};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConcurrentPostsStore {
|
||||
inner: Arc<RwLock<PostsStore>>,
|
||||
}
|
||||
|
||||
impl ConcurrentPostsStore {
|
||||
pub fn new() -> Self {
|
||||
Self { inner: Arc::new(RwLock::new(PostsStore::new())) }
|
||||
}
|
||||
|
||||
pub async fn read(&self) -> RwLockReadGuard<'_, PostsStore> {
|
||||
self.inner.read().await
|
||||
}
|
||||
|
||||
pub fn write_blocking(&self) -> RwLockWriteGuard<'_, PostsStore> {
|
||||
self.inner.blocking_write()
|
||||
}
|
||||
|
||||
pub async fn get(&self, id: &str) -> Option<Arc<Post>> {
|
||||
self.read().await.get(id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConcurrentPostsStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PostsStore {
|
||||
posts: HashMap<PostId, Arc<Post>>,
|
||||
created_ix: BTreeSet<CreatedIxEntry>,
|
||||
tags_ix: HashMap<ShString22, HashSet<PostId>>,
|
||||
}
|
||||
|
||||
// TODO: shrink the various collections on removal to deallocate unneeded space
|
||||
|
||||
impl PostsStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
posts: HashMap::new(),
|
||||
created_ix: BTreeSet::new(),
|
||||
tags_ix: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&Arc<Post>> {
|
||||
self.posts.get(id)
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, post: Post) -> Option<Arc<Post>> {
|
||||
let old_post = self.remove(post.id());
|
||||
|
||||
// Insert the post into each of the tag indexes.
|
||||
for tag in post.tags() {
|
||||
// First, get the existing `HashSet` for the tag, or create a new one if one does not
|
||||
// already exist. Then, insert the post's ID into the `HashSet`.
|
||||
match self.tags_ix.entry(tag.clone()) {
|
||||
hash_map::Entry::Occupied(entry) => entry.into_mut(),
|
||||
hash_map::Entry::Vacant(entry) => entry.insert(HashSet::new()),
|
||||
}.insert(post.id().clone());
|
||||
}
|
||||
|
||||
// Insert the post into the correct position of the created BTree index.
|
||||
self.created_ix.insert(CreatedIxEntry::new(&post));
|
||||
|
||||
// Wrap the post with an atomic reference counter and insert it into the main posts
|
||||
// `HashMap`.
|
||||
self.posts.insert(post.id().clone(), Arc::new(post));
|
||||
|
||||
old_post
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: &str) -> Option<Arc<Post>> {
|
||||
match self.posts.remove(id) {
|
||||
Some(post) => {
|
||||
// Remove the post's entry in the created index.
|
||||
self.created_ix
|
||||
.remove(&CreatedIxEntry::new(&post));
|
||||
|
||||
// Remove every occurence of the post from the tags index.
|
||||
for tag in post.tags() {
|
||||
if let Some(tag_ix) = self.tags_ix.get_mut(tag) {
|
||||
tag_ix.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
Some(post)
|
||||
},
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.tags_ix.clear();
|
||||
self.created_ix.clear();
|
||||
self.posts.clear();
|
||||
}
|
||||
|
||||
pub fn last_updated(&self) -> Option<DateTime<Utc>> {
|
||||
self.iter().map(|post| post.updated()).max()
|
||||
}
|
||||
|
||||
pub fn iter(&self)
|
||||
-> impl '_
|
||||
+ Iterator<Item = &Arc<Post>>
|
||||
+ ExactSizeIterator
|
||||
+ FusedIterator
|
||||
+ Clone
|
||||
{
|
||||
self.posts.values()
|
||||
}
|
||||
|
||||
pub fn iter_by_created(&self)
|
||||
-> impl '_
|
||||
+ Iterator<Item = &Arc<Post>>
|
||||
+ DoubleEndedIterator
|
||||
+ ExactSizeIterator
|
||||
+ FusedIterator
|
||||
+ Clone
|
||||
{
|
||||
// For each entry of the created index, look up the corresponding post in the posts map and
|
||||
// return the post. Every entry in the created index should contain the ID of a post in the
|
||||
// posts map, so the `expect` should never fail.
|
||||
self.created_ix
|
||||
.iter()
|
||||
.map(|entry| self.get(&entry.id)
|
||||
.expect("invalid entry in `created_ix` pointing to a post that does not exist"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PostsStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct CreatedIxEntry {
|
||||
created: DateTime<Utc>,
|
||||
id: PostId,
|
||||
}
|
||||
|
||||
impl CreatedIxEntry {
|
||||
fn new(post: &Post) -> Self {
|
||||
Self {
|
||||
created: post.created(),
|
||||
id: post.id().clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod codeblock;
|
||||
pub mod post;
|
||||
pub mod db;
|
||||
pub mod time;
|
||||
pub mod uuid;
|
||||
@@ -0,0 +1,88 @@
|
||||
use std::{borrow, fmt, ops};
|
||||
|
||||
use libshire::strings::ShString22;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct PostId(ShString22);
|
||||
|
||||
impl PostId {
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn from_file_name(file_name: &str) -> Option<Self> {
|
||||
fn is_invalid_char(c: char) -> bool {
|
||||
c == '/' || c == '\\' || c == '.'
|
||||
}
|
||||
|
||||
let prefix = file_name
|
||||
.strip_suffix(super::POST_FILE_EXTENSION)?;
|
||||
|
||||
if prefix.contains(is_invalid_char) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self(ShString22::new_from_str(prefix)))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn into_inner(self) -> ShString22 {
|
||||
self.0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn as_inner(&self) -> &ShString22 {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Deref for PostId {
|
||||
type Target = str;
|
||||
|
||||
#[inline]
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&*self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::DerefMut for PostId {
|
||||
#[inline]
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut *self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for PostId {
|
||||
#[inline]
|
||||
fn as_ref(&self) -> &str {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl AsMut<str> for PostId {
|
||||
#[inline]
|
||||
fn as_mut(&mut self) -> &mut str {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl borrow::Borrow<str> for PostId {
|
||||
#[inline]
|
||||
fn borrow(&self) -> &str {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl borrow::BorrowMut<str> for PostId {
|
||||
#[inline]
|
||||
fn borrow_mut(&mut self) -> &mut str {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PostId {
|
||||
#[inline]
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(&**self, f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
mod id;
|
||||
mod parse;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use libshire::{strings::ShString22, uuid::Uuid};
|
||||
use maud::{Markup, PreEscaped};
|
||||
|
||||
pub use id::PostId;
|
||||
pub use parse::{parse, ParseError};
|
||||
|
||||
const POST_FILE_EXTENSION: &str = ".kdl.md";
|
||||
|
||||
pub struct Post {
|
||||
uuid: Uuid,
|
||||
id: PostId,
|
||||
title: String,
|
||||
subtitle: Option<String>,
|
||||
author: ShString22,
|
||||
html: Markup,
|
||||
tags: Vec<ShString22>,
|
||||
created: DateTime<Utc>,
|
||||
updated: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
pub fn uuid(&self) -> Uuid {
|
||||
self.uuid
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &PostId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn subtitle(&self) -> Option<&str> {
|
||||
self.subtitle.as_deref()
|
||||
}
|
||||
|
||||
pub fn author(&self) -> &str {
|
||||
&self.author
|
||||
}
|
||||
|
||||
pub fn html(&self) -> PreEscaped<&str> {
|
||||
PreEscaped(&self.html.0)
|
||||
}
|
||||
|
||||
pub fn tags(&self) -> &[ShString22] {
|
||||
&self.tags
|
||||
}
|
||||
|
||||
pub fn created(&self) -> DateTime<Utc> {
|
||||
self.created
|
||||
}
|
||||
|
||||
pub fn updated(&self) -> DateTime<Utc> {
|
||||
self.updated
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
use std::{error, fmt};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use libshire::{strings::ShString22, uuid::{Uuid, UuidV5Error}};
|
||||
use maud::{html, PreEscaped};
|
||||
|
||||
use crate::codeblock::CodeBlockRenderer;
|
||||
|
||||
use super::{id::PostId, Post};
|
||||
|
||||
pub fn parse(
|
||||
code_renderer: &CodeBlockRenderer,
|
||||
namespace: Uuid,
|
||||
post_id: PostId,
|
||||
file_name: &str,
|
||||
created: DateTime<Utc>,
|
||||
updated: DateTime<Utc>,
|
||||
source: &str,
|
||||
) -> Result<Post, ParseError>
|
||||
{
|
||||
MdPost::parse(file_name, source)
|
||||
.and_then(|post| render_mdpost(
|
||||
code_renderer,
|
||||
namespace,
|
||||
post_id,
|
||||
created,
|
||||
updated,
|
||||
post
|
||||
))
|
||||
}
|
||||
|
||||
fn render_mdpost(
|
||||
code_renderer: &CodeBlockRenderer,
|
||||
namespace: Uuid,
|
||||
id: PostId,
|
||||
created: DateTime<Utc>,
|
||||
updated: DateTime<Utc>,
|
||||
mdpost: MdPost,
|
||||
) -> Result<Post, ParseError>
|
||||
{
|
||||
use pulldown_cmark::{Options, Parser, html::push_html};
|
||||
|
||||
const PARSER_OPTIONS: Options = Options::ENABLE_TABLES
|
||||
.union(Options::ENABLE_FOOTNOTES)
|
||||
.union(Options::ENABLE_STRIKETHROUGH);
|
||||
|
||||
let uuid = Uuid::new_v5(namespace, &*id)
|
||||
.map_err(|err| match err {
|
||||
UuidV5Error::NameTooLong(len) => ParseError::IdTooLong(len),
|
||||
})?;
|
||||
|
||||
let mut parser = PostMdParser::new(
|
||||
Parser::new_ext(&mdpost.markdown, PARSER_OPTIONS),
|
||||
code_renderer
|
||||
);
|
||||
|
||||
let mut html_buf = String::new();
|
||||
push_html(&mut html_buf, parser.by_ref());
|
||||
|
||||
Ok(Post {
|
||||
uuid,
|
||||
id,
|
||||
title: mdpost.title,
|
||||
subtitle: mdpost.subtitle,
|
||||
author: mdpost.author,
|
||||
html: PreEscaped(html_buf),
|
||||
tags: mdpost.tags,
|
||||
created,
|
||||
updated,
|
||||
})
|
||||
}
|
||||
|
||||
/// Iterator struct which wraps another event iterator in order to render code blocks, collect the links
|
||||
/// encountered and generate a summary of the text content.
|
||||
struct PostMdParser<'p, I> {
|
||||
iter: I,
|
||||
code_renderer: &'p CodeBlockRenderer,
|
||||
links: Vec<String>,
|
||||
summary: String,
|
||||
}
|
||||
|
||||
impl<'p, I> PostMdParser<'p, I> {
|
||||
fn new(iter: I, code_renderer: &'p CodeBlockRenderer) -> Self {
|
||||
Self {
|
||||
iter,
|
||||
code_renderer,
|
||||
links: Vec::new(),
|
||||
summary: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'e, 'p, I> Iterator for PostMdParser<'p, I> where I: Iterator<Item = pulldown_cmark::Event<'e>> {
|
||||
type Item = pulldown_cmark::Event<'e>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
use pulldown_cmark::{CodeBlockKind, CowStr, Event, LinkType, Tag};
|
||||
|
||||
self.iter.next().map(|event| match event {
|
||||
// When we reach a code block, we want to collect the text content until the code block finishes
|
||||
// and have the `CodeBlockRenderer` render it
|
||||
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) => {
|
||||
let mut code_buf = String::new();
|
||||
|
||||
for event in self.iter.by_ref() {
|
||||
match event {
|
||||
// The code block has finished, so break out of the loop
|
||||
Event::End(Tag::CodeBlock(_)) => break,
|
||||
// All text events until the end of the code block should be considered as code, so
|
||||
// add the text to the `code_buf` to be rendered later
|
||||
Event::Text(text) => code_buf.push_str(&text),
|
||||
// Ignore all other events
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
let highlighted = self.code_renderer.render(&lang, &code_buf);
|
||||
Event::Html(CowStr::Boxed(highlighted.into_string().into_boxed_str()))
|
||||
},
|
||||
|
||||
Event::Code(code) => {
|
||||
Event::Html(CowStr::Boxed(html! {
|
||||
code .inline_code { (code) }
|
||||
}.into_string().into_boxed_str()))
|
||||
},
|
||||
|
||||
event => {
|
||||
match &event {
|
||||
Event::Start(Tag::Link(LinkType::Inline | LinkType::Autolink, destination, _title)) => {
|
||||
self.links.push(destination.clone().into_string());
|
||||
},
|
||||
|
||||
//TODO: better way of generating a summary
|
||||
Event::Text(text) => {
|
||||
if self.summary.is_empty() {
|
||||
self.summary = text.clone().into_string();
|
||||
}
|
||||
},
|
||||
|
||||
_ => (),
|
||||
}
|
||||
|
||||
event
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode)]
|
||||
struct HeaderNode {
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
title: String,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
subtitle: Option<String>,
|
||||
#[knuffel(child, unwrap(argument))]
|
||||
author: String,
|
||||
#[knuffel(children(name="tag"))]
|
||||
tags: Vec<TagNode>,
|
||||
}
|
||||
|
||||
#[derive(knuffel::Decode)]
|
||||
struct TagNode {
|
||||
#[knuffel(argument)]
|
||||
tag: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MdPost {
|
||||
markdown: String,
|
||||
title: String,
|
||||
subtitle: Option<String>,
|
||||
author: ShString22,
|
||||
tags: Vec<ShString22>,
|
||||
}
|
||||
|
||||
impl MdPost {
|
||||
fn parse(file_name: &str, source: &str) -> Result<Self, ParseError> {
|
||||
const END_OF_HEADER_DELIM: &str = "\n---\n";
|
||||
|
||||
let (header, md) = source.split_once(END_OF_HEADER_DELIM)
|
||||
.ok_or(ParseError::MissingHeader)?;
|
||||
|
||||
let header = knuffel::parse::<HeaderNode>(file_name, header)
|
||||
.map_err(|err| ParseError::InvalidHeader(Box::new(err)))?;
|
||||
|
||||
let md = md.trim_start();
|
||||
|
||||
Ok(Self {
|
||||
markdown: md.to_owned(),
|
||||
title: header.title,
|
||||
subtitle: header.subtitle,
|
||||
author: header.author.into(),
|
||||
tags: header.tags.into_iter().map(|tag| tag.tag.into()).collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ParseError {
|
||||
MissingHeader,
|
||||
InvalidHeader(Box<knuffel::Error>),
|
||||
IdTooLong(usize),
|
||||
}
|
||||
|
||||
impl fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingHeader => write!(f, "post file has no header"),
|
||||
Self::InvalidHeader(err) => fmt::Display::fmt(err, f),
|
||||
Self::IdTooLong(len) => write!(f, "post id too long ({} bytes)", len),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl error::Error for ParseError {}
|
||||
@@ -0,0 +1,5 @@
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
|
||||
pub fn unix_epoch() -> DateTime<Utc> {
|
||||
DateTime::from_utc(NaiveDateTime::from_timestamp(0, 0), Utc)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use std::ops;
|
||||
|
||||
use knuffel::{
|
||||
ast::{Literal, TypeName},
|
||||
decode::{Context, Kind},
|
||||
errors::{DecodeError, ExpectedType},
|
||||
span::Spanned,
|
||||
traits::ErrorSpan,
|
||||
DecodeScalar,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Default, Debug)]
|
||||
#[repr(transparent)]
|
||||
pub struct Uuid(pub libshire::uuid::Uuid);
|
||||
|
||||
impl Uuid {
|
||||
pub fn as_inner(&self) -> &libshire::uuid::Uuid {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Deref for Uuid {
|
||||
type Target = libshire::uuid::Uuid;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.as_inner()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: ErrorSpan> DecodeScalar<S> for Uuid {
|
||||
fn type_check(type_name: &Option<Spanned<TypeName, S>>, ctx: &mut Context<S>) {
|
||||
if let Some(type_name) = type_name {
|
||||
ctx.emit_error(DecodeError::TypeName {
|
||||
span: type_name.span().clone(),
|
||||
found: Some((&**type_name).clone()),
|
||||
expected: ExpectedType::no_type(),
|
||||
rust_type: "Uuid",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_decode(
|
||||
value: &Spanned<Literal, S>,
|
||||
ctx: &mut Context<S>,
|
||||
) -> Result<Self, DecodeError<S>> {
|
||||
match &**value {
|
||||
Literal::String(s) => match s.parse() {
|
||||
Ok(uuid) => Ok(Self(uuid)),
|
||||
Err(err) => {
|
||||
ctx.emit_error(DecodeError::conversion(value, err));
|
||||
Ok(Default::default())
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
ctx.emit_error(DecodeError::scalar_kind(Kind::String, value));
|
||||
Ok(Default::default())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user