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