Compare commits

..

2 commits

12 changed files with 252 additions and 128 deletions

View file

@ -22,7 +22,7 @@ blazingly fast markdown blog software written in rust memory safe
- [ ] ^ replace HashMap with HashCache once i implement [this](https://github.com/wvwwvwwv/scalable-concurrent-containers/issues/139) - [ ] ^ replace HashMap with HashCache once i implement [this](https://github.com/wvwwvwwv/scalable-concurrent-containers/issues/139)
- [x] (de)compress cache with zstd on startup/shutdown - [x] (de)compress cache with zstd on startup/shutdown
- [ ] make date parsing less strict - [ ] make date parsing less strict
- [ ] make date formatting better - [x] make date formatting better
- [ ] date formatting respects user timezone - [ ] date formatting respects user timezone
- [x] clean up imports and require less features - [x] clean up imports and require less features
- [ ] improve home page - [ ] improve home page
@ -36,9 +36,16 @@ blazingly fast markdown blog software written in rust memory safe
the default configuration with comments looks like this the default configuration with comments looks like this
```toml ```toml
title = "bingus-blog" # title of the website title = "bingus-blog" # title of the blog
description = "blazingly fast markdown blog software written in rust memory safe" # description of the website # description of the blog
raw_access = true # allow users to see the raw markdown of a post description = "blazingly fast markdown blog software written in rust memory safe"
markdown_access = true # allow users to see the raw markdown of a post
# endpoint: /posts/<name>.md
date_format = "RFC3339" # format string used to format dates in the backend
# it's highly recommended to leave this as default,
# so the date can be formatted by the browser.
# format: https://docs.rs/chrono/latest/chrono/format/strftime/index.html#specifiers
js_enable = true # enable javascript (required for above)
[rss] [rss]
enable = false # serve an rss field under /feed.xml enable = false # serve an rss field under /feed.xml

View file

@ -3,21 +3,21 @@ use std::time::Duration;
use askama_axum::Template; use askama_axum::Template;
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::http::header::CONTENT_TYPE;
use axum::http::{header, Request}; use axum::http::{header, Request};
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::get; use axum::routing::get;
use axum::{Json, Router}; use axum::{Json, Router};
use rss::{Category, ChannelBuilder, ItemBuilder}; use rss::{Category, ChannelBuilder, ItemBuilder};
use serde::Deserialize; use serde::Deserialize;
use tokio::io::AsyncReadExt;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use tracing::{info, info_span, Span}; use tracing::{info, info_span, Span};
use crate::config::Config; use crate::config::{Config, DateFormat};
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::filters; use crate::filters;
use crate::post::{MarkdownPosts, PostManager, PostMetadata, RenderStats}; use crate::post::{MarkdownPosts, PostManager, PostMetadata, RenderStats, ReturnedPost};
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@ -31,6 +31,8 @@ struct IndexTemplate {
title: String, title: String,
description: String, description: String,
posts: Vec<PostMetadata>, posts: Vec<PostMetadata>,
df: DateFormat,
js: bool,
} }
#[derive(Template)] #[derive(Template)]
@ -40,6 +42,8 @@ struct PostTemplate {
rendered: String, rendered: String,
rendered_in: RenderStats, rendered_in: RenderStats,
markdown_access: bool, markdown_access: bool,
df: DateFormat,
js: bool,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -61,6 +65,8 @@ async fn index(
title: config.title.clone(), title: config.title.clone(),
description: config.description.clone(), description: config.description.clone(),
posts, posts,
df: config.date_format.clone(),
js: config.js_enable,
}) })
} }
@ -138,26 +144,22 @@ async fn post(
State(AppState { config, posts }): State<AppState>, State(AppState { config, posts }): State<AppState>,
Path(name): Path<String>, Path(name): Path<String>,
) -> AppResult<Response> { ) -> AppResult<Response> {
if name.ends_with(".md") && config.raw_access { match posts.get_post(&name).await? {
let mut file = tokio::fs::OpenOptions::new() ReturnedPost::Rendered(meta, rendered, rendered_in) => {
.read(true) let page = PostTemplate {
.open(config.dirs.posts.join(&name)) meta,
.await?; rendered,
rendered_in,
markdown_access: config.markdown_access,
df: config.date_format.clone(),
js: config.js_enable,
};
let mut buf = Vec::new(); Ok(page.into_response())
file.read_to_end(&mut buf).await?; }
ReturnedPost::Raw(body, content_type) => {
Ok(([("content-type", "text/plain")], buf).into_response()) Ok(([(CONTENT_TYPE, content_type)], body).into_response())
} else { }
let post = posts.get_post(&name).await?;
let page = PostTemplate {
meta: post.0,
rendered: post.1,
rendered_in: post.2,
markdown_access: config.raw_access,
};
Ok(page.into_response())
} }
} }

