1use axum::extract::FromRequestParts;
19use axum::http::request::Parts;
20use axum::response::{IntoResponse, Response};
21use http::header::{HeaderName, HeaderValue};
22use std::convert::Infallible;
23
24pub const HTMX_JS: &[u8] = include_bytes!("../vendor/htmx.min.js");
30
31pub const HTMX_JS_PATH: &str = "/static/js/htmx.min.js";
33
34pub const HTMX_SSE_JS: &[u8] = include_bytes!("../vendor/sse.js");
36
37pub const HTMX_SSE_JS_PATH: &str = "/static/js/sse.js";
39
40pub const AUTUMN_WIDGETS_JS: &[u8] = include_bytes!("../vendor/autumn-widgets.js");
52
53pub const AUTUMN_WIDGETS_JS_PATH: &str = "/static/js/autumn-widgets.js";
55
56pub const HTMX_CSRF_JS_PATH: &str = "/static/js/autumn-htmx-csrf.js";
65
66#[cfg(feature = "htmx")]
76pub const IDIOMORPH_JS: &[u8] = include_bytes!("../vendor/idiomorph.min.js");
77
78#[cfg(feature = "htmx")]
80pub const IDIOMORPH_JS_PATH: &str = "/static/js/idiomorph.min.js";
81
82pub const HTMX_CSRF_JS: &str = r#"(function () {
87 document.addEventListener("htmx:configRequest", function (evt) {
88 var meta = document.querySelector('meta[name="csrf-token"], meta[name="autumn-csrf-token"]');
89
90 if (!meta || !evt.detail || !evt.detail.headers) {
91 return;
92 }
93
94 var header = meta.getAttribute("data-header") || "X-CSRF-Token";
95 evt.detail.headers[header] = meta.getAttribute("content") || "";
96 });
97})();
98"#;
99
100pub const HTMX_VERSION: &str = "2.0.4";
105
106#[allow(dead_code)]
113#[derive(Debug, Clone, Default)]
114pub struct HxRequest {
115 pub is_htmx: bool,
117 pub target: Option<String>,
119 pub trigger: Option<String>,
121 pub trigger_name: Option<String>,
123 pub current_url: Option<String>,
125 pub history_restore_request: bool,
127 pub prompt: Option<String>,
129 pub boosted: bool,
131}
132
133impl<S> FromRequestParts<S> for HxRequest
134where
135 S: Send + Sync,
136{
137 type Rejection = Infallible;
138
139 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
140 let header_str = |name: &'static str| -> Option<String> {
141 parts
142 .headers
143 .get(name)
144 .and_then(|v| v.to_str().ok())
145 .map(ToString::to_string)
146 };
147 let header_bool =
148 |name: &'static str| -> bool { parts.headers.get(name).is_some_and(|v| v == "true") };
149
150 Ok(Self {
151 is_htmx: header_bool("hx-request"),
152 target: header_str("hx-target"),
153 trigger: header_str("hx-trigger"),
154 trigger_name: header_str("hx-trigger-name"),
155 current_url: header_str("hx-current-url"),
156 history_restore_request: header_bool("hx-history-restore-request"),
157 prompt: header_str("hx-prompt"),
158 boosted: header_bool("hx-boosted"),
159 })
160 }
161}
162
163pub trait HxResponseExt: IntoResponse + Sized {
170 fn hx_location(self, url: &str) -> Response {
172 append_hx_header(self, "hx-location", url)
173 }
174
175 fn hx_push_url(self, url: &str) -> Response {
177 append_hx_header(self, "hx-push-url", url)
178 }
179
180 fn hx_redirect(self, url: &str) -> Response {
182 append_hx_header(self, "hx-redirect", url)
183 }
184
185 fn hx_refresh(self) -> Response {
187 append_hx_header(self, "hx-refresh", "true")
188 }
189
190 fn hx_replace_url(self, url: &str) -> Response {
192 append_hx_header(self, "hx-replace-url", url)
193 }
194
195 fn hx_reswap(self, swap: &str) -> Response {
197 append_hx_header(self, "hx-reswap", swap)
198 }
199
200 fn hx_retarget(self, target: &str) -> Response {
202 append_hx_header(self, "hx-retarget", target)
203 }
204
205 fn hx_trigger(self, event: &str) -> Response {
207 append_hx_header(self, "hx-trigger", event)
208 }
209
210 fn hx_trigger_after_settle(self, event: &str) -> Response {
212 append_hx_header(self, "hx-trigger-after-settle", event)
213 }
214
215 fn hx_trigger_after_swap(self, event: &str) -> Response {
217 append_hx_header(self, "hx-trigger-after-swap", event)
218 }
219}
220
221impl<T: IntoResponse> HxResponseExt for T {}
222
223fn append_hx_header<T: IntoResponse>(response: T, name: &'static str, value: &str) -> Response {
224 let mut res = response.into_response();
225 if let Ok(v) = HeaderValue::from_str(value) {
226 res.headers_mut().insert(HeaderName::from_static(name), v);
227 }
228 res
229}
230
231#[cfg(feature = "maud")]
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum OobMethod {
234 OuterHTML,
235 InnerHTML,
236 BeforeBegin,
237 AfterBegin,
238 BeforeEnd,
239 AfterEnd,
240 Delete,
241}
242
243#[cfg(feature = "maud")]
244impl OobMethod {
245 #[must_use]
246 pub const fn as_str(&self) -> &'static str {
247 match self {
248 Self::OuterHTML => "outerHTML",
249 Self::InnerHTML => "innerHTML",
250 Self::BeforeBegin => "beforebegin",
251 Self::AfterBegin => "afterbegin",
252 Self::BeforeEnd => "beforeend",
253 Self::AfterEnd => "afterend",
254 Self::Delete => "delete",
255 }
256 }
257}
258
259#[cfg(feature = "maud")]
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub enum OobSwap {
262 True,
264 OuterHTML,
266 InnerHTML,
267 BeforeBegin,
268 AfterBegin,
269 BeforeEnd,
270 AfterEnd,
271 Delete,
272 Target(OobMethod, String),
274 Raw,
276 Custom(String),
278}
279
280#[cfg(feature = "maud")]
281impl OobSwap {
282 #[must_use]
283 pub fn format_value<'a>(&'a self, id: &'a str) -> std::borrow::Cow<'a, str> {
284 let clean_id = id.strip_prefix('#').unwrap_or(id);
285 if clean_id.is_empty() {
286 return match self {
287 Self::True => std::borrow::Cow::Borrowed("true"),
288 Self::OuterHTML => std::borrow::Cow::Borrowed("outerHTML"),
289 Self::InnerHTML => std::borrow::Cow::Borrowed("innerHTML"),
290 Self::BeforeBegin => std::borrow::Cow::Borrowed("beforebegin"),
291 Self::AfterBegin => std::borrow::Cow::Borrowed("afterbegin"),
292 Self::BeforeEnd => std::borrow::Cow::Borrowed("beforeend"),
293 Self::AfterEnd => std::borrow::Cow::Borrowed("afterend"),
294 Self::Delete => std::borrow::Cow::Borrowed("delete"),
295 Self::Target(method, _) => std::borrow::Cow::Borrowed(method.as_str()),
296 Self::Custom(val) => std::borrow::Cow::Borrowed(val),
297 Self::Raw => {
298 unreachable!("Raw strategy should not be formatted into a template wrapper")
299 }
300 };
301 }
302 match self {
303 Self::True => std::borrow::Cow::Borrowed("true"),
304 Self::OuterHTML => std::borrow::Cow::Borrowed("outerHTML"),
305 Self::InnerHTML => std::borrow::Cow::Owned(format!("innerHTML:#{clean_id}")),
306 Self::BeforeBegin => std::borrow::Cow::Owned(format!("beforebegin:#{clean_id}")),
307 Self::AfterBegin => std::borrow::Cow::Owned(format!("afterbegin:#{clean_id}")),
308 Self::BeforeEnd => std::borrow::Cow::Owned(format!("beforeend:#{clean_id}")),
309 Self::AfterEnd => std::borrow::Cow::Owned(format!("afterend:#{clean_id}")),
310 Self::Delete => std::borrow::Cow::Owned(format!("delete:#{clean_id}")),
311 Self::Target(method, selector) => {
312 std::borrow::Cow::Owned(format!("{}:{}", method.as_str(), selector))
313 }
314 Self::Custom(val) => std::borrow::Cow::Borrowed(val),
315 Self::Raw => {
316 unreachable!("Raw strategy should not be formatted into a template wrapper")
317 }
318 }
319 }
320
321 #[must_use]
335 const fn inserts_child_nodes(&self) -> bool {
336 match self {
337 Self::InnerHTML
338 | Self::BeforeBegin
339 | Self::AfterBegin
340 | Self::BeforeEnd
341 | Self::AfterEnd => true,
342 Self::Target(method, _) => matches!(
343 method,
344 OobMethod::InnerHTML
345 | OobMethod::BeforeBegin
346 | OobMethod::AfterBegin
347 | OobMethod::BeforeEnd
348 | OobMethod::AfterEnd
349 ),
350 Self::True | Self::OuterHTML | Self::Delete | Self::Custom(_) | Self::Raw => false,
351 }
352 }
353}
354
355#[cfg(feature = "maud")]
356#[derive(Debug, Clone)]
357struct OobFragment {
358 id: String,
359 strategy: OobSwap,
360 markup: maud::Markup,
361}
362
363#[cfg(feature = "maud")]
364#[derive(Debug, Clone)]
395pub struct HtmxFragments {
396 primary: Option<maud::Markup>,
397 oob: Vec<OobFragment>,
398}
399
400#[cfg(feature = "maud")]
401impl HtmxFragments {
402 #[must_use]
404 pub const fn new(primary: maud::Markup) -> Self {
405 Self {
406 primary: Some(primary),
407 oob: Vec::new(),
408 }
409 }
410
411 #[must_use]
413 pub const fn oob_only() -> Self {
414 Self {
415 primary: None,
416 oob: Vec::new(),
417 }
418 }
419
420 #[must_use]
422 pub fn oob(self, id: impl Into<String>, markup: maud::Markup) -> Self {
423 self.oob_with_strategy(id, OobSwap::True, markup)
424 }
425
426 #[must_use]
428 pub fn oob_with_strategy(
429 mut self,
430 id: impl Into<String>,
431 strategy: OobSwap,
432 markup: maud::Markup,
433 ) -> Self {
434 self.oob.push(OobFragment {
435 id: id.into(),
436 strategy,
437 markup,
438 });
439 self
440 }
441}
442
443#[cfg(feature = "maud")]
444fn escape_attribute(w: &mut String, s: &str) {
445 for c in s.chars() {
446 match c {
447 '&' => w.push_str("&"),
448 '"' => w.push_str("""),
449 '<' => w.push_str("<"),
450 '>' => w.push_str(">"),
451 _ => w.push(c),
452 }
453 }
454}
455
456#[cfg(feature = "maud")]
457#[must_use]
458pub fn escape_attribute_string(s: &str) -> String {
459 let mut w = String::with_capacity(s.len() + 10);
460 escape_attribute(&mut w, s);
461 w
462}
463
464#[cfg(feature = "maud")]
465#[must_use]
466pub fn inject_hx_swap_oob(html: &str, oob_value: &str) -> Option<String> {
467 let mut idx = 0;
468 while let Some(start_pos) = html[idx..].find('<') {
469 let abs_start = idx + start_pos;
470 let remaining = &html[abs_start..];
471 if remaining.starts_with("<!--") {
472 let comment_end = remaining.find("-->")?;
473 idx = abs_start + comment_end + 3;
474 } else {
475 let mut tag_name_end = 0;
476 for (char_idx, c) in remaining.char_indices().skip(1) {
477 if c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '>' || c == '/' {
478 tag_name_end = char_idx;
479 break;
480 }
481 }
482 if tag_name_end == 0 {
483 return None;
484 }
485 let insert_pos = abs_start + tag_name_end;
486 let escaped_val = escape_attribute_string(oob_value);
487 let mut result = String::with_capacity(html.len() + escaped_val.len() + 30);
488 result.push_str(&html[..insert_pos]);
489 result.push_str(" hx-swap-oob=\"");
490 result.push_str(&escaped_val);
491 result.push('"');
492 result.push_str(&html[insert_pos..]);
493 return Some(result);
494 }
495 }
496 None
497}
498
499#[cfg(feature = "maud")]
500impl maud::Render for HtmxFragments {
501 fn render_to(&self, w: &mut String) {
502 if let Some(primary) = &self.primary {
503 primary.render_to(w);
504 }
505 for oob in &self.oob {
506 let rendered = &oob.markup.0;
507 if has_oob_attribute(rendered) || matches!(oob.strategy, OobSwap::Raw) {
508 w.push_str(rendered);
509 } else {
510 let value = oob.strategy.format_value(&oob.id);
511 let carrier = if oob.strategy.inserts_child_nodes() {
529 "div"
530 } else {
531 "template"
532 };
533 w.push('<');
534 w.push_str(carrier);
535 w.push_str(" hx-swap-oob=\"");
536 escape_attribute(w, &value);
537 w.push_str("\">");
538 w.push_str(rendered);
539 w.push_str("</");
540 w.push_str(carrier);
541 w.push('>');
542 }
543 }
544 }
545}
546
547#[cfg(feature = "maud")]
548impl IntoResponse for HtmxFragments {
549 fn into_response(self) -> Response {
550 use maud::Render;
551
552 let mut capacity = 0;
553 if let Some(primary) = &self.primary {
554 capacity += primary.0.len();
555 }
556 for oob in &self.oob {
557 capacity += oob.markup.0.len() + 64;
558 }
559 let mut w = String::with_capacity(capacity);
560
561 self.render_to(&mut w);
562 axum::response::Html(w).into_response()
563 }
564}
565
566#[cfg(feature = "maud")]
567fn has_oob_attribute(html: &str) -> bool {
568 let mut in_tag = false;
569 let mut in_quote = None;
570 let mut chars = html.char_indices().peekable();
571
572 while let Some((idx, c)) = chars.next() {
573 if in_tag {
574 if let Some(q) = in_quote {
575 if c == q {
576 in_quote = None;
577 }
578 } else if c == '"' || c == '\'' {
579 in_quote = Some(c);
580 } else if c == '>' {
581 in_tag = false;
582 } else {
583 let remaining = &html[idx..];
584 let match_len = if remaining
585 .get(..11)
586 .is_some_and(|s| s.eq_ignore_ascii_case("hx-swap-oob"))
587 {
588 Some(11)
589 } else if remaining
590 .get(..16)
591 .is_some_and(|s| s.eq_ignore_ascii_case("data-hx-swap-oob"))
592 {
593 Some(16)
594 } else {
595 None
596 };
597
598 if let Some(len) = match_len {
599 let after = remaining.chars().nth(len);
600 match after {
601 None | Some('=' | ' ' | '\t' | '\n' | '\r' | '>' | '/') => {
602 let is_word_start = if idx == 0 {
603 true
604 } else if let Some(prev_char) = html[..idx].chars().next_back() {
605 prev_char.is_ascii_whitespace()
606 || prev_char == '/'
607 || prev_char == '<'
608 || prev_char == '"'
609 || prev_char == '\''
610 } else {
611 true
612 };
613 if is_word_start {
614 return true;
615 }
616 }
617 _ => {}
618 }
619 }
620 }
621 } else if c == '<' {
622 let remaining = &html[idx..];
623 if remaining.starts_with("<!--") {
624 while let Some((_, next_c)) = chars.next() {
625 if next_c == '-' {
626 let rem = &html[chars.peek().map_or(html.len(), |&(i, _)| i)..];
627 if rem.starts_with("->") {
628 chars.next();
629 chars.next();
630 break;
631 }
632 }
633 }
634 } else {
635 in_tag = true;
636 in_quote = None;
637 }
638 }
639 }
640 false
641}
642
643#[must_use]
648pub fn extract_html_id(html: &str) -> Option<String> {
649 let start_tag_end = html.find('>')?;
650 let start_tag = &html[..start_tag_end];
651 let mut id_idx = None;
652 let mut search_start = 0;
653 while let Some(offset) = start_tag[search_start..].find("id=") {
654 let absolute_idx = search_start + offset;
655 if absolute_idx > 0 {
656 let prev_char = start_tag.as_bytes()[absolute_idx - 1];
657 if prev_char == b' ' || prev_char == b'\t' || prev_char == b'\n' || prev_char == b'\r' {
658 id_idx = Some(absolute_idx);
659 break;
660 }
661 }
662 search_start = absolute_idx + 3;
663 }
664 let idx = id_idx?;
665 let after_id = &start_tag[idx + 3..];
666 let mut chars = after_id.chars();
667 let quote = chars.next()?;
668 if quote == '"' || quote == '\'' {
669 let mut val = String::new();
670 for c in chars {
671 if c == quote {
672 break;
673 }
674 val.push(c);
675 }
676 Some(val)
677 } else {
678 None
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685 use axum::http::Request;
686
687 #[test]
688 #[allow(clippy::const_is_empty)]
689 fn htmx_js_is_not_empty() {
690 assert!(!HTMX_JS.is_empty(), "htmx.min.js should not be empty");
691 assert!(!HTMX_SSE_JS.is_empty(), "sse.js should not be empty");
692 }
693
694 #[test]
695 fn htmx_js_looks_like_javascript() {
696 let start = std::str::from_utf8(&HTMX_JS[..50]).expect("htmx should be valid UTF-8");
697 assert!(
698 start.contains("htmx") || start.contains("function") || start.contains('('),
699 "htmx.min.js doesn't look like JavaScript: {start}"
700 );
701 let sse_start = std::str::from_utf8(&HTMX_SSE_JS[..50]).expect("sse should be valid UTF-8");
702 assert!(
703 sse_start.contains("Server")
704 || sse_start.contains("function")
705 || sse_start.contains('/')
706 || sse_start.contains('*'),
707 "sse.js doesn't look like JavaScript: {sse_start}"
708 );
709 }
710
711 #[test]
712 fn htmx_version_matches_expected() {
713 assert_eq!(HTMX_VERSION, "2.0.4");
714 }
715
716 #[test]
717 fn htmx_asset_paths_are_same_origin_static_paths() {
718 assert_eq!(HTMX_JS_PATH, "/static/js/htmx.min.js");
719 assert_eq!(HTMX_CSRF_JS_PATH, "/static/js/autumn-htmx-csrf.js");
720 assert_eq!(HTMX_SSE_JS_PATH, "/static/js/sse.js");
721 }
722
723 #[test]
724 fn htmx_csrf_js_configures_request_header_without_inline_wrapper() {
725 assert!(HTMX_CSRF_JS.contains("htmx:configRequest"));
726 assert!(HTMX_CSRF_JS.contains("X-CSRF-Token"));
727 assert!(HTMX_CSRF_JS.contains("csrf-token"));
728 assert!(!HTMX_CSRF_JS.contains("<script"));
729 }
730
731 #[test]
732 fn widgets_js_wires_nav_bar() {
733 let js = std::str::from_utf8(AUTUMN_WIDGETS_JS)
734 .expect("autumn-widgets.js should be valid UTF-8");
735 assert!(js.contains("data-autumn-nav"), "{js}");
736 assert!(js.contains("data-nav-toggle"), "{js}");
737 assert!(js.contains("data-nav-menu-toggle"), "{js}");
738 assert!(js.contains("aria-expanded"), "{js}");
739 assert!(js.contains("Escape"), "{js}");
740 }
741
742 #[test]
743 fn widgets_js_wires_modal() {
744 let js = std::str::from_utf8(AUTUMN_WIDGETS_JS)
745 .expect("autumn-widgets.js should be valid UTF-8");
746 assert!(js.contains("data-modal-open"), "{js}");
750 assert!(js.contains("data-modal-close"), "{js}");
751 assert!(js.contains("showModal"), "{js}");
752 assert!(
753 js.contains("'command' in HTMLButtonElement.prototype"),
754 "{js}"
755 );
756 assert!(!js.contains("window.confirm"), "{js}");
759 }
760
761 #[test]
762 fn widgets_js_polyfills_light_dismiss_backdrop_click() {
763 let js = std::str::from_utf8(AUTUMN_WIDGETS_JS)
768 .expect("autumn-widgets.js should be valid UTF-8");
769 assert!(
770 js.contains(r#"dialog[closedby="any"][open]"#),
771 "must polyfill backdrop-click dismissal for closedby=\"any\" dialogs: {js}"
772 );
773 }
774
775 #[tokio::test]
776 async fn hx_request_extractor_parses_headers() -> Result<(), axum::http::Error> {
777 let req = Request::builder()
778 .header("hx-request", "true")
779 .header("hx-target", "my-div")
780 .header("hx-trigger", "btn")
781 .header("hx-trigger-name", "btn-name")
782 .header("hx-current-url", "http://example.com")
783 .header("hx-history-restore-request", "true")
784 .header("hx-prompt", "yes")
785 .header("hx-boosted", "true")
786 .body(())?;
787 let (mut parts, ()) = req.into_parts();
788
789 let hx = HxRequest::from_request_parts(&mut parts, &())
790 .await
791 .expect("infallible");
792
793 assert!(hx.is_htmx);
794 assert_eq!(hx.target.as_deref(), Some("my-div"));
795 assert_eq!(hx.trigger.as_deref(), Some("btn"));
796 assert_eq!(hx.trigger_name.as_deref(), Some("btn-name"));
797 assert_eq!(hx.current_url.as_deref(), Some("http://example.com"));
798 assert!(hx.history_restore_request);
799 assert_eq!(hx.prompt.as_deref(), Some("yes"));
800 assert!(hx.boosted);
801 Ok(())
802 }
803
804 #[tokio::test]
805 async fn hx_response_ext_adds_headers() {
806 use axum::response::IntoResponse;
807 let response = "hello"
808 .hx_location("/some-location")
809 .hx_push_url("/new-url")
810 .hx_redirect("/login")
811 .hx_refresh()
812 .hx_replace_url("/old-url")
813 .hx_reswap("innerHTML")
814 .hx_retarget("#target")
815 .hx_trigger("my-event")
816 .hx_trigger_after_settle("settled-event")
817 .hx_trigger_after_swap("swapped-event")
818 .into_response();
819
820 let headers = response.headers();
821 assert_eq!(headers.get("hx-location").unwrap(), "/some-location");
822 assert_eq!(headers.get("hx-push-url").unwrap(), "/new-url");
823 assert_eq!(headers.get("hx-redirect").unwrap(), "/login");
824 assert_eq!(headers.get("hx-refresh").unwrap(), "true");
825 assert_eq!(headers.get("hx-replace-url").unwrap(), "/old-url");
826 assert_eq!(headers.get("hx-reswap").unwrap(), "innerHTML");
827 assert_eq!(headers.get("hx-retarget").unwrap(), "#target");
828 assert_eq!(headers.get("hx-trigger").unwrap(), "my-event");
829 assert_eq!(
830 headers.get("hx-trigger-after-settle").unwrap(),
831 "settled-event"
832 );
833 assert_eq!(
834 headers.get("hx-trigger-after-swap").unwrap(),
835 "swapped-event"
836 );
837 }
838
839 #[tokio::test]
840 async fn hx_response_ext_ignores_invalid_header_values() {
841 use axum::response::IntoResponse;
842
843 let invalid_header_value = "invalid\nvalue";
846
847 let response = "hello"
848 .hx_location(invalid_header_value)
849 .hx_push_url(invalid_header_value)
850 .hx_redirect(invalid_header_value)
851 .hx_refresh() .hx_replace_url(invalid_header_value)
853 .hx_reswap(invalid_header_value)
854 .hx_retarget(invalid_header_value)
855 .hx_trigger(invalid_header_value)
856 .hx_trigger_after_settle(invalid_header_value)
857 .hx_trigger_after_swap(invalid_header_value)
858 .into_response();
859
860 let headers = response.headers();
861 assert!(headers.get("hx-location").is_none());
862 assert!(headers.get("hx-push-url").is_none());
863 assert!(headers.get("hx-redirect").is_none());
864 assert_eq!(headers.get("hx-refresh").unwrap(), "true");
866 assert!(headers.get("hx-replace-url").is_none());
867 assert!(headers.get("hx-reswap").is_none());
868 assert!(headers.get("hx-retarget").is_none());
869 assert!(headers.get("hx-trigger").is_none());
870 assert!(headers.get("hx-trigger-after-settle").is_none());
871 assert!(headers.get("hx-trigger-after-swap").is_none());
872 }
873
874 #[tokio::test]
875 async fn hx_request_extractor_handles_missing_headers() -> Result<(), axum::http::Error> {
876 let req = Request::builder().body(())?;
877 let (mut parts, ()) = req.into_parts();
878
879 let hx = HxRequest::from_request_parts(&mut parts, &())
880 .await
881 .expect("infallible");
882
883 assert!(!hx.is_htmx);
884 assert_eq!(hx.target, None);
885 assert_eq!(hx.trigger, None);
886 assert_eq!(hx.trigger_name, None);
887 assert_eq!(hx.current_url, None);
888 assert!(!hx.history_restore_request);
889 assert_eq!(hx.prompt, None);
890 assert!(!hx.boosted);
891 Ok(())
892 }
893
894 #[cfg(feature = "maud")]
895 #[tokio::test]
896 async fn htmx_fragments_renders_correctly() {
897 use axum::response::IntoResponse;
898
899 let primary = maud::html! { div { "primary body" } };
900 let oob1 = maud::html! { div id="badge" { "3" } };
901 let oob2 = maud::html! { li { "new item" } };
902
903 let response = HtmxFragments::new(primary)
904 .oob("badge", oob1)
905 .oob_with_strategy("list", OobSwap::BeforeEnd, oob2)
906 .into_response();
907
908 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
909 .await
910 .unwrap();
911 let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
912
913 assert!(
914 body_str.contains("<div>primary body</div>"),
915 "got: {body_str}"
916 );
917 assert!(
918 body_str
919 .contains("<template hx-swap-oob=\"true\"><div id=\"badge\">3</div></template>"),
920 "got: {body_str}"
921 );
922 assert!(
926 body_str.contains("<div hx-swap-oob=\"beforeend:#list\"><li>new item</li></div>"),
927 "got: {body_str}"
928 );
929 assert!(
930 !body_str.contains("<template hx-swap-oob=\"beforeend"),
931 "positional swap must not emit a <template> carrier: {body_str}"
932 );
933 }
934
935 #[cfg(feature = "maud")]
940 #[tokio::test]
941 async fn htmx_fragments_positional_oob_uses_div_carrier_with_real_children() {
942 use axum::response::IntoResponse;
943 use maud::Render;
944
945 for (strategy, value) in [
948 (OobSwap::BeforeEnd, "beforeend:#list"),
949 (OobSwap::AfterBegin, "afterbegin:#list"),
950 (OobSwap::BeforeBegin, "beforebegin:#list"),
951 (OobSwap::AfterEnd, "afterend:#list"),
952 ] {
953 let fragment = maud::html! { li { "row" } };
954 let rendered = HtmxFragments::oob_only()
955 .oob_with_strategy("list", strategy, fragment)
956 .render()
957 .into_string();
958
959 let open = format!("<div hx-swap-oob=\"{value}\">");
960 assert!(
961 rendered.starts_with(&open) && rendered.ends_with("</div>"),
962 "positional swap {value:?} must use a <div> carrier: {rendered}"
963 );
964 assert!(
965 !rendered.contains("<template"),
966 "positional swap {value:?} must not use a <template> carrier: {rendered}"
967 );
968
969 let children = rendered
975 .strip_prefix(&open)
976 .and_then(|s| s.strip_suffix("</div>"))
977 .expect("carrier open/close tags");
978 assert_eq!(
979 children, "<li>row</li>",
980 "the <div> carrier's childNodes must be the fragment htmx inserts",
981 );
982 }
983
984 let response = HtmxFragments::oob_only()
987 .oob("badge", maud::html! { div id="badge" { "3" } })
988 .into_response();
989 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
990 .await
991 .unwrap();
992 let body_str = String::from_utf8(body.to_vec()).unwrap();
993 assert_eq!(
994 body_str,
995 "<template hx-swap-oob=\"true\"><div id=\"badge\">3</div></template>"
996 );
997 }
998
999 #[cfg(feature = "maud")]
1007 #[tokio::test]
1008 async fn htmx_fragments_inner_html_oob_uses_div_carrier_with_real_children() {
1009 use maud::Render;
1010
1011 for (strategy, value) in [
1014 (OobSwap::InnerHTML, "innerHTML:#count"),
1015 (
1016 OobSwap::Target(OobMethod::InnerHTML, "#count".to_string()),
1017 "innerHTML:#count",
1018 ),
1019 ] {
1020 let fragment = maud::html! { span { "5" } };
1021 let rendered = HtmxFragments::oob_only()
1022 .oob_with_strategy("count", strategy, fragment)
1023 .render()
1024 .into_string();
1025
1026 let open = format!("<div hx-swap-oob=\"{value}\">");
1027 assert!(
1028 rendered.starts_with(&open) && rendered.ends_with("</div>"),
1029 "innerHTML swap {value:?} must use a <div> carrier: {rendered}"
1030 );
1031 assert!(
1032 !rendered.contains("<template"),
1033 "innerHTML swap {value:?} must not use a <template> carrier: {rendered}"
1034 );
1035
1036 let children = rendered
1042 .strip_prefix(&open)
1043 .and_then(|s| s.strip_suffix("</div>"))
1044 .expect("carrier open/close tags");
1045 assert_eq!(
1046 children, "<span>5</span>",
1047 "the <div> carrier's childNodes must be the fragment htmx inserts",
1048 );
1049 }
1050 }
1051
1052 #[cfg(feature = "maud")]
1053 #[tokio::test]
1054 async fn htmx_fragments_empty_primary() {
1055 use axum::response::IntoResponse;
1056
1057 let oob = maud::html! { div id="badge" { "3" } };
1058
1059 let response = HtmxFragments::oob_only().oob("badge", oob).into_response();
1060
1061 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1062 .await
1063 .unwrap();
1064 let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
1065
1066 assert_eq!(
1068 body_str,
1069 "<template hx-swap-oob=\"true\"><div id=\"badge\">3</div></template>"
1070 );
1071 }
1072
1073 #[cfg(feature = "maud")]
1074 #[tokio::test]
1075 async fn htmx_fragments_no_double_wrap() {
1076 use axum::response::IntoResponse;
1077
1078 let oob = maud::html! { div id="badge" hx-swap-oob="true" { "3" } };
1080
1081 let response = HtmxFragments::oob_only().oob("badge", oob).into_response();
1082
1083 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1084 .await
1085 .unwrap();
1086 let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
1087
1088 assert_eq!(body_str, "<div id=\"badge\" hx-swap-oob=\"true\">3</div>");
1090 }
1091
1092 #[cfg(feature = "maud")]
1093 #[tokio::test]
1094 async fn htmx_fragments_composes_with_headers() {
1095 use axum::response::IntoResponse;
1096
1097 let primary = maud::html! { div { "ok" } };
1098 let response = HtmxFragments::new(primary)
1099 .hx_trigger("custom-event")
1100 .into_response();
1101
1102 let headers = response.headers();
1103 assert_eq!(headers.get("hx-trigger").unwrap(), "custom-event");
1104
1105 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1106 .await
1107 .unwrap();
1108 let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
1109 assert!(body_str.contains("<div>ok</div>"));
1110 }
1111
1112 #[test]
1113 fn test_has_oob_attribute_detector() {
1114 assert!(has_oob_attribute("<div hx-swap-oob=\"true\"></div>"));
1116 assert!(has_oob_attribute("<div data-hx-swap-oob=\"true\"></div>"));
1117 assert!(has_oob_attribute("<div hx-swap-oob = 'true' ></div>"));
1118 assert!(has_oob_attribute("<div hx-swap-oob></div>"));
1119 assert!(has_oob_attribute(
1120 "<div class=\"x\" hx-swap-oob=\"true\"></div>"
1121 ));
1122 assert!(has_oob_attribute(
1123 "<div hx-swap-oob=\"true\" class=\"x\"></div>"
1124 ));
1125
1126 assert!(!has_oob_attribute("<div>Learn hx-swap-oob today</div>"));
1128 assert!(!has_oob_attribute("<div class=\"hx-swap-oob\"></div>"));
1129 assert!(!has_oob_attribute(
1130 "<div id=\"some-hx-swap-oob-element\"></div>"
1131 ));
1132 assert!(!has_oob_attribute(
1133 "<!-- <div hx-swap-oob=\"true\"></div> -->"
1134 ));
1135 assert!(!has_oob_attribute(
1136 "<div class=\"x\">some text hx-swap-oob=\"true\"</div>"
1137 ));
1138 }
1139
1140 #[test]
1141 fn test_oob_swap_format_value_empty_id() {
1142 assert_eq!(OobSwap::True.format_value(""), "true");
1143 assert_eq!(OobSwap::True.format_value("#"), "true");
1144 assert_eq!(OobSwap::InnerHTML.format_value(""), "innerHTML");
1145 assert_eq!(OobSwap::InnerHTML.format_value("#"), "innerHTML");
1146 assert_eq!(OobSwap::BeforeEnd.format_value(""), "beforeend");
1147 assert_eq!(OobSwap::BeforeEnd.format_value("#"), "beforeend");
1148 assert_eq!(
1149 OobSwap::Target(OobMethod::InnerHTML, "#target".to_string()).format_value(""),
1150 "innerHTML"
1151 );
1152 assert_eq!(
1153 OobSwap::Target(OobMethod::BeforeEnd, "#target".to_string()).format_value("#"),
1154 "beforeend"
1155 );
1156
1157 assert_eq!(OobSwap::InnerHTML.format_value("my-id"), "innerHTML:#my-id");
1159 assert_eq!(
1160 OobSwap::InnerHTML.format_value("#my-id"),
1161 "innerHTML:#my-id"
1162 );
1163 }
1164
1165 #[test]
1166 fn test_inject_hx_swap_oob() {
1167 assert_eq!(
1168 inject_hx_swap_oob("<li id=\"1\"></li>", "beforeend:#container"),
1169 Some("<li hx-swap-oob=\"beforeend:#container\" id=\"1\"></li>".to_string())
1170 );
1171 assert_eq!(
1172 inject_hx_swap_oob("<!-- comment -->\n <div class=\"foo\"></div>", "outerHTML"),
1173 Some(
1174 "<!-- comment -->\n <div hx-swap-oob=\"outerHTML\" class=\"foo\"></div>"
1175 .to_string()
1176 )
1177 );
1178 assert_eq!(inject_hx_swap_oob("Hello world", "true"), None);
1179 }
1180
1181 #[cfg(feature = "maud")]
1182 #[test]
1183 fn test_inject_on_maud_markup() {
1184 use maud::html;
1185 let oob = html! { li id="item-1" { "Item" } };
1186 let injected = inject_hx_swap_oob(&oob.0, "beforeend:#container");
1187 assert_eq!(
1188 injected,
1189 Some("<li hx-swap-oob=\"beforeend:#container\" id=\"item-1\">Item</li>".to_string())
1190 );
1191 }
1192}