diff --git a/Cargo.lock b/Cargo.lock
index 4a306bd..fd89cfb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -128,6 +128,19 @@ dependencies = [
"syn 2.0.60",
]
+[[package]]
+name = "atom_syndication"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "571832dcff775e26562e8e6930cd483de5587301d40d3a3b85d532b6383e15a7"
+dependencies = [
+ "chrono",
+ "derive_builder",
+ "diligent-date-parser",
+ "never",
+ "quick-xml 0.30.0",
+]
+
[[package]]
name = "autocfg"
version = "1.2.0"
@@ -290,6 +303,7 @@ dependencies = [
"comrak",
"console-subscriber",
"fronma",
+ "rss",
"scc",
"serde",
"syntect",
@@ -300,6 +314,7 @@ dependencies = [
"tower-http",
"tracing",
"tracing-subscriber",
+ "url",
"zstd",
]
@@ -576,12 +591,30 @@ version = "1.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "322ef0094744e63628e6f0eb2295517f79276a5b342a4c2ff3042566ca181d4e"
+[[package]]
+name = "diligent-date-parser"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6cf7fe294274a222363f84bcb63cdea762979a0443b4cf1f4f8fd17c86b1182"
+dependencies = [
+ "chrono",
+]
+
[[package]]
name = "either"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a47c1c47d2f5964e29c61246e81db715514cd532db6b5116a25ea3c03d6780a2"
+[[package]]
+name = "encoding_rs"
+version = "0.8.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "entities"
version = "1.0.1"
@@ -918,6 +951,16 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+[[package]]
+name = "idna"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
[[package]]
name = "indenter"
version = "0.3.3"
@@ -1061,6 +1104,12 @@ dependencies = [
"windows-sys 0.48.0",
]
+[[package]]
+name = "never"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91"
+
[[package]]
name = "nom"
version = "7.1.3"
@@ -1208,7 +1257,7 @@ dependencies = [
"base64",
"indexmap 2.2.6",
"line-wrap",
- "quick-xml",
+ "quick-xml 0.31.0",
"serde",
"time",
]
@@ -1266,6 +1315,16 @@ dependencies = [
"prost",
]
+[[package]]
+name = "quick-xml"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956"
+dependencies = [
+ "encoding_rs",
+ "memchr",
+]
+
[[package]]
name = "quick-xml"
version = "0.31.0"
@@ -1358,6 +1417,18 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56"
+[[package]]
+name = "rss"
+version = "2.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7b2c77eb4450d7d5f98df52c381cd6c4e19b75dad9209a9530b85a44510219a"
+dependencies = [
+ "atom_syndication",
+ "derive_builder",
+ "never",
+ "quick-xml 0.30.0",
+]
+
[[package]]
name = "rustc-demangle"
version = "0.1.23"
@@ -1652,6 +1723,21 @@ dependencies = [
"time-core",
]
+[[package]]
+name = "tinyvec"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
[[package]]
name = "tokio"
version = "1.37.0"
@@ -1929,18 +2015,45 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "unicode-bidi"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75"
+
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
+[[package]]
+name = "unicode-normalization"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5"
+dependencies = [
+ "tinyvec",
+]
+
[[package]]
name = "unicode_categories"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
+[[package]]
+name = "url"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
[[package]]
name = "valuable"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 6016a12..661cd3c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -37,6 +37,7 @@ comrak = { version = "0.22.0", features = [
], default-features = false }
console-subscriber = { version = "0.2.0", optional = true }
fronma = "0.2.0"
+rss = "2.0.7"
scc = { version = "2.1.0", features = ["serde"] }
serde = { version = "1.0.197", features = ["derive"] }
syntect = "5.2.0"
@@ -56,4 +57,5 @@ tower-http = { version = "0.5.2", features = [
], default-features = false }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
+url = { version = "2.5.0", features = ["serde"] }
zstd = { version = "0.13.1", default-features = false }
diff --git a/README.md b/README.md
index 30c3c67..de24d7f 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ blazingly fast markdown blog software written in rust memory safe
## TODO
-- [ ] RSS
+- [x] RSS
- [x] finish writing this document
- [x] document config
- [ ] extend syntect options
@@ -27,6 +27,7 @@ blazingly fast markdown blog software written in rust memory safe
- [x] clean up imports and require less features
- [ ] improve home page
- [x] tags
+- [ ] multi-language support
- [x] be blazingly fast
- [x] 100+ MiB binary size
@@ -39,6 +40,11 @@ title = "bingus-blog" # title of the website
description = "blazingly fast markdown blog software written in rust memory safe" # description of the website
raw_access = true # allow users to see the raw markdown of a post
+[rss]
+enable = false # serve an rss field under /feed.xml
+ # this may be a bit resource intensive
+link = "https://..." # public url of the blog, required if rss is enabled
+
[dirs]
posts = "posts" # where posts are stored
media = "media" # directory served under /media/
diff --git a/src/config.rs b/src/config.rs
index d0c72ee..6f0ae6a 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -6,6 +6,7 @@ use color_eyre::eyre::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tracing::{error, info};
+use url::Url;
use crate::ranged_i128_visitor::RangedI128Visitor;
@@ -50,6 +51,12 @@ pub struct DirsConfig {
pub media: PathBuf,
}
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct RssConfig {
+ pub enable: bool,
+ pub link: Url,
+}
+
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(default)]
pub struct Config {
@@ -57,6 +64,7 @@ pub struct Config {
pub description: String,
pub raw_access: bool,
pub num_posts: usize,
+ pub rss: RssConfig,
pub dirs: DirsConfig,
pub http: HttpConfig,
pub render: RenderConfig,
@@ -70,6 +78,16 @@ impl Default for Config {
description: "blazingly fast markdown blog software written in rust memory safe".into(),
raw_access: true,
num_posts: 5,
+ // i have a love-hate relationship with serde
+ // it was engimatic at first, but then i started actually using it
+ // writing my own serialize and deserialize implementations.. spending
+ // a lot of time in the docs trying to understand each and every option..
+ // now with this knowledge i can do stuff like this! (see rss field)
+ // and i'm proud to say that it still makes 0 sense.
+ rss: RssConfig {
+ enable: false,
+ link: Url::parse("http://example.com").unwrap(),
+ },
dirs: Default::default(),
http: Default::default(),
render: Default::default(),
diff --git a/src/error.rs b/src/error.rs
index 57f6a42..25dda7e 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -53,6 +53,10 @@ pub type AppResult = Result;
pub enum AppError {
#[error("failed to fetch post: {0}")]
PostError(#[from] PostError),
+ #[error("rss is disabled")]
+ RssDisabled,
+ #[error(transparent)]
+ UrlError(#[from] url::ParseError),
}
impl From for AppError {
@@ -75,7 +79,8 @@ impl IntoResponse for AppError {
PostError::NotFound(_) => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
},
- //_ => StatusCode::INTERNAL_SERVER_ERROR,
+ AppError::RssDisabled => StatusCode::FORBIDDEN,
+ AppError::UrlError(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
(
status_code,
diff --git a/src/main.rs b/src/main.rs
index d94fcab..4fcc1ac 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -18,11 +18,13 @@ use std::time::Duration;
use askama_axum::Template;
use axum::extract::{Path, Query, State};
-use axum::http::Request;
+use axum::http::{header, Request};
use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::{get, Router};
use axum::Json;
use color_eyre::eyre::{self, Context};
+use error::AppError;
+use rss::{Category, ChannelBuilder, ItemBuilder};
use serde::Deserialize;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
@@ -57,8 +59,8 @@ struct IndexTemplate {
}
#[derive(Template)]
-#[template(path = "view_post.html")]
-struct ViewPostTemplate {
+#[template(path = "post.html")]
+struct PostTemplate {
meta: PostMetadata,
rendered: String,
rendered_in: RenderStats,
@@ -78,7 +80,7 @@ async fn index(
) -> AppResult {
let posts = state
.posts
- .get_max_n_posts_with_optional_tag_sorted(query.num_posts, query.tag.as_ref())
+ .get_max_n_post_metadata_with_optional_tag_sorted(query.num_posts, query.tag.as_ref())
.await?;
Ok(IndexTemplate {
@@ -94,12 +96,73 @@ async fn all_posts(
) -> AppResult>> {
let posts = state
.posts
- .get_max_n_posts_with_optional_tag_sorted(query.num_posts, query.tag.as_ref())
+ .get_max_n_post_metadata_with_optional_tag_sorted(query.num_posts, query.tag.as_ref())
.await?;
Ok(Json(posts))
}
+async fn rss(
+ State(state): State,
+ Query(query): Query,
+) -> AppResult {
+ if !state.config.rss.enable {
+ return Err(AppError::RssDisabled);
+ }
+
+ let posts = state
+ .posts
+ .get_all_posts_filtered(|metadata, _| {
+ !query
+ .tag
+ .as_ref()
+ .is_some_and(|tag| !metadata.tags.contains(tag))
+ })
+ .await?;
+
+ let mut channel = ChannelBuilder::default();
+ channel
+ .title(&state.config.title)
+ .link(state.config.rss.link.to_string())
+ .description(&state.config.description);
+ //TODO: .language()
+
+ for (metadata, content, _) in posts {
+ channel.item(
+ ItemBuilder::default()
+ .title(metadata.title)
+ .description(metadata.description)
+ .author(metadata.author)
+ .categories(
+ metadata
+ .tags
+ .into_iter()
+ .map(|tag| Category {
+ name: tag,
+ domain: None,
+ })
+ .collect::>(),
+ )
+ .pub_date(metadata.created_at.map(|date| date.to_rfc2822()))
+ .content(content)
+ .link(
+ state
+ .config
+ .rss
+ .link
+ .join(&format!("/posts/{}", metadata.name))?
+ .to_string(),
+ )
+ .build(),
+ );
+ }
+
+ let body = channel.build().to_string();
+ drop(channel);
+
+ Ok(([(header::CONTENT_TYPE, "text/xml")], body).into_response())
+}
+
async fn post(State(state): State, Path(name): Path) -> AppResult {
if name.ends_with(".md") && state.config.raw_access {
let mut file = tokio::fs::OpenOptions::new()
@@ -113,7 +176,7 @@ async fn post(State(state): State, Path(name): Path) -> AppRes
Ok(([("content-type", "text/plain")], buf).into_response())
} else {
let post = state.posts.get_post(&name).await?;
- let page = ViewPostTemplate {
+ let page = PostTemplate {
meta: post.0,
rendered: post.1,
rendered_in: post.2,
@@ -250,6 +313,7 @@ async fn main() -> eyre::Result<()> {
)
.route("/posts/:name", get(post))
.route("/posts", get(all_posts))
+ .route("/feed.xml", get(rss))
.nest_service("/static", ServeDir::new("static").precompressed_gzip())
.nest_service("/media", ServeDir::new("media"))
.layer(
@@ -316,30 +380,24 @@ async fn main() -> eyre::Result<()> {
}
// write cache to file
- let AppState { config, posts } = Arc::::try_unwrap(state).unwrap_or_else(|state| {
- warn!("couldn't unwrap Arc over AppState, more than one strong reference exists for Arc. cloning instead");
- // TODO: only do this when persistence is enabled
- // first check config from inside the arc, then try unwrap
- AppState::clone(state.as_ref())
- });
+ let config = &state.config;
+ let posts = &state.posts;
if config.cache.enable
&& config.cache.persistence
- && let Some(cache) = posts.into_cache()
+ && let Some(cache) = posts.cache()
{
let path = &config.cache.file;
- let serialized = bitcode::serialize(&cache).context("failed to serialize cache")?;
+ let serialized = bitcode::serialize(cache).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()))?;
+ let compression_level = config.cache.compression_level;
if config.cache.compress {
let cache_file = cache_file.into_std().await;
tokio::task::spawn_blocking(move || {
std::io::Write::write_all(
- &mut zstd::stream::write::Encoder::new(
- cache_file,
- config.cache.compression_level,
- )?
- .auto_finish(),
+ &mut zstd::stream::write::Encoder::new(cache_file, compression_level)?
+ .auto_finish(),
&serialized,
)
})
diff --git a/src/post/cache.rs b/src/post/cache.rs
index 6437aa3..f6b86cc 100644
--- a/src/post/cache.rs
+++ b/src/post/cache.rs
@@ -8,7 +8,7 @@ use crate::config::RenderConfig;
use crate::post::PostMetadata;
/// do not persist cache if this version number changed
-pub const CACHE_VERSION: u16 = 1;
+pub const CACHE_VERSION: u16 = 2;
#[derive(Serialize, Deserialize, Clone)]
pub struct CacheValue {
@@ -111,6 +111,7 @@ impl Cache {
let old_size = self.0.len();
let mut i = 0;
+ // TODO: multithread
self.0
.retain_async(|k, v| {
if get_mtime(k).is_some_and(|mtime| mtime == v.mtime) {
diff --git a/src/post/mod.rs b/src/post/mod.rs
index ceed862..1e2619f 100644
--- a/src/post/mod.rs
+++ b/src/post/mod.rs
@@ -5,7 +5,6 @@ use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime};
-use askama::Template;
use chrono::{DateTime, Utc};
use fronma::parser::{parse, ParsedData};
use serde::{Deserialize, Serialize};
@@ -63,14 +62,6 @@ pub struct PostMetadata {
pub tags: Vec,
}
-use crate::filters;
-#[derive(Template)]
-#[template(path = "post.html")]
-struct Post<'a> {
- pub meta: &'a PostMetadata,
- pub rendered_markdown: String,
-}
-
#[allow(unused)]
pub enum RenderStats {
Cached(Duration),
@@ -127,12 +118,7 @@ impl PostManager {
let parsing = parsing_start.elapsed();
let before_render = Instant::now();
- let rendered_markdown = render(body, &self.config);
- let post = Post {
- meta: &metadata,
- rendered_markdown,
- }
- .render()?;
+ let post = render(body, &self.config);
let rendering = before_render.elapsed();
if let Some(cache) = self.cache.as_ref() {
@@ -151,7 +137,7 @@ impl PostManager {
Ok((metadata, post, (parsing, rendering)))
}
- pub async fn list_posts(
+ pub async fn get_all_post_metadata_filtered(
&self,
filter: impl Fn(&PostMetadata) -> bool,
) -> Result, PostError> {
@@ -200,21 +186,53 @@ impl PostManager {
Ok(posts)
}
- pub async fn get_max_n_posts_with_optional_tag_sorted(
+ pub async fn get_all_posts_filtered(
+ &self,
+ filter: impl Fn(&PostMetadata, &str) -> bool,
+ ) -> Result, PostError> {
+ let mut posts = Vec::new();
+
+ let mut read_dir = fs::read_dir(&self.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 name = path
+ .clone()
+ .file_stem()
+ .unwrap()
+ .to_string_lossy()
+ .to_string();
+
+ let post = self.get_post(&name).await?;
+ if filter(&post.0, &post.1) {
+ posts.push(post);
+ }
+ }
+ }
+
+ Ok(posts)
+ }
+
+ pub async fn get_max_n_post_metadata_with_optional_tag_sorted(
&self,
n: Option,
tag: Option<&String>,
) -> Result, PostError> {
let mut posts = self
- .list_posts(|metadata| !tag.is_some_and(|tag| !metadata.tags.contains(tag)))
+ .get_all_post_metadata_filtered(|metadata| {
+ !tag.is_some_and(|tag| !metadata.tags.contains(tag))
+ })
.await?;
- posts.sort_unstable_by_key(|metadata| metadata.created_at.unwrap_or_default());
-
+ // we still want some semblance of order if created_at is None so sort by mtime as well
+ posts.sort_unstable_by_key(|metadata| metadata.modified_at.unwrap_or_default());
+ posts.sort_by_key(|metadata| metadata.created_at.unwrap_or_default());
+ posts.reverse();
if let Some(n) = n {
- posts = Vec::from(&posts[posts.len().saturating_sub(n)..]);
+ posts.truncate(n);
}
- posts.reverse();
Ok(posts)
}
@@ -257,8 +275,8 @@ impl PostManager {
}
}
- pub fn into_cache(self) -> Option {
- self.cache
+ pub fn cache(&self) -> Option<&Cache> {
+ self.cache.as_ref()
}
pub async fn cleanup(&self) {
diff --git a/templates/post.html b/templates/post.html
index d148e5f..a4ddf62 100644
--- a/templates/post.html
+++ b/templates/post.html
@@ -1,16 +1,52 @@
{%- import "macros.askama" as macros -%}
-
- {{ meta.title }}
- - by {{ meta.author }}
-
-{{ meta.description }}
-
-
-
- {% call macros::table(meta) %}
-
- link
- back to home
-
-
-{{ rendered_markdown|escape("none") }}
+
+
+
+
+
+
+
+
+
+ {% match meta.icon %} {% when Some with (url) %}
+
+
+ {% when None %} {% endmatch %}
+ {{ meta.title }}
+
+
+
+
+
+
+
+ {{ meta.title }}
+ - by {{ meta.author }}
+
+ {{ meta.description }}
+
+
+ {{ rendered|escape("none") }}
+
+
+
+
+
diff --git a/templates/view_post.html b/templates/view_post.html
deleted file mode 100644
index e469da5..0000000
--- a/templates/view_post.html
+++ /dev/null
@@ -1,38 +0,0 @@
-
-
-
-
-
-
-
-
-
- {% match meta.icon %} {% when Some with (url) %}
-
-
- {% when None %} {% endmatch %}
- {{ meta.title }}
-
-
-
-
-
- {{ rendered|escape("none") }}
-
-
-
-