View file

@ -1,5 +1,5 @@
use std::env; use std::env;
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv6Addr};
use std::path::PathBuf; use std::path::PathBuf;
use color_eyre::eyre::{bail, Context, Result}; use color_eyre::eyre::{bail, Context, Result};
@ -59,13 +59,22 @@ pub struct RssConfig {
pub link: Url, pub link: Url,
} }
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub enum DateFormat {
#[default]
RFC3339,
#[serde(untagged)]
Strftime(String),
}
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(default)] #[serde(default)]
pub struct Config { pub struct Config {
pub title: String, pub title: String,
pub description: String, pub description: String,
pub raw_access: bool, pub markdown_access: bool,
pub num_posts: usize, pub date_format: DateFormat,
pub js_enable: bool,
pub rss: RssConfig, pub rss: RssConfig,
pub dirs: DirsConfig, pub dirs: DirsConfig,
pub http: HttpConfig, pub http: HttpConfig,
@ -78,8 +87,9 @@ impl Default for Config {
Self { Self {
title: "bingus-blog".into(), title: "bingus-blog".into(),
description: "blazingly fast markdown blog software written in rust memory safe".into(), description: "blazingly fast markdown blog software written in rust memory safe".into(),
raw_access: true, markdown_access: true,
num_posts: 5, date_format: Default::default(),
js_enable: true,
// i have a love-hate relationship with serde // i have a love-hate relationship with serde
// it was engimatic at first, but then i started actually using it // it was engimatic at first, but then i started actually using it
// writing my own serialize and deserialize implementations.. spending // writing my own serialize and deserialize implementations.. spending
@ -111,7 +121,7 @@ impl Default for DirsConfig {
impl Default for HttpConfig { impl Default for HttpConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
host: IpAddr::V4(Ipv4Addr::UNSPECIFIED), host: IpAddr::V6(Ipv6Addr::UNSPECIFIED),
port: 3000, port: 3000,
} }
} }

View file

@ -1,11 +1,29 @@
use std::{collections::HashMap, time::Duration}; use std::collections::HashMap;
use std::fmt::Display;
use std::time::Duration;
use chrono::{DateTime, TimeZone}; use chrono::{DateTime, TimeZone};
use crate::config::DateFormat;
use crate::post::PostMetadata; use crate::post::PostMetadata;
pub fn date<T: TimeZone>(date: &DateTime<T>) -> Result<String, askama::Error> { fn format_date<T>(date: &DateTime<T>, date_format: &DateFormat) -> String
Ok(date.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) where
T: TimeZone,
T::Offset: Display,
{
match date_format {
DateFormat::RFC3339 => date.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
DateFormat::Strftime(ref format_string) => date.format(format_string).to_string(),
}
}
pub fn date<T>(date: &DateTime<T>, date_format: &DateFormat) -> Result<String, askama::Error>
where
T: TimeZone,
T::Offset: Display,
{
Ok(format_date(date, date_format))
} }
pub fn duration(duration: &&Duration) -> Result<String, askama::Error> { pub fn duration(duration: &&Duration) -> Result<String, askama::Error> {

View file

@ -1,15 +1,14 @@
use std::hash::{DefaultHasher, Hash, Hasher}; use std::hash::{DefaultHasher, Hash, Hasher};
use std::io::Read; use std::io::Read;
use crate::config::{Config, RenderConfig};
use crate::post::PostMetadata;
use color_eyre::eyre::{self, Context}; use color_eyre::eyre::{self, Context};
use scc::HashMap; use scc::HashMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
use tracing::{debug, instrument}; use tracing::{debug, instrument};
use crate::config::{Config, RenderConfig};
use crate::post::PostMetadata;
/// do not persist cache if this version number changed /// do not persist cache if this version number changed
pub const CACHE_VERSION: u16 = 2; pub const CACHE_VERSION: u16 = 2;

View file

@ -1,11 +1,16 @@
use std::collections::BTreeSet;
use std::io::{self, Write}; use std::io::{self, Write};
use std::ops::Deref; use std::ops::Deref;
use std::path::Path; use std::path::Path;
use std::time::Duration; use std::time::Duration;
use std::time::Instant; use std::time::Instant;
use std::time::SystemTime;
use axum::http::HeaderValue;
use chrono::{DateTime, Utc};
use color_eyre::eyre::{self, Context}; use color_eyre::eyre::{self, Context};
use fronma::parser::{parse, ParsedData}; use fronma::parser::{parse, ParsedData};
use serde::Deserialize;
use tokio::fs; use tokio::fs;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
@ -13,9 +18,40 @@ use tracing::{error, info, warn};
use crate::config::Config; use crate::config::Config;
use crate::markdown_render::render; use crate::markdown_render::render;
use crate::post::cache::{load_cache, Cache, CACHE_VERSION}; use crate::post::cache::{load_cache, Cache, CACHE_VERSION};
use crate::post::{FrontMatter, PostError, PostManager, PostMetadata, RenderStats}; use crate::post::{PostError, PostManager, PostMetadata, RenderStats, ReturnedPost};
use crate::systemtime_as_secs::as_secs; use crate::systemtime_as_secs::as_secs;
#[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>>,
#[serde(default)]
pub tags: BTreeSet<String>,
}
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())),
tags: self.tags.into_iter().collect(),
}
}
}
pub struct MarkdownPosts<C> pub struct MarkdownPosts<C>
where where
C: Deref<Target = Config>, C: Deref<Target = Config>,
@ -219,8 +255,10 @@ where
.to_string(); .to_string();
let post = self.get_post(&name).await?; let post = self.get_post(&name).await?;
if filter(&post.0, &post.1) { if let ReturnedPost::Rendered(meta, content, stats) = post
posts.push(post); && filter(&meta, &content)
{
posts.push((meta, content, stats));
} }
} }
} }
@ -228,39 +266,66 @@ where
Ok(posts) Ok(posts)
} }
async fn get_post(&self, name: &str) -> Result<(PostMetadata, String, RenderStats), PostError> { async fn get_post(&self, name: &str) -> Result<ReturnedPost, PostError> {
let start = Instant::now(); if self.config.markdown_access && name.ends_with(".md") {
let path = self.config.dirs.posts.join(name.to_owned() + ".md"); let path = self.config.dirs.posts.join(name);
let stat = match tokio::fs::metadata(&path).await { let mut file = match tokio::fs::OpenOptions::new().read(true).open(&path).await {
Ok(value) => value, Ok(value) => value,
Err(err) => match err.kind() { Err(err) => match err.kind() {
io::ErrorKind::NotFound => { io::ErrorKind::NotFound => {
if let Some(cache) = self.cache.as_ref() { if let Some(cache) = self.cache.as_ref() {
cache.remove(name).await; cache.remove(name).await;
}
return Err(PostError::NotFound(name.to_string()));
} }
return Err(PostError::NotFound(name.to_string())); _ => return Err(PostError::IoError(err)),
} },
_ => return Err(PostError::IoError(err)), };
},
};
let mtime = as_secs(&stat.modified()?);
if let Some(cache) = self.cache.as_ref() let mut buf = Vec::with_capacity(4096);
&& let Some(hit) = cache.lookup(name, mtime, &self.config.render).await
{ file.read_to_end(&mut buf).await?;
Ok((
hit.metadata, Ok(ReturnedPost::Raw(
hit.rendered, buf,
RenderStats::Cached(start.elapsed()), HeaderValue::from_static("text/plain"),
)) ))
} else { } else {
let (metadata, rendered, stats) = self.parse_and_render(name.to_string(), path).await?; let start = Instant::now();
Ok(( let path = self.config.dirs.posts.join(name.to_owned() + ".md");
metadata,
rendered, let stat = match tokio::fs::metadata(&path).await {
RenderStats::ParsedAndRendered(start.elapsed(), stats.0, stats.1), Ok(value) => value,
)) Err(err) => match err.kind() {
io::ErrorKind::NotFound => {
if let Some(cache) = self.cache.as_ref() {
cache.remove(name).await;
}
return Err(PostError::NotFound(name.to_string()));
}
_ => return Err(PostError::IoError(err)),
},
};
let mtime = as_secs(&stat.modified()?);
if let Some(cache) = self.cache.as_ref()
&& let Some(hit) = cache.lookup(name, mtime, &self.config.render).await
{
Ok(ReturnedPost::Rendered(
hit.metadata,
hit.rendered,
RenderStats::Cached(start.elapsed()),
))
} else {
let (metadata, rendered, stats) =
self.parse_and_render(name.to_string(), path).await?;
Ok(ReturnedPost::Rendered(
metadata,
rendered,
RenderStats::ParsedAndRendered(start.elapsed(), stats.0, stats.1),
))
}
} }
} }

