1use axum::extract::FromRequestParts;
26use axum::http::request::Parts;
27use axum::response::{IntoResponse, Response};
28use http::header::{HeaderName, HeaderValue};
29use std::convert::Infallible;
30
31pub const HTMX_JS: &[u8] = include_bytes!("../vendor/htmx.min.js");
38
39pub const HTMX_JS_PATH: &str = "/static/js/htmx.min.js";
41
42pub const HTMX_SSE_JS: &[u8] = include_bytes!("../vendor/sse.js");
44
45pub const HTMX_SSE_JS_PATH: &str = "/static/js/sse.js";
47
48pub const AUTUMN_WIDGETS_JS: &[u8] = include_bytes!("../vendor/autumn-widgets.js");
61
62pub const AUTUMN_WIDGETS_JS_PATH: &str = "/static/js/autumn-widgets.js";
64
65pub const HTMX_CSRF_JS_PATH: &str = "/static/js/autumn-htmx-csrf.js";
74
75#[cfg(feature = "htmx")]
86pub const IDIOMORPH_JS: &[u8] = include_bytes!("../vendor/idiomorph.min.js");
87
88#[cfg(feature = "htmx")]
90pub const IDIOMORPH_JS_PATH: &str = "/static/js/idiomorph.min.js";
91
92pub const HTMX_CSRF_JS: &str = r#"(function () {
97 document.addEventListener("htmx:configRequest", function (evt) {
98 var meta = document.querySelector('meta[name="csrf-token"], meta[name="autumn-csrf-token"]');
99
100 if (!meta || !evt.detail || !evt.detail.headers) {
101 return;
102 }
103
104 var header = meta.getAttribute("data-header") || "X-CSRF-Token";
105 evt.detail.headers[header] = meta.getAttribute("content") || "";
106 });
107})();
108"#;
109
110#[cfg(feature = "htmx")]
114pub(crate) static FRAMEWORK_SCRIPTS: crate::assets::PluginAssets =
115 crate::assets::PluginAssets::framework(&[
116 ("js/htmx.min.js", HTMX_JS),
117 ("js/sse.js", HTMX_SSE_JS),
118 ("js/autumn-widgets.js", AUTUMN_WIDGETS_JS),
119 ("js/autumn-htmx-csrf.js", HTMX_CSRF_JS.as_bytes()),
120 ("js/idiomorph.min.js", IDIOMORPH_JS),
121 #[cfg(feature = "webmcp")]
122 ("js/autumn-webmcp.js", crate::webmcp::SHIM_JS.as_bytes()),
123 ]);
124
125#[cfg(feature = "htmx")]
129fn framework_serves(logical_path: &str) -> bool {
130 logical_path != "js/htmx.min.js" || !crate::assets::htmx_is_vendored()
131}
132
133#[cfg(feature = "htmx")]
136pub(crate) fn framework_script(logical_path: &str) -> Option<&'static crate::assets::PluginAsset> {
137 FRAMEWORK_SCRIPTS
138 .get(logical_path)
139 .filter(|asset| framework_serves(asset.logical_path()))
140}
141
142#[cfg(feature = "htmx")]
144pub(crate) fn framework_scripts() -> impl Iterator<Item = &'static crate::assets::PluginAsset> {
145 FRAMEWORK_SCRIPTS
146 .iter()
147 .filter(|asset| framework_serves(asset.logical_path()))
148}
149
150#[cfg(feature = "htmx")]
153pub(crate) fn is_framework_script_fingerprint(rel_path: &str) -> bool {
154 framework_scripts().any(|asset| asset.url().strip_prefix("/static/") == Some(rel_path))
155}
156
157pub const HTMX_VERSION: &str = "2.0.4";
162
163#[allow(dead_code)]
170#[derive(Debug, Clone, Default)]
171pub struct HxRequest {
172 pub is_htmx: bool,
174 pub target: Option<String>,
176 pub trigger: Option<String>,
178 pub trigger_name: Option<String>,
180 pub current_url: Option<String>,
182 pub history_restore_request: bool,
184 pub prompt: Option<String>,
186 pub boosted: bool,
188}
189
190impl<S> FromRequestParts<S> for HxRequest
191where
192 S: Send + Sync,
193{
194 type Rejection = Infallible;
195
196 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
197 let header_str = |name: &'static str| -> Option<String> {
198 parts
199 .headers
200 .get(name)
201 .and_then(|v| v.to_str().ok())
202 .map(ToString::to_string)
203 };
204 let header_bool =
205 |name: &'static str| -> bool { parts.headers.get(name).is_some_and(|v| v == "true") };
206
207 Ok(Self {
208 is_htmx: header_bool("hx-request"),
209 target: header_str("hx-target"),
210 trigger: header_str("hx-trigger"),
211 trigger_name: header_str("hx-trigger-name"),
212 current_url: header_str("hx-current-url"),
213 history_restore_request: header_bool("hx-history-restore-request"),
214 prompt: header_str("hx-prompt"),
215 boosted: header_bool("hx-boosted"),
216 })
217 }
218}
219
220pub trait HxResponseExt: IntoResponse + Sized {
227 fn hx_location(self, url: &str) -> Response {
229 append_hx_header(self, "hx-location", url)
230 }
231
232 fn hx_push_url(self, url: &str) -> Response {
234 append_hx_header(self, "hx-push-url", url)
235 }
236
237 fn hx_redirect(self, url: &str) -> Response {
239 append_hx_header(self, "hx-redirect", url)
240 }
241
242 fn hx_refresh(self) -> Response {
244 append_hx_header(self, "hx-refresh", "true")
245 }
246
247 fn hx_replace_url(self, url: &str) -> Response {
249 append_hx_header(self, "hx-replace-url", url)
250 }
251
252 fn hx_reswap(self, swap: &str) -> Response {
254 append_hx_header(self, "hx-reswap", swap)
255 }
256
257 fn hx_retarget(self, target: &str) -> Response {
259 append_hx_header(self, "hx-retarget", target)
260 }
261
262 fn hx_trigger(self, event: &str) -> Response {
264 append_hx_header(self, "hx-trigger", event)
265 }
266
267 fn hx_trigger_after_settle(self, event: &str) -> Response {
269 append_hx_header(self, "hx-trigger-after-settle", event)
270 }
271
272 fn hx_trigger_after_swap(self, event: &str) -> Response {
274 append_hx_header(self, "hx-trigger-after-swap", event)
275 }
276}
277
278impl<T: IntoResponse> HxResponseExt for T {}
279
280fn append_hx_header<T: IntoResponse>(response: T, name: &'static str, value: &str) -> Response {
281 let mut res = response.into_response();
282 if let Ok(v) = HeaderValue::from_str(value) {
283 res.headers_mut().insert(HeaderName::from_static(name), v);
284 }
285 res
286}
287
288#[cfg(feature = "maud")]
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum OobMethod {
291 OuterHTML,
292 InnerHTML,
293 BeforeBegin,
294 AfterBegin,
295 BeforeEnd,
296 AfterEnd,
297 Delete,
298}
299
300#[cfg(feature = "maud")]
301impl OobMethod {
302 #[must_use]
303 pub const fn as_str(&self) -> &'static str {
304 match self {
305 Self::OuterHTML => "outerHTML",
306 Self::InnerHTML => "innerHTML",
307 Self::BeforeBegin => "beforebegin",
308 Self::AfterBegin => "afterbegin",
309 Self::BeforeEnd => "beforeend",
310 Self::AfterEnd => "afterend",
311 Self::Delete => "delete",
312 }
313 }
314}
315
316#[cfg(feature = "maud")]
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub enum OobSwap {
319 True,
321 OuterHTML,
323 InnerHTML,
324 BeforeBegin,
325 AfterBegin,
326 BeforeEnd,
327 AfterEnd,
328 Delete,
329 Target(OobMethod, String),
331 Raw,
333 Custom(String),
335}
336
337#[cfg(feature = "maud")]
338impl OobSwap {
339 #[must_use]
340 pub fn format_value<'a>(&'a self, id: &'a str) -> std::borrow::Cow<'a, str> {
341 let clean_id = id.strip_prefix('#').unwrap_or(id);
342 if clean_id.is_empty() {
343 return match self {
344 Self::True => std::borrow::Cow::Borrowed("true"),
345 Self::OuterHTML => std::borrow::Cow::Borrowed("outerHTML"),
346 Self::InnerHTML => std::borrow::Cow::Borrowed("innerHTML"),
347 Self::BeforeBegin => std::borrow::Cow::Borrowed("beforebegin"),
348 Self::AfterBegin => std::borrow::Cow::Borrowed("afterbegin"),
349 Self::BeforeEnd => std::borrow::Cow::Borrowed("beforeend"),
350 Self::AfterEnd => std::borrow::Cow::Borrowed("afterend"),
351 Self::Delete => std::borrow::Cow::Borrowed("delete"),
352 Self::Target(method, _) => std::borrow::Cow::Borrowed(method.as_str()),
353 Self::Custom(val) => std::borrow::Cow::Borrowed(val),
354 Self::Raw => {
355 unreachable!("Raw strategy should not be formatted into a template wrapper")
356 }
357 };
358 }
359 match self {
360 Self::True => std::borrow::Cow::Borrowed("true"),
361 Self::OuterHTML => std::borrow::Cow::Borrowed("outerHTML"),
362 Self::InnerHTML => std::borrow::Cow::Owned(format!("innerHTML:#{clean_id}")),
363 Self::BeforeBegin => std::borrow::Cow::Owned(format!("beforebegin:#{clean_id}")),
364 Self::AfterBegin => std::borrow::Cow::Owned(format!("afterbegin:#{clean_id}")),
365 Self::BeforeEnd => std::borrow::Cow::Owned(format!("beforeend:#{clean_id}")),
366 Self::AfterEnd => std::borrow::Cow::Owned(format!("afterend:#{clean_id}")),
367 Self::Delete => std::borrow::Cow::Owned(format!("delete:#{clean_id}")),
368 Self::Target(method, selector) => {
369 std::borrow::Cow::Owned(format!("{}:{}", method.as_str(), selector))
370 }
371 Self::Custom(val) => std::borrow::Cow::Borrowed(val),
372 Self::Raw => {
373 unreachable!("Raw strategy should not be formatted into a template wrapper")
374 }
375 }
376 }
377
378 #[must_use]
392 const fn inserts_child_nodes(&self) -> bool {
393 match self {
394 Self::InnerHTML
395 | Self::BeforeBegin
396 | Self::AfterBegin
397 | Self::BeforeEnd
398 | Self::AfterEnd => true,
399 Self::Target(method, _) => matches!(
400 method,
401 OobMethod::InnerHTML
402 | OobMethod::BeforeBegin
403 | OobMethod::AfterBegin
404 | OobMethod::BeforeEnd
405 | OobMethod::AfterEnd
406 ),
407 Self::True | Self::OuterHTML | Self::Delete | Self::Custom(_) | Self::Raw => false,
408 }
409 }
410}
411
412#[cfg(feature = "maud")]
413#[derive(Debug, Clone)]
414struct OobFragment {
415 id: String,
416 strategy: OobSwap,
417 markup: maud::Markup,
418}
419
420#[cfg(feature = "maud")]
421#[derive(Debug, Clone)]
452pub struct HtmxFragments {
453 primary: Option<maud::Markup>,
454 oob: Vec<OobFragment>,
455}
456
457#[cfg(feature = "maud")]
458impl HtmxFragments {
459 #[must_use]
461 pub const fn new(primary: maud::Markup) -> Self {
462 Self {
463 primary: Some(primary),
464 oob: Vec::new(),
465 }
466 }
467
468 #[must_use]
470 pub const fn oob_only() -> Self {
471 Self {
472 primary: None,
473 oob: Vec::new(),
474 }
475 }
476
477 #[must_use]
479 pub fn oob(self, id: impl Into<String>, markup: maud::Markup) -> Self {
480 self.oob_with_strategy(id, OobSwap::True, markup)
481 }
482
483 #[must_use]
485 pub fn oob_with_strategy(
486 mut self,
487 id: impl Into<String>,
488 strategy: OobSwap,
489 markup: maud::Markup,
490 ) -> Self {
491 self.oob.push(OobFragment {
492 id: id.into(),
493 strategy,
494 markup,
495 });
496 self
497 }
498}
499
500#[cfg(feature = "maud")]
501fn escape_attribute(w: &mut String, s: &str) {
502 for c in s.chars() {
503 match c {
504 '&' => w.push_str("&"),
505 '"' => w.push_str("""),
506 '<' => w.push_str("<"),
507 '>' => w.push_str(">"),
508 _ => w.push(c),
509 }
510 }
511}
512
513#[cfg(feature = "maud")]
514#[must_use]
515pub fn escape_attribute_string(s: &str) -> String {
516 let mut w = String::with_capacity(s.len() + 10);
517 escape_attribute(&mut w, s);
518 w
519}
520
521#[cfg(feature = "maud")]
522#[must_use]
523pub fn inject_hx_swap_oob(html: &str, oob_value: &str) -> Option<String> {
524 let mut idx = 0;
525 while let Some(start_pos) = html[idx..].find('<') {
526 let abs_start = idx + start_pos;
527 let remaining = &html[abs_start..];
528 if remaining.starts_with("<!--") {
529 let comment_end = remaining.find("-->")?;
530 idx = abs_start + comment_end + 3;
531 } else {
532 let mut tag_name_end = 0;
533 for (char_idx, c) in remaining.char_indices().skip(1) {
534 if c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '>' || c == '/' {
535 tag_name_end = char_idx;
536 break;
537 }
538 }
539 if tag_name_end == 0 {
540 return None;
541 }
542 let insert_pos = abs_start + tag_name_end;
543 let escaped_val = escape_attribute_string(oob_value);
544 let mut result = String::with_capacity(html.len() + escaped_val.len() + 30);
545 result.push_str(&html[..insert_pos]);
546 result.push_str(" hx-swap-oob=\"");
547 result.push_str(&escaped_val);
548 result.push('"');
549 result.push_str(&html[insert_pos..]);
550 return Some(result);
551 }
552 }
553 None
554}
555
556#[cfg(feature = "maud")]
557impl maud::Render for HtmxFragments {
558 fn render_to(&self, w: &mut String) {
559 if let Some(primary) = &self.primary {
560 primary.render_to(w);
561 }
562 for oob in &self.oob {
563 let rendered = &oob.markup.0;
564 if has_oob_attribute(rendered) || matches!(oob.strategy, OobSwap::Raw) {
565 w.push_str(rendered);
566 } else {
567 let value = oob.strategy.format_value(&oob.id);
568 let carrier = if oob.strategy.inserts_child_nodes() {
586 "div"
587 } else {
588 "template"
589 };
590 w.push('<');
591 w.push_str(carrier);
592 w.push_str(" hx-swap-oob=\"");
593 escape_attribute(w, &value);
594 w.push_str("\">");
595 w.push_str(rendered);
596 w.push_str("</");
597 w.push_str(carrier);
598 w.push('>');
599 }
600 }
601 }
602}
603
604#[cfg(feature = "maud")]
605impl IntoResponse for HtmxFragments {
606 fn into_response(self) -> Response {
607 use maud::Render;
608
609 let mut capacity = 0;
610 if let Some(primary) = &self.primary {
611 capacity += primary.0.len();
612 }
613 for oob in &self.oob {
614 capacity += oob.markup.0.len() + 64;
615 }
616 let mut w = String::with_capacity(capacity);
617
618 self.render_to(&mut w);
619 axum::response::Html(w).into_response()
620 }
621}
622
623#[cfg(feature = "maud")]
624fn has_oob_attribute(html: &str) -> bool {
625 let mut in_tag = false;
626 let mut in_quote = None;
627 let mut chars = html.char_indices().peekable();
628
629 while let Some((idx, c)) = chars.next() {
630 if in_tag {
631 if let Some(q) = in_quote {
632 if c == q {
633 in_quote = None;
634 }
635 } else if c == '"' || c == '\'' {
636 in_quote = Some(c);
637 } else if c == '>' {
638 in_tag = false;
639 } else {
640 let remaining = &html[idx..];
641 let match_len = if remaining
642 .get(..11)
643 .is_some_and(|s| s.eq_ignore_ascii_case("hx-swap-oob"))
644 {
645 Some(11)
646 } else if remaining
647 .get(..16)
648 .is_some_and(|s| s.eq_ignore_ascii_case("data-hx-swap-oob"))
649 {
650 Some(16)
651 } else {
652 None
653 };
654
655 if let Some(len) = match_len {
656 let after = remaining.chars().nth(len);
657 match after {
658 None | Some('=' | ' ' | '\t' | '\n' | '\r' | '>' | '/') => {
659 let is_word_start = if idx == 0 {
660 true
661 } else if let Some(prev_char) = html[..idx].chars().next_back() {
662 prev_char.is_ascii_whitespace()
663 || prev_char == '/'
664 || prev_char == '<'
665 || prev_char == '"'
666 || prev_char == '\''
667 } else {
668 true
669 };
670 if is_word_start {
671 return true;
672 }
673 }
674 _ => {}
675 }
676 }
677 }
678 } else if c == '<' {
679 let remaining = &html[idx..];
680 if remaining.starts_with("<!--") {
681 while let Some((_, next_c)) = chars.next() {
682 if next_c == '-' {
683 let rem = &html[chars.peek().map_or(html.len(), |&(i, _)| i)..];
684 if rem.starts_with("->") {
685 chars.next();
686 chars.next();
687 break;
688 }
689 }
690 }
691 } else {
692 in_tag = true;
693 in_quote = None;
694 }
695 }
696 }
697 false
698}
699
700#[must_use]
705pub fn extract_html_id(html: &str) -> Option<String> {
706 let start_tag_end = html.find('>')?;
707 let start_tag = &html[..start_tag_end];
708 let mut id_idx = None;
709 let mut search_start = 0;
710 while let Some(offset) = start_tag[search_start..].find("id=") {
711 let absolute_idx = search_start + offset;
712 if absolute_idx > 0 {
713 let prev_char = start_tag.as_bytes()[absolute_idx - 1];
714 if prev_char == b' ' || prev_char == b'\t' || prev_char == b'\n' || prev_char == b'\r' {
715 id_idx = Some(absolute_idx);
716 break;
717 }
718 }
719 search_start = absolute_idx + 3;
720 }
721 let idx = id_idx?;
722 let after_id = &start_tag[idx + 3..];
723 let mut chars = after_id.chars();
724 let quote = chars.next()?;
725 if quote == '"' || quote == '\'' {
726 let mut val = String::new();
727 for c in chars {
728 if c == quote {
729 break;
730 }
731 val.push(c);
732 }
733 Some(val)
734 } else {
735 None
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742 use axum::http::Request;
743
744 #[test]
745 #[allow(clippy::const_is_empty)]
746 fn htmx_js_is_not_empty() {
747 assert!(!HTMX_JS.is_empty(), "htmx.min.js should not be empty");
748 assert!(!HTMX_SSE_JS.is_empty(), "sse.js should not be empty");
749 }
750
751 #[test]
752 fn htmx_js_looks_like_javascript() {
753 let start = std::str::from_utf8(&HTMX_JS[..50]).expect("htmx should be valid UTF-8");
754 assert!(
755 start.contains("htmx") || start.contains("function") || start.contains('('),
756 "htmx.min.js doesn't look like JavaScript: {start}"
757 );
758 let sse_start = std::str::from_utf8(&HTMX_SSE_JS[..50]).expect("sse should be valid UTF-8");
759 assert!(
760 sse_start.contains("Server")
761 || sse_start.contains("function")
762 || sse_start.contains('/')
763 || sse_start.contains('*'),
764 "sse.js doesn't look like JavaScript: {sse_start}"
765 );
766 }
767
768 #[test]
769 fn htmx_version_matches_expected() {
770 assert_eq!(HTMX_VERSION, "2.0.4");
771 }
772
773 #[test]
774 fn htmx_asset_paths_are_same_origin_static_paths() {
775 assert_eq!(HTMX_JS_PATH, "/static/js/htmx.min.js");
776 assert_eq!(HTMX_CSRF_JS_PATH, "/static/js/autumn-htmx-csrf.js");
777 assert_eq!(HTMX_SSE_JS_PATH, "/static/js/sse.js");
778 }
779
780 #[test]
781 fn htmx_csrf_js_configures_request_header_without_inline_wrapper() {
782 assert!(HTMX_CSRF_JS.contains("htmx:configRequest"));
783 assert!(HTMX_CSRF_JS.contains("X-CSRF-Token"));
784 assert!(HTMX_CSRF_JS.contains("csrf-token"));
785 assert!(!HTMX_CSRF_JS.contains("<script"));
786 }
787
788 #[test]
789 fn widgets_js_wires_nav_bar() {
790 let js = std::str::from_utf8(AUTUMN_WIDGETS_JS)
791 .expect("autumn-widgets.js should be valid UTF-8");
792 assert!(js.contains("data-autumn-nav"), "{js}");
793 assert!(js.contains("data-nav-toggle"), "{js}");
794 assert!(js.contains("data-nav-menu-toggle"), "{js}");
795 assert!(js.contains("aria-expanded"), "{js}");
796 assert!(js.contains("Escape"), "{js}");
797 }
798
799 #[test]
800 fn widgets_js_wires_modal() {
801 let js = std::str::from_utf8(AUTUMN_WIDGETS_JS)
802 .expect("autumn-widgets.js should be valid UTF-8");
803 assert!(js.contains("data-modal-open"), "{js}");
807 assert!(js.contains("data-modal-close"), "{js}");
808 assert!(js.contains("showModal"), "{js}");
809 assert!(
810 js.contains("'command' in HTMLButtonElement.prototype"),
811 "{js}"
812 );
813 assert!(!js.contains("window.confirm"), "{js}");
816 }
817
818 #[test]
819 fn widgets_js_polyfills_light_dismiss_backdrop_click() {
820 let js = std::str::from_utf8(AUTUMN_WIDGETS_JS)
825 .expect("autumn-widgets.js should be valid UTF-8");
826 assert!(
827 js.contains(r#"dialog[closedby="any"][open]"#),
828 "must polyfill backdrop-click dismissal for closedby=\"any\" dialogs: {js}"
829 );
830 }
831
832 #[tokio::test]
833 async fn hx_request_extractor_parses_headers() -> Result<(), axum::http::Error> {
834 let req = Request::builder()
835 .header("hx-request", "true")
836 .header("hx-target", "my-div")
837 .header("hx-trigger", "btn")
838 .header("hx-trigger-name", "btn-name")
839 .header("hx-current-url", "http://example.com")
840 .header("hx-history-restore-request", "true")
841 .header("hx-prompt", "yes")
842 .header("hx-boosted", "true")
843 .body(())?;
844 let (mut parts, ()) = req.into_parts();
845
846 let hx = HxRequest::from_request_parts(&mut parts, &())
847 .await
848 .expect("infallible");
849
850 assert!(hx.is_htmx);
851 assert_eq!(hx.target.as_deref(), Some("my-div"));
852 assert_eq!(hx.trigger.as_deref(), Some("btn"));
853 assert_eq!(hx.trigger_name.as_deref(), Some("btn-name"));
854 assert_eq!(hx.current_url.as_deref(), Some("http://example.com"));
855 assert!(hx.history_restore_request);
856 assert_eq!(hx.prompt.as_deref(), Some("yes"));
857 assert!(hx.boosted);
858 Ok(())
859 }
860
861 #[tokio::test]
862 async fn hx_response_ext_adds_headers() {
863 use axum::response::IntoResponse;
864 let response = "hello"
865 .hx_location("/some-location")
866 .hx_push_url("/new-url")
867 .hx_redirect("/login")
868 .hx_refresh()
869 .hx_replace_url("/old-url")
870 .hx_reswap("innerHTML")
871 .hx_retarget("#target")
872 .hx_trigger("my-event")
873 .hx_trigger_after_settle("settled-event")
874 .hx_trigger_after_swap("swapped-event")
875 .into_response();
876
877 let headers = response.headers();
878 assert_eq!(headers.get("hx-location").unwrap(), "/some-location");
879 assert_eq!(headers.get("hx-push-url").unwrap(), "/new-url");
880 assert_eq!(headers.get("hx-redirect").unwrap(), "/login");
881 assert_eq!(headers.get("hx-refresh").unwrap(), "true");
882 assert_eq!(headers.get("hx-replace-url").unwrap(), "/old-url");
883 assert_eq!(headers.get("hx-reswap").unwrap(), "innerHTML");
884 assert_eq!(headers.get("hx-retarget").unwrap(), "#target");
885 assert_eq!(headers.get("hx-trigger").unwrap(), "my-event");
886 assert_eq!(
887 headers.get("hx-trigger-after-settle").unwrap(),
888 "settled-event"
889 );
890 assert_eq!(
891 headers.get("hx-trigger-after-swap").unwrap(),
892 "swapped-event"
893 );
894 }
895
896 #[tokio::test]
897 async fn hx_response_ext_ignores_invalid_header_values() {
898 use axum::response::IntoResponse;
899
900 let invalid_header_value = "invalid\nvalue";
903
904 let response = "hello"
905 .hx_location(invalid_header_value)
906 .hx_push_url(invalid_header_value)
907 .hx_redirect(invalid_header_value)
908 .hx_refresh() .hx_replace_url(invalid_header_value)
910 .hx_reswap(invalid_header_value)
911 .hx_retarget(invalid_header_value)
912 .hx_trigger(invalid_header_value)
913 .hx_trigger_after_settle(invalid_header_value)
914 .hx_trigger_after_swap(invalid_header_value)
915 .into_response();
916
917 let headers = response.headers();
918 assert!(headers.get("hx-location").is_none());
919 assert!(headers.get("hx-push-url").is_none());
920 assert!(headers.get("hx-redirect").is_none());
921 assert_eq!(headers.get("hx-refresh").unwrap(), "true");
923 assert!(headers.get("hx-replace-url").is_none());
924 assert!(headers.get("hx-reswap").is_none());
925 assert!(headers.get("hx-retarget").is_none());
926 assert!(headers.get("hx-trigger").is_none());
927 assert!(headers.get("hx-trigger-after-settle").is_none());
928 assert!(headers.get("hx-trigger-after-swap").is_none());
929 }
930
931 #[tokio::test]
932 async fn hx_request_extractor_handles_missing_headers() -> Result<(), axum::http::Error> {
933 let req = Request::builder().body(())?;
934 let (mut parts, ()) = req.into_parts();
935
936 let hx = HxRequest::from_request_parts(&mut parts, &())
937 .await
938 .expect("infallible");
939
940 assert!(!hx.is_htmx);
941 assert_eq!(hx.target, None);
942 assert_eq!(hx.trigger, None);
943 assert_eq!(hx.trigger_name, None);
944 assert_eq!(hx.current_url, None);
945 assert!(!hx.history_restore_request);
946 assert_eq!(hx.prompt, None);
947 assert!(!hx.boosted);
948 Ok(())
949 }
950
951 #[cfg(feature = "maud")]
952 #[tokio::test]
953 async fn htmx_fragments_renders_correctly() {
954 use axum::response::IntoResponse;
955
956 let primary = maud::html! { div { "primary body" } };
957 let oob1 = maud::html! { div id="badge" { "3" } };
958 let oob2 = maud::html! { li { "new item" } };
959
960 let response = HtmxFragments::new(primary)
961 .oob("badge", oob1)
962 .oob_with_strategy("list", OobSwap::BeforeEnd, oob2)
963 .into_response();
964
965 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
966 .await
967 .unwrap();
968 let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
969
970 assert!(
971 body_str.contains("<div>primary body</div>"),
972 "got: {body_str}"
973 );
974 assert!(
975 body_str
976 .contains("<template hx-swap-oob=\"true\"><div id=\"badge\">3</div></template>"),
977 "got: {body_str}"
978 );
979 assert!(
983 body_str.contains("<div hx-swap-oob=\"beforeend:#list\"><li>new item</li></div>"),
984 "got: {body_str}"
985 );
986 assert!(
987 !body_str.contains("<template hx-swap-oob=\"beforeend"),
988 "positional swap must not emit a <template> carrier: {body_str}"
989 );
990 }
991
992 #[cfg(feature = "maud")]
997 #[tokio::test]
998 async fn htmx_fragments_positional_oob_uses_div_carrier_with_real_children() {
999 use axum::response::IntoResponse;
1000 use maud::Render;
1001
1002 for (strategy, value) in [
1005 (OobSwap::BeforeEnd, "beforeend:#list"),
1006 (OobSwap::AfterBegin, "afterbegin:#list"),
1007 (OobSwap::BeforeBegin, "beforebegin:#list"),
1008 (OobSwap::AfterEnd, "afterend:#list"),
1009 ] {
1010 let fragment = maud::html! { li { "row" } };
1011 let rendered = HtmxFragments::oob_only()
1012 .oob_with_strategy("list", strategy, fragment)
1013 .render()
1014 .into_string();
1015
1016 let open = format!("<div hx-swap-oob=\"{value}\">");
1017 assert!(
1018 rendered.starts_with(&open) && rendered.ends_with("</div>"),
1019 "positional swap {value:?} must use a <div> carrier: {rendered}"
1020 );
1021 assert!(
1022 !rendered.contains("<template"),
1023 "positional swap {value:?} must not use a <template> carrier: {rendered}"
1024 );
1025
1026 let children = rendered
1032 .strip_prefix(&open)
1033 .and_then(|s| s.strip_suffix("</div>"))
1034 .expect("carrier open/close tags");
1035 assert_eq!(
1036 children, "<li>row</li>",
1037 "the <div> carrier's childNodes must be the fragment htmx inserts",
1038 );
1039 }
1040
1041 let response = HtmxFragments::oob_only()
1044 .oob("badge", maud::html! { div id="badge" { "3" } })
1045 .into_response();
1046 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1047 .await
1048 .unwrap();
1049 let body_str = String::from_utf8(body.to_vec()).unwrap();
1050 assert_eq!(
1051 body_str,
1052 "<template hx-swap-oob=\"true\"><div id=\"badge\">3</div></template>"
1053 );
1054 }
1055
1056 #[cfg(feature = "maud")]
1064 #[tokio::test]
1065 async fn htmx_fragments_inner_html_oob_uses_div_carrier_with_real_children() {
1066 use maud::Render;
1067
1068 for (strategy, value) in [
1071 (OobSwap::InnerHTML, "innerHTML:#count"),
1072 (
1073 OobSwap::Target(OobMethod::InnerHTML, "#count".to_string()),
1074 "innerHTML:#count",
1075 ),
1076 ] {
1077 let fragment = maud::html! { span { "5" } };
1078 let rendered = HtmxFragments::oob_only()
1079 .oob_with_strategy("count", strategy, fragment)
1080 .render()
1081 .into_string();
1082
1083 let open = format!("<div hx-swap-oob=\"{value}\">");
1084 assert!(
1085 rendered.starts_with(&open) && rendered.ends_with("</div>"),
1086 "innerHTML swap {value:?} must use a <div> carrier: {rendered}"
1087 );
1088 assert!(
1089 !rendered.contains("<template"),
1090 "innerHTML swap {value:?} must not use a <template> carrier: {rendered}"
1091 );
1092
1093 let children = rendered
1099 .strip_prefix(&open)
1100 .and_then(|s| s.strip_suffix("</div>"))
1101 .expect("carrier open/close tags");
1102 assert_eq!(
1103 children, "<span>5</span>",
1104 "the <div> carrier's childNodes must be the fragment htmx inserts",
1105 );
1106 }
1107 }
1108
1109 #[cfg(feature = "maud")]
1110 #[tokio::test]
1111 async fn htmx_fragments_empty_primary() {
1112 use axum::response::IntoResponse;
1113
1114 let oob = maud::html! { div id="badge" { "3" } };
1115
1116 let response = HtmxFragments::oob_only().oob("badge", oob).into_response();
1117
1118 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1119 .await
1120 .unwrap();
1121 let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
1122
1123 assert_eq!(
1125 body_str,
1126 "<template hx-swap-oob=\"true\"><div id=\"badge\">3</div></template>"
1127 );
1128 }
1129
1130 #[cfg(feature = "maud")]
1131 #[tokio::test]
1132 async fn htmx_fragments_no_double_wrap() {
1133 use axum::response::IntoResponse;
1134
1135 let oob = maud::html! { div id="badge" hx-swap-oob="true" { "3" } };
1137
1138 let response = HtmxFragments::oob_only().oob("badge", oob).into_response();
1139
1140 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1141 .await
1142 .unwrap();
1143 let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
1144
1145 assert_eq!(body_str, "<div id=\"badge\" hx-swap-oob=\"true\">3</div>");
1147 }
1148
1149 #[cfg(feature = "maud")]
1150 #[tokio::test]
1151 async fn htmx_fragments_composes_with_headers() {
1152 use axum::response::IntoResponse;
1153
1154 let primary = maud::html! { div { "ok" } };
1155 let response = HtmxFragments::new(primary)
1156 .hx_trigger("custom-event")
1157 .into_response();
1158
1159 let headers = response.headers();
1160 assert_eq!(headers.get("hx-trigger").unwrap(), "custom-event");
1161
1162 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1163 .await
1164 .unwrap();
1165 let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
1166 assert!(body_str.contains("<div>ok</div>"));
1167 }
1168
1169 #[test]
1170 fn test_has_oob_attribute_detector() {
1171 assert!(has_oob_attribute("<div hx-swap-oob=\"true\"></div>"));
1173 assert!(has_oob_attribute("<div data-hx-swap-oob=\"true\"></div>"));
1174 assert!(has_oob_attribute("<div hx-swap-oob = 'true' ></div>"));
1175 assert!(has_oob_attribute("<div hx-swap-oob></div>"));
1176 assert!(has_oob_attribute(
1177 "<div class=\"x\" hx-swap-oob=\"true\"></div>"
1178 ));
1179 assert!(has_oob_attribute(
1180 "<div hx-swap-oob=\"true\" class=\"x\"></div>"
1181 ));
1182
1183 assert!(!has_oob_attribute("<div>Learn hx-swap-oob today</div>"));
1185 assert!(!has_oob_attribute("<div class=\"hx-swap-oob\"></div>"));
1186 assert!(!has_oob_attribute(
1187 "<div id=\"some-hx-swap-oob-element\"></div>"
1188 ));
1189 assert!(!has_oob_attribute(
1190 "<!-- <div hx-swap-oob=\"true\"></div> -->"
1191 ));
1192 assert!(!has_oob_attribute(
1193 "<div class=\"x\">some text hx-swap-oob=\"true\"</div>"
1194 ));
1195 }
1196
1197 #[test]
1198 fn test_oob_swap_format_value_empty_id() {
1199 assert_eq!(OobSwap::True.format_value(""), "true");
1200 assert_eq!(OobSwap::True.format_value("#"), "true");
1201 assert_eq!(OobSwap::InnerHTML.format_value(""), "innerHTML");
1202 assert_eq!(OobSwap::InnerHTML.format_value("#"), "innerHTML");
1203 assert_eq!(OobSwap::BeforeEnd.format_value(""), "beforeend");
1204 assert_eq!(OobSwap::BeforeEnd.format_value("#"), "beforeend");
1205 assert_eq!(
1206 OobSwap::Target(OobMethod::InnerHTML, "#target".to_string()).format_value(""),
1207 "innerHTML"
1208 );
1209 assert_eq!(
1210 OobSwap::Target(OobMethod::BeforeEnd, "#target".to_string()).format_value("#"),
1211 "beforeend"
1212 );
1213
1214 assert_eq!(OobSwap::InnerHTML.format_value("my-id"), "innerHTML:#my-id");
1216 assert_eq!(
1217 OobSwap::InnerHTML.format_value("#my-id"),
1218 "innerHTML:#my-id"
1219 );
1220 }
1221
1222 #[test]
1223 fn test_inject_hx_swap_oob() {
1224 assert_eq!(
1225 inject_hx_swap_oob("<li id=\"1\"></li>", "beforeend:#container"),
1226 Some("<li hx-swap-oob=\"beforeend:#container\" id=\"1\"></li>".to_string())
1227 );
1228 assert_eq!(
1229 inject_hx_swap_oob("<!-- comment -->\n <div class=\"foo\"></div>", "outerHTML"),
1230 Some(
1231 "<!-- comment -->\n <div hx-swap-oob=\"outerHTML\" class=\"foo\"></div>"
1232 .to_string()
1233 )
1234 );
1235 assert_eq!(inject_hx_swap_oob("Hello world", "true"), None);
1236 }
1237
1238 #[cfg(feature = "maud")]
1239 #[test]
1240 fn test_inject_on_maud_markup() {
1241 use maud::html;
1242 let oob = html! { li id="item-1" { "Item" } };
1243 let injected = inject_hx_swap_oob(&oob.0, "beforeend:#container");
1244 assert_eq!(
1245 injected,
1246 Some("<li hx-swap-oob=\"beforeend:#container\" id=\"item-1\">Item</li>".to_string())
1247 );
1248 }
1249}