Skip to main content

codoseo_web/
render.rs

1//! Rendering helpers shared by every route: templates to HTML, htmx request detection, and
2//! the `HX-Trigger` toast header.
3
4use std::convert::Infallible;
5
6use askama::Template;
7use axum::extract::FromRequestParts;
8use axum::http::request::Parts;
9use axum::http::{HeaderMap, HeaderName, HeaderValue};
10use axum::response::Html;
11
12use crate::error::AppError;
13
14/// Renders a template, turning a template error into a 500 page.
15pub fn html<T: Template>(t: &T) -> Result<Html<String>, AppError> {
16    Ok(Html(t.render()?))
17}
18
19pub fn is_htmx(headers: &HeaderMap) -> bool {
20    headers.get("hx-request").is_some_and(|v| v == "true")
21}
22
23/// What htmx told us about the request. A boosted request (a normal link or form that htmx
24/// took over) still wants the whole page; a plain `hx-get` wants just its fragment.
25#[derive(Debug, Clone, Default)]
26pub struct Hx {
27    pub request: bool,
28    pub boosted: bool,
29    pub target: Option<String>,
30}
31
32impl Hx {
33    /// True when only a fragment should be returned (htmx asked, and not as a boosted
34    /// navigation).
35    pub fn partial(&self) -> bool {
36        self.request && !self.boosted
37    }
38}
39
40impl<S: Send + Sync> FromRequestParts<S> for Hx {
41    type Rejection = Infallible;
42
43    async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Hx, Infallible> {
44        let h = &parts.headers;
45        Ok(Hx {
46            request: is_htmx(h),
47            boosted: h.get("hx-boosted").is_some_and(|v| v == "true"),
48            target: h
49                .get("hx-target")
50                .and_then(|v| v.to_str().ok())
51                .map(str::to_owned),
52        })
53    }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum ToastKind {
58    Ok,
59    Info,
60    Error,
61}
62
63impl ToastKind {
64    fn as_str(self) -> &'static str {
65        match self {
66            ToastKind::Ok => "ok",
67            ToastKind::Info => "info",
68            ToastKind::Error => "error",
69        }
70    }
71}
72
73/// An `HX-Trigger` header that makes `app.js` show a toast:
74/// `([toast(ToastKind::Ok, "Crawl queued")], html)` from a handler.
75pub fn toast(kind: ToastKind, message: &str) -> (HeaderName, HeaderValue) {
76    let payload = serde_json::json!({ "toast": { "kind": kind.as_str(), "message": message } });
77    (
78        HeaderName::from_static("hx-trigger"),
79        HeaderValue::from_str(&payload.to_string())
80            .unwrap_or_else(|_| HeaderValue::from_static("{}")),
81    )
82}
83
84/// `HX-Redirect`: htmx does a full navigation to `to`.
85pub fn hx_redirect(to: &str) -> (HeaderName, HeaderValue) {
86    (
87        HeaderName::from_static("hx-redirect"),
88        HeaderValue::from_str(to).unwrap_or_else(|_| HeaderValue::from_static("/")),
89    )
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn toast_header_is_json() {
98        let (name, value) = toast(ToastKind::Ok, "Crawl \"queued\"");
99        assert_eq!(name, "hx-trigger");
100        let v: serde_json::Value = serde_json::from_str(value.to_str().unwrap()).unwrap();
101        assert_eq!(v["toast"]["kind"], "ok");
102        assert_eq!(v["toast"]["message"], "Crawl \"queued\"");
103    }
104}