diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/atom.rs | 142 | ||||
-rw-r--r-- | src/blog.rs | 186 | ||||
-rw-r--r-- | src/error.rs | 88 | ||||
-rw-r--r-- | src/main.rs | 212 | ||||
-rw-r--r-- | src/posts.rs | 62 | ||||
-rw-r--r-- | src/posts/article.rs | 0 | ||||
-rw-r--r-- | src/posts/mod.rs | 228 | ||||
-rw-r--r-- | src/posts/syndication.rs | 96 | ||||
-rw-r--r-- | src/templates.rs | 55 | ||||
-rw-r--r-- | src/utils.rs | 3 |
10 files changed, 613 insertions, 459 deletions
diff --git a/src/atom.rs b/src/atom.rs new file mode 100644 index 0000000..4410edb --- /dev/null +++ b/src/atom.rs @@ -0,0 +1,142 @@ +use atom_syndication::{ + Category, Content as AtomContent, Entry as AtomEntry, Feed, Generator, Link, Person, Text, + TextType, +}; +use chrono::{DateTime, Utc}; + +use crate::posts::Post; + +pub struct Context { + /// the page for which the feed is being generated for + pub page_title: String, + /// the human-readable page the feed is for + pub page_url: String, + /// the url to the atom xml document + pub self_url: String, + /// current site language + pub lang: String, +} + +pub async fn atom<P: Post + Clone>(ctx: Context, entries: Vec<P>) -> Feed { + let mut authors = Vec::new(); + let me = Person { + name: "cel".to_owned(), + email: Some("cel@blos.sm".to_owned()), + uri: Some("/contact".to_owned()), + }; + authors.push(me.clone()); + let page_link = Link { + href: ctx.page_url.clone(), + rel: "alternate".into(), + hreflang: Some(ctx.lang.clone()), + mime_type: Some("text/html".into()), + title: Some(ctx.page_title.clone()), + length: None, + }; + let home_link = Link { + // TODO?: localisation + href: "/".into(), + rel: "via".into(), + hreflang: Some(ctx.lang.clone()), + mime_type: Some("text/html".into()), + // TODO: localisation + title: Some("cel's garden".into()), + length: None, + }; + let self_link = Link { + href: ctx.self_url.into(), + rel: "self".into(), + hreflang: Some(ctx.lang.clone()), + mime_type: Some("application/atom+xml".into()), + // TODO: localisation + title: Some("atom feed".into()), + length: None, + }; + let mut links = Vec::new(); + links.push(page_link); + links.push(home_link); + links.push(self_link); + let mut feed = Feed { + title: Text { + value: ctx.page_title, + base: None, + lang: Some(ctx.lang.clone()), + r#type: TextType::Text, + }, + id: ctx.page_url, + updated: entries + .clone() + .into_iter() + .fold(DateTime::<Utc>::MIN_UTC, |acc, entry| { + let updated_at = entry.updated_at().to_owned(); + if updated_at > acc { + updated_at + } else { + acc + } + }) + .into(), + authors: authors.clone(), + categories: Vec::new(), + contributors: authors.clone(), + generator: Some(Generator { + value: "blossom".into(), + uri: Some("https://bunny.garden/cel/blossom".into()), + version: None, + }), + icon: Some("/favicon.ico".into()), + links: links.clone(), + logo: Some("/logo.png".into()), + entries: Vec::new(), + // TODO: determine base url from lang + base: Some("https://en.blos.sm/".into()), + lang: Some(ctx.lang), + ..Default::default() + }; + for entry in entries { + // TODO: localisation: url from lang + filtering entries translated or not + let categories = entry + .tags() + .into_iter() + .map(|category| Category { + term: category.clone(), + scheme: None, + label: Some(category.clone()), + }) + .collect(); + let published = Some(entry.published_at().to_owned().into()); + let title = if let Some(title) = entry.subject() { + title.to_owned() + } else { + entry.content()[..30].to_owned() + "..." + }; + let entry = AtomEntry { + title: Text { + value: title, + base: None, + lang: Some(entry.lang().to_owned()), + r#type: TextType::Text, + }, + id: entry.link(), + updated: entry.updated_at().to_owned().into(), + authors: authors.clone(), + categories, + contributors: authors.clone(), + links: links.clone(), + published, + rights: None, + source: None, + summary: None, + content: Some(AtomContent { + base: None, + lang: Some(entry.lang().to_owned()), + value: Some(entry.content().to_owned()), + src: Some(entry.link()), + content_type: Some("html".to_string()), + }), + ..Default::default() + }; + feed.entries.push(entry); + } + feed +} diff --git a/src/blog.rs b/src/blog.rs new file mode 100644 index 0000000..22cee05 --- /dev/null +++ b/src/blog.rs @@ -0,0 +1,186 @@ +use std::path::Path; + +use markdown::{mdast::Node, CompileOptions, Constructs, Options, ParseOptions}; +use serde::Deserialize; +use tokio::{fs, io::AsyncReadExt}; + +use chrono::{DateTime, Utc}; + +use crate::{ + error::BlossomError, + posts::{Post, PostType}, + Result, +}; + +static ARTICLES_DIR: &str = "./articles"; + +#[derive(Clone)] +pub struct Blogpost { + file_name: String, + title: String, + published_at: DateTime<Utc>, + updated_at: Option<DateTime<Utc>>, + tags: Vec<String>, + content: String, +} + +#[derive(Deserialize)] +pub struct BlogpostMetadata { + title: String, + published_at: String, + updated_at: Option<String>, + tags: Vec<String>, +} + +impl Post for Blogpost { + fn id(&self) -> &str { + &self.file_name + } + + fn subject(&self) -> Option<&str> { + Some(&self.title) + } + + fn published_at(&self) -> &DateTime<Utc> { + &self.published_at + } + + fn updated_at(&self) -> &DateTime<Utc> { + if let Some(updated_at) = &self.updated_at { + updated_at + } else { + &self.published_at + } + } + + fn tags(&self) -> &Vec<String> { + &self.tags + } + + fn lang(&self) -> &str { + "en" + } + + fn post_type(&self) -> PostType { + PostType::Article + } + + fn content(&self) -> &str { + &self.content + } +} + +impl Blogpost { + pub fn file_name(&self) -> &str { + &self.file_name + } + + async fn from_path(path: impl AsRef<Path>) -> Result<Self> { + let mut file = fs::File::open(&path).await?; + let mut buf = String::new(); + file.read_to_string(&mut buf).await?; + let parse_options = ParseOptions { + constructs: Constructs { + frontmatter: true, + ..Constructs::gfm() + }, + ..ParseOptions::default() + }; + let metadata: BlogpostMetadata = markdown::to_mdast(&buf, &parse_options) + .unwrap() + .try_into()?; + let file_name = path + .as_ref() + .file_name() + .ok_or(BlossomError::NotAFile)? + .to_string_lossy(); + let file_name = file_name[..file_name.len() - 3].to_owned(); + let updated_at = if let Some(updated_at) = metadata.updated_at { + Some(updated_at.parse::<DateTime<Utc>>()?) + } else { + None + }; + // TODO: cache render only when needed + let options = Options { + parse: parse_options, + compile: CompileOptions { + gfm_task_list_item_checkable: true, + allow_dangerous_html: true, + ..CompileOptions::default() + }, + }; + let content = markdown::to_html_with_options(&buf, &options).unwrap(); + Ok(Self { + file_name, + title: metadata.title, + published_at: metadata.published_at.parse::<DateTime<Utc>>()?, + updated_at, + tags: metadata.tags, + content, + }) + } + + // async fn render(&mut self) -> Result<String> { + // let path = Path::new(ARTICLES_DIR) + // .join(&self.file_name) + // .with_extension("md"); + // // TODO: remove unwraps when file read failure + // let mut file = fs::File::open(&path).await.unwrap(); + // let mut buf = String::new(); + // file.read_to_string(&mut buf).await.unwrap(); + // let parse_options = ParseOptions { + // constructs: Constructs { + // frontmatter: true, + // ..Constructs::gfm() + // }, + // ..ParseOptions::default() + // }; + // let options = Options { + // parse: parse_options, + // compile: CompileOptions { + // gfm_task_list_item_checkable: true, + // allow_dangerous_html: true, + // ..CompileOptions::default() + // }, + // }; + // let content = markdown::to_html_with_options(&buf, &options).unwrap(); + // Ok(content) + // } +} + +impl TryFrom<Node> for BlogpostMetadata { + type Error = BlossomError; + + fn try_from(tree: Node) -> std::prelude::v1::Result<Self, Self::Error> { + let children = tree.children(); + if let Some(children) = children { + if let Some(toml) = children.into_iter().find_map(|el| match el { + Node::Toml(toml) => Some(&toml.value), + _ => None, + }) { + return Ok(toml::from_str(toml)?); + }; + } + Err(BlossomError::NoMetadata) + } +} + +pub async fn get_blogposts() -> Result<Vec<Blogpost>> { + let mut blogposts: Vec<Blogpost> = Vec::new(); + let mut articles_dir = fs::read_dir(ARTICLES_DIR).await?; + while let Some(entry) = articles_dir.next_entry().await? { + let blogpost = Blogpost::from_path(entry.path()).await?; + blogposts.push(blogpost); + } + blogposts.sort_by_key(|post| post.published_at); + blogposts.reverse(); + Ok(blogposts) +} + +pub async fn get_blogpost(file_name: &str) -> Result<Blogpost> { + let path = Path::new(ARTICLES_DIR) + .join(Path::new(file_name)) + .with_extension("md"); + println!("{:?}", path); + Blogpost::from_path(path).await +} diff --git a/src/error.rs b/src/error.rs index 42c01b1..611ce79 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,66 +1,112 @@ use std::string::FromUtf8Error; -use rocket::{http::Status, Responder}; +use poem::{error::ResponseError, http::StatusCode, IntoResponse}; +use thiserror::Error; -#[derive(Responder, Debug)] +use crate::templates; + +#[derive(Error, Debug)] pub enum BlossomError { - Reqwest(Status, #[response(ignore)] reqwest::Error), - ListenBrainz(Status, #[response(ignore)] listenbrainz::Error), - Skinnyverse(Status, #[response(ignore)] mastodon_async::Error), - Chrono(Status, #[response(ignore)] chrono::ParseError), - Io(Status, #[response(ignore)] std::io::Error), - Deserialization(Status, #[response(ignore)] toml::de::Error), - Syndicator(Status, #[response(ignore)] atom_syndication::Error), - Utf8Conversion(Status, #[response(ignore)] FromUtf8Error), - NotFound(Status), - NoMetadata(Status), - Unimplemented(Status), + #[error("http client error")] + Reqwest(reqwest::Error), + #[error("listenbrainz error")] + ListenBrainz(listenbrainz::Error), + #[error("mastadon error")] + Skinnyverse(mastodon_async::Error), + #[error("failed to parse timestamp")] + Chrono(chrono::ParseError), + #[error("io error")] + Io(std::io::Error), + #[error("toml deserialization error")] + Deserialization(toml::de::Error), + #[error("atom syndication error")] + Syndicator(atom_syndication::Error), + #[error("utf8 conversion error")] + Utf8Conversion(FromUtf8Error), + #[error("not found")] + NotFound, + #[error("no metadata")] + NoMetadata, + #[error("unimplemented")] + Unimplemented, + #[error("not a file")] + NotAFile, +} + +impl ResponseError for BlossomError { + fn status(&self) -> poem::http::StatusCode { + match self { + BlossomError::Reqwest(_) => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::ListenBrainz(_) => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::Skinnyverse(_) => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::Chrono(_) => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::Io(_) => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::Deserialization(_) => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::Syndicator(_) => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::Utf8Conversion(_) => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::NotFound => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::NoMetadata => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::Unimplemented => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::NotAFile => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn as_response(&self) -> poem::Response + where + Self: std::error::Error + Send + Sync + 'static, + { + templates::Error { + status: self.status(), + message: self.to_string(), + } + .into_response() + } } impl From<reqwest::Error> for BlossomError { fn from(e: reqwest::Error) -> Self { - BlossomError::Reqwest(Status::new(500), e) + BlossomError::Reqwest(e) } } impl From<listenbrainz::Error> for BlossomError { fn from(e: listenbrainz::Error) -> Self { - BlossomError::ListenBrainz(Status::new(500), e) + BlossomError::ListenBrainz(e) } } impl From<mastodon_async::Error> for BlossomError { fn from(e: mastodon_async::Error) -> Self { - BlossomError::Skinnyverse(Status::new(500), e) + BlossomError::Skinnyverse(e) } } impl From<chrono::ParseError> for BlossomError { fn from(e: chrono::ParseError) -> Self { - BlossomError::Chrono(Status::new(500), e) + BlossomError::Chrono(e) } } impl From<std::io::Error> for BlossomError { fn from(e: std::io::Error) -> Self { - BlossomError::Io(Status::new(500), e) + BlossomError::Io(e) } } impl From<toml::de::Error> for BlossomError { fn from(e: toml::de::Error) -> Self { - BlossomError::Deserialization(Status::new(500), e) + BlossomError::Deserialization(e) } } impl From<atom_syndication::Error> for BlossomError { fn from(e: atom_syndication::Error) -> Self { - BlossomError::Syndicator(Status::new(500), e) + BlossomError::Syndicator(e) } } impl From<FromUtf8Error> for BlossomError { fn from(e: FromUtf8Error) -> Self { - BlossomError::Utf8Conversion(Status::new(500), e) + BlossomError::Utf8Conversion(e) } } diff --git a/src/main.rs b/src/main.rs index ed768b4..c38b2eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,155 +1,139 @@ +mod atom; +mod blog; mod error; mod live; mod posts; mod scrobbles; mod skweets; - -use std::borrow::Cow; -use std::collections::HashSet; -use std::time::Duration; - -use atom_syndication::Feed; -use rocket::fs::{relative, FileServer}; -use rocket::http::{ContentType, Status}; -use rocket::{Request, State}; -use rocket_dyn_templates::{context, Template}; +mod templates; +mod utils; + +use std::rc::Rc; +use std::{collections::HashSet, time::Duration}; + +use poem::http::StatusCode; +use poem::Response; +use poem::{ + endpoint::EmbeddedFilesEndpoint, + get, handler, + listener::TcpListener, + middleware::AddData, + web::{Data, Path, Query}, + EndpointExt, Route, Server, +}; +use rust_embed::RustEmbed; use error::BlossomError; +use serde::Deserialize; type Result<T> = std::result::Result<T, BlossomError>; -struct Clients { - listenbrainz: listenbrainz::raw::Client, - skinnyverse: mastodon_async::Mastodon, - reqwest: reqwest::Client, -} - -#[macro_use] -extern crate rocket; +#[derive(RustEmbed)] +#[folder = "static/"] +struct Static; -#[get("/")] -async fn home(clients: &State<Clients>) -> Template { +#[handler] +async fn home(Data(reqwest): Data<&reqwest::Client>) -> templates::Home { + let listenbrainz_client = listenbrainz::raw::Client::new(); let (live, listenbrainz, blogposts) = tokio::join!( - live::get_live_status(&clients.reqwest), - scrobbles::get_now_playing(&clients.listenbrainz), + live::get_live_status(reqwest), + scrobbles::get_now_playing(&listenbrainz_client), // skweets::get_recents(&clients.skinnyverse), - posts::get_blogposts() + blog::get_blogposts() ); let is_live = live.unwrap_or_default().online; let listenbrainz = listenbrainz.unwrap_or_default(); let blogposts = blogposts.unwrap_or_default(); - Template::render( - "home", - context! { - is_live, - listenbrainz, - // skweets, - blogposts, - }, - ) + templates::Home { + is_live, + listenbrainz, + blogposts, + } +} + +// #[get("/blog/<blogpost>")] +#[handler] +async fn blogpost(Path(blogpost): Path<String>) -> Result<templates::Blogpost> { + let blogpost = blog::get_blogpost(&blogpost).await?; + Ok(templates::Blogpost { + blogpost, + filter_tags: HashSet::new(), + }) } -#[get("/blog/<blogpost>")] -async fn blogpost(blogpost: &str) -> Result<Template> { - let mut blogpost = posts::get_blogpost(blogpost).await?; - blogpost.render().await?; - Ok(Template::render( - "blogpost", - context! { - blogpost, - }, - )) +#[derive(Deserialize)] +struct FilterTags { + filter: String, } -#[get("/blog?<filter>")] -async fn blog(filter: Vec<String>) -> Result<Template> { - let mut blogposts = posts::get_blogposts().await?; - let tags: Vec<String> = posts::get_tags(&blogposts) +// #[get("/blog?<filter>")] +#[handler] +async fn get_blog(filter_tags: Option<Query<FilterTags>>) -> Result<templates::Blog> { + let mut blogposts = blog::get_blogposts().await?; + let tags: Vec<String> = posts::Post::get_tags(&blogposts) .into_iter() .map(|tag| tag.to_owned()) .collect(); let mut filter_hashset: HashSet<String> = HashSet::new(); - if !filter.is_empty() { - filter_hashset.extend(filter.into_iter()); - blogposts = posts::filter_by_tags(blogposts, &filter_hashset); - } - for blogpost in &mut blogposts { - blogpost.render().await?; + if let Some(Query(FilterTags { filter })) = filter_tags { + filter_hashset.insert(filter); + blogposts = posts::Post::filter_by_tags(blogposts, &filter_hashset); } - let reverse = true; - Ok(Template::render( - "blog", - context! { - reverse, - tags, - filter_hashset, - blogposts, - }, - )) + Ok(templates::Blog { + blogposts, + tags, + filter_tags: filter_hashset, + }) } -#[get("/feed")] -async fn feed() -> Result<(Status, (ContentType, String))> { - let posts = posts::get_blogposts().await?; - let feed = posts::syndication::atom(posts).await; +#[handler] +async fn feed() -> Result<Response> { + let posts = blog::get_blogposts().await?; + // TODO: i18n + let context = atom::Context { + page_title: "celeste's hard drive".to_owned(), + page_url: "https://en.blos.sm".to_owned(), + self_url: "https://en.blos.sm/feed".to_owned(), + lang: "en".to_owned(), + }; + let feed = atom::atom(context, posts).await; let feed: String = String::from_utf8(feed.write_to(Vec::new())?)?; - Ok(( - Status::new(200), - (ContentType::new("application", "atom+xml"), feed), - )) -} - -#[get("/contact")] -async fn contact() -> Template { - Template::render("contact", context! {}) + Ok(Response::builder() + .status(StatusCode::OK) + .content_type("application/atom+xml") + .body(feed)) } -#[get("/plants")] -async fn plants() -> Result<Template> { - Err(BlossomError::Unimplemented(Status::NotImplemented)) +#[handler] +async fn contact() -> templates::Contact { + templates::Contact } -#[catch(default)] -fn catcher(status: Status, req: &Request) -> Template { - let message; - if status.code == 404 { - message = "i either haven't built this page yet or it looks like you're a little lost"; - } else if status.code == 500 { - message = "omg the server went kaputt!!"; - } else if status.code == 501 { - message = "it looks like this is not yet here!!!"; - } else { - message = "idk i got bored"; - } - let status = format!("{}", status); - Template::render( - "error", - context! { status: status, req: req.uri(), message: message }, - ) +#[handler] +async fn plants() -> Result<()> { + Err(BlossomError::Unimplemented) } #[tokio::main] -async fn main() -> std::result::Result<(), rocket::Error> { - let mut skinny_data = mastodon_async::Data::default(); - skinny_data.base = Cow::from("https://skinnyver.se"); - - let _rocket = rocket::build() - .manage(Clients { - listenbrainz: listenbrainz::raw::Client::new(), - skinnyverse: mastodon_async::Mastodon::from(skinny_data), - reqwest: reqwest::Client::builder() +async fn main() -> std::result::Result<(), std::io::Error> { + // let mut skinny_data = mastodon_async::Data::default(); + // skinny_data.base = Cow::from("https://skinnyver.se"); + let blossom = Route::new() + .at("/", get(home)) + .at("/blog", get(get_blog)) + .at("/blog/:blogpost", get(blogpost)) + .at("/feed", get(feed)) + .at("/contact", get(contact)) + .at("/plants", get(plants)) + .nest("/static/", EmbeddedFilesEndpoint::<Static>::new()) + .with(AddData::new( + reqwest::Client::builder() .connect_timeout(Duration::from_secs(1)) .build() .unwrap(), - }) - .attach(Template::custom(|engines| { - engines.tera.autoescape_on(vec![]); - })) - .mount("/", routes![home, contact, blog, blogpost, feed, plants]) - .register("/", catchers![catcher]) - .mount("/", FileServer::from("./static")) - .launch() - .await?; + )); - Ok(()) + Server::new(TcpListener::bind("0.0.0.0:3000")) + .run(blossom) + .await } diff --git a/src/posts.rs b/src/posts.rs new file mode 100644 index 0000000..415f743 --- /dev/null +++ b/src/posts.rs @@ -0,0 +1,62 @@ +use std::collections::HashSet; + +use chrono::{DateTime, Utc}; +use serde::Serialize; + +#[derive(Serialize, Debug)] +pub enum PostType { + Article, + Note, +} + +pub trait Post { + fn id(&self) -> &str; + fn subject(&self) -> Option<&str>; + fn published_at(&self) -> &DateTime<Utc>; + fn updated_at(&self) -> &DateTime<Utc>; + fn tags(&self) -> &Vec<String>; + fn lang(&self) -> &str; + fn post_type(&self) -> PostType; + fn content(&self) -> &str; + + fn link(&self) -> String { + "https://en.blos.sm/posts/".to_owned() + self.id() + } + + fn get_tags<'a>(posts: &'a Vec<Self>) -> Vec<&'a String> + where + Self: Sized, + { + let mut tags = posts + .into_iter() + .fold(HashSet::new(), |mut acc, post| { + let tags = post.tags(); + for tag in tags { + acc.insert(tag); + } + acc + }) + .into_iter() + .collect::<Vec<_>>(); + tags.sort(); + tags + } + + fn filter_by_tags(posts: Vec<Self>, filter_tags: &HashSet<String>) -> Vec<Self> + where + Self: Sized, + { + posts + .into_iter() + .filter(|post| { + for tag in post.tags() { + match filter_tags.contains(tag) { + true => return true, + false => continue, + } + } + false + }) + .collect() + } +} diff --git a/src/posts/article.rs b/src/posts/article.rs deleted file mode 100644 index e69de29..0000000 --- a/src/posts/article.rs +++ /dev/null diff --git a/src/posts/mod.rs b/src/posts/mod.rs deleted file mode 100644 index c75dd53..0000000 --- a/src/posts/mod.rs +++ /dev/null @@ -1,228 +0,0 @@ -mod article; -mod note; -pub mod syndication; - -use std::collections::HashSet; - -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use markdown::{mdast::Node, CompileOptions, Constructs, Options, ParseOptions}; -use rocket::http::Status; -use serde::{ser::SerializeStruct, Deserialize, Serialize}; -use tokio::fs; -use tokio::io::AsyncReadExt; - -use crate::{error::BlossomError, Result}; - -#[derive(Serialize, Debug)] -enum PostType { - Article, - Note, -} - -#[derive(Debug)] -pub struct Post<D: Content> { - id: String, - subject: Option<String>, - created_at: DateTime<Utc>, - updated_at: Option<DateTime<Utc>>, - tags: Vec<String>, - post_type: PostType, - render: Option<String>, - data: D, -} - -impl<D: Content> Post<D> { - pub async fn render(&mut self) -> Result<()> { - self.render = Some(self.data.render().await?); - Ok(()) - } -} - -impl<D: Content + Serialize> Serialize for Post<D> { - fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("Post", 7)?; - state.serialize_field("subject", &self.subject)?; - state.serialize_field("created_at", &self.created_at.to_string())?; - state.serialize_field( - "updated_at", - &self.updated_at.and_then(|time| Some(time.to_string())), - )?; - state.serialize_field("tags", &self.tags)?; - state.serialize_field("post_type", &self.post_type)?; - state.serialize_field("render", &self.render)?; - state.serialize_field("data", &self.data)?; - state.end() - } -} - -pub async fn get_blogposts() -> Result<Vec<Post<Article>>> { - let mut blogposts: Vec<Post<Article>> = Vec::new(); - let mut articles_dir = fs::read_dir("./articles").await?; - while let Some(file) = articles_dir.next_entry().await? { - let name = file.file_name(); - let name = name.to_str().unwrap_or_default()[..name.len() - 3].to_owned(); - let blogpost: Article = Article { - path: file.path().to_str().unwrap_or_default().to_owned(), - name, - }; - let blogpost = Post::try_from(blogpost).await.unwrap(); - blogposts.push(blogpost); - } - blogposts.sort_by_key(|post| post.created_at); - blogposts.reverse(); - Ok(blogposts) -} - -pub async fn get_blogpost(post_name: &str) -> Result<Post<Article>> { - let mut articles_dir = fs::read_dir("./articles").await?; - while let Some(file) = articles_dir.next_entry().await? { - let name = file.file_name(); - let name = &name.to_str().unwrap_or_default()[..name.len() - 3]; - if name == post_name { - let blogpost = Article { - path: file.path().to_str().unwrap_or_default().to_owned(), - name: name.to_owned(), - }; - let blogpost = Post::try_from(blogpost).await?; - return Ok(blogpost); - } - } - Err(BlossomError::NotFound(Status::new(404))) -} - -pub fn get_tags<D: Content>(posts: &Vec<Post<D>>) -> Vec<&String> { - let mut tags = posts - .into_iter() - .fold(HashSet::new(), |mut acc, post| { - let tags = &post.tags; - for tag in tags { - acc.insert(tag); - } - acc - }) - .into_iter() - .collect::<Vec<_>>(); - tags.sort(); - tags -} - -pub fn filter_by_tags<'p, D: Content>( - posts: Vec<Post<D>>, - filter_tags: &HashSet<String>, -) -> Vec<Post<D>> { - posts - .into_iter() - .filter(|post| { - for tag in &post.tags { - match filter_tags.contains(tag) { - true => return true, - false => continue, - } - } - false - }) - .collect() -} - -#[async_trait] -pub trait Content { - async fn render(&self) -> Result<String>; -} - -#[derive(Serialize, Debug)] -pub struct Article { - path: String, - name: String, -} - -impl Article { - async fn tree(&self) -> Result<Node> { - let mut file = fs::File::open(&self.path).await?; - let mut buf = String::new(); - file.read_to_string(&mut buf).await?; - Ok(markdown::to_mdast( - &buf, - &ParseOptions { - constructs: Constructs { - frontmatter: true, - ..Constructs::default() - }, - ..ParseOptions::default() - }, - ) - .unwrap()) - } - - async fn frontmatter(&self) -> Result<String> { - let tree = self.tree().await?; - let children = tree.children(); - if let Some(children) = children { - if let Some(toml) = children.into_iter().find_map(|el| match el { - Node::Toml(toml) => Some(toml.value.to_owned()), - _ => None, - }) { - return Ok(toml); - }; - } - Err(BlossomError::NoMetadata(Status::new(500))) - } -} - -#[async_trait] -impl Content for Article { - async fn render(&self) -> Result<String> { - let mut file = fs::File::open(&self.path).await?; - let mut buf = String::new(); - file.read_to_string(&mut buf).await?; - let options = Options { - parse: ParseOptions { - constructs: Constructs { - frontmatter: true, - ..Constructs::gfm() - }, - ..ParseOptions::default() - }, - compile: CompileOptions { - gfm_task_list_item_checkable: true, - allow_dangerous_html: true, - ..CompileOptions::default() - }, - }; - Ok(markdown::to_html_with_options(&buf, &options).unwrap()) - } -} - -#[derive(Deserialize)] -struct ArticleMetadata { - title: String, - created_at: String, - updated_at: Option<String>, - tags: Vec<String>, -} - -impl Post<Article> { - async fn try_from(article: Article) -> Result<Post<Article>> { - let metadata = article.frontmatter().await?; - let metadata: ArticleMetadata = toml::from_str(&metadata)?; - let updated_at = if let Some(updated_at) = metadata.updated_at { - Some(updated_at.parse::<DateTime<Utc>>()?) - } else { - None - }; - - Ok(Post { - id: article.name.to_owned(), - subject: Some(metadata.title), - created_at: metadata.created_at.parse::<DateTime<Utc>>()?, - updated_at, - tags: metadata.tags, - post_type: PostType::Article, - render: None, - data: article, - }) - } -} diff --git a/src/posts/syndication.rs b/src/posts/syndication.rs deleted file mode 100644 index f6f0b17..0000000 --- a/src/posts/syndication.rs +++ /dev/null @@ -1,96 +0,0 @@ -use atom_syndication::{Category, Content, Entry, Feed, Generator, Link, Person, Text, TextType}; - -use super::{Article, Post}; - -pub async fn atom(posts: Vec<Post<Article>>) -> Feed { - let me = Person { - name: "cel".into(), - email: Some("cel@blos.sm".into()), - uri: Some("https://blos.sm".into()), - }; - let mut authors = Vec::new(); - authors.push(me); - let link = Link { - href: "https://blos.sm/feed".into(), - rel: "self".into(), - hreflang: Some("en".into()), - mime_type: Some("application/atom+xml".into()), - title: Some("atom feed".into()), - length: None, - }; - let mut links = Vec::new(); - links.push(link); - let mut feed = Feed { - title: Text { - value: "cel's site".into(), - base: None, - lang: Some("en".into()), - r#type: TextType::Text, - }, - id: "https://blos.sm".into(), - updated: posts[0].created_at.into(), - authors: authors.clone(), - categories: Vec::new(), - contributors: authors.clone(), - generator: Some(Generator { - value: "blos.sm".into(), - uri: Some("https://bunny.garden/cel/blos.sm".into()), - version: None, - }), - icon: Some("/icon.png".into()), - links: links.clone(), - logo: Some("/logo.png".into()), - rights: None, - subtitle: None, - entries: Vec::new(), - base: Some("https://blos.sm".into()), - lang: Some("en".into()), - ..Default::default() - }; - for mut post in posts { - post.render().await.unwrap_or_default(); - let mut id = String::from("https://blos.sm/blog/"); - id.push_str(&post.data.name); - let categories = post - .tags - .into_iter() - .map(|tag| Category { - term: tag.clone(), - scheme: None, - label: Some(tag.clone()), - }) - .collect(); - let entry = Entry { - title: Text { - value: post.subject.unwrap_or_default(), - base: None, - lang: Some("en".into()), - r#type: TextType::Text, - }, - id: id.clone(), - updated: if let Some(updated_at) = post.updated_at { - updated_at.into() - } else { - post.created_at.into() - }, - authors: authors.clone(), - categories, - contributors: authors.clone(), - links: links.clone(), - published: Some(post.created_at.into()), - rights: None, - source: None, - summary: None, - content: Some(Content { - base: None, - lang: Some("en".into()), - value: post.render, - src: Some(id), - content_type: Some("html".to_string()), - }), - ..Default::default() - }; - feed.entries.push(entry); - } - feed -} diff --git a/src/templates.rs b/src/templates.rs new file mode 100644 index 0000000..861930f --- /dev/null +++ b/src/templates.rs @@ -0,0 +1,55 @@ +use std::collections::HashSet; + +use askama::Template; +use poem::http::StatusCode; +use rand::{thread_rng, Rng}; + +use crate::posts::Post; +use crate::{blog, scrobbles::NowPlayingData}; + +mod filters { + pub fn mytruncate(s: impl std::fmt::Display, length: usize) -> ::askama::Result<String> { + let mut s = s.to_string(); + s.truncate(length); + Ok(s) + } +} + +#[derive(Template)] +#[template(path = "base.html")] +struct Base; + +#[derive(Template)] +#[template(path = "home.html")] +pub struct Home { + pub is_live: bool, + pub listenbrainz: NowPlayingData, + pub blogposts: Vec<blog::Blogpost>, +} + +#[derive(Template)] +#[template(path = "blogpost.html")] +pub struct Blogpost { + pub blogpost: blog::Blogpost, + pub filter_tags: HashSet<String>, +} + +// filtertags, blogpost-panel +#[derive(Template)] +#[template(path = "blog.html")] +pub struct Blog { + pub blogposts: Vec<blog::Blogpost>, + pub tags: Vec<String>, + pub filter_tags: HashSet<String>, +} + +#[derive(Template)] +#[template(path = "contact.html")] +pub struct Contact; + +#[derive(Template)] +#[template(path = "error.html")] +pub struct Error { + pub status: StatusCode, + pub message: String, +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..4db404a --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,3 @@ +pub trait Tags { + type Tag; +} |