Add hx-boosted support to Layout

It will now skip rendering the layout if the request is coming from an
hx-boosted link or form and only update the head title with a
hx-swap-oob.
This commit is contained in:
Tyler Hallada 2023-09-26 23:36:31 -04:00
parent 81b4ef860e
commit 092a38ad52
12 changed files with 191 additions and 78 deletions

1
Cargo.lock generated
View File

@ -744,6 +744,7 @@ dependencies = [
"dotenvy", "dotenvy",
"feed-rs", "feed-rs",
"futures", "futures",
"headers",
"http", "http",
"maud", "maud",
"notify", "notify",

View File

@ -26,6 +26,7 @@ clap = { version = "4.4", features = ["derive", "env"] }
dotenvy = "0.15" dotenvy = "0.15"
feed-rs = "1.3" feed-rs = "1.3"
futures = "0.3" futures = "0.3"
headers = "0.3"
http = "0.2.9" http = "0.2.9"
maud = { version = "0.25", features = ["axum"] } maud = { version = "0.25", features = ["axum"] }
notify = "6" notify = "6"

View File

@ -228,7 +228,6 @@ header.feed-header button {
display: grid; display: grid;
grid-template-columns: fit-content(100%) minmax(100px, 400px); grid-template-columns: fit-content(100%) minmax(100px, 400px);
grid-gap: 16px; grid-gap: 16px;
width: 100%;
margin: 16px; margin: 16px;
margin-bottom: 32px; margin-bottom: 32px;
} }

View File

