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

@ -5,8 +5,10 @@ mod posts_store;
mod render;
mod service;
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 miette::{IntoDiagnostic, Context};
@ -26,8 +28,22 @@ pub struct Config {
posts_dir: PathBuf,
#[knuffel(child, unwrap(argument))]
static_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)]
@ -38,10 +54,14 @@ pub struct RssConfig {
title: String,
#[knuffel(child, unwrap(argument))]
ttl: u32,
}
#[derive(knuffel::Decode, Clone, Debug)]
pub struct AtomConfig {
#[knuffel(child, unwrap(argument))]
protocol: String,
num_posts: usize,
#[knuffel(child, unwrap(argument))]
domain: String,
title: String,
}
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
// argument.
let config = {
let config = Arc::new({
let config_path = env::args().nth(1)
.ok_or_else(|| miette::Error::msg("No config file specified"))?;
@ -61,7 +81,7 @@ fn main() -> miette::Result<()> {
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.
@ -71,6 +91,7 @@ fn main() -> miette::Result<()> {
// 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()
@ -96,7 +117,7 @@ fn main() -> miette::Result<()> {
}
async fn run(
config: Config,
config: Arc<Config>,
posts_store: ConcurrentPostsStore,
) -> miette::Result<()>
{

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

@ -29,7 +29,7 @@ impl ConcurrentPostsStore {
}
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>> {
self.posts.get(id).cloned()
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_str());
let old_post = self.remove(post.id());
// Insert the post into each of the tag indexes.
for tag in post.tags() {
@ -109,19 +109,23 @@ impl PostsStore {
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>>
+ Iterator<Item = &Arc<Post>>
+ ExactSizeIterator
+ FusedIterator
+ Clone
{
self.posts.values().cloned()
self.posts.values()
}
pub fn iter_by_created(&self)
-> impl '_
+ Iterator<Item = Arc<Post>>
+ Iterator<Item = &Arc<Post>>
+ DoubleEndedIterator
+ ExactSizeIterator
+ FusedIterator

@ -3,20 +3,23 @@ use std::{
fs,
io::{self, Read},
path::PathBuf,
sync::mpsc,
sync::{Arc, mpsc},
};
use chrono::{DateTime, Utc};
use chrono::DateTime;
use notify::DebouncedEvent;
use tracing::{info, warn, error};
use crate::{
codeblock::CodeBlockRenderer,
Config,
post::{ParseError, Post, PostId},
posts_store::ConcurrentPostsStore,
time::unix_epoch,
};
pub struct Renderer {
config: Arc<Config>,
posts: ConcurrentPostsStore,
code_renderer: CodeBlockRenderer,
posts_dir_path: PathBuf,
@ -25,6 +28,7 @@ pub struct Renderer {
impl Renderer {
pub fn new(
config: Arc<Config>,
posts: ConcurrentPostsStore,
code_renderer: CodeBlockRenderer,
posts_dir_path: PathBuf,
@ -38,6 +42,7 @@ impl Renderer {
tx.send(DebouncedEvent::Rescan).unwrap();
(Self {
config,
posts,
code_renderer,
posts_dir_path,
@ -185,10 +190,11 @@ impl Renderer {
let (created, updated) = metadata.created()
.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(|_| {
let now = Utc::now();
(now, now)
let epoch = unix_epoch();
(epoch, epoch)
});
let contents = {
@ -202,6 +208,7 @@ impl Renderer {
Post::parse(
&self.code_renderer,
*self.config.namespace_uuid,
target.id.clone(),
&target.path.to_string_lossy(),
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 {
@for post in posts.read().await.iter_by_created().rev().take(5) {
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 index;
mod post;

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

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

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