aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/atom.rs142
-rw-r--r--src/blog.rs186
-rw-r--r--src/error.rs88
-rw-r--r--src/main.rs212
-rw-r--r--src/posts.rs62
-rw-r--r--src/posts/article.rs0
-rw-r--r--src/posts/mod.rs228
-rw-r--r--src/posts/syndication.rs96
-rw-r--r--src/templates.rs55
-rw-r--r--src/utils.rs3
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;
+}