use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
use http::header::{HeaderName, HeaderValue};
use std::convert::Infallible;
pub const HTMX_JS: &[u8] = include_bytes!("../vendor/htmx.min.js");
pub const HTMX_JS_PATH: &str = "/static/js/htmx.min.js";
pub const HTMX_SSE_JS: &[u8] = include_bytes!("../vendor/sse.js");
pub const HTMX_SSE_JS_PATH: &str = "/static/js/sse.js";
pub const AUTUMN_WIDGETS_JS: &[u8] = include_bytes!("../vendor/autumn-widgets.js");
pub const AUTUMN_WIDGETS_JS_PATH: &str = "/static/js/autumn-widgets.js";
pub const HTMX_CSRF_JS_PATH: &str = "/static/js/autumn-htmx-csrf.js";
#[cfg(feature = "htmx")]
pub const IDIOMORPH_JS: &[u8] = include_bytes!("../vendor/idiomorph.min.js");
#[cfg(feature = "htmx")]
pub const IDIOMORPH_JS_PATH: &str = "/static/js/idiomorph.min.js";
pub const HTMX_CSRF_JS: &str = r#"(function () {
document.addEventListener("htmx:configRequest", function (evt) {
var meta = document.querySelector('meta[name="csrf-token"], meta[name="autumn-csrf-token"]');
if (!meta || !evt.detail || !evt.detail.headers) {
return;
}
var header = meta.getAttribute("data-header") || "X-CSRF-Token";
evt.detail.headers[header] = meta.getAttribute("content") || "";
});
})();
"#;
pub const HTMX_VERSION: &str = "2.0.4";
#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
pub struct HxRequest {
pub is_htmx: bool,
pub target: Option<String>,
pub trigger: Option<String>,
pub trigger_name: Option<String>,
pub current_url: Option<String>,
pub history_restore_request: bool,
pub prompt: Option<String>,
pub boosted: bool,
}
impl<S> FromRequestParts<S> for HxRequest
where
S: Send + Sync,
{
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let header_str = |name: &'static str| -> Option<String> {
parts
.headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(ToString::to_string)
};
let header_bool =
|name: &'static str| -> bool { parts.headers.get(name).is_some_and(|v| v == "true") };
Ok(Self {
is_htmx: header_bool("hx-request"),
target: header_str("hx-target"),
trigger: header_str("hx-trigger"),
trigger_name: header_str("hx-trigger-name"),
current_url: header_str("hx-current-url"),
history_restore_request: header_bool("hx-history-restore-request"),
prompt: header_str("hx-prompt"),
boosted: header_bool("hx-boosted"),
})
}
}
pub trait HxResponseExt: IntoResponse + Sized {
fn hx_location(self, url: &str) -> Response {
append_hx_header(self, "hx-location", url)
}
fn hx_push_url(self, url: &str) -> Response {
append_hx_header(self, "hx-push-url", url)
}
fn hx_redirect(self, url: &str) -> Response {
append_hx_header(self, "hx-redirect", url)
}
fn hx_refresh(self) -> Response {
append_hx_header(self, "hx-refresh", "true")
}
fn hx_replace_url(self, url: &str) -> Response {
append_hx_header(self, "hx-replace-url", url)
}
fn hx_reswap(self, swap: &str) -> Response {
append_hx_header(self, "hx-reswap", swap)
}
fn hx_retarget(self, target: &str) -> Response {
append_hx_header(self, "hx-retarget", target)
}
fn hx_trigger(self, event: &str) -> Response {
append_hx_header(self, "hx-trigger", event)
}
fn hx_trigger_after_settle(self, event: &str) -> Response {
append_hx_header(self, "hx-trigger-after-settle", event)
}
fn hx_trigger_after_swap(self, event: &str) -> Response {
append_hx_header(self, "hx-trigger-after-swap", event)
}
}
impl<T: IntoResponse> HxResponseExt for T {}
fn append_hx_header<T: IntoResponse>(response: T, name: &'static str, value: &str) -> Response {
let mut res = response.into_response();
if let Ok(v) = HeaderValue::from_str(value) {
res.headers_mut().insert(HeaderName::from_static(name), v);
}
res
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OobMethod {
OuterHTML,
InnerHTML,
BeforeBegin,
AfterBegin,
BeforeEnd,
AfterEnd,
Delete,
}
#[cfg(feature = "maud")]
impl OobMethod {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::OuterHTML => "outerHTML",
Self::InnerHTML => "innerHTML",
Self::BeforeBegin => "beforebegin",
Self::AfterBegin => "afterbegin",
Self::BeforeEnd => "beforeend",
Self::AfterEnd => "afterend",
Self::Delete => "delete",
}
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OobSwap {
True,
OuterHTML,
InnerHTML,
BeforeBegin,
AfterBegin,
BeforeEnd,
AfterEnd,
Delete,
Target(OobMethod, String),
Raw,
Custom(String),
}
#[cfg(feature = "maud")]
impl OobSwap {
#[must_use]
pub fn format_value<'a>(&'a self, id: &'a str) -> std::borrow::Cow<'a, str> {
let clean_id = id.strip_prefix('#').unwrap_or(id);
if clean_id.is_empty() {
return match self {
Self::True => std::borrow::Cow::Borrowed("true"),
Self::OuterHTML => std::borrow::Cow::Borrowed("outerHTML"),
Self::InnerHTML => std::borrow::Cow::Borrowed("innerHTML"),
Self::BeforeBegin => std::borrow::Cow::Borrowed("beforebegin"),
Self::AfterBegin => std::borrow::Cow::Borrowed("afterbegin"),
Self::BeforeEnd => std::borrow::Cow::Borrowed("beforeend"),
Self::AfterEnd => std::borrow::Cow::Borrowed("afterend"),
Self::Delete => std::borrow::Cow::Borrowed("delete"),
Self::Target(method, _) => std::borrow::Cow::Borrowed(method.as_str()),
Self::Custom(val) => std::borrow::Cow::Borrowed(val),
Self::Raw => {
unreachable!("Raw strategy should not be formatted into a template wrapper")
}
};
}
match self {
Self::True => std::borrow::Cow::Borrowed("true"),
Self::OuterHTML => std::borrow::Cow::Borrowed("outerHTML"),
Self::InnerHTML => std::borrow::Cow::Owned(format!("innerHTML:#{clean_id}")),
Self::BeforeBegin => std::borrow::Cow::Owned(format!("beforebegin:#{clean_id}")),
Self::AfterBegin => std::borrow::Cow::Owned(format!("afterbegin:#{clean_id}")),
Self::BeforeEnd => std::borrow::Cow::Owned(format!("beforeend:#{clean_id}")),
Self::AfterEnd => std::borrow::Cow::Owned(format!("afterend:#{clean_id}")),
Self::Delete => std::borrow::Cow::Owned(format!("delete:#{clean_id}")),
Self::Target(method, selector) => {
std::borrow::Cow::Owned(format!("{}:{}", method.as_str(), selector))
}
Self::Custom(val) => std::borrow::Cow::Borrowed(val),
Self::Raw => {
unreachable!("Raw strategy should not be formatted into a template wrapper")
}
}
}
#[must_use]
const fn inserts_child_nodes(&self) -> bool {
match self {
Self::InnerHTML
| Self::BeforeBegin
| Self::AfterBegin
| Self::BeforeEnd
| Self::AfterEnd => true,
Self::Target(method, _) => matches!(
method,
OobMethod::InnerHTML
| OobMethod::BeforeBegin
| OobMethod::AfterBegin
| OobMethod::BeforeEnd
| OobMethod::AfterEnd
),
Self::True | Self::OuterHTML | Self::Delete | Self::Custom(_) | Self::Raw => false,
}
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone)]
struct OobFragment {
id: String,
strategy: OobSwap,
markup: maud::Markup,
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone)]
pub struct HtmxFragments {
primary: Option<maud::Markup>,
oob: Vec<OobFragment>,
}
#[cfg(feature = "maud")]
impl HtmxFragments {
#[must_use]
pub const fn new(primary: maud::Markup) -> Self {
Self {
primary: Some(primary),
oob: Vec::new(),
}
}
#[must_use]
pub const fn oob_only() -> Self {
Self {
primary: None,
oob: Vec::new(),
}
}
#[must_use]
pub fn oob(self, id: impl Into<String>, markup: maud::Markup) -> Self {
self.oob_with_strategy(id, OobSwap::True, markup)
}
#[must_use]
pub fn oob_with_strategy(
mut self,
id: impl Into<String>,
strategy: OobSwap,
markup: maud::Markup,
) -> Self {
self.oob.push(OobFragment {
id: id.into(),
strategy,
markup,
});
self
}
}
#[cfg(feature = "maud")]
fn escape_attribute(w: &mut String, s: &str) {
for c in s.chars() {
match c {
'&' => w.push_str("&"),
'"' => w.push_str("""),
'<' => w.push_str("<"),
'>' => w.push_str(">"),
_ => w.push(c),
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn escape_attribute_string(s: &str) -> String {
let mut w = String::with_capacity(s.len() + 10);
escape_attribute(&mut w, s);
w
}
#[cfg(feature = "maud")]
#[must_use]
pub fn inject_hx_swap_oob(html: &str, oob_value: &str) -> Option<String> {
let mut idx = 0;
while let Some(start_pos) = html[idx..].find('<') {
let abs_start = idx + start_pos;
let remaining = &html[abs_start..];
if remaining.starts_with("<!--") {
let comment_end = remaining.find("-->")?;
idx = abs_start + comment_end + 3;
} else {
let mut tag_name_end = 0;
for (char_idx, c) in remaining.char_indices().skip(1) {
if c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '>' || c == '/' {
tag_name_end = char_idx;
break;
}
}
if tag_name_end == 0 {
return None;
}
let insert_pos = abs_start + tag_name_end;
let escaped_val = escape_attribute_string(oob_value);
let mut result = String::with_capacity(html.len() + escaped_val.len() + 30);
result.push_str(&html[..insert_pos]);
result.push_str(" hx-swap-oob=\"");
result.push_str(&escaped_val);
result.push('"');
result.push_str(&html[insert_pos..]);
return Some(result);
}
}
None
}
#[cfg(feature = "maud")]
impl maud::Render for HtmxFragments {
fn render_to(&self, w: &mut String) {
if let Some(primary) = &self.primary {
primary.render_to(w);
}
for oob in &self.oob {
let rendered = &oob.markup.0;
if has_oob_attribute(rendered) || matches!(oob.strategy, OobSwap::Raw) {
w.push_str(rendered);
} else {
let value = oob.strategy.format_value(&oob.id);
let carrier = if oob.strategy.inserts_child_nodes() {
"div"
} else {
"template"
};
w.push('<');
w.push_str(carrier);
w.push_str(" hx-swap-oob=\"");
escape_attribute(w, &value);
w.push_str("\">");
w.push_str(rendered);
w.push_str("</");
w.push_str(carrier);
w.push('>');
}
}
}
}
#[cfg(feature = "maud")]
impl IntoResponse for HtmxFragments {
fn into_response(self) -> Response {
use maud::Render;
let mut capacity = 0;
if let Some(primary) = &self.primary {
capacity += primary.0.len();
}
for oob in &self.oob {
capacity += oob.markup.0.len() + 64;
}
let mut w = String::with_capacity(capacity);
self.render_to(&mut w);
axum::response::Html(w).into_response()
}
}
#[cfg(feature = "maud")]
fn has_oob_attribute(html: &str) -> bool {
let mut in_tag = false;
let mut in_quote = None;
let mut chars = html.char_indices().peekable();
while let Some((idx, c)) = chars.next() {
if in_tag {
if let Some(q) = in_quote {
if c == q {
in_quote = None;
}
} else if c == '"' || c == '\'' {
in_quote = Some(c);
} else if c == '>' {
in_tag = false;
} else {
let remaining = &html[idx..];
let match_len = if remaining
.get(..11)
.is_some_and(|s| s.eq_ignore_ascii_case("hx-swap-oob"))
{
Some(11)
} else if remaining
.get(..16)
.is_some_and(|s| s.eq_ignore_ascii_case("data-hx-swap-oob"))
{
Some(16)
} else {
None
};
if let Some(len) = match_len {
let after = remaining.chars().nth(len);
match after {
None | Some('=' | ' ' | '\t' | '\n' | '\r' | '>' | '/') => {
let is_word_start = if idx == 0 {
true
} else if let Some(prev_char) = html[..idx].chars().next_back() {
prev_char.is_ascii_whitespace()
|| prev_char == '/'
|| prev_char == '<'
|| prev_char == '"'
|| prev_char == '\''
} else {
true
};
if is_word_start {
return true;
}
}
_ => {}
}
}
}
} else if c == '<' {
let remaining = &html[idx..];
if remaining.starts_with("<!--") {
while let Some((_, next_c)) = chars.next() {
if next_c == '-' {
let rem = &html[chars.peek().map_or(html.len(), |&(i, _)| i)..];
if rem.starts_with("->") {
chars.next();
chars.next();
break;
}
}
}
} else {
in_tag = true;
in_quote = None;
}
}
}
false
}
#[must_use]
pub fn extract_html_id(html: &str) -> Option<String> {
let start_tag_end = html.find('>')?;
let start_tag = &html[..start_tag_end];
let mut id_idx = None;
let mut search_start = 0;
while let Some(offset) = start_tag[search_start..].find("id=") {
let absolute_idx = search_start + offset;
if absolute_idx > 0 {
let prev_char = start_tag.as_bytes()[absolute_idx - 1];
if prev_char == b' ' || prev_char == b'\t' || prev_char == b'\n' || prev_char == b'\r' {
id_idx = Some(absolute_idx);
break;
}
}
search_start = absolute_idx + 3;
}
let idx = id_idx?;
let after_id = &start_tag[idx + 3..];
let mut chars = after_id.chars();
let quote = chars.next()?;
if quote == '"' || quote == '\'' {
let mut val = String::new();
for c in chars {
if c == quote {
break;
}
val.push(c);
}
Some(val)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::Request;
#[test]
#[allow(clippy::const_is_empty)]
fn htmx_js_is_not_empty() {
assert!(!HTMX_JS.is_empty(), "htmx.min.js should not be empty");
assert!(!HTMX_SSE_JS.is_empty(), "sse.js should not be empty");
}
#[test]
fn htmx_js_looks_like_javascript() {
let start = std::str::from_utf8(&HTMX_JS[..50]).expect("htmx should be valid UTF-8");
assert!(
start.contains("htmx") || start.contains("function") || start.contains('('),
"htmx.min.js doesn't look like JavaScript: {start}"
);
let sse_start = std::str::from_utf8(&HTMX_SSE_JS[..50]).expect("sse should be valid UTF-8");
assert!(
sse_start.contains("Server")
|| sse_start.contains("function")
|| sse_start.contains('/')
|| sse_start.contains('*'),
"sse.js doesn't look like JavaScript: {sse_start}"
);
}
#[test]
fn htmx_version_matches_expected() {
assert_eq!(HTMX_VERSION, "2.0.4");
}
#[test]
fn htmx_asset_paths_are_same_origin_static_paths() {
assert_eq!(HTMX_JS_PATH, "/static/js/htmx.min.js");
assert_eq!(HTMX_CSRF_JS_PATH, "/static/js/autumn-htmx-csrf.js");
assert_eq!(HTMX_SSE_JS_PATH, "/static/js/sse.js");
}
#[test]
fn htmx_csrf_js_configures_request_header_without_inline_wrapper() {
assert!(HTMX_CSRF_JS.contains("htmx:configRequest"));
assert!(HTMX_CSRF_JS.contains("X-CSRF-Token"));
assert!(HTMX_CSRF_JS.contains("csrf-token"));
assert!(!HTMX_CSRF_JS.contains("<script"));
}
#[test]
fn widgets_js_wires_nav_bar() {
let js = std::str::from_utf8(AUTUMN_WIDGETS_JS)
.expect("autumn-widgets.js should be valid UTF-8");
assert!(js.contains("data-autumn-nav"), "{js}");
assert!(js.contains("data-nav-toggle"), "{js}");
assert!(js.contains("data-nav-menu-toggle"), "{js}");
assert!(js.contains("aria-expanded"), "{js}");
assert!(js.contains("Escape"), "{js}");
}
#[test]
fn widgets_js_wires_modal() {
let js = std::str::from_utf8(AUTUMN_WIDGETS_JS)
.expect("autumn-widgets.js should be valid UTF-8");
assert!(js.contains("data-modal-open"), "{js}");
assert!(js.contains("data-modal-close"), "{js}");
assert!(js.contains("showModal"), "{js}");
assert!(
js.contains("'command' in HTMLButtonElement.prototype"),
"{js}"
);
assert!(!js.contains("window.confirm"), "{js}");
}
#[test]
fn widgets_js_polyfills_light_dismiss_backdrop_click() {
let js = std::str::from_utf8(AUTUMN_WIDGETS_JS)
.expect("autumn-widgets.js should be valid UTF-8");
assert!(
js.contains(r#"dialog[closedby="any"][open]"#),
"must polyfill backdrop-click dismissal for closedby=\"any\" dialogs: {js}"
);
}
#[tokio::test]
async fn hx_request_extractor_parses_headers() -> Result<(), axum::http::Error> {
let req = Request::builder()
.header("hx-request", "true")
.header("hx-target", "my-div")
.header("hx-trigger", "btn")
.header("hx-trigger-name", "btn-name")
.header("hx-current-url", "http://example.com")
.header("hx-history-restore-request", "true")
.header("hx-prompt", "yes")
.header("hx-boosted", "true")
.body(())?;
let (mut parts, ()) = req.into_parts();
let hx = HxRequest::from_request_parts(&mut parts, &())
.await
.expect("infallible");
assert!(hx.is_htmx);
assert_eq!(hx.target.as_deref(), Some("my-div"));
assert_eq!(hx.trigger.as_deref(), Some("btn"));
assert_eq!(hx.trigger_name.as_deref(), Some("btn-name"));
assert_eq!(hx.current_url.as_deref(), Some("http://example.com"));
assert!(hx.history_restore_request);
assert_eq!(hx.prompt.as_deref(), Some("yes"));
assert!(hx.boosted);
Ok(())
}
#[tokio::test]
async fn hx_response_ext_adds_headers() {
use axum::response::IntoResponse;
let response = "hello"
.hx_location("/some-location")
.hx_push_url("/new-url")
.hx_redirect("/login")
.hx_refresh()
.hx_replace_url("/old-url")
.hx_reswap("innerHTML")
.hx_retarget("#target")
.hx_trigger("my-event")
.hx_trigger_after_settle("settled-event")
.hx_trigger_after_swap("swapped-event")
.into_response();
let headers = response.headers();
assert_eq!(headers.get("hx-location").unwrap(), "/some-location");
assert_eq!(headers.get("hx-push-url").unwrap(), "/new-url");
assert_eq!(headers.get("hx-redirect").unwrap(), "/login");
assert_eq!(headers.get("hx-refresh").unwrap(), "true");
assert_eq!(headers.get("hx-replace-url").unwrap(), "/old-url");
assert_eq!(headers.get("hx-reswap").unwrap(), "innerHTML");
assert_eq!(headers.get("hx-retarget").unwrap(), "#target");
assert_eq!(headers.get("hx-trigger").unwrap(), "my-event");
assert_eq!(
headers.get("hx-trigger-after-settle").unwrap(),
"settled-event"
);
assert_eq!(
headers.get("hx-trigger-after-swap").unwrap(),
"swapped-event"
);
}
#[tokio::test]
async fn hx_response_ext_ignores_invalid_header_values() {
use axum::response::IntoResponse;
let invalid_header_value = "invalid\nvalue";
let response = "hello"
.hx_location(invalid_header_value)
.hx_push_url(invalid_header_value)
.hx_redirect(invalid_header_value)
.hx_refresh() .hx_replace_url(invalid_header_value)
.hx_reswap(invalid_header_value)
.hx_retarget(invalid_header_value)
.hx_trigger(invalid_header_value)
.hx_trigger_after_settle(invalid_header_value)
.hx_trigger_after_swap(invalid_header_value)
.into_response();
let headers = response.headers();
assert!(headers.get("hx-location").is_none());
assert!(headers.get("hx-push-url").is_none());
assert!(headers.get("hx-redirect").is_none());
assert_eq!(headers.get("hx-refresh").unwrap(), "true");
assert!(headers.get("hx-replace-url").is_none());
assert!(headers.get("hx-reswap").is_none());
assert!(headers.get("hx-retarget").is_none());
assert!(headers.get("hx-trigger").is_none());
assert!(headers.get("hx-trigger-after-settle").is_none());
assert!(headers.get("hx-trigger-after-swap").is_none());
}
#[tokio::test]
async fn hx_request_extractor_handles_missing_headers() -> Result<(), axum::http::Error> {
let req = Request::builder().body(())?;
let (mut parts, ()) = req.into_parts();
let hx = HxRequest::from_request_parts(&mut parts, &())
.await
.expect("infallible");
assert!(!hx.is_htmx);
assert_eq!(hx.target, None);
assert_eq!(hx.trigger, None);
assert_eq!(hx.trigger_name, None);
assert_eq!(hx.current_url, None);
assert!(!hx.history_restore_request);
assert_eq!(hx.prompt, None);
assert!(!hx.boosted);
Ok(())
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn htmx_fragments_renders_correctly() {
use axum::response::IntoResponse;
let primary = maud::html! { div { "primary body" } };
let oob1 = maud::html! { div id="badge" { "3" } };
let oob2 = maud::html! { li { "new item" } };
let response = HtmxFragments::new(primary)
.oob("badge", oob1)
.oob_with_strategy("list", OobSwap::BeforeEnd, oob2)
.into_response();
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
assert!(
body_str.contains("<div>primary body</div>"),
"got: {body_str}"
);
assert!(
body_str
.contains("<template hx-swap-oob=\"true\"><div id=\"badge\">3</div></template>"),
"got: {body_str}"
);
assert!(
body_str.contains("<div hx-swap-oob=\"beforeend:#list\"><li>new item</li></div>"),
"got: {body_str}"
);
assert!(
!body_str.contains("<template hx-swap-oob=\"beforeend"),
"positional swap must not emit a <template> carrier: {body_str}"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn htmx_fragments_positional_oob_uses_div_carrier_with_real_children() {
use axum::response::IntoResponse;
use maud::Render;
for (strategy, value) in [
(OobSwap::BeforeEnd, "beforeend:#list"),
(OobSwap::AfterBegin, "afterbegin:#list"),
(OobSwap::BeforeBegin, "beforebegin:#list"),
(OobSwap::AfterEnd, "afterend:#list"),
] {
let fragment = maud::html! { li { "row" } };
let rendered = HtmxFragments::oob_only()
.oob_with_strategy("list", strategy, fragment)
.render()
.into_string();
let open = format!("<div hx-swap-oob=\"{value}\">");
assert!(
rendered.starts_with(&open) && rendered.ends_with("</div>"),
"positional swap {value:?} must use a <div> carrier: {rendered}"
);
assert!(
!rendered.contains("<template"),
"positional swap {value:?} must not use a <template> carrier: {rendered}"
);
let children = rendered
.strip_prefix(&open)
.and_then(|s| s.strip_suffix("</div>"))
.expect("carrier open/close tags");
assert_eq!(
children, "<li>row</li>",
"the <div> carrier's childNodes must be the fragment htmx inserts",
);
}
let response = HtmxFragments::oob_only()
.oob("badge", maud::html! { div id="badge" { "3" } })
.into_response();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert_eq!(
body_str,
"<template hx-swap-oob=\"true\"><div id=\"badge\">3</div></template>"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn htmx_fragments_inner_html_oob_uses_div_carrier_with_real_children() {
use maud::Render;
for (strategy, value) in [
(OobSwap::InnerHTML, "innerHTML:#count"),
(
OobSwap::Target(OobMethod::InnerHTML, "#count".to_string()),
"innerHTML:#count",
),
] {
let fragment = maud::html! { span { "5" } };
let rendered = HtmxFragments::oob_only()
.oob_with_strategy("count", strategy, fragment)
.render()
.into_string();
let open = format!("<div hx-swap-oob=\"{value}\">");
assert!(
rendered.starts_with(&open) && rendered.ends_with("</div>"),
"innerHTML swap {value:?} must use a <div> carrier: {rendered}"
);
assert!(
!rendered.contains("<template"),
"innerHTML swap {value:?} must not use a <template> carrier: {rendered}"
);
let children = rendered
.strip_prefix(&open)
.and_then(|s| s.strip_suffix("</div>"))
.expect("carrier open/close tags");
assert_eq!(
children, "<span>5</span>",
"the <div> carrier's childNodes must be the fragment htmx inserts",
);
}
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn htmx_fragments_empty_primary() {
use axum::response::IntoResponse;
let oob = maud::html! { div id="badge" { "3" } };
let response = HtmxFragments::oob_only().oob("badge", oob).into_response();
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
assert_eq!(
body_str,
"<template hx-swap-oob=\"true\"><div id=\"badge\">3</div></template>"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn htmx_fragments_no_double_wrap() {
use axum::response::IntoResponse;
let oob = maud::html! { div id="badge" hx-swap-oob="true" { "3" } };
let response = HtmxFragments::oob_only().oob("badge", oob).into_response();
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
assert_eq!(body_str, "<div id=\"badge\" hx-swap-oob=\"true\">3</div>");
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn htmx_fragments_composes_with_headers() {
use axum::response::IntoResponse;
let primary = maud::html! { div { "ok" } };
let response = HtmxFragments::new(primary)
.hx_trigger("custom-event")
.into_response();
let headers = response.headers();
assert_eq!(headers.get("hx-trigger").unwrap(), "custom-event");
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
assert!(body_str.contains("<div>ok</div>"));
}
#[test]
fn test_has_oob_attribute_detector() {
assert!(has_oob_attribute("<div hx-swap-oob=\"true\"></div>"));
assert!(has_oob_attribute("<div data-hx-swap-oob=\"true\"></div>"));
assert!(has_oob_attribute("<div hx-swap-oob = 'true' ></div>"));
assert!(has_oob_attribute("<div hx-swap-oob></div>"));
assert!(has_oob_attribute(
"<div class=\"x\" hx-swap-oob=\"true\"></div>"
));
assert!(has_oob_attribute(
"<div hx-swap-oob=\"true\" class=\"x\"></div>"
));
assert!(!has_oob_attribute("<div>Learn hx-swap-oob today</div>"));
assert!(!has_oob_attribute("<div class=\"hx-swap-oob\"></div>"));
assert!(!has_oob_attribute(
"<div id=\"some-hx-swap-oob-element\"></div>"
));
assert!(!has_oob_attribute(
"<!-- <div hx-swap-oob=\"true\"></div> -->"
));
assert!(!has_oob_attribute(
"<div class=\"x\">some text hx-swap-oob=\"true\"</div>"
));
}
#[test]
fn test_oob_swap_format_value_empty_id() {
assert_eq!(OobSwap::True.format_value(""), "true");
assert_eq!(OobSwap::True.format_value("#"), "true");
assert_eq!(OobSwap::InnerHTML.format_value(""), "innerHTML");
assert_eq!(OobSwap::InnerHTML.format_value("#"), "innerHTML");
assert_eq!(OobSwap::BeforeEnd.format_value(""), "beforeend");
assert_eq!(OobSwap::BeforeEnd.format_value("#"), "beforeend");
assert_eq!(
OobSwap::Target(OobMethod::InnerHTML, "#target".to_string()).format_value(""),
"innerHTML"
);
assert_eq!(
OobSwap::Target(OobMethod::BeforeEnd, "#target".to_string()).format_value("#"),
"beforeend"
);
assert_eq!(OobSwap::InnerHTML.format_value("my-id"), "innerHTML:#my-id");
assert_eq!(
OobSwap::InnerHTML.format_value("#my-id"),
"innerHTML:#my-id"
);
}
#[test]
fn test_inject_hx_swap_oob() {
assert_eq!(
inject_hx_swap_oob("<li id=\"1\"></li>", "beforeend:#container"),
Some("<li hx-swap-oob=\"beforeend:#container\" id=\"1\"></li>".to_string())
);
assert_eq!(
inject_hx_swap_oob("<!-- comment -->\n <div class=\"foo\"></div>", "outerHTML"),
Some(
"<!-- comment -->\n <div hx-swap-oob=\"outerHTML\" class=\"foo\"></div>"
.to_string()
)
);
assert_eq!(inject_hx_swap_oob("Hello world", "true"), None);
}
#[cfg(feature = "maud")]
#[test]
fn test_inject_on_maud_markup() {
use maud::html;
let oob = html! { li id="item-1" { "Item" } };
let injected = inject_hx_swap_oob(&oob.0, "beforeend:#container");
assert_eq!(
injected,
Some("<li hx-swap-oob=\"beforeend:#container\" id=\"item-1\">Item</li>".to_string())
);
}
}