@ -2,11 +2,13 @@ use std::fs;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::response::Response; use axum::response::Response;
use axum::TypedHeader;
use maud::{html, PreEscaped}; use maud::{html, PreEscaped};
use sqlx::PgPool; use sqlx::PgPool;
use crate::config::Config; use crate::config::Config;
use crate::error::Result; use crate::error::Result;
use crate::htmx::HXBoosted;
use crate::models::entry::Entry; use crate::models::entry::Entry;
use crate::partials::layout::Layout; use crate::partials::layout::Layout;
use crate::uuid::Base62Uuid; use crate::uuid::Base62Uuid;
@ -15,6 +17,7 @@ pub async fn get(
Path(id): Path<Base62Uuid>, Path(id): Path<Base62Uuid>,
State(pool): State<PgPool>, State(pool): State<PgPool>,
State(config): State<Config>, State(config): State<Config>,
hx_boosted: Option<TypedHeader<HXBoosted>>,
layout: Layout, layout: Layout,
) -> Result<Response> { ) -> Result<Response> {
let entry = Entry::get(&pool, id.as_uuid()).await?; let entry = Entry::get(&pool, id.as_uuid()).await?;
@ -25,7 +28,10 @@ pub async fn get(
.published_at .published_at
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true); .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let content = fs::read_to_string(content_path).unwrap_or_else(|_| "No content".to_string()); let content = fs::read_to_string(content_path).unwrap_or_else(|_| "No content".to_string());
Ok(layout.with_subtitle(&title).render(html! { Ok(layout
.with_subtitle(&title)
.boosted(hx_boosted)
.render(html! {
article { article {
header { header {
h2 class="title" { a href=(entry.url) { (title) } } h2 class="title" { a href=(entry.url) { (title) } }

View File

@ -4,7 +4,7 @@ use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::sse::{Event, KeepAlive}; use axum::response::sse::{Event, KeepAlive};
use axum::response::{IntoResponse, Redirect, Response, Sse}; use axum::response::{IntoResponse, Redirect, Response, Sse};
use axum::Form; use axum::{Form, TypedHeader};
use feed_rs::parser; use feed_rs::parser;
use maud::html; use maud::html;
use serde::Deserialize; use serde::Deserialize;
@ -16,6 +16,7 @@ use tokio_stream::StreamExt;
use crate::actors::crawl_scheduler::{CrawlSchedulerHandle, CrawlSchedulerHandleMessage}; use crate::actors::crawl_scheduler::{CrawlSchedulerHandle, CrawlSchedulerHandleMessage};
use crate::actors::feed_crawler::FeedCrawlerHandleMessage; use crate::actors::feed_crawler::FeedCrawlerHandleMessage;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::htmx::HXBoosted;
use crate::models::entry::{Entry, GetEntriesOptions}; use crate::models::entry::{Entry, GetEntriesOptions};
use crate::models::feed::{CreateFeed, Feed}; use crate::models::feed::{CreateFeed, Feed};
use crate::partials::add_feed_form::add_feed_form; use crate::partials::add_feed_form::add_feed_form;
@ -27,6 +28,7 @@ use crate::uuid::Base62Uuid;
pub async fn get( pub async fn get(
Path(id): Path<Base62Uuid>, Path(id): Path<Base62Uuid>,
State(pool): State<PgPool>, State(pool): State<PgPool>,
hx_boosted: Option<TypedHeader<HXBoosted>>,
layout: Layout, layout: Layout,
) -> Result<Response> { ) -> Result<Response> {
let feed = Feed::get(&pool, id.as_uuid()).await?; let feed = Feed::get(&pool, id.as_uuid()).await?;
@ -37,7 +39,7 @@ pub async fn get(
let title = feed.title.unwrap_or_else(|| "Untitled Feed".to_string()); let title = feed.title.unwrap_or_else(|| "Untitled Feed".to_string());
let entries = Entry::get_all(&pool, &options).await?; let entries = Entry::get_all(&pool, &options).await?;
let delete_url = format!("/feed/{}/delete", id); let delete_url = format!("/feed/{}/delete", id);
Ok(layout.with_subtitle(&title).render(html! { Ok(layout.with_subtitle(&title).boosted(hx_boosted).render(html! {
header class="feed-header" { header class="feed-header" {
h2 { (title) } h2 { (title) }
button class="edit-feed" { "✏️ Edit feed" } button class="edit-feed" { "✏️ Edit feed" }

View File

@ -1,19 +1,28 @@
use axum::extract::State; use axum::extract::State;
use axum::response::Response; use axum::response::Response;
use axum::TypedHeader;
use maud::html; use maud::html;
use sqlx::PgPool; use sqlx::PgPool;
use crate::error::Result; use crate::error::Result;
use crate::htmx::HXBoosted;
use crate::models::feed::{Feed, GetFeedsOptions}; use crate::models::feed::{Feed, GetFeedsOptions};
use crate::partials::add_feed_form::add_feed_form; use crate::partials::add_feed_form::add_feed_form;
use crate::partials::feed_list::feed_list; use crate::partials::feed_list::feed_list;
use crate::partials::layout::Layout; use crate::partials::layout::Layout;
use crate::partials::opml_import_form::opml_import_form; use crate::partials::opml_import_form::opml_import_form;
pub async fn get(State(pool): State<PgPool>, layout: Layout) -> Result<Response> { pub async fn get(
State(pool): State<PgPool>,
hx_boosted: Option<TypedHeader<HXBoosted>>,
layout: Layout,
) -> Result<Response> {
let options = GetFeedsOptions::default(); let options = GetFeedsOptions::default();
let feeds = Feed::get_all(&pool, &options).await?; let feeds = Feed::get_all(&pool, &options).await?;
Ok(layout.with_subtitle("feeds").render(html! { Ok(layout
.with_subtitle("feeds")
.boosted(hx_boosted)
.render(html! {
header { h2 { "Feeds" } } header { h2 { "Feeds" } }
div class="feeds" { div class="feeds" {
ul id="feeds" { ul id="feeds" {

View File

@ -1,16 +1,22 @@
use axum::extract::State; use axum::extract::State;
use axum::response::Response; use axum::response::Response;
use axum::TypedHeader;
use maud::html; use maud::html;
use sqlx::PgPool; use sqlx::PgPool;
use crate::error::Result; use crate::error::Result;
use crate::htmx::HXBoosted;
use crate::models::entry::Entry; use crate::models::entry::Entry;
use crate::partials::{layout::Layout, entry_list::entry_list}; use crate::partials::{entry_list::entry_list, layout::Layout};
pub async fn get(State(pool): State<PgPool>, layout: Layout) -> Result<Response> { pub async fn get(
State(pool): State<PgPool>,
hx_boosted: Option<TypedHeader<HXBoosted>>,
layout: Layout,
) -> Result<Response> {
let options = Default::default(); let options = Default::default();
let entries = Entry::get_all(&pool, &options).await?; let entries = Entry::get_all(&pool, &options).await?;
Ok(layout.render(html! { Ok(layout.boosted(hx_boosted).render(html! {
ul class="entries" { ul class="entries" {
(entry_list(entries, &options)) (entry_list(entries, &options))
} }

View File

@ -9,6 +9,7 @@ use axum::response::{
sse::{Event, Sse}, sse::{Event, Sse},
Response, Response,
}; };
use axum::TypedHeader;
use bytes::Bytes; use bytes::Bytes;
use maud::{html, PreEscaped}; use maud::{html, PreEscaped};
use tokio::sync::watch::Receiver; use tokio::sync::watch::Receiver;
@ -17,13 +18,17 @@ use tokio_stream::Stream;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use crate::error::Result; use crate::error::Result;
use crate::htmx::HXBoosted;
use crate::log::MEM_LOG; use crate::log::MEM_LOG;
use crate::partials::layout::Layout; use crate::partials::layout::Layout;
pub async fn get(layout: Layout) -> Result<Response> { pub async fn get(hx_boosted: Option<TypedHeader<HXBoosted>>, layout: Layout) -> Result<Response> {
let mem_buf = MEM_LOG.lock().unwrap(); let mem_buf = MEM_LOG.lock().unwrap();
Ok(layout.with_subtitle("log").render(html! { Ok(layout
pre id="log" hx-sse="connect:/log/stream swap:message" hx-swap="beforeend" { .with_subtitle("log")
.boosted(hx_boosted)
.render(html! {
pre id="log" hx-sse="connect:/log/stream swap:message" hx-swap="beforeend" hx-target="#log" {
(PreEscaped(convert_escaped(from_utf8(mem_buf.as_slices().0).unwrap()).unwrap())) (PreEscaped(convert_escaped(from_utf8(mem_buf.as_slices().0).unwrap()).unwrap()))
} }
})) }))

View File

@ -1,4 +1,5 @@
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::TypedHeader;
use axum::{extract::State, Form}; use axum::{extract::State, Form};
use maud::html; use maud::html;
use serde::Deserialize; use serde::Deserialize;
@ -7,7 +8,7 @@ use sqlx::PgPool;
use crate::auth::verify_password; use crate::auth::verify_password;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::htmx::HXRedirect; use crate::htmx::{HXBoosted, HXRedirect};
use crate::partials::login_form::{login_form, LoginFormProps}; use crate::partials::login_form::{login_form, LoginFormProps};
use crate::{ use crate::{
models::user::{AuthContext, User}, models::user::{AuthContext, User},
@ -21,8 +22,11 @@ pub struct Login {
password: String, password: String,
} }
pub async fn get(layout: Layout) -> Result<Response> { pub async fn get(hx_boosted: Option<TypedHeader<HXBoosted>>, layout: Layout) -> Result<Response> {
Ok(layout.with_subtitle("login").render(html! { Ok(layout
.with_subtitle("login")
.boosted(hx_boosted)
.render(html! {
header { header {
h2 { "Login" } h2 { "Login" }
} }

View File

@ -1,4 +1,5 @@
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::TypedHeader;
use axum::{extract::State, Form}; use axum::{extract::State, Form};
use maud::html; use maud::html;
use serde::Deserialize; use serde::Deserialize;
@ -6,7 +7,7 @@ use serde_with::{serde_as, NoneAsEmptyString};
use sqlx::PgPool; use sqlx::PgPool;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::htmx::HXRedirect; use crate::htmx::{HXBoosted, HXRedirect};
use crate::models::user::{AuthContext, CreateUser, User}; use crate::models::user::{AuthContext, CreateUser, User};
use crate::partials::layout::Layout; use crate::partials::layout::Layout;
use crate::partials::signup_form::{signup_form, SignupFormProps}; use crate::partials::signup_form::{signup_form, SignupFormProps};
@ -21,8 +22,11 @@ pub struct Signup {
pub name: Option<String>, pub name: Option<String>,
} }
pub async fn get(layout: Layout) -> Result<Response> { pub async fn get(hx_boosted: Option<TypedHeader<HXBoosted>>, layout: Layout) -> Result<Response> {
Ok(layout.with_subtitle("signup").render(html! { Ok(layout
.with_subtitle("signup")
.boosted(hx_boosted)
.render(html! {
header { header {
h2 { "Signup" } h2 { "Signup" }
} }

View File

@ -1,9 +1,12 @@
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use headers::{self, Header};
use http::header::{HeaderName, HeaderValue}; use http::header::{HeaderName, HeaderValue};
use http::StatusCode; use http::StatusCode;
#[allow(clippy::declare_interior_mutable_const)] #[allow(clippy::declare_interior_mutable_const)]
const HX_LOCATION: HeaderName = HeaderName::from_static("hx-location"); pub const HX_LOCATION: HeaderName = HeaderName::from_static("hx-location");
#[allow(clippy::declare_interior_mutable_const)]
pub const HX_BOOSTED: HeaderName = HeaderName::from_static("hx-boosted");
/// Sets the HX-Location header so that HTMX redirects to the given URI. Unlike /// Sets the HX-Location header so that HTMX redirects to the given URI. Unlike
/// axum::response::Redirect this does not return a 300-level status code (instead, a 200 status /// axum::response::Redirect this does not return a 300-level status code (instead, a 200 status
@ -30,3 +33,51 @@ impl IntoResponse for HXRedirect {
.into_response() .into_response()
} }
} }
#[derive(Debug)]
pub struct HXBoosted(bool);
impl HXBoosted {
pub fn is_boosted(&self) -> bool {
self.0
}
}
impl Header for HXBoosted {
fn name() -> &'static HeaderName {
static HX_BOOSTED_STATIC: HeaderName = HX_BOOSTED;
&HX_BOOSTED_STATIC
}
fn decode<'i, I>(values: &mut I) -> Result<Self, headers::Error>
where
I: Iterator<Item = &'i HeaderValue>,
{
let value = values
.next()
.ok_or_else(headers::Error::invalid)?;
if value == "true" {
Ok(HXBoosted(true))
} else if value == "false" {
Ok(HXBoosted(false))
} else {
Err(headers::Error::invalid())
}
}
fn encode<E>(&self, values: &mut E)
where
E: Extend<HeaderValue>,
{
let s = if self.0 {
"true"
} else {
"false"
};
let value = HeaderValue::from_static(s);
values.extend(std::iter::once(value));
}
}

View File

@ -8,15 +8,16 @@ use axum::{
extract::{FromRef, FromRequestParts, State}, extract::{FromRef, FromRequestParts, State},
http::request::Parts, http::request::Parts,
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
TypedHeader,
}; };
use axum_login::{extractors::AuthContext, SqlxStore}; use axum_login::{extractors::AuthContext, SqlxStore};
use maud::{html, Markup, DOCTYPE}; use maud::{html, Markup, DOCTYPE};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::partials::header::header;
use crate::{config::Config, partials::footer::footer};
use crate::models::user::User; use crate::models::user::User;
use crate::{config::Config, partials::footer::footer};
use crate::{htmx::HXBoosted, partials::header::header};
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
use crate::{CSS_MANIFEST, JS_MANIFEST}; use crate::{CSS_MANIFEST, JS_MANIFEST};
@ -25,6 +26,7 @@ pub struct Layout {
pub title: String, pub title: String,
pub subtitle: Option<String>, pub subtitle: Option<String>,
pub user: Option<User>, pub user: Option<User>,
pub boosted: bool,
} }
#[async_trait] #[async_trait]
@ -39,7 +41,8 @@ where
let State(config) = State::<Config>::from_request_parts(parts, state) let State(config) = State::<Config>::from_request_parts(parts, state)
.await .await
.map_err(|err| err.into_response())?; .map_err(|err| err.into_response())?;
let auth_context = AuthContext::<Uuid, User, SqlxStore<PgPool, User>>::from_request_parts(parts, state) let auth_context =
AuthContext::<Uuid, User, SqlxStore<PgPool, User>>::from_request_parts(parts, state)
.await .await
.map_err(|err| err.into_response())?; .map_err(|err| err.into_response())?;
Ok(Self { Ok(Self {
@ -115,6 +118,22 @@ impl Layout {
self self
} }
/// If the given HX-Boosted header is present and "true", makes this Layout boosted.
///
/// A boosted layout will skip rendering the layout and only render the template with a
/// hx-swap-oob <title> element to update the document title.
///
/// Links and forms that are boosted with the hx-boost attribute are only updating a portion of
/// the page inside the layout, so there is no need to render and send the layout again.
pub fn boosted(mut self, hx_boosted: Option<TypedHeader<HXBoosted>>) -> Self {
if let Some(hx_boosted) = hx_boosted {
if hx_boosted.is_boosted() {
self.boosted = true;
}
}
self
}
fn full_title(&self) -> String { fn full_title(&self) -> String {
if let Some(subtitle) = &self.subtitle { if let Some(subtitle) = &self.subtitle {
format!("{} - {}", self.title, subtitle) format!("{} - {}", self.title, subtitle)
@ -124,7 +143,14 @@ impl Layout {
} }
pub fn render(self, template: Markup) -> Response { pub fn render(self, template: Markup) -> Response {
let with_layout = html! { if self.boosted {
html! {
title hx-swap-oob="true" { (self.full_title()) }
(template)
}
.into_response()
} else {
html! {
(DOCTYPE) (DOCTYPE)
html lang="en" { html lang="en" {
head { head {
@ -137,15 +163,14 @@ impl Layout {
link rel="stylesheet" href=(css_file) {} link rel="stylesheet" href=(css_file) {}
} }
} }
body hx-booster="true" { body hx-boost="true" hx-target="#main-content" {
(header(&self.title, self.user)) (header(&self.title, self.user))
(template) main id="main-content" { (template) }
(footer()) (footer())
} }
} }
} }
.into_string(); .into_response()
}
Html(with_layout).into_response()
} }
} }