View file

@ -1,47 +1,15 @@
pub mod cache; pub mod cache;
pub mod markdown_posts; pub mod markdown_posts;
use std::collections::BTreeSet; use std::time::Duration;
use std::time::{Duration, SystemTime};
use axum::http::HeaderValue;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::error::PostError; use crate::error::PostError;
pub use crate::post::markdown_posts::MarkdownPosts; pub use crate::post::markdown_posts::MarkdownPosts;
#[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>>,
#[serde(default)]
pub tags: BTreeSet<String>,
}
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())),
tags: self.tags.into_iter().collect(),
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PostMetadata { pub struct PostMetadata {
pub name: String, pub name: String,
@ -54,13 +22,18 @@ pub struct PostMetadata {
pub tags: Vec<String>, pub tags: Vec<String>,
} }
#[allow(unused)]
pub enum RenderStats { pub enum RenderStats {
Cached(Duration), Cached(Duration),
// format: Total, Parsed in, Rendered in // format: Total, Parsed in, Rendered in
ParsedAndRendered(Duration, Duration, Duration), ParsedAndRendered(Duration, Duration, Duration),
} }
#[allow(clippy::large_enum_variant)] // Raw will be returned very rarely
pub enum ReturnedPost {
Rendered(PostMetadata, String, RenderStats),
Raw(Vec<u8>, HeaderValue),
}
pub trait PostManager { pub trait PostManager {
async fn get_all_post_metadata( async fn get_all_post_metadata(
&self, &self,
@ -97,10 +70,13 @@ pub trait PostManager {
#[allow(unused)] #[allow(unused)]
async fn get_post_metadata(&self, name: &str) -> Result<PostMetadata, PostError> { async fn get_post_metadata(&self, name: &str) -> Result<PostMetadata, PostError> {
self.get_post(name).await.map(|(meta, ..)| meta) match self.get_post(name).await? {
ReturnedPost::Rendered(metadata, ..) => Ok(metadata),
ReturnedPost::Raw(..) => Err(PostError::NotFound(name.to_string())),
}
} }
async fn get_post(&self, name: &str) -> Result<(PostMetadata, String, RenderStats), PostError>; async fn get_post(&self, name: &str) -> Result<ReturnedPost, PostError>;
async fn cleanup(&self); async fn cleanup(&self);
} }

4
static/main.js Normal file
View file

@ -0,0 +1,4 @@
for (let el of document.querySelectorAll(".date-rfc3339")) {
let date = new Date(Date.parse(el.textContent));
el.textContent = date.toLocaleString();
}

View file

@ -84,6 +84,35 @@ div.post {
margin-bottom: 1em; margin-bottom: 1em;
} }
.table {
display: grid;
/*grid-template-columns: auto auto auto;
grid-template-rows: auto auto;*/
width: max-content;
}
.table > :not(.value)::after {
content: ":";
}
.table > .value {
margin-left: 1em;
text-align: end;
grid-column: 2;
}
.table > .created {
grid-row: 1;
}
.table > .modified {
grid-row: 2;
}
.table > .tags {
grid-row: 3;
}
/* BEGIN cool effect everyone liked */ /* BEGIN cool effect everyone liked */
body { body {

View file

@ -9,6 +9,9 @@
<meta property="og:description" content="{{ description }}" /> <meta property="og:description" content="{{ description }}" />
<title>{{ title }}</title> <title>{{ title }}</title>
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
{% if js %}
<script src="/static/main.js" defer></script>
{% endif %}
</head> </head>
<body> <body>
<main> <main>

View file

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

View file

@ -2,20 +2,21 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<head> <meta charset="UTF-8" />
<meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="description" content="{{ meta.title }}" />
<meta name="description" content="{{ meta.title }}" /> <meta property="og:title" content="{{ meta.title }}" />
<meta property="og:title" content="{{ meta.title }}" /> <meta property="og:description" content="{{ meta.description }}" />
<meta property="og:description" content="{{ meta.description }}" /> {% match meta.icon %} {% when Some with (url) %}
{% match meta.icon %} {% when Some with (url) %} <meta property="og:image" content="{{ url }}" />
<meta property="og:image" content="{{ url }}" /> <link rel="shortcut icon" href="{{ url }}" />
<link rel="shortcut icon" href="{{ url }}" /> {% when None %} {% endmatch %}
{% when None %} {% endmatch %} <title>{{ meta.title }}</title>
<title>{{ meta.title }}</title> <link rel="stylesheet" href="/static/style.css" />
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/post.css" />
<link rel="stylesheet" href="/static/post.css" /> {% if js %}
</head> <script src="/static/main.js" defer></script>
{% endif %}
</head> </head>
<body> <body>
<main> <main>
@ -24,11 +25,9 @@
<span class="post-author">- by {{ meta.author }}</span> <span class="post-author">- by {{ meta.author }}</span>
</h1> </h1>
<p class="post-desc">{{ meta.description }}</p> <p class="post-desc">{{ meta.description }}</p>
<div class="" post> <div class="post">
<!-- prettier-ignore --> <!-- prettier-ignore -->
<div>
{% call macros::table(meta) %} {% call macros::table(meta) %}
</div>
<a href="/posts/{{ meta.name }}">link</a><br /> <a href="/posts/{{ meta.name }}">link</a><br />
<a href="/">back to home</a> <a href="/">back to home</a>
</div> </div>