Add hero image support with <xeblog-hero>

Also lightens the JavaScript load and shifts ad impressions to only when
people from Reddit and Hacker News visit. I may have this include
Twitter in the future.

Signed-off-by: Xe <me@christine.website>
This commit is contained in:
Cadey Ratio 2022-06-08 14:58:29 -04:00
parent 396150f72b
commit dc3f6471e7
10 changed files with 601 additions and 240 deletions

584
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@ repository = "https://github.com/Xe/site"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
axum = "0.5" axum = { version = "0.5", features = ["headers"] }
axum-macros = "0.2" axum-macros = "0.2"
axum-extra = "0.3" axum-extra = "0.3"
color-eyre = "0.6" color-eyre = "0.6"
@ -28,9 +28,11 @@ kankyo = "0.3"
lazy_static = "1.4" lazy_static = "1.4"
log = "0.4" log = "0.4"
lol_html = "0.3" lol_html = "0.3"
maud = { version = "0.23.0", features = ["axum"] }
mime = "0.3.0" mime = "0.3.0"
prometheus = { version = "0.13", default-features = false, features = ["process"] } prometheus = { version = "0.13", default-features = false, features = ["process"] }
rand = "0" rand = "0"
regex = "1"
reqwest = { version = "0.11", features = ["json"] } reqwest = { version = "0.11", features = ["json"] }
serde_dhall = "0.11.1" serde_dhall = "0.11.1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }

View File

