diff --git a/Cargo.lock b/Cargo.lock index 1b790a0..575bb2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 8c3166b..12a4fc0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/bin/blog_server/config.rs b/src/bin/blog_server/config.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/bin/blog_server/config.rs @@ -0,0 +1 @@ + diff --git a/src/bin/blog_server/main.rs b/src/bin/blog_server/main.rs index 5b7f43f..c04f1b7 100644 --- a/src/bin/blog_server/main.rs +++ b/src/bin/blog_server/main.rs @@ -1,3 +1,4 @@ +mod config; mod fs_watcher; mod render; mod service; diff --git a/src/bin/blog_server/render.rs b/src/bin/blog_server/render.rs index 575ecaa..f0f85f8 100644 --- a/src/bin/blog_server/render.rs +++ b/src/bin/blog_server/render.rs @@ -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 { 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, diff --git a/src/bin/blog_server/service/atom.rs b/src/bin/blog_server/service/atom.rs index 1fc9a23..286a817 100644 --- a/src/bin/blog_server/service/atom.rs +++ b/src/bin/blog_server/service/atom.rs @@ -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() diff --git a/src/bin/blog_server/service/index.rs b/src/bin/blog_server/service/index.rs index 77fd765..74bd14a 100644 --- a/src/bin/blog_server/service/index.rs +++ b/src/bin/blog_server/service/index.rs @@ -49,14 +49,14 @@ pub async fn handle(Extension(posts): Extension) -> 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")) } } } diff --git a/src/bin/blog_server/service/post.rs b/src/bin/blog_server/service/post.rs index 9cf859b..26725df 100644 --- a/src/bin/blog_server/service/post.rs +++ b/src/bin/blog_server/service/post.rs @@ -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()) diff --git a/src/bin/blog_server/service/posts_list.rs b/src/bin/blog_server/service/posts_list.rs index c6a58a0..5ed4f00 100644 --- a/src/bin/blog_server/service/posts_list.rs +++ b/src/bin/blog_server/service/posts_list.rs @@ -23,14 +23,14 @@ pub async fn handle(Extension(posts): Extension) -> 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")) } } } diff --git a/src/bin/blog_server/service/rss.rs b/src/bin/blog_server/service/rss.rs index b5ed17c..5d91f0d 100644 --- a/src/bin/blog_server/service/rss.rs +++ b/src/bin/blog_server/service/rss.rs @@ -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::>(); diff --git a/src/lib/db.rs b/src/lib/db.rs index a4e996c..3a57633 100644 --- a/src/lib/db.rs +++ b/src/lib/db.rs @@ -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>, - created_ix: BTreeSet, - tags_ix: HashMap>, + posts: HashMap>, + published_ix: BTreeSet, + tags_ix: HashMap>, } // 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> { 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> + 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, - id: PostId, +struct PublishedIxEntry { + published: DateTime, + id: Id, } -impl CreatedIxEntry { +impl PublishedIxEntry { fn new(post: &Post) -> Self { Self { - created: post.created(), + published: post.published(), id: post.id().clone(), } } diff --git a/src/lib/post/error.rs b/src/lib/post/error.rs new file mode 100644 index 0000000..31aa194 --- /dev/null +++ b/src/lib/post/error.rs @@ -0,0 +1,47 @@ +use std::{error, fmt}; + +use kdl::KdlError; + +#[derive(Debug)] +pub enum Error { + NoDelim, + Syntax(Box), + 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 for Error { + fn from(err: KdlError) -> Self { + Self::Syntax(Box::new(err)) + } +} diff --git a/src/lib/post/header.rs b/src/lib/post/header.rs new file mode 100644 index 0000000..43a7f2c --- /dev/null +++ b/src/lib/post/header.rs @@ -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, + pub(super) author: ShString22, + pub(super) tags: Vec, + pub(super) published: DateTime, +} + +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 { + self.published + } +} + +impl<'a> TryFrom<&'a KdlDocument> for Header { + type Error = Error; + + fn try_from(doc: &'a KdlDocument) -> Result { + 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::>()?; + + 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 for Header { + type Error = Error; + + fn try_from(value: KdlDocument) -> Result { + Self::try_from(&value) + } +} diff --git a/src/lib/post/id.rs b/src/lib/post/id.rs index e0a7c24..2b5e800 100644 --- a/src/lib/post/id.rs +++ b/src/lib/post/id.rs @@ -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 { @@ -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 for PostId { +impl AsRef for Id { #[inline] fn as_ref(&self) -> &str { self } } -impl AsMut for PostId { +impl AsMut for Id { #[inline] fn as_mut(&mut self) -> &mut str { self } } -impl borrow::Borrow for PostId { +impl borrow::Borrow for Id { #[inline] fn borrow(&self) -> &str { self } } -impl borrow::BorrowMut for PostId { +impl borrow::BorrowMut 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) diff --git a/src/lib/post/markdown_post.rs b/src/lib/post/markdown_post.rs new file mode 100644 index 0000000..c84f62d --- /dev/null +++ b/src/lib/post/markdown_post.rs @@ -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 for MarkdownPost { + type Error = Error; + + fn try_from(source: PostSource) -> Result { + Ok(Self { + header: source.header.try_into()?, + markdown: source.markdown, + }) + } +} diff --git a/src/lib/post/mod.rs b/src/lib/post/mod.rs index 3c1df47..2579a6a 100644 --- a/src/lib/post/mod.rs +++ b/src/lib/post/mod.rs @@ -1,61 +1,18 @@ +mod error; mod id; -mod parse; - -use chrono::{DateTime, Utc}; -use libshire::{strings::ShString22, uuid::Uuid}; -use maud::{Markup, PreEscaped}; - -pub use id::PostId; -pub use parse::{parse, ParseError}; +mod header; +mod markdown_post; +mod render; +mod rendered_post; +mod source; + +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, - author: ShString22, - html: Markup, - tags: Vec, - created: DateTime, - updated: DateTime, -} - -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 { - self.created - } - - pub fn updated(&self) -> DateTime { - self.updated - } -} +pub type Post = RenderedPost; diff --git a/src/lib/post/parse.rs b/src/lib/post/parse.rs deleted file mode 100644 index 8f60c07..0000000 --- a/src/lib/post/parse.rs +++ /dev/null @@ -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, - updated: DateTime, - source: &str, -) -> Result -{ - 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, - updated: DateTime, - mdpost: MdPost, -) -> Result -{ - 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, - 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> { - type Item = pulldown_cmark::Event<'e>; - - fn next(&mut self) -> Option { - 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, - #[knuffel(child, unwrap(argument))] - author: String, - #[knuffel(children(name="tag"))] - tags: Vec, -} - -#[derive(knuffel::Decode)] -struct TagNode { - #[knuffel(argument)] - tag: String, -} - -#[derive(Debug)] -struct MdPost { - markdown: String, - title: String, - subtitle: Option, - author: ShString22, - tags: Vec, -} - -impl MdPost { - fn parse(file_name: &str, source: &str) -> Result { - 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::(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), - 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 {} diff --git a/src/lib/post/render.rs b/src/lib/post/render.rs new file mode 100644 index 0000000..309771c --- /dev/null +++ b/src/lib/post/render.rs @@ -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> { + type Item = pulldown_cmark::Event<'e>; + + fn next(&mut self) -> Option { + 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, + }) + } +} diff --git a/src/lib/post/rendered_post.rs b/src/lib/post/rendered_post.rs new file mode 100644 index 0000000..87bfa59 --- /dev/null +++ b/src/lib/post/rendered_post.rs @@ -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, + html: Markup, +} + +impl RenderedPost { + pub fn new_from_str( + code_renderer: &CodeBlockRenderer, + namespace: Uuid, + id: Id, + updated: Option>, + source: &str + ) -> Result + { + let markdown_post = source + .parse::() + .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>, + markdown_post: MarkdownPost + ) -> Result + { + 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 { + self.header.published() + } + + #[inline] + #[must_use] + pub fn updated(&self) -> DateTime { + self.updated + } + + #[inline] + #[must_use] + pub fn html(&self) -> PreEscaped<&str> { + PreEscaped(&self.html.0) + } +} diff --git a/src/lib/post/source.rs b/src/lib/post/source.rs new file mode 100644 index 0000000..b71b09b --- /dev/null +++ b/src/lib/post/source.rs @@ -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 { + 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) + } +} diff --git a/src/lib/time.rs b/src/lib/time.rs index f59bb36..1d46ea2 100644 --- a/src/lib/time.rs +++ b/src/lib/time.rs @@ -1,5 +1,19 @@ use chrono::{DateTime, NaiveDateTime, Utc}; +#[inline] +#[must_use] +pub fn datetime_unix(seconds: i64, nanoseconds: u32) -> DateTime { + DateTime::from_utc(NaiveDateTime::from_timestamp(seconds, nanoseconds), Utc) +} + +#[inline] +#[must_use] +pub fn datetime_unix_seconds(seconds: i64) -> DateTime { + datetime_unix(seconds, 0) +} + +#[inline] +#[must_use] pub fn unix_epoch() -> DateTime { - DateTime::from_utc(NaiveDateTime::from_timestamp(0, 0), Utc) + datetime_unix(0, 0) }