Skip to main content

autumn_web/
htmx.rs

1//! Embedded htmx JavaScript.
2//!
3//! htmx is embedded directly in the Autumn binary via [`include_bytes!`]
4//! and served at [`HTMX_JS_PATH`]. A small CSRF helper is also served at
5//! [`HTMX_CSRF_JS_PATH`] so htmx forms can work with Autumn's default
6//! `script-src 'self'` Content Security Policy. No CDN, no npm, no build step
7//! required.
8//!
9//! The framework automatically mounts a route handler that serves this
10//! file with immutable caching headers. Reference it in your HTML
11//! templates:
12//!
13//! ```html
14//! <script src="/static/js/htmx.min.js"></script>
15//! <script src="/static/js/autumn-htmx-csrf.js"></script>
16//! ```
17
18use axum::extract::FromRequestParts;
19use axum::http::request::Parts;
20use axum::response::{IntoResponse, Response};
21use http::header::{HeaderName, HeaderValue};
22use std::convert::Infallible;
23
24/// htmx 2.x minified JavaScript, embedded at compile time.
25///
26/// This is the raw byte content of the minified htmx library. It is
27/// served automatically by the framework at `/static/js/htmx.min.js`
28/// with `Cache-Control: public, max-age=31536000, immutable`.
29pub const HTMX_JS: &[u8] = include_bytes!("../vendor/htmx.min.js");
30
31/// Same-origin path where Autumn serves embedded htmx.
32pub const HTMX_JS_PATH: &str = "/static/js/htmx.min.js";
33
34/// Same-origin path where Autumn serves the htmx CSRF helper.
35///
36/// The helper reads a CSRF token from either:
37/// - `<meta name="csrf-token" content="...">`
38/// - `<meta name="autumn-csrf-token" content="...">`
39///
40/// The request header defaults to `X-CSRF-Token`; override it with
41/// `data-header="..."` on the meta tag when using a custom CSRF header name.
42pub const HTMX_CSRF_JS_PATH: &str = "/static/js/autumn-htmx-csrf.js";
43
44/// CSP-compatible htmx CSRF helper JavaScript.
45///
46/// Served as an external same-origin script so applications do not need inline
47/// JavaScript under Autumn's default `script-src 'self'` policy.
48pub const HTMX_CSRF_JS: &str = r#"(function () {
49  document.addEventListener("htmx:configRequest", function (evt) {
50    var meta = document.querySelector('meta[name="csrf-token"], meta[name="autumn-csrf-token"]');
51
52    if (!meta || !evt.detail || !evt.detail.headers) {
53      return;
54    }
55
56    var header = meta.getAttribute("data-header") || "X-CSRF-Token";
57    evt.detail.headers[header] = meta.getAttribute("content") || "";
58  });
59})();
60"#;
61
62/// htmx version string for diagnostics and cache busting.
63///
64/// Corresponds to the version of the embedded htmx JS file.
65/// Re-exported at the crate root as [`HTMX_VERSION`].
66pub const HTMX_VERSION: &str = "2.0.4";
67
68/// Extractor for htmx request headers.
69///
70/// Extracts the standard `hx-*` headers sent by htmx requests, enabling
71/// conditional rendering (e.g. sending back partials instead of full pages).
72///
73/// See <https://htmx.org/reference/#request_headers> for more details.
74#[allow(dead_code)]
75#[derive(Debug, Clone, Default)]
76pub struct HxRequest {
77    /// Indicates that the request is via htmx (`HX-Request`)
78    pub is_htmx: bool,
79    /// The id of the target element if provided (`HX-Target`)
80    pub target: Option<String>,
81    /// The id of the triggered element if provided (`HX-Trigger`)
82    pub trigger: Option<String>,
83    /// The name of the triggered element if provided (`HX-Trigger-Name`)
84    pub trigger_name: Option<String>,
85    /// The current URL of the browser (`HX-Current-URL`)
86    pub current_url: Option<String>,
87    /// `true` if the request is for history restoration after a miss (`HX-History-Restore-Request`)
88    pub history_restore_request: bool,
89    /// The user response to an hx-prompt (`HX-Prompt`)
90    pub prompt: Option<String>,
91    /// `true` if the request is via an element using hx-boost (`HX-Boosted`)
92    pub boosted: bool,
93}
94
95impl<S> FromRequestParts<S> for HxRequest
96where
97    S: Send + Sync,
98{
99    type Rejection = Infallible;
100
101    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
102        let header_str = |name: &'static str| -> Option<String> {
103            parts
104                .headers
105                .get(name)
106                .and_then(|v| v.to_str().ok())
107                .map(ToString::to_string)
108        };
109        let header_bool =
110            |name: &'static str| -> bool { parts.headers.get(name).is_some_and(|v| v == "true") };
111
112        Ok(Self {
113            is_htmx: header_bool("hx-request"),
114            target: header_str("hx-target"),
115            trigger: header_str("hx-trigger"),
116            trigger_name: header_str("hx-trigger-name"),
117            current_url: header_str("hx-current-url"),
118            history_restore_request: header_bool("hx-history-restore-request"),
119            prompt: header_str("hx-prompt"),
120            boosted: header_bool("hx-boosted"),
121        })
122    }
123}
124
125/// Extension trait for adding htmx response headers to any `IntoResponse` type.
126///
127/// This trait provides a fluent API for controlling htmx behavior from the server.
128/// If an invalid header value is provided, it is gracefully ignored.
129///
130/// See <https://htmx.org/reference/#response_headers> for more details.
131pub trait HxResponseExt: IntoResponse + Sized {
132    /// Allows you to do a client-side redirect that does not do a full page reload (`HX-Location`).
133    fn hx_location(self, url: &str) -> Response {
134        append_hx_header(self, "hx-location", url)
135    }
136
137    /// Pushes a new URL into the history stack (`HX-Push-Url`).
138    fn hx_push_url(self, url: &str) -> Response {
139        append_hx_header(self, "hx-push-url", url)
140    }
141
142    /// Triggers a client-side redirect (`HX-Redirect`).
143    fn hx_redirect(self, url: &str) -> Response {
144        append_hx_header(self, "hx-redirect", url)
145    }
146
147    /// Tells the client to do a full page refresh (`HX-Refresh`).
148    fn hx_refresh(self) -> Response {
149        append_hx_header(self, "hx-refresh", "true")
150    }
151
152    /// Replaces the current URL in the location bar (`HX-Replace-Url`).
153    fn hx_replace_url(self, url: &str) -> Response {
154        append_hx_header(self, "hx-replace-url", url)
155    }
156
157    /// Specifies how the response will be swapped (`HX-Reswap`).
158    fn hx_reswap(self, swap: &str) -> Response {
159        append_hx_header(self, "hx-reswap", swap)
160    }
161
162    /// Specifies the target element to update (`HX-Retarget`).
163    fn hx_retarget(self, target: &str) -> Response {
164        append_hx_header(self, "hx-retarget", target)
165    }
166
167    /// Triggers client-side events (`HX-Trigger`).
168    fn hx_trigger(self, event: &str) -> Response {
169        append_hx_header(self, "hx-trigger", event)
170    }
171
172    /// Triggers client-side events after the settle step (`HX-Trigger-After-Settle`).
173    fn hx_trigger_after_settle(self, event: &str) -> Response {
174        append_hx_header(self, "hx-trigger-after-settle", event)
175    }
176
177    /// Triggers client-side events after the swap step (`HX-Trigger-After-Swap`).
178    fn hx_trigger_after_swap(self, event: &str) -> Response {
179        append_hx_header(self, "hx-trigger-after-swap", event)
180    }
181}
182
183impl<T: IntoResponse> HxResponseExt for T {}
184
185fn append_hx_header<T: IntoResponse>(response: T, name: &'static str, value: &str) -> Response {
186    let mut res = response.into_response();
187    if let Ok(v) = HeaderValue::from_str(value) {
188        res.headers_mut().insert(HeaderName::from_static(name), v);
189    }
190    res
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use axum::http::Request;
197
198    #[test]
199    #[allow(clippy::const_is_empty)]
200    fn htmx_js_is_not_empty() {
201        assert!(!HTMX_JS.is_empty(), "htmx.min.js should not be empty");
202    }
203
204    #[test]
205    fn htmx_js_looks_like_javascript() {
206        let start = std::str::from_utf8(&HTMX_JS[..50]).expect("htmx should be valid UTF-8");
207        assert!(
208            start.contains("htmx") || start.contains("function") || start.contains('('),
209            "htmx.min.js doesn't look like JavaScript: {start}"
210        );
211    }
212
213    #[test]
214    fn htmx_version_matches_expected() {
215        assert_eq!(HTMX_VERSION, "2.0.4");
216    }
217
218    #[test]
219    fn htmx_asset_paths_are_same_origin_static_paths() {
220        assert_eq!(HTMX_JS_PATH, "/static/js/htmx.min.js");
221        assert_eq!(HTMX_CSRF_JS_PATH, "/static/js/autumn-htmx-csrf.js");
222    }
223
224    #[test]
225    fn htmx_csrf_js_configures_request_header_without_inline_wrapper() {
226        assert!(HTMX_CSRF_JS.contains("htmx:configRequest"));
227        assert!(HTMX_CSRF_JS.contains("X-CSRF-Token"));
228        assert!(HTMX_CSRF_JS.contains("csrf-token"));
229        assert!(!HTMX_CSRF_JS.contains("<script"));
230    }
231
232    #[tokio::test]
233    async fn hx_request_extractor_parses_headers() -> Result<(), axum::http::Error> {
234        let req = Request::builder()
235            .header("hx-request", "true")
236            .header("hx-target", "my-div")
237            .header("hx-trigger", "btn")
238            .header("hx-trigger-name", "btn-name")
239            .header("hx-current-url", "http://example.com")
240            .header("hx-history-restore-request", "true")
241            .header("hx-prompt", "yes")
242            .header("hx-boosted", "true")
243            .body(())?;
244        let (mut parts, ()) = req.into_parts();
245
246        let hx = HxRequest::from_request_parts(&mut parts, &())
247            .await
248            .expect("infallible");
249
250        assert!(hx.is_htmx);
251        assert_eq!(hx.target.as_deref(), Some("my-div"));
252        assert_eq!(hx.trigger.as_deref(), Some("btn"));
253        assert_eq!(hx.trigger_name.as_deref(), Some("btn-name"));
254        assert_eq!(hx.current_url.as_deref(), Some("http://example.com"));
255        assert!(hx.history_restore_request);
256        assert_eq!(hx.prompt.as_deref(), Some("yes"));
257        assert!(hx.boosted);
258        Ok(())
259    }
260
261    #[tokio::test]
262    async fn hx_response_ext_adds_headers() {
263        use axum::response::IntoResponse;
264        let response = "hello"
265            .hx_location("/some-location")
266            .hx_push_url("/new-url")
267            .hx_redirect("/login")
268            .hx_refresh()
269            .hx_replace_url("/old-url")
270            .hx_reswap("innerHTML")
271            .hx_retarget("#target")
272            .hx_trigger("my-event")
273            .hx_trigger_after_settle("settled-event")
274            .hx_trigger_after_swap("swapped-event")
275            .into_response();
276
277        let headers = response.headers();
278        assert_eq!(headers.get("hx-location").unwrap(), "/some-location");
279        assert_eq!(headers.get("hx-push-url").unwrap(), "/new-url");
280        assert_eq!(headers.get("hx-redirect").unwrap(), "/login");
281        assert_eq!(headers.get("hx-refresh").unwrap(), "true");
282        assert_eq!(headers.get("hx-replace-url").unwrap(), "/old-url");
283        assert_eq!(headers.get("hx-reswap").unwrap(), "innerHTML");
284        assert_eq!(headers.get("hx-retarget").unwrap(), "#target");
285        assert_eq!(headers.get("hx-trigger").unwrap(), "my-event");
286        assert_eq!(
287            headers.get("hx-trigger-after-settle").unwrap(),
288            "settled-event"
289        );
290        assert_eq!(
291            headers.get("hx-trigger-after-swap").unwrap(),
292            "swapped-event"
293        );
294    }
295
296    #[tokio::test]
297    async fn hx_response_ext_ignores_invalid_header_values() {
298        use axum::response::IntoResponse;
299
300        // This value is invalid because it contains a newline character.
301        // It should be gracefully ignored by the append_hx_header function.
302        let invalid_header_value = "invalid\nvalue";
303
304        let response = "hello"
305            .hx_location(invalid_header_value)
306            .hx_push_url(invalid_header_value)
307            .hx_redirect(invalid_header_value)
308            .hx_refresh() // valid by default
309            .hx_replace_url(invalid_header_value)
310            .hx_reswap(invalid_header_value)
311            .hx_retarget(invalid_header_value)
312            .hx_trigger(invalid_header_value)
313            .hx_trigger_after_settle(invalid_header_value)
314            .hx_trigger_after_swap(invalid_header_value)
315            .into_response();
316
317        let headers = response.headers();
318        assert!(headers.get("hx-location").is_none());
319        assert!(headers.get("hx-push-url").is_none());
320        assert!(headers.get("hx-redirect").is_none());
321        // hx_refresh is always set to "true" internally, so it will be present
322        assert_eq!(headers.get("hx-refresh").unwrap(), "true");
323        assert!(headers.get("hx-replace-url").is_none());
324        assert!(headers.get("hx-reswap").is_none());
325        assert!(headers.get("hx-retarget").is_none());
326        assert!(headers.get("hx-trigger").is_none());
327        assert!(headers.get("hx-trigger-after-settle").is_none());
328        assert!(headers.get("hx-trigger-after-swap").is_none());
329    }
330
331    #[tokio::test]
332    async fn hx_request_extractor_handles_missing_headers() -> Result<(), axum::http::Error> {
333        let req = Request::builder().body(())?;
334        let (mut parts, ()) = req.into_parts();
335
336        let hx = HxRequest::from_request_parts(&mut parts, &())
337            .await
338            .expect("infallible");
339
340        assert!(!hx.is_htmx);
341        assert_eq!(hx.target, None);
342        assert_eq!(hx.trigger, None);
343        assert_eq!(hx.trigger_name, None);
344        assert_eq!(hx.current_url, None);
345        assert!(!hx.history_restore_request);
346        assert_eq!(hx.prompt, None);
347        assert!(!hx.boosted);
348        Ok(())
349    }
350}