More modular rendering of posts, post publish time now lives in the header
This commit is contained in:
1 parent
14726ca7d4
commit
341ccde5d5
21 files changed
+562
-326
No files matched your search
Generated
+28
@@ -164,6 +164,7 @@ dependencies = [
|
||||
"atom_syndication",
|
||||
"axum",
|
||||
"chrono",
|
||||
"kdl",
|
||||
"knuffel",
|
||||
"libshire",
|
||||
"maud",
|
||||
@@ -584,6 +585,17 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d"
|
||||
|
||||
[[package]]
|
||||
name = "kdl"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7ba123fe3f30838b649efd5606531e8326623c5f44491c7e631f3b970e20cdb"
|
||||
dependencies = [
|
||||
"miette",
|
||||
"nom",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kernel32-sys"
|
||||
version = "0.2.2"
|
||||
@@ -764,6 +776,12 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.5.3"
|
||||
@@ -845,6 +863,16 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "4.0.17"
|
||||
|
||||
@@ -25,6 +25,7 @@ mime = "0.3"
|
||||
maud = "0.23"
|
||||
atom_syndication = "0.11"
|
||||
rss = "2"
|
||||
kdl = "4"
|
||||
knuffel = "2"
|
||||
pulldown-cmark = "0.9"
|
||||
syntect = "4"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod config;
|
||||
mod fs_watcher;
|
||||
mod render;
|
||||
mod service;
|
||||
|
||||
@@ -12,9 +12,8 @@ use tracing::{info, warn, error};
|
||||
|
||||
use blog::{
|
||||
codeblock::CodeBlockRenderer,
|
||||
post::{parse as parse_post, ParseError, Post, PostId},
|
||||
post::{Error as ParseError, Post, Id},
|
||||
db::ConcurrentPostsStore,
|
||||
time::unix_epoch,
|
||||
};
|
||||
|
||||
use crate::Config;
|
||||
@@ -189,14 +188,10 @@ impl Renderer {
|
||||
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 updated = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.map(DateTime::from);
|
||||
|
||||
let contents = {
|
||||
let mut buf = String::new();
|
||||
@@ -207,12 +202,10 @@ impl Renderer {
|
||||
|
||||
drop(fd);
|
||||
|
||||
parse_post(
|
||||
Post::new_from_str(
|
||||
&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)))
|
||||
@@ -229,7 +222,7 @@ enum Event {
|
||||
|
||||
struct EventTarget {
|
||||
pub path: PathBuf,
|
||||
pub id: PostId,
|
||||
pub id: Id,
|
||||
}
|
||||
|
||||
impl fmt::Debug for EventTarget {
|
||||
@@ -242,7 +235,7 @@ 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)
|
||||
.and_then(Id::from_file_name)
|
||||
.map(|id| Self {
|
||||
path,
|
||||
id,
|
||||
|
||||
@@ -17,7 +17,7 @@ pub async fn handle(
|
||||
let guard = posts.read().await;
|
||||
|
||||
let atom_entries = guard
|
||||
.iter_by_created()
|
||||
.iter_by_published()
|
||||
.take(config.atom.num_posts)
|
||||
.map(|post| {
|
||||
atom::EntryBuilder::default()
|
||||
|
||||
@@ -49,14 +49,14 @@ pub async fn handle(Extension(posts): Extension<ConcurrentPostsStore>) -> Html {
|
||||
h2 { "Articles" }
|
||||
p { "Some recent ones:" }
|
||||
ul .articles_list {
|
||||
@for post in posts.read().await.iter_by_created().rev().take(3) {
|
||||
@for post in posts.read().await.iter_by_published().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"))
|
||||
"Published " (post.published().format("%Y/%m/%d"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ pub async fn handle(
|
||||
@if let Some(subtitle) = post.subtitle() {
|
||||
p .article_subtitle { (subtitle) }
|
||||
}
|
||||
p .article_published_date { "Published " (post.created().format("%Y/%m/%d")) }
|
||||
p .article_published_date { "Published " (post.published().format("%Y/%m/%d")) }
|
||||
}
|
||||
article .article_content {
|
||||
(post.html())
|
||||
|
||||
@@ -23,14 +23,14 @@ pub async fn handle(Extension(posts): Extension<ConcurrentPostsStore>) -> Html {
|
||||
"A collection of words I have written, against my better judgement."
|
||||
}
|
||||
ul .articles_list {
|
||||
@for post in posts.read().await.iter_by_created().rev() {
|
||||
@for post in posts.read().await.iter_by_published().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"))
|
||||
"Published " (post.published().format("%Y/%m/%d"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ pub async fn handle(
|
||||
let (rss_items, updated) = {
|
||||
let guard = posts.read().await;
|
||||
|
||||
let rss_items = guard.iter_by_created()
|
||||
let rss_items = guard.iter_by_published()
|
||||
.take(config.rss.num_posts)
|
||||
.map(|post| {
|
||||
rss::ItemBuilder::default()
|
||||
@@ -36,7 +36,7 @@ pub async fn handle(
|
||||
config.self_ref.domain,
|
||||
post.id()
|
||||
)))
|
||||
.pub_date(Some(post.created().to_rfc2822()))
|
||||
.pub_date(Some(post.published().to_rfc2822()))
|
||||
.build()
|
||||
})
|
||||
.collect::<Vec<rss::Item>>();
|
||||
|
||||
+22
-22
@@ -8,7 +8,7 @@ use chrono::{DateTime, Utc};
|
||||
use libshire::strings::ShString22;
|
||||
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
|
||||
|
||||
use crate::post::{Post, PostId};
|
||||
use crate::post::{Post, Id};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConcurrentPostsStore {
|
||||
@@ -40,9 +40,9 @@ impl Default for ConcurrentPostsStore {
|
||||
}
|
||||
|
||||
pub struct PostsStore {
|
||||
posts: HashMap<PostId, Arc<Post>>,
|
||||
created_ix: BTreeSet<CreatedIxEntry>,
|
||||
tags_ix: HashMap<ShString22, HashSet<PostId>>,
|
||||
posts: HashMap<Id, Arc<Post>>,
|
||||
published_ix: BTreeSet<PublishedIxEntry>,
|
||||
tags_ix: HashMap<ShString22, HashSet<Id>>,
|
||||
}
|
||||
|
||||
// TODO: shrink the various collections on removal to deallocate unneeded space
|
||||
@@ -51,7 +51,7 @@ impl PostsStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
posts: HashMap::new(),
|
||||
created_ix: BTreeSet::new(),
|
||||
published_ix: BTreeSet::new(),
|
||||
tags_ix: HashMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -73,8 +73,8 @@ impl PostsStore {
|
||||
}.insert(post.id().clone());
|
||||
}
|
||||
|
||||
// Insert the post into the correct position of the created BTree index.
|
||||
self.created_ix.insert(CreatedIxEntry::new(&post));
|
||||
// Insert the post into the correct position of the published BTree index.
|
||||
self.published_ix.insert(PublishedIxEntry::new(&post));
|
||||
|
||||
// Wrap the post with an atomic reference counter and insert it into the main posts
|
||||
// `HashMap`.
|
||||
@@ -86,9 +86,9 @@ impl PostsStore {
|
||||
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 the post's entry in the published index.
|
||||
self.published_ix
|
||||
.remove(&PublishedIxEntry::new(&post));
|
||||
|
||||
// Remove every occurence of the post from the tags index.
|
||||
for tag in post.tags() {
|
||||
@@ -105,7 +105,7 @@ impl PostsStore {
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.tags_ix.clear();
|
||||
self.created_ix.clear();
|
||||
self.published_ix.clear();
|
||||
self.posts.clear();
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ impl PostsStore {
|
||||
self.posts.values()
|
||||
}
|
||||
|
||||
pub fn iter_by_created(&self)
|
||||
pub fn iter_by_published(&self)
|
||||
-> impl '_
|
||||
+ Iterator<Item = &Arc<Post>>
|
||||
+ DoubleEndedIterator
|
||||
@@ -131,13 +131,13 @@ impl PostsStore {
|
||||
+ 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
|
||||
// For each entry of the published index, look up the corresponding post in the posts map
|
||||
// and return the post. Every entry in the published index should contain the ID of a post
|
||||
// in the posts map, so the `expect` should never fail.
|
||||
self.published_ix
|
||||
.iter()
|
||||
.map(|entry| self.get(&entry.id)
|
||||
.expect("invalid entry in `created_ix` pointing to a post that does not exist"))
|
||||
.expect("invalid entry in `published_ix` pointing to a post that does not exist"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,15 +148,15 @@ impl Default for PostsStore {
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct CreatedIxEntry {
|
||||
created: DateTime<Utc>,
|
||||
id: PostId,
|
||||
struct PublishedIxEntry {
|
||||
published: DateTime<Utc>,
|
||||
id: Id,
|
||||
}
|
||||
|
||||
impl CreatedIxEntry {
|
||||
impl PublishedIxEntry {
|
||||
fn new(post: &Post) -> Self {
|
||||
Self {
|
||||
created: post.created(),
|
||||
published: post.published(),
|
||||
id: post.id().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
use std::{error, fmt};
|
||||
|
||||
use kdl::KdlError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
NoDelim,
|
||||
Syntax(Box<KdlError>),
|
||||
FieldMissing {
|
||||
field: &'static str,
|
||||
},
|
||||
BadType {
|
||||
field: &'static str,
|
||||
expected: &'static str,
|
||||
},
|
||||
IdTooLong(usize),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::NoDelim => {
|
||||
write!(f, "post has no header; no delimiter `\\n---\\n` found")
|
||||
},
|
||||
Self::Syntax(err) => {
|
||||
write!(f, "syntax error in post header: {}", err)
|
||||
},
|
||||
Self::FieldMissing { field } => {
|
||||
write!(f, "missing required post header field `{}`", field)
|
||||
},
|
||||
Self::BadType { field, expected } => {
|
||||
write!(f, "expected post header field `{}` to be {}", field, expected)
|
||||
},
|
||||
Self::IdTooLong(len) => {
|
||||
write!(f, "post id too long: {} bytes", len)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl error::Error for Error {}
|
||||
|
||||
impl From<KdlError> for Error {
|
||||
fn from(err: KdlError) -> Self {
|
||||
Self::Syntax(Box::new(err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use kdl::KdlDocument;
|
||||
use libshire::strings::ShString22;
|
||||
|
||||
use crate::time::{datetime_unix_seconds, unix_epoch};
|
||||
|
||||
use super::error::Error;
|
||||
|
||||
pub struct Header {
|
||||
pub(super) title: String,
|
||||
pub(super) subtitle: Option<String>,
|
||||
pub(super) author: ShString22,
|
||||
pub(super) tags: Vec<ShString22>,
|
||||
pub(super) published: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn subtitle(&self) -> Option<&str> {
|
||||
self.subtitle.as_deref()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn author(&self) -> &str {
|
||||
&self.author
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn tags(&self) -> &[ShString22] {
|
||||
&self.tags
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn published(&self) -> DateTime<Utc> {
|
||||
self.published
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> TryFrom<&'a KdlDocument> for Header {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(doc: &'a KdlDocument) -> Result<Self, Self::Error> {
|
||||
let title = doc
|
||||
.get_arg("title")
|
||||
.ok_or(Error::FieldMissing { field: "title" })
|
||||
.and_then(|value| value
|
||||
.as_string()
|
||||
.ok_or(Error::BadType { field: "title", expected: "string" })
|
||||
.map(|title| title.to_owned()))?;
|
||||
|
||||
let subtitle = doc
|
||||
.get_arg("subtitle")
|
||||
.map(|value| value
|
||||
.as_string()
|
||||
.ok_or(Error::BadType { field: "subtitle", expected: "string" })
|
||||
.map(|subtitle| subtitle.to_owned()))
|
||||
.transpose()?;
|
||||
|
||||
let author = doc
|
||||
.get_arg("title")
|
||||
.ok_or(Error::FieldMissing { field: "author" })
|
||||
.and_then(|value| value
|
||||
.as_string()
|
||||
.ok_or(Error::BadType { field: "author", expected: "string" })
|
||||
.map(ShString22::from))?;
|
||||
|
||||
let tags = doc
|
||||
.get("tags")
|
||||
.map(|node| node.entries())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|entry| match entry.name() {
|
||||
Some(_) => None,
|
||||
None => Some(entry.value()),
|
||||
})
|
||||
.map(|value| value
|
||||
.as_string()
|
||||
.ok_or(Error::BadType { field: "tag", expected: "string" })
|
||||
.map(ShString22::from))
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
let published = doc
|
||||
.get_arg("published")
|
||||
.map(|value| value
|
||||
.as_i64()
|
||||
.ok_or(Error::BadType { field: "published", expected: "integer unix timestamp (seconds)" })
|
||||
.map(datetime_unix_seconds))
|
||||
.transpose()?
|
||||
.unwrap_or_else(unix_epoch);
|
||||
|
||||
Ok(Header {
|
||||
title,
|
||||
subtitle,
|
||||
author,
|
||||
tags,
|
||||
published,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<KdlDocument> for Header {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: KdlDocument) -> Result<Self, Self::Error> {
|
||||
Self::try_from(&value)
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -3,9 +3,9 @@ use std::{borrow, fmt, ops};
|
||||
use libshire::strings::ShString22;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct PostId(ShString22);
|
||||
pub struct Id(ShString22);
|
||||
|
||||
impl PostId {
|
||||
impl Id {
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn from_file_name(file_name: &str) -> Option<Self> {
|
||||
@@ -36,7 +36,7 @@ impl PostId {
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Deref for PostId {
|
||||
impl ops::Deref for Id {
|
||||
type Target = str;
|
||||
|
||||
#[inline]
|
||||
@@ -45,42 +45,42 @@ impl ops::Deref for PostId {
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::DerefMut for PostId {
|
||||
impl ops::DerefMut for Id {
|
||||
#[inline]
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut *self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for PostId {
|
||||
impl AsRef<str> for Id {
|
||||
#[inline]
|
||||
fn as_ref(&self) -> &str {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl AsMut<str> for PostId {
|
||||
impl AsMut<str> for Id {
|
||||
#[inline]
|
||||
fn as_mut(&mut self) -> &mut str {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl borrow::Borrow<str> for PostId {
|
||||
impl borrow::Borrow<str> for Id {
|
||||
#[inline]
|
||||
fn borrow(&self) -> &str {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl borrow::BorrowMut<str> for PostId {
|
||||
impl borrow::BorrowMut<str> for Id {
|
||||
#[inline]
|
||||
fn borrow_mut(&mut self) -> &mut str {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PostId {
|
||||
impl fmt::Display for Id {
|
||||
#[inline]
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(&**self, f)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
use super::{error::Error, header::Header, source::PostSource};
|
||||
|
||||
pub struct MarkdownPost {
|
||||
pub(super) header: Header,
|
||||
pub(super) markdown: String,
|
||||
}
|
||||
|
||||
impl MarkdownPost {
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn header(&self) -> &Header {
|
||||
&self.header
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn markdown(&self) -> &str {
|
||||
&self.markdown
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<PostSource> for MarkdownPost {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(source: PostSource) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
header: source.header.try_into()?,
|
||||
markdown: source.markdown,
|
||||
})
|
||||
}
|
||||
}
|
||||
+13
-56
@@ -1,61 +1,18 @@
|
||||
mod error;
|
||||
mod id;
|
||||
mod parse;
|
||||
mod header;
|
||||
mod markdown_post;
|
||||
mod render;
|
||||
mod rendered_post;
|
||||
mod source;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use libshire::{strings::ShString22, uuid::Uuid};
|
||||
use maud::{Markup, PreEscaped};
|
||||
|
||||
pub use id::PostId;
|
||||
pub use parse::{parse, ParseError};
|
||||
pub use error::Error;
|
||||
pub use header::Header;
|
||||
pub use id::Id;
|
||||
pub use markdown_post::MarkdownPost;
|
||||
pub use rendered_post::RenderedPost;
|
||||
pub use source::PostSource;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
pub type Post = RenderedPost;
|
||||
@@ -1,215 +0,0 @@
|
||||
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,81 @@
|
||||
use maud::{html, Markup, PreEscaped};
|
||||
use pulldown_cmark::{
|
||||
CodeBlockKind,
|
||||
CowStr,
|
||||
Event,
|
||||
Options,
|
||||
Parser,
|
||||
Tag,
|
||||
html::push_html,
|
||||
};
|
||||
|
||||
use crate::codeblock::CodeBlockRenderer;
|
||||
|
||||
pub(super) fn render_markdown(code_renderer: &CodeBlockRenderer, markdown: &str) -> Markup {
|
||||
const PARSER_OPTIONS: Options = Options::ENABLE_TABLES
|
||||
.union(Options::ENABLE_FOOTNOTES)
|
||||
.union(Options::ENABLE_STRIKETHROUGH);
|
||||
|
||||
let mut parser = {
|
||||
let parser = Parser::new_ext(markdown, PARSER_OPTIONS);
|
||||
PostMdParser::new(parser, code_renderer)
|
||||
};
|
||||
|
||||
let mut html_buf = String::new();
|
||||
push_html(&mut html_buf, parser.by_ref());
|
||||
|
||||
PreEscaped(html_buf)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl<'p, I> PostMdParser<'p, I> {
|
||||
fn new(iter: I, code_renderer: &'p CodeBlockRenderer) -> Self {
|
||||
Self {
|
||||
iter,
|
||||
code_renderer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
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 => event,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use libshire::{strings::ShString22, uuid::{Uuid, UuidV5Error}};
|
||||
use maud::{Markup, PreEscaped};
|
||||
|
||||
use crate::{codeblock::CodeBlockRenderer, time::unix_epoch};
|
||||
|
||||
use super::{
|
||||
error::Error,
|
||||
header::Header,
|
||||
id::Id,
|
||||
markdown_post::MarkdownPost,
|
||||
render::render_markdown,
|
||||
source::PostSource,
|
||||
};
|
||||
|
||||
pub struct RenderedPost {
|
||||
uuid: Uuid,
|
||||
id: Id,
|
||||
header: Header,
|
||||
updated: DateTime<Utc>,
|
||||
html: Markup,
|
||||
}
|
||||
|
||||
impl RenderedPost {
|
||||
pub fn new_from_str(
|
||||
code_renderer: &CodeBlockRenderer,
|
||||
namespace: Uuid,
|
||||
id: Id,
|
||||
updated: Option<DateTime<Utc>>,
|
||||
source: &str
|
||||
) -> Result<Self, Error>
|
||||
{
|
||||
let markdown_post = source
|
||||
.parse::<PostSource>()
|
||||
.and_then(MarkdownPost::try_from)?;
|
||||
|
||||
Self::new_from_markdown_post(code_renderer, namespace, id, updated, markdown_post)
|
||||
}
|
||||
|
||||
pub fn new_from_markdown_post(
|
||||
code_renderer: &CodeBlockRenderer,
|
||||
namespace: Uuid,
|
||||
id: Id,
|
||||
updated: Option<DateTime<Utc>>,
|
||||
markdown_post: MarkdownPost
|
||||
) -> Result<Self, Error>
|
||||
{
|
||||
let uuid = Uuid::new_v5(namespace, &*id)
|
||||
.map_err(|err| match err {
|
||||
UuidV5Error::NameTooLong(len) => Error::IdTooLong(len),
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
uuid,
|
||||
id,
|
||||
header: markdown_post.header,
|
||||
updated: updated.unwrap_or_else(unix_epoch),
|
||||
html: render_markdown(code_renderer, &markdown_post.markdown),
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn uuid(&self) -> Uuid {
|
||||
self.uuid
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn id(&self) -> &Id {
|
||||
&self.id
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn header(&self) -> &Header {
|
||||
&self.header
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn title(&self) -> &str {
|
||||
self.header.title()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn subtitle(&self) -> Option<&str> {
|
||||
self.header.subtitle()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn author(&self) -> &str {
|
||||
self.header.author()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn tags(&self) -> &[ShString22] {
|
||||
self.header.tags()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn published(&self) -> DateTime<Utc> {
|
||||
self.header.published()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn updated(&self) -> DateTime<Utc> {
|
||||
self.updated
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn html(&self) -> PreEscaped<&str> {
|
||||
PreEscaped(&self.html.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use std::{fmt, str};
|
||||
|
||||
use kdl::KdlDocument;
|
||||
|
||||
use super::error::Error;
|
||||
|
||||
const DELIM: &str = "\n---\n";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PostSource {
|
||||
pub(super) header: KdlDocument,
|
||||
pub(super) markdown: String,
|
||||
}
|
||||
|
||||
impl PostSource {
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn header(&self) -> &KdlDocument {
|
||||
&self.header
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn header_mut(&mut self) -> &mut KdlDocument {
|
||||
&mut self.header
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn markdown(&self) -> &str {
|
||||
&self.markdown
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn markdown_mut(&mut self) -> &mut String {
|
||||
&mut self.markdown
|
||||
}
|
||||
}
|
||||
|
||||
impl str::FromStr for PostSource {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let (header, markdown) = s.split_once(DELIM)
|
||||
.ok_or(Error::NoDelim)?;
|
||||
|
||||
Ok(PostSource {
|
||||
header: header.parse()?,
|
||||
markdown: markdown.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PostSource {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}{}{}", self.header, DELIM, self.markdown)
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -1,5 +1,19 @@
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn datetime_unix(seconds: i64, nanoseconds: u32) -> DateTime<Utc> {
|
||||
DateTime::from_utc(NaiveDateTime::from_timestamp(seconds, nanoseconds), Utc)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn datetime_unix_seconds(seconds: i64) -> DateTime<Utc> {
|
||||
datetime_unix(seconds, 0)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn unix_epoch() -> DateTime<Utc> {
|
||||
DateTime::from_utc(NaiveDateTime::from_timestamp(0, 0), Utc)
|
||||
datetime_unix(0, 0)
|
||||
}
|
||||
Reference in new issue
Block a user