Atom feed, GUID for RSS feed

main
Pantonshire 4 years ago
parent aa06d2c5ef
commit 1b4be0862c

18
Cargo.lock generated

@ -485,9 +485,9 @@ dependencies = [
[[package]] [[package]]
name = "http-body" name = "http-body"
version = "0.4.4" version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ff4f84919677303da5f147645dbea6b1881f368d03ac84e1dc09031ebd7b2c6" checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1"
dependencies = [ dependencies = [
"bytes", "bytes",
"http", "http",
@ -650,7 +650,7 @@ checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836"
[[package]] [[package]]
name = "libshire" name = "libshire"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/pantonshire/libshire#62dae931409c14531cf338d73269e415c638dede" source = "git+https://github.com/pantonshire/libshire#bd2b3a8a29b34ffeecd658b74ef81a1431fc91f0"
[[package]] [[package]]
name = "line-wrap" name = "line-wrap"
@ -920,9 +920,9 @@ dependencies = [
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.10.0" version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87f3e037eac156d1775da914196f0f37741a274155e34a0b7e427c35d2a2ecb9" checksum = "7709cef83f0c1f58f666e746a08b21e0085f7440fa6a29cc194d68aac97a4225"
[[package]] [[package]]
name = "onig" name = "onig"
@ -1108,9 +1108,9 @@ dependencies = [
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.5.5" version = "1.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a11647b6b25ff05a515cb92c365cec08801e83423a235b51e231e1808747286" checksum = "d83f127d94bdbcda4c8cc2e50f6f84f4b611f69c902699ca385a39c3a75f9ff1"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@ -1119,9 +1119,9 @@ dependencies = [
[[package]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.6.25" version = "0.6.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" checksum = "49b3de9ec5dc0a3417da371aab17d729997c15010e7fd24ff707773a33bddb64"
[[package]] [[package]]
name = "rss" name = "rss"

@ -5,8 +5,10 @@ mod posts_store;
mod render; mod render;
mod service; mod service;
mod template; mod template;
mod time;
mod uuid;
use std::{env, fs, path::PathBuf, thread}; use std::{env, fs, path::PathBuf, sync::Arc, thread};
use axum::Server; use axum::Server;
use miette::{IntoDiagnostic, Context}; use miette::{IntoDiagnostic, Context};
@ -26,8 +28,22 @@ pub struct Config {
posts_dir: PathBuf, posts_dir: PathBuf,
#[knuffel(child, unwrap(argument))] #[knuffel(child, unwrap(argument))]
static_dir: PathBuf, static_dir: PathBuf,
#[knuffel(child, unwrap(argument))]
namespace_uuid: uuid::Uuid,
#[knuffel(child)]
self_ref: SelfRefConfig,
#[knuffel(child)] #[knuffel(child)]
rss: RssConfig, 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)] #[derive(knuffel::Decode, Clone, Debug)]
@ -38,10 +54,14 @@ pub struct RssConfig {
title: String, title: String,
#[knuffel(child, unwrap(argument))] #[knuffel(child, unwrap(argument))]
ttl: u32, ttl: u32,
}
#[derive(knuffel::Decode, Clone, Debug)]
pub struct AtomConfig {
#[knuffel(child, unwrap(argument))] #[knuffel(child, unwrap(argument))]
protocol: String, num_posts: usize,
#[knuffel(child, unwrap(argument))] #[knuffel(child, unwrap(argument))]
domain: String, title: String,
} }
fn main() -> miette::Result<()> { fn main() -> miette::Result<()> {
@ -49,7 +69,7 @@ fn main() -> miette::Result<()> {
// Load the configuration from the KDL config file specified by the first command-line // Load the configuration from the KDL config file specified by the first command-line
// argument. // argument.
let config = { let config = Arc::new({
let config_path = env::args().nth(1) let config_path = env::args().nth(1)
.ok_or_else(|| miette::Error::msg("No config file specified"))?; .ok_or_else(|| miette::Error::msg("No config file specified"))?;
@ -61,7 +81,7 @@ fn main() -> miette::Result<()> {
knuffel::parse::<Config>(&config_path, &contents) knuffel::parse::<Config>(&config_path, &contents)
.wrap_err_with(|| format!("Failed to parse config file {}", config_path))? .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, // Create the data structure used to store the rendered posts. This uses an `Arc` internally,
// so clones will point to the same underlying data. // so clones will point to the same underlying data.
@ -71,6 +91,7 @@ fn main() -> miette::Result<()> {
// Create the post renderer and the mpsc channel that will be used to communicate with it. // Create the post renderer and the mpsc channel that will be used to communicate with it.
let (renderer, tx) = Renderer::new( let (renderer, tx) = Renderer::new(
config.clone(),
posts_store.clone(), posts_store.clone(),
code_renderer, code_renderer,
config.posts_dir.clone() config.posts_dir.clone()
@ -96,7 +117,7 @@ fn main() -> miette::Result<()> {
} }
async fn run( async fn run(
config: Config, config: Arc<Config>,
posts_store: ConcurrentPostsStore, posts_store: ConcurrentPostsStore,
) -> miette::Result<()> ) -> miette::Result<()>
{ {

@ -1,7 +1,7 @@
use std::{borrow, error, fmt, ops}; use std::{borrow, error, fmt, ops};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use libshire::strings::ShString22; use libshire::{strings::ShString22, uuid::{Uuid, UuidV5Error}};
use maud::{Markup, PreEscaped}; use maud::{Markup, PreEscaped};
use crate::codeblock::CodeBlockRenderer; use crate::codeblock::CodeBlockRenderer;
@ -73,6 +73,7 @@ impl fmt::Display for PostId {
} }
pub struct Post { pub struct Post {
uuid: Uuid,
id: PostId, id: PostId,
title: String, title: String,
author: String, author: String,
@ -83,8 +84,8 @@ pub struct Post {
} }
impl Post { impl Post {
pub fn id_str(&self) -> &str { pub fn uuid(&self) -> Uuid {
&self.id self.uuid
} }
pub fn id(&self) -> &PostId { pub fn id(&self) -> &PostId {
@ -117,6 +118,7 @@ impl Post {
pub fn parse( pub fn parse(
code_renderer: &CodeBlockRenderer, code_renderer: &CodeBlockRenderer,
namespace: Uuid,
post_id: PostId, post_id: PostId,
file_name: &str, file_name: &str,
created: DateTime<Utc>, created: DateTime<Utc>,
@ -124,17 +126,25 @@ impl Post {
source: &str, source: &str,
) -> Result<Self, ParseError> ) -> Result<Self, ParseError>
{ {
let mdpost = MdPost::parse(file_name, source)?; MdPost::parse(file_name, source)
Ok(Self::from_mdpost(code_renderer, post_id, created, updated, mdpost)) .and_then(|post| Self::from_mdpost(
code_renderer,
namespace,
post_id,
created,
updated,
post
))
} }
fn from_mdpost( fn from_mdpost(
code_renderer: &CodeBlockRenderer, code_renderer: &CodeBlockRenderer,
namespace: Uuid,
id: PostId, id: PostId,
created: DateTime<Utc>, created: DateTime<Utc>,
updated: DateTime<Utc>, updated: DateTime<Utc>,
mdpost: MdPost, mdpost: MdPost,
) -> Self ) -> Result<Self, ParseError>
{ {
use pulldown_cmark::{Options, Parser, html::push_html}; use pulldown_cmark::{Options, Parser, html::push_html};
@ -142,6 +152,11 @@ impl Post {
.union(Options::ENABLE_FOOTNOTES) .union(Options::ENABLE_FOOTNOTES)
.union(Options::ENABLE_STRIKETHROUGH); .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( let mut parser = PostMdParser::new(
Parser::new_ext(&mdpost.markdown, PARSER_OPTIONS), Parser::new_ext(&mdpost.markdown, PARSER_OPTIONS),
code_renderer code_renderer
@ -150,7 +165,8 @@ impl Post {
let mut html_buf = String::new(); let mut html_buf = String::new();
push_html(&mut html_buf, parser.by_ref()); push_html(&mut html_buf, parser.by_ref());
Self { Ok(Self {
uuid,
id, id,
title: mdpost.title, title: mdpost.title,
author: mdpost.author, author: mdpost.author,
@ -158,7 +174,7 @@ impl Post {
tags: mdpost.tags, tags: mdpost.tags,
created, created,
updated, updated,
} })
} }
} }
@ -281,13 +297,15 @@ impl MdPost {
pub enum ParseError { pub enum ParseError {
MissingHeader, MissingHeader,
InvalidHeader(Box<knuffel::Error>), InvalidHeader(Box<knuffel::Error>),
IdTooLong(usize),
} }
impl fmt::Display for ParseError { impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
ParseError::MissingHeader => write!(f, "Post file has no header"), Self::MissingHeader => write!(f, "post file has no header"),
ParseError::InvalidHeader(err) => fmt::Display::fmt(err, f), Self::InvalidHeader(err) => fmt::Display::fmt(err, f),
Self::IdTooLong(len) => write!(f, "post id too long ({} bytes)", len),
} }
} }
} }

@ -29,7 +29,7 @@ impl ConcurrentPostsStore {
} }
pub async fn get(&self, id: &str) -> Option<Arc<Post>> { pub async fn get(&self, id: &str) -> Option<Arc<Post>> {
self.read().await.get(id) self.read().await.get(id).cloned()
} }
} }
@ -56,12 +56,12 @@ impl PostsStore {
} }
} }
pub fn get(&self, id: &str) -> Option<Arc<Post>> { pub fn get(&self, id: &str) -> Option<&Arc<Post>> {
self.posts.get(id).cloned() self.posts.get(id)
} }
pub fn insert(&mut self, post: Post) -> Option<Arc<Post>> { pub fn insert(&mut self, post: Post) -> Option<Arc<Post>> {
let old_post = self.remove(post.id_str()); let old_post = self.remove(post.id());
// Insert the post into each of the tag indexes. // Insert the post into each of the tag indexes.
for tag in post.tags() { for tag in post.tags() {
@ -109,19 +109,23 @@ impl PostsStore {
self.posts.clear(); self.posts.clear();
} }
pub fn last_updated(&self) -> Option<DateTime<Utc>> {
self.iter().map(|post| post.updated()).max()
}
pub fn iter(&self) pub fn iter(&self)
-> impl '_ -> impl '_
+ Iterator<Item = Arc<Post>> + Iterator<Item = &Arc<Post>>
+ ExactSizeIterator + ExactSizeIterator
+ FusedIterator + FusedIterator
+ Clone + Clone
{ {
self.posts.values().cloned() self.posts.values()
} }
pub fn iter_by_created(&self) pub fn iter_by_created(&self)
-> impl '_ -> impl '_
+ Iterator<Item = Arc<Post>> + Iterator<Item = &Arc<Post>>
+ DoubleEndedIterator + DoubleEndedIterator
+ ExactSizeIterator + ExactSizeIterator
+ FusedIterator + FusedIterator

@ -3,20 +3,23 @@ use std::{
fs, fs,
io::{self, Read}, io::{self, Read},
path::PathBuf, path::PathBuf,
sync::mpsc, sync::{Arc, mpsc},
}; };
use chrono::{DateTime, Utc}; use chrono::DateTime;
use notify::DebouncedEvent; use notify::DebouncedEvent;
use tracing::{info, warn, error}; use tracing::{info, warn, error};
use crate::{ use crate::{
codeblock::CodeBlockRenderer, codeblock::CodeBlockRenderer,
Config,
post::{ParseError, Post, PostId}, post::{ParseError, Post, PostId},
posts_store::ConcurrentPostsStore, posts_store::ConcurrentPostsStore,
time::unix_epoch,
}; };
pub struct Renderer { pub struct Renderer {
config: Arc<Config>,
posts: ConcurrentPostsStore, posts: ConcurrentPostsStore,
code_renderer: CodeBlockRenderer, code_renderer: CodeBlockRenderer,
posts_dir_path: PathBuf, posts_dir_path: PathBuf,
@ -25,6 +28,7 @@ pub struct Renderer {
impl Renderer { impl Renderer {
pub fn new( pub fn new(
config: Arc<Config>,
posts: ConcurrentPostsStore, posts: ConcurrentPostsStore,
code_renderer: CodeBlockRenderer, code_renderer: CodeBlockRenderer,
posts_dir_path: PathBuf, posts_dir_path: PathBuf,
@ -38,6 +42,7 @@ impl Renderer {
tx.send(DebouncedEvent::Rescan).unwrap(); tx.send(DebouncedEvent::Rescan).unwrap();
(Self { (Self {
config,
posts, posts,
code_renderer, code_renderer,
posts_dir_path, posts_dir_path,
@ -185,10 +190,11 @@ impl Renderer {
let (created, updated) = metadata.created() let (created, updated) = metadata.created()
.and_then(|created| metadata.modified() .and_then(|created| metadata.modified()
.map(|modified| (DateTime::<Utc>::from(created), DateTime::<Utc>::from(modified)))) .map(|modified| (DateTime::from(created), DateTime::from(modified))))
// If created / modified metadata is not available, default to the UNIX epoch.
.unwrap_or_else(|_| { .unwrap_or_else(|_| {
let now = Utc::now(); let epoch = unix_epoch();
(now, now) (epoch, epoch)
}); });
let contents = { let contents = {
@ -202,6 +208,7 @@ impl Renderer {
Post::parse( Post::parse(
&self.code_renderer, &self.code_renderer,
*self.config.namespace_uuid,
target.id.clone(), target.id.clone(),
&target.path.to_string_lossy(), &target.path.to_string_lossy(),
created, created,

@ -0,0 +1,82 @@
use std::sync::Arc;
use atom_syndication as atom;
use axum::{
body::Bytes,
extract::Extension,
};
use super::response::Atom;
use crate::{
Config,
posts_store::ConcurrentPostsStore,
time::unix_epoch,
};
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())
}

@ -43,7 +43,7 @@ pub async fn handle(Extension(posts): Extension<ConcurrentPostsStore>) -> Html {
ul { ul {
@for post in posts.read().await.iter_by_created().rev().take(5) { @for post in posts.read().await.iter_by_created().rev().take(5) {
li { li {
a href={"/articles/" (post.id_str())} { (post.title()) } a href={"/articles/" (post.id())} { (post.title()) }
} }
} }
} }

@ -1,3 +1,4 @@
mod atom;
mod contact; mod contact;
mod index; mod index;
mod post; mod post;

@ -23,7 +23,7 @@ pub async fn handle(Extension(posts): Extension<ConcurrentPostsStore>) -> Html {
ul { ul {
@for post in posts.read().await.iter_by_created().rev() { @for post in posts.read().await.iter_by_created().rev() {
li { li {
a href={"/articles/" (post.id_str())} { (post.title()) } a href={"/articles/" (post.id())} { (post.title()) }
span class="quiet" { span class="quiet" {
" — " (post.created().format("%Y/%m/%d")) " — " (post.created().format("%Y/%m/%d"))
} }

@ -6,37 +6,53 @@ use axum::{
}; };
use super::response::Rss; use super::response::Rss;
use crate::{posts_store::ConcurrentPostsStore, Config}; use crate::{
Config,
posts_store::ConcurrentPostsStore,
time::unix_epoch,
};
pub async fn handle( pub async fn handle(
Extension(config): Extension<Arc<Config>>, Extension(config): Extension<Arc<Config>>,
Extension(posts): Extension<ConcurrentPostsStore>, Extension(posts): Extension<ConcurrentPostsStore>,
) -> Rss<Bytes> { ) -> Rss<Bytes> {
let rss_items = posts.read() let (rss_items, updated) = {
.await let guard = posts.read().await;
.iter_by_created()
.take(config.rss.num_posts) let rss_items = guard.iter_by_created()
.map(|post| { .take(config.rss.num_posts)
rss::ItemBuilder::default() .map(|post| {
.title(Some(post.title().to_owned())) rss::ItemBuilder::default()
.link(Some(format!( .title(Some(post.title().to_owned()))
"{}://{}/articles/{}", .guid(Some(rss::GuidBuilder::default()
config.rss.protocol, .value(post.uuid().to_string())
config.rss.domain, .permalink(false)
post.id() .build()))
))) .link(Some(format!(
.pub_date(Some(post.created().to_rfc2822())) "{}://{}/articles/{}",
.build() config.self_ref.protocol,
}) config.self_ref.domain,
.collect::<Vec<rss::Item>>(); 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() Rss(rss::ChannelBuilder::default()
.title(config.rss.title.clone()) .title(config.rss.title.clone())
.link(format!( .link(format!(
"{}://{}", "{}://{}",
config.rss.protocol, config.rss.domain config.self_ref.protocol, config.self_ref.domain
)) ))
.ttl(Some(config.rss.ttl.to_string())) .ttl(Some(config.rss.ttl.to_string()))
.last_build_date(Some(updated.to_rfc2822()))
.items(rss_items) .items(rss_items)
.build() .build()
.to_string() .to_string()

@ -16,6 +16,7 @@ use crate::{
posts_store::ConcurrentPostsStore posts_store::ConcurrentPostsStore
}; };
use super::{ use super::{
atom,
contact, contact,
index, index,
post, post,
@ -26,13 +27,14 @@ use super::{
}; };
pub fn service( pub fn service(
config: Config, config: Arc<Config>,
posts_store: ConcurrentPostsStore, posts_store: ConcurrentPostsStore,
) -> Router ) -> Router
{ {
Router::new() Router::new()
.route("/", get(index::handle)) .route("/", get(index::handle))
.route("/rss.xml", get(rss::handle)) .route("/rss.xml", get(rss::handle))
.route("/atom.xml", get(atom::handle))
.route("/contact", get(contact::handle)) .route("/contact", get(contact::handle))
.route("/articles", get(posts_list::handle)) .route("/articles", get(posts_list::handle))
.route("/articles/:post_id", get(post::handle)) .route("/articles/:post_id", get(post::handle))
@ -40,7 +42,7 @@ pub fn service(
.fallback(handle_fallback.into_service()) .fallback(handle_fallback.into_service())
.layer(ConcurrencyLimitLayer::new(config.concurrency_limit)) .layer(ConcurrencyLimitLayer::new(config.concurrency_limit))
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(Extension(Arc::new(config))) .layer(Extension(config))
.layer(Extension(posts_store)) .layer(Extension(posts_store))
} }

@ -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())
}
}
}
}
Loading…
Cancel
Save