evie/server/src/html.rs

896 lines
29 KiB
Rust

use chrono::prelude::*;
use std::{env, vec};
use blogdb::posts::Post;
use blogdb::BlogDb;
use lol_html::html_content::ContentType;
use lol_html::{comments, element, rewrite_str, RewriteStrSettings};
use crate::routes::api::editor::Blocks;
use crate::routes::SearchResponse;
include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
/// Gets template html string from `src/templates`
///
/// # Examples
///
/// ```
/// // `src/templates/blog-roll.html`
/// //
/// // <h1>Blog</h1>
/// // <ul></ul>
///
/// let template = template!("blog-roll");
/// assert_eq!(template, "<h1>Blog</h1>\n<ul></ul>");
/// ```
macro_rules! template {
($t:tt) => {
TEMPLATES.get($t).unwrap_or(&"")
};
}
/// Makes a page with default settings and content inserted into <main></main>
/// (or replacing <main></main> if content has a <main> tag)
pub(crate) fn make_page<S>(content: S, settings: PageSettings) -> String
where
S: AsRef<str>,
{
rewrite_str(
template!("default"),
RewriteStrSettings {
element_content_handlers: vec![
element!("head", |head| {
head.prepend(
&["<title>", &settings.title, "</title>"].concat(),
ContentType::Html,
);
if let Some(stylesheets) = &settings.stylesheets {
for url in stylesheets {
head.append(
&[r#"<link rel="stylesheet" href=""#, url, r#"" />"#].concat(),
ContentType::Html,
)
}
}
Ok(())
}),
element!("body", |body| {
body.prepend("<script>0</script>", ContentType::Html);
Ok(())
}),
element!("main", |main| {
if content.as_ref().contains("<main>") {
main.replace(content.as_ref(), ContentType::Html);
} else {
main.set_inner_content(content.as_ref(), ContentType::Html);
}
Ok(())
}),
element!("site-header", |site_header| {
if settings.site_header {
site_header.replace(template!("site-header"), ContentType::Html);
}
Ok(())
}),
element!("site-footer", |site_footer| {
if settings.site_footer {
site_footer.replace(template!("site-footer"), ContentType::Html);
}
Ok(())
}),
element!("br", |br| {
br.remove();
Ok(())
}),
comments!("*", |comments| {
comments.remove();
Ok(())
}),
],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
pub(crate) fn rewrite_el<S, T, R>(input: S, name: T, with: R) -> String
where
S: AsRef<str>,
T: AsRef<str>,
R: AsRef<str>,
{
rewrite_str(
input.as_ref(),
RewriteStrSettings {
element_content_handlers: vec![element!(name.as_ref(), |el| {
el.replace(with.as_ref(), ContentType::Html);
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
pub(crate) fn remove_el<S, T>(input: S, name: T) -> String
where
S: AsRef<str>,
T: AsRef<str>,
{
rewrite_str(
input.as_ref(),
RewriteStrSettings {
element_content_handlers: vec![element!(name.as_ref(), |el| {
el.remove();
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
fn remove_comments<S: AsRef<str>>(input: S) -> String {
rewrite_str(
input.as_ref(),
RewriteStrSettings {
element_content_handlers: vec![comments!("*", |co| {
co.remove();
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
pub(crate) fn homepage_editor<S: AsRef<str>>(homepage_content: S) -> String {
editor(homepage_content, "/api/editor/update/home")
}
pub(crate) fn about_editor<S: AsRef<str>>(about_content: S) -> String {
editor(about_content, "/api/editor/update/about")
}
pub(crate) fn draft_editor<S, T>(post_content: S, post_id: T) -> String
where
S: AsRef<str>,
T: AsRef<str>,
{
editor(
post_content,
["/api/editor/update/", post_id.as_ref()].concat(),
)
}
fn editor<S, T>(content: S, endpoint: T) -> String
where
S: AsRef<str>,
T: AsRef<str>,
{
let content = content.as_ref();
let data = if !content.is_empty() && content.starts_with("[") && content.ends_with("]") {
content
} else {
"[]"
};
let settings = [
"const data={blocks:",
data,
"};const onChange=()=>{editor.save().then(async(savedData)=>{const endpoint = \"",endpoint.as_ref(),"\"; const response = await fetch(endpoint, { method: \"POST\", mode: \"same-origin\", headers: {\"Content-Type\": \"application/json\"}, body: JSON.stringify(savedData.blocks) });})};"
].concat();
let html = rewrite_str(
template!("post-editor"),
RewriteStrSettings {
element_content_handlers: vec![element!("#editor", move |script| {
script.prepend(&settings, ContentType::Html);
Ok(())
})],
strict: false,
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
make_page(
html,
PageSettings::new("Editor", Some(vec!["/assets/css/editor.css"]), true, false),
)
}
/// Rewrites `<login-status></login-status>` with html from template, and sets the `next` hidden
/// input element to the next location
pub(crate) fn login_status(next: &str, success: bool) -> String {
let status = if success {
""
} else {
template!("login-status")
};
let next = next.strip_prefix("/").unwrap_or(next);
let html = rewrite_el(template!("login"), "login-status", status);
let html = rewrite_str(
&html,
RewriteStrSettings {
element_content_handlers: vec![element!(r#"input[name="next"]"#, |input| {
input.set_attribute("value", next).unwrap_or_default();
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
let html = rewrite_str(
&html,
RewriteStrSettings {
element_content_handlers: vec![element!(r#"input[name="next"]"#, |input| {
input.set_attribute("value", next).unwrap_or_default();
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
make_page(
html,
PageSettings::new("Login", Some(vec!["/assets/css/login.css"]), false, false),
)
}
pub(crate) async fn admin_page(session_id: i64, db: &BlogDb) -> String {
let content = admin_widgets(template!("admin"), session_id, db).await;
make_page(
&content,
PageSettings::new("Admin", Some(vec!["/assets/css/admin.css"]), true, false),
)
}
pub(crate) async fn admin_widgets(input: &str, session_id: i64, db: &BlogDb) -> String {
let posts_html = admin_entries(EntryType::Post, session_id, db).await;
let drafts_html = admin_entries(EntryType::Draft, session_id, db).await;
let user_html = template!("admin-widgets/user");
rewrite_str(
input,
RewriteStrSettings {
element_content_handlers: vec![element!("admin-widget", move |a| {
match a.get_attribute("type") {
Some(attr) => {
match attr.as_str() {
"drafts" => a.replace(&drafts_html, ContentType::Html),
"posts" => a.replace(&posts_html, ContentType::Html),
"user" => a.replace(user_html, ContentType::Html),
_ => a.remove(),
}
Ok(())
}
None => todo!(),
}
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
enum EntryType {
Post,
Draft,
}
async fn admin_entries(entry_type: EntryType, session_id: i64, db: &BlogDb) -> String {
let (list_template, entry_template) = match entry_type {
EntryType::Post => (
template!("admin-widgets/posts"),
template!("admin-widgets/post"),
),
EntryType::Draft => (
template!("admin-widgets/drafts"),
template!("admin-widgets/draft"),
),
};
let is_empty: bool;
let entries_html = match entry_type {
EntryType::Post => {
let mut entries = db.get_posts().await.unwrap_or(vec![]);
entries.reverse();
let mut entry_list_html = String::new();
is_empty = entries.is_empty();
if !is_empty {
for entry in entries {
let href = ["/blog/", &entry.id].concat();
let mut entry_html = admin_entry(
entry_template,
&entry.id,
&entry.title,
&href,
&entry.content,
);
entry_html = rewrite_str(
&entry_html,
RewriteStrSettings {
element_content_handlers: vec![element!("time", |time| {
time.set_inner_content(&entry.date, ContentType::Text);
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
entry_list_html.push_str(&entry_html);
}
}
entry_list_html
}
EntryType::Draft => {
let mut entries = db.get_drafts(session_id).await.unwrap_or(vec![]);
entries.reverse();
let mut entry_list_html = String::new();
is_empty = entries.is_empty();
if !is_empty {
for entry in entries {
let href = ["/editor/", &entry.id].concat();
let entry_html = admin_entry(
entry_template,
&entry.id,
&entry.title,
&href,
&entry.content,
);
entry_list_html.push_str(&entry_html);
}
}
entry_list_html
}
};
let final_html = rewrite_str(
list_template,
RewriteStrSettings {
element_content_handlers: vec![element!("ul", |ul| {
if is_empty {
match entry_type {
EntryType::Post => ul.replace("<h2 class=\"empty-message\">No Published Posts</h2>", ContentType::Html),
EntryType::Draft => ul.replace("<div class=\"entry-content empty-message\"><h2>No Drafts</h2><p>Start a <a href=\"/editor/new\" class=\"button\">New Draft</a></p></div>", ContentType::Html)
}
} else {
ul.set_inner_content(&entries_html, ContentType::Html);
}
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
final_html
}
fn admin_entry(entry_template: &str, id: &str, title: &str, href: &str, content: &str) -> String {
rewrite_str(
entry_template,
RewriteStrSettings {
element_content_handlers: vec![
element!("a.entry-content", |a| {
a.set_attribute("href", href).unwrap_or_default();
Ok(())
}),
element!("h2", |h2| {
h2.set_inner_content(title, ContentType::Html);
Ok(())
}),
element!("p", |p| {
let post_content: Blocks = serde_json::from_str(content).unwrap_or_default();
let content = rewrite_str(
&post_content.desc(),
RewriteStrSettings {
element_content_handlers: vec![element!("*", |all| {
all.remove_and_keep_content();
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
p.set_inner_content(&content, ContentType::Text);
Ok(())
}),
element!("input", |input| {
input.set_attribute("value", id).unwrap_or_default();
Ok(())
}),
],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
pub(crate) async fn about_page(db: &BlogDb) -> String {
let about = db.get_about().await;
let about_content: Blocks = serde_json::from_str(&about).unwrap_or_default();
let about_html = remove_comments(about_content.to_html());
make_page(&about_html, PageSettings::title("About"))
}
pub(crate) async fn home_page(db: &BlogDb) -> String {
let home = db.get_homepage().await;
let home_content: Blocks = serde_json::from_str(&home).unwrap_or_default();
let home_html = home_content.to_html();
make_page(&home_html, PageSettings::title("Evie Ippolito"))
}
pub(crate) fn insert_blog_widget<S>(input: S) -> String
where
S: AsRef<str>,
{
rewrite_str(
template!("blog"),
RewriteStrSettings {
element_content_handlers: vec![element!("meta", |meta| { Ok(()) })],
..RewriteStrSettings::new()
},
)
.unwrap()
}
fn meta<S, T, R, I>(slug: S, title: T, description: R, image: I) -> String
where
S: AsRef<str>,
T: AsRef<str>,
R: AsRef<str>,
I: AsRef<str>,
{
rewrite_str(
template!("head/meta"),
RewriteStrSettings {
element_content_handlers: vec![element!("meta", |meta| {
if let Some(attr) = meta.get_attribute("property") {
let content = match attr.as_str() {
"og:url" => {
#[cfg(debug_assertions)]
{
&["http://localhost", slug.as_ref()].concat()
}
#[cfg(not(debug_assertions))]
{
&["https://", env!("DOMAIN"), slug.as_ref()].concat()
}
}
"og:type" => "article",
"og:title" => title.as_ref(),
"og:description" => description.as_ref(),
"og:image" => {
let image = image.as_ref();
if !image.is_empty() {
#[cfg(debug_assertions)]
{
&["http://localhost", image].concat()
}
#[cfg(not(debug_assertions))]
{
&["https://", env!("DOMAIN"), image].concat()
}
} else {
meta.remove();
""
}
}
_ => "",
};
let _ = meta.set_attribute("content", content);
}
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
/// Creates a blog page from a post's content
pub(crate) async fn blog_page<S>(id: S, db: &BlogDb) -> String
where
S: AsRef<str>,
{
let template = template!("blog-page");
let html = make_page(template, PageSettings::title("Blog"));
let post = db.get_post(id).await.unwrap_or_default();
let head_content = head::<_, _, [String; 0]>(post.title, []);
let post_content: Blocks = serde_json::from_str(&post.content).unwrap_or_default();
let post_html: String = post_content.to_html();
let image = if let Some(url) = post_content.image() {
&[url].concat()
} else {
""
};
let meta = meta(
["/blog/", &post.id].concat(),
post_content.title(),
post_content.desc(),
image,
);
let html = rewrite_str(
&html,
RewriteStrSettings {
element_content_handlers: vec![element!("time", |time| {
time.after(&post_html, ContentType::Html);
time.replace(
&[
r#"<a class="animated-link" style="align-self: flex-start; margin-block-end: 0" href=""#,
{
#[cfg(debug_assertions)]
{
"http://localhost"
}
#[cfg(not(debug_assertions))]
{
&["https://", env!("DOMAIN")].concat()
}
},
"/blog/",
&post.id,
r#""><time>"#,
&post.date,
"</time></a>",
]
.concat(),
ContentType::Html,
);
Ok(())
}), element!("head", |head| {
head.replace(&head_content, ContentType::Html);
head.append(&meta, ContentType::Html);
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
html
}
/// Get `count` number of blog entries, if `count` is [None] then get all entries
async fn get_blog_entries_count(db: &BlogDb, count: Option<u32>) -> String {
let posts = match count {
Some(count) => match db.get_count_posts(count).await {
Ok(posts) => posts,
Err(_) => return "".to_string(),
},
None => match db.get_posts().await {
Ok(posts) => posts,
Err(_) => return "".to_string(),
},
};
let mut post_entries = String::new();
for post in posts.iter() {
let post_entry = post_entry(post);
post_entries.push_str(&post_entry);
}
post_entries
}
pub(crate) async fn blog_roll(db: &BlogDb) -> String {
let post_entries = get_blog_entries_count(db, None).await;
let blog_roll_html = rewrite_str(
template!("blog-roll"),
RewriteStrSettings {
element_content_handlers: vec![element!("ul", move |ul| {
ul.set_inner_content(&post_entries, ContentType::Html);
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
make_page(
blog_roll_html,
PageSettings::new("Blog", Some(vec!["/assets/css/blog.css"]), true, true),
)
}
fn post_entry(post: &Post) -> String {
rewrite_str(
template!("blog-roll-entry"),
RewriteStrSettings {
element_content_handlers: vec![
element!("a", |a| {
a.set_attribute("href", &["/blog/", &post.id].concat())
.unwrap_or_default();
Ok(())
}),
element!("img", |img| {
let post_content: Blocks =
serde_json::from_str(&post.content).unwrap_or_default();
match post_content.image() {
Some(src) => {
let _ = img.set_attribute("src", src);
}
None => img.remove(),
}
Ok(())
}),
element!("h2", |h2| {
h2.set_inner_content(&post.title, ContentType::Text);
Ok(())
}),
element!("time", |time| {
time.set_inner_content(&post.date, ContentType::Text);
Ok(())
}),
element!("p", |p| {
let post_content: Blocks =
serde_json::from_str(&post.content).unwrap_or_default();
let content = remove_el(post_content.desc(), "br");
p.set_inner_content(&content, ContentType::Html);
Ok(())
}),
],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
pub(crate) fn search_page_no_query() -> String {
let search_page_html = make_page(template!("search-page"), PageSettings::title("Search"));
rewrite_str(
&search_page_html,
RewriteStrSettings {
element_content_handlers: vec![element!("ul", move |ul| {
ul.remove();
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
pub(crate) async fn search_page(
query: String,
search_response: SearchResponse,
db: &BlogDb,
) -> String {
let mut entries = String::new();
let search_page_html = if !search_response.is_empty() {
for entry in search_response {
let post = match db.get_post(&entry.id[0]).await {
Ok(post) => post,
Err(_) => continue,
};
let entry_html = post_entry(&post);
entries.push_str(&entry_html);
}
[
"<h1>Results for “",
&query,
"”</h1>",
"<ul>",
&entries,
"</ul>",
]
.concat()
} else {
["<h1>No results for “", &query, "”</h1>"].concat()
};
make_page(
&search_page_html,
PageSettings::new(
["Results for “", &query, ""].concat(),
Some(vec!["/assets/css/blog.css"]),
true,
true,
),
)
}
/// Creates a <link rel="stylesheet" href="style_url"/> for each given style_url
fn stylesheets<S, I>(style_urls: I) -> String
where
S: AsRef<str>,
I: IntoIterator<Item = S>,
{
style_urls
.into_iter()
.map(|url| [r#"<link href="#, url.as_ref(), r#" rel="stylesheet"/>"#].concat())
.collect::<String>()
}
fn head<S, T, I>(title: S, style_urls: I) -> String
where
S: AsRef<str>,
T: AsRef<str>,
I: IntoIterator<Item = T>,
{
let content = rewrite_str(
template!("head/content"),
RewriteStrSettings {
element_content_handlers: vec![element!("title", move |t| {
t.set_inner_content(title.as_ref(), ContentType::Html);
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
let links_html = stylesheets(style_urls);
[content, links_html].concat()
}
pub(crate) struct PageSettings {
title: String,
stylesheets: Option<Vec<String>>,
site_header: bool,
site_footer: bool,
}
impl PageSettings {
pub(crate) fn title<S>(title: S) -> Self
where
S: ToString,
{
Self {
title: title.to_string(),
stylesheets: None,
site_header: true,
site_footer: true,
}
}
pub(crate) fn new<S, T>(
title: S,
stylesheets: Option<Vec<T>>,
site_header: bool,
site_footer: bool,
) -> Self
where
S: ToString,
T: ToString,
{
let stylesheets =
stylesheets.map(|s| s.iter().map(|t| t.to_string()).collect::<Vec<String>>());
Self {
title: title.to_string(),
stylesheets,
site_header,
site_footer,
}
}
}
pub(crate) fn make_404<S>(message: S) -> String
where
S: AsRef<str>,
{
make_page(message, PageSettings::title("Not found"))
}
pub(crate) fn zhuzh_anchors<S>(input: S) -> String
where
S: AsRef<str>,
{
rewrite_str(
input.as_ref(),
RewriteStrSettings {
element_content_handlers: vec![element!("a", move |t| {
if let Some(href) = t.get_attribute("href") {
if !href.starts_with("/") {
t.set_attribute("target", "_blank").unwrap_or_default();
}
}
match t.get_attribute("class") {
Some(class) => t
.set_attribute("class", &[&class, " animated-link-underline"].concat())
.unwrap_or_default(),
None => {
t.set_attribute("class", "animated-link-underline")
.unwrap_or_default();
}
}
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
pub(crate) fn sanitize<S: AsRef<str>>(input: S) -> String {
rewrite_str(
input.as_ref(),
RewriteStrSettings {
element_content_handlers: vec![element!("*", move |all| {
all.remove_and_keep_content();
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default()
}
pub(crate) async fn rss(db: &BlogDb) -> String {
let posts = db.get_posts().await.unwrap_or_default();
let mut items = String::new();
for post in posts {
let url = {
#[cfg(debug_assertions)]
{
["http://localhost/blog/", &post.id].concat()
}
#[cfg(not(debug_assertions))]
{
["https://", env!("DOMAIN"), "/blog/", &entry.id].concat()
}
};
let item = rewrite_str(
template!("item"),
RewriteStrSettings {
element_content_handlers: vec![
element!("title", |title| {
title.set_inner_content(&post.title, ContentType::Text);
Ok(())
}),
element!("link", |link| {
link.replace(&["<link>", &url, "</link>"].concat(), ContentType::Html); // slight hack to get around lol_html weirdness
Ok(())
}),
element!("guid", |guid| {
guid.set_inner_content(&url, ContentType::Text);
Ok(())
}),
element!("pubDate", |pub_date| {
let date = {
let date: [u32; 3] = post
.date
.splitn(3, "-")
.map(|d| d.parse::<u32>().unwrap_or_default())
.collect::<Vec<u32>>()
.try_into()
.unwrap_or_default();
let date_time: DateTime<Utc> = Utc
.with_ymd_and_hms(date[0] as i32, date[1], date[2], 0, 0, 0)
.unwrap();
date_time.to_rfc2822()
};
pub_date.set_inner_content(&date, ContentType::Text);
Ok(())
}),
element!("description", |description| {
let post_content: Blocks =
serde_json::from_str(&post.content).unwrap_or_default();
let desc = ["<![CDATA[", &post_content.to_html(), "]]>"].concat();
description.set_inner_content(&desc, ContentType::Html);
Ok(())
}),
],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
items.push_str(&item);
}
let feed = rewrite_str(
template!("feed"),
RewriteStrSettings {
element_content_handlers: vec![element!("channel", move |channel| {
channel.append(&items, ContentType::Html);
Ok(())
})],
..RewriteStrSettings::new()
},
)
.unwrap_or_default();
feed
}