@ -0,0 +1,101 @@
---
title: "Site Update: Hero Images"
date: 2022-06-08
---
For a while I've been wondering how I can add dramatic flair to my website with
so-called "hero images". These images are tools that let you describe the mood a
website wants to evoke. I've been unsure how to best implement these on my
website for a while, but with the advent of MidJourney and other image
generation APIs/algorithms I think I have found a way to create these without
too much effort on my part and the results are pretty fantastic:
<xeblog-hero file="secret-to-life" prompt="the secret to life, the universe and everything, concept art"></xeblog-hero>
I have generated a bunch of other images that I'm going to use for my other
posts. I'll give out a desktop wallpaper sized version of each of these images
on my [Patreon](https://patreon.com/cadey).
Under the hood this is powered by
[lol_html](https://github.com/cloudflare/lol-html) and
[Maud](https://maud.lambda.xyz/). The magic is mostly contained in a function
that generates a `<figure>` HTML element (which I just learned exists today). I
use a function that looks like this for generating the `<xeblog-hero>` snippets:
```rust
pub fn xeblog_hero(file: String, prompt: Option<String>) -> Markup {
html! {
figure.hero style="margin:0" {
picture style="margin:0" {
source type="image/avif" srcset={"https://cdn.xeiaso.net/file/christine-static/hero/" (file) ".avif"};
source type="image/webp" srcset={"https://cdn.xeiaso.net/file/christine-static/hero/" (file) ".webp"};
img style="padding:0" alt={"hero image " (file)} src={"https://cdn.xeiaso.net/file/christine-static/hero/" (file) "-smol.png"};
}
figcaption { "Image generated by MidJourney" @if let Some(prompt) = prompt { " -- " (prompt) } }
}
}
}
```
I have it wired up with lol_html like this:
```rust
lol_html::element!("xeblog-hero", |el| {
let file = el.get_attribute("file").expect("wanted xeblog-hero to contain file");
el.replace(&crate::tmpl::xeblog_hero(file, el.get_attribute("prompt")).0, ContentType::Html);
Ok(())
})
```
The result is that I can declare hero images with HTML fragments like this:
```html
<xeblog-hero file="miku-dark-souls" prompt="hatsune miku, elden ring, dark souls, concept art, crowbar"></xeblog-hero>
```
And I get this:
<xeblog-hero file="miku-dark-souls" prompt="hatsune miku, elden ring, dark souls, concept art, crowbar"></xeblog-hero>
<xeblog-conv name="Mara" mood="hacker">This is powered by the
[`<figure>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/figure)
tag, which is a new discovery to us. This is probably one of the most useful
tags we never knew about and removed the need to write a bunch of annoying CSS
and HTML.</xeblog-conv>
The webp and AVIF versions of the hero images have a higher resolution version
so that it looks nicer on retina screens. However, the png versions of these are
locked to a resolution of 800x356 pixels because I was unable to crush them
below a size of half a megabyte at full resolution. Realistically, this should
only affect older browsers on slower hardware, so I don't expect this to have
too much impact on most users.
<xeblog-conv name="Cadey" mood="coffee">If you don't want to see these hero
images, you can remove them with a userstyle like this:
```css
figure.hero {
display: none;
}
```
</xeblog-conv>
I'm likely going to convert over most of my website templates to use Maud. I'm
very happy with it and I think it is incredibly useful to express your HTML in
Rust instead of something that has to be compiled to Rust. In practice it
reminds me of the Nim library [emerald](http://flyx.github.io/emerald/), which
lets you write HTML using Nim functions similar to how you use Maud.
Here's a few more examples of hero images I have generated:
<xeblog-hero file="the-forbidden-shape" prompt="the forbidden shape"></xeblog-hero>
<xeblog-hero file="great-wave-cyberpunk" prompt="the great wave off of kanagawa, cyberpunk, hanzi inscription"></xeblog-hero>
Normally I will only have one image per post and it will usually be after the
introduction paragraph. The prompt will usually be related to the article topic,
but sometimes I will take artistic liberty. If you have suggestions for prompts,
please [contact me](/contact) with those ideas.
I hope these updates on how I've been messing with my site are interesting. I'm
trying to capture the spirit of how I'm implementing these changes as well as
details of how everything fits together.

View File

@ -7,7 +7,7 @@ use comrak::{
ComrakPlugins, ComrakPlugins,
}; };
use lazy_static::lazy_static; use lazy_static::lazy_static;
use lol_html::{rewrite_str, element, RewriteStrSettings, html_content::ContentType}; use lol_html::{element, html_content::ContentType, rewrite_str, RewriteStrSettings};
use std::cell::RefCell; use std::cell::RefCell;
use url::Url; use url::Url;
@ -80,7 +80,7 @@ pub fn render(inp: &str) -> Result<String> {
let name = el.get_attribute("name").expect("wanted xeblog-conv to contain name"); let name = el.get_attribute("name").expect("wanted xeblog-conv to contain name");
let name_lower = name.clone().to_lowercase(); let name_lower = name.clone().to_lowercase();
let mood = el.get_attribute("mood").expect("wanted xeblog-conv to contain mood"); let mood = el.get_attribute("mood").expect("wanted xeblog-conv to contain mood");
el.before(&format!(r#" el.before(&format!(r#"
<div class="conversation"> <div class="conversation">
<div class="conversation-picture conversation-smol"> <div class="conversation-picture conversation-smol">
@ -95,6 +95,11 @@ pub fn render(inp: &str) -> Result<String> {
el.remove_and_keep_content(); el.remove_and_keep_content();
Ok(()) Ok(())
}),
element!("xeblog-hero", |el| {
let file = el.get_attribute("file").expect("wanted xeblog-hero to contain file");
el.replace(&crate::tmpl::xeblog_hero(file, el.get_attribute("prompt")).0, ContentType::Html);
Ok(())
}) })
], ],
..RewriteStrSettings::default() ..RewriteStrSettings::default()

View File

@ -4,6 +4,7 @@ use axum::{
extract::{Extension, Path}, extract::{Extension, Path},
response::Html, response::Html,
}; };
use http::HeaderMap;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use prometheus::{opts, register_int_counter_vec, IntCounterVec}; use prometheus::{opts, register_int_counter_vec, IntCounterVec};
use std::sync::Arc; use std::sync::Arc;
@ -72,10 +73,11 @@ pub async fn series_view(
Ok(Html(result)) Ok(Html(result))
} }
#[instrument(skip(state))] #[instrument(skip(state, headers))]
pub async fn post_view( pub async fn post_view(
Path(name): Path<String>, Path(name): Path<String>,
Extension(state): Extension<Arc<State>>, Extension(state): Extension<Arc<State>>,
headers: HeaderMap,
) -> Result { ) -> Result {
let mut want: Option<Post> = None; let mut want: Option<Post> = None;
@ -85,6 +87,13 @@ pub async fn post_view(
} }
} }
let referer = if let Some(referer) = headers.get(http::header::REFERER) {
let referer = referer.to_str()?.to_string();
Some(referer)
} else {
None
};
match want { match want {
None => Err(super::Error::PostNotFound(name)), None => Err(super::Error::PostNotFound(name)),
Some(post) => { Some(post) => {
@ -93,7 +102,7 @@ pub async fn post_view(
.inc(); .inc();
let body = templates::Html(post.body_html.clone()); let body = templates::Html(post.body_html.clone());
let mut result: Vec<u8> = vec![]; let mut result: Vec<u8> = vec![];
templates::blogpost_html(&mut result, post, body)?; templates::blogpost_html(&mut result, post, body, referer)?;
Ok(Html(result)) Ok(Html(result))
} }
} }

View File

@ -130,6 +130,9 @@ pub enum Error {
#[error("axum http error: {0}")] #[error("axum http error: {0}")]
AxumHTTP(#[from] axum::http::Error), AxumHTTP(#[from] axum::http::Error),
#[error("string conversion error: {0}")]
ToStr(#[from] http::header::ToStrError),
} }
pub type Result<T = Html<Vec<u8>>> = std::result::Result<T, Error>; pub type Result<T = Html<Vec<u8>>> = std::result::Result<T, Error>;

View File

@ -30,6 +30,7 @@ pub mod app;
pub mod handlers; pub mod handlers;
pub mod post; pub mod post;
pub mod signalboost; pub mod signalboost;
pub mod tmpl;
mod domainsocket; mod domainsocket;
use domainsocket::*; use domainsocket::*;

38
src/tmpl/mod.rs Normal file
View File

@ -0,0 +1,38 @@
use maud::{html, Markup};
pub mod nag;
pub fn xeblog_hero(file: String, prompt: Option<String>) -> Markup {
html! {
figure.hero style="margin:0" {
picture style="margin:0" {
source type="image/avif" srcset={"https://cdn.xeiaso.net/file/christine-static/hero/" (file) ".avif"};
source type="image/webp" srcset={"https://cdn.xeiaso.net/file/christine-static/hero/" (file) ".webp"};
img style="padding:0" alt={"hero image " (file)} src={"https://cdn.xeiaso.net/file/christine-static/hero/" (file) "-smol.png"};
}
figcaption { "Image generated by MidJourney" @if let Some(prompt) = prompt { " -- " (prompt) } }
}
}
}
pub fn xeblog_conv(name: String, mood: String, body: Markup) -> Markup {
let name_lower = name.clone().to_lowercase();
html! {
.conversation {
."conversation-picture"."conversation-smol" {
picture {
source type="image/avif" srcset={"https://cdn.xeiaso.net/file/christine-static/stickers/" (name_lower) "/" (mood) ".avif"};
source type="image/webp" srcset={"https://cdn.xeiaso.net/file/christine-static/stickers/" (name_lower) "/" (mood) ".webp"};
img alt={(name) " is " (mood)} src={"https://cdn.xeiaso.net/file/christine-static/stickers/" (name_lower) "/" (mood) ".png"};
}
}
."conversation-chat" {
"<"
b { (name) }
"> "
(body)
}
}
}
}

66
src/tmpl/nag.rs Normal file
View File

@ -0,0 +1,66 @@
use super::xeblog_conv;
use crate::post::Post;
use chrono::prelude::*;
use lazy_static::lazy_static;
use maud::{html, Markup};
use regex::Regex;
lazy_static! {
static ref HN: Regex = Regex::new(r#"^https?://news.ycombinator.com"#).unwrap();
static ref REDDIT: Regex = Regex::new(r#"^https?://((.+).)?reddit.com"#).unwrap();
}
pub fn referer(referer: Option<String>) -> Markup {
if referer.is_none() {
return html! {};
}
let referer = referer.unwrap();
let nag = html! {
script r#async src="https://media.ethicalads.io/media/client/ethicalads.min.js" { "" }
div.adaptive data-ea-publisher="christinewebsite" data-ea-type="image" data-ea-style="stickybox" {
.warning {
(xeblog_conv(
"Cadey".into(),
"coffee".into(),
html! {
"Hello! Thank you for visiting my website. You seem to be visiting from a news aggregator and have ads disabled. These ads help pay for running the website and are done by "
a href="https://www.ethicalads.io/" { "Ethical Ads" }
". I do not receive detailed analytics on the ads and from what I understand neither does Ethical Ads. If you don't want to disable your ad blocker, please consider donating on "
a href="https://patreon.com/cadey" { "Patreon" }
". It helps fund the website's hosting bills and pay for the expensive technical editor that I use for my longer articles. Thanks and be well!"
},
))
}
}
};
if HN.is_match(&referer) {
return nag;
}
if REDDIT.is_match(&referer) {
return nag;
}
html! {}
}
pub fn prerelease(post: &Post) -> Markup {
if Utc::today().num_days_from_ce() < post.date.num_days_from_ce() {
html! {
.warning {
(xeblog_conv("Mara".into(), "hacker".into(), html!{
"Hey, this post is set to go live on "
(format!("{}", post.detri()))
" UTC. Right now you are reading a pre-publication version of this post. Please do not share this on social media. This post will automatically go live for everyone on the intended publication date. If you want access to these posts, please join the "
a href="https://patreon.com/cadey" { "Patreon" }
". It helps me afford the copyeditor that I contract for the technical content I write."
}))
}
}
} else {
html! {}
}
}

View File

@ -1,8 +1,7 @@
@use super::{header_html, footer_html, mara}; @use super::{header_html, footer_html};
@use crate::post::Post; @use crate::{post::Post, tmpl::nag};
@use chrono::prelude::*;
@(post: Post, body: impl ToHtml) @(post: Post, body: impl ToHtml, referer: Option<String>)
@:header_html(Some(&post.front_matter.title.clone()), None) @:header_html(Some(&post.front_matter.title.clone()), None)
@ -27,8 +26,6 @@
<link rel="canonical" href="@post.front_matter.redirect_to.as_ref().unwrap()" /> <link rel="canonical" href="@post.front_matter.redirect_to.as_ref().unwrap()" />
} }
<script type="module" src="/static/js/conversation.js"></script>
<script type="application/ld+json"> <script type="application/ld+json">
@{ @{
"@@context": "http://schema.org", "@@context": "http://schema.org",
@ -59,25 +56,16 @@
</script> </script>
} }
<div id="refererNotice"></div> @Html(nag::referer(referer).0)
<script type="module" src="/static/js/hnwarn.js"></script>
<h1>@post.front_matter.title</h1> <h1>@post.front_matter.title</h1>
@if Utc::today().num_days_from_ce() < post.date.num_days_from_ce() { @Html(nag::prerelease(&post).0)
<div class="warning">
@:mara("hacker", "Mara", Html(format!(r#"Hey, this post is set to go live to the public on {} UTC. Right now you are reading a pre-publication version of this post. Please do not share this on social media. This post will automatically go live for everyone on the intended publication date. If you want access to these posts, please join the <a href="https://patreon.com/cadey">Patreon</a>. It helps me afford the copyeditor that I contract for the technical content I write."#, post.detri())))
</div>
} else {
<script async src="https://media.ethicalads.io/media/client/ethicalads.min.js"></script>
}
<small>A @post.read_time_estimate_minutes minute read.</small> <small>A @post.read_time_estimate_minutes minute read.</small>
@body @body
<div class="adaptive" data-ea-publisher="christinewebsite" data-ea-type="image" data-ea-style="stickybox"></div>
<hr /> <hr />
@if post.front_matter.vod.is_some() { @if post.front_matter.vod.is_some() {