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 serves each script at two URLs: its plain path
10//! (`/static/js/htmx.min.js`, revalidated on every use) and a content-hashed
11//! path (`/static/js/htmx.min.<hash>.js`, cached `immutable` for a year).
12//! Reference them through [`asset_url`](crate::assets::asset_url), which
13//! returns the hashed form, so a framework upgrade that changes a script
14//! also changes its URL:
15//!
16//! ```rust,ignore
17//! html! {
18//!     script src=(asset_url("js/htmx.min.js")) {}
19//!     script src=(asset_url("js/autumn-htmx-csrf.js")) {}
20//! }
21//! ```
22//!
23//! The plain paths keep working for templates that hard-code them.
24
25use axum::extract::FromRequestParts;
26use axum::http::request::Parts;
27use axum::response::{IntoResponse, Response};
28use http::header::{HeaderName, HeaderValue};
29use std::convert::Infallible;
30
31/// htmx 2.x minified JavaScript, embedded at compile time.
32///
33/// This is the raw byte content of the minified htmx library. It is
34/// served automatically by the framework at `/static/js/htmx.min.js`
35/// (revalidated) and at the content-hashed URL `asset_url("js/htmx.min.js")`
36/// returns (cached `immutable`).
37pub const HTMX_JS: &[u8] = include_bytes!("../vendor/htmx.min.js");
38
39/// Same-origin path where Autumn serves embedded htmx.
40pub const HTMX_JS_PATH: &str = "/static/js/htmx.min.js";
41
42/// htmx SSE extension JavaScript, embedded at compile time.
43pub const HTMX_SSE_JS: &[u8] = include_bytes!("../vendor/sse.js");
44
45/// Same-origin path where Autumn serves embedded htmx SSE extension.
46pub const HTMX_SSE_JS_PATH: &str = "/static/js/sse.js";
47
48/// Autumn widget runtime JavaScript, embedded at compile time.
49///
50/// Provides CSP-compatible event-listener wiring for built-in widgets
51/// (autocomplete selection, min-length enforcement). Served automatically
52/// at [`AUTUMN_WIDGETS_JS_PATH`] and at the content-hashed URL
53/// `asset_url("js/autumn-widgets.js")` returns.
54///
55/// Reference it once in your layout template:
56///
57/// ```html
58/// <script src="/static/js/autumn-widgets.js" defer></script>
59/// ```
60pub const AUTUMN_WIDGETS_JS: &[u8] = include_bytes!("../vendor/autumn-widgets.js");
61
62/// Same-origin path where Autumn serves the widget runtime script.
63pub const AUTUMN_WIDGETS_JS_PATH: &str = "/static/js/autumn-widgets.js";
64
65/// Same-origin path where Autumn serves the htmx CSRF helper.
66///
67/// The helper reads a CSRF token from either:
68/// - `<meta name="csrf-token" content="...">`
69/// - `<meta name="autumn-csrf-token" content="...">`
70///
71/// The request header defaults to `X-CSRF-Token`; override it with
72/// `data-header="..."` on the meta tag when using a custom CSRF header name.
73pub const HTMX_CSRF_JS_PATH: &str = "/static/js/autumn-htmx-csrf.js";
74
75/// Idiomorph DOM-morphing library, embedded at compile time.
76///
77/// Serves at [`IDIOMORPH_JS_PATH`] and at the content-hashed URL
78/// `asset_url("js/idiomorph.min.js")` returns.
79/// Enables `hx-swap="morph"` on htmx requests for smooth DOM updates.
80///
81/// Reference in your layout:
82/// ```html
83/// <script src="/static/js/idiomorph.min.js"></script>
84/// ```
85#[cfg(feature = "htmx")]
86pub const IDIOMORPH_JS: &[u8] = include_bytes!("../vendor/idiomorph.min.js");
87
88/// Same-origin path where Autumn serves the idiomorph library.
89#[cfg(feature = "htmx")]
90pub const IDIOMORPH_JS_PATH: &str = "/static/js/idiomorph.min.js";
91
92/// CSP-compatible htmx CSRF helper JavaScript.
93///
94/// Served as an external same-origin script so applications do not need inline
95/// JavaScript under Autumn's default `script-src 'self'` policy.
96pub 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/// The framework's own scripts as a fingerprinted bundle mounted at
111/// `/static/`, so each keeps its historical plain path and also gets a
112/// content-hashed one. Logical paths are relative to `/static/`.
113#[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/// `true` when the framework serves `logical_path` itself. htmx is the one
126/// exception: once an app pins its own copy with `autumn assets add htmx@…`,
127/// `ServeDir` serves that file and the built-in one steps aside.
128#[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/// The framework script at `logical_path` (relative to `/static/`), when the
134/// framework is serving it.
135#[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/// Every framework script the framework is serving.
143#[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/// `true` when `rel_path` (relative to `/static/`) is the fingerprinted path
151/// of a framework script the framework is serving.
152#[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
157/// htmx version string for diagnostics and cache busting.
158///
159/// Corresponds to the version of the embedded htmx JS file.
160/// Re-exported at the crate root as [`HTMX_VERSION`].
161pub const HTMX_VERSION: &str = "2.0.4";
162
163/// Extractor for htmx request headers.
164///
165/// Extracts the standard `hx-*` headers sent by htmx requests, enabling
166/// conditional rendering (e.g. sending back partials instead of full pages).
167///
168/// See <https://htmx.org/reference/#request_headers> for more details.
169#[allow(dead_code)]
170#[derive(Debug, Clone, Default)]
171pub struct HxRequest {
172    /// Indicates that the request is via htmx (`HX-Request`)
173    pub is_htmx: bool,
174    /// The id of the target element if provided (`HX-Target`)
175    pub target: Option<String>,
176    /// The id of the triggered element if provided (`HX-Trigger`)
177    pub trigger: Option<String>,
178    /// The name of the triggered element if provided (`HX-Trigger-Name`)
179    pub trigger_name: Option<String>,
180    /// The current URL of the browser (`HX-Current-URL`)
181    pub current_url: Option<String>,
182    /// `true` if the request is for history restoration after a miss (`HX-History-Restore-Request`)
183    pub history_restore_request: bool,
184    /// The user response to an hx-prompt (`HX-Prompt`)
185    pub prompt: Option<String>,
186    /// `true` if the request is via an element using hx-boost (`HX-Boosted`)
187    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
220/// Extension trait for adding htmx response headers to any `IntoResponse` type.
221///
222/// This trait provides a fluent API for controlling htmx behavior from the server.
223/// If an invalid header value is provided, it is gracefully ignored.
224///
225/// See <https://htmx.org/reference/#response_headers> for more details.
226pub trait HxResponseExt: IntoResponse + Sized {
227    /// Allows you to do a client-side redirect that does not do a full page reload (`HX-Location`).
228    fn hx_location(self, url: &str) -> Response {
229        append_hx_header(self, "hx-location", url)
230    }
231
232    /// Pushes a new URL into the history stack (`HX-Push-Url`).
233    fn hx_push_url(self, url: &str) -> Response {
234        append_hx_header(self, "hx-push-url", url)
235    }
236
237    /// Triggers a client-side redirect (`HX-Redirect`).
238    fn hx_redirect(self, url: &str) -> Response {
239        append_hx_header(self, "hx-redirect", url)
240    }
241
242    /// Tells the client to do a full page refresh (`HX-Refresh`).
243    fn hx_refresh(self) -> Response {
244        append_hx_header(self, "hx-refresh", "true")
245    }
246
247    /// Replaces the current URL in the location bar (`HX-Replace-Url`).
248    fn hx_replace_url(self, url: &str) -> Response {
249        append_hx_header(self, "hx-replace-url", url)
250    }
251
252    /// Specifies how the response will be swapped (`HX-Reswap`).
253    fn hx_reswap(self, swap: &str) -> Response {
254        append_hx_header(self, "hx-reswap", swap)
255    }
256
257    /// Specifies the target element to update (`HX-Retarget`).
258    fn hx_retarget(self, target: &str) -> Response {
259        append_hx_header(self, "hx-retarget", target)
260    }
261
262    /// Triggers client-side events (`HX-Trigger`).
263    fn hx_trigger(self, event: &str) -> Response {
264        append_hx_header(self, "hx-trigger", event)
265    }
266
267    /// Triggers client-side events after the settle step (`HX-Trigger-After-Settle`).
268    fn hx_trigger_after_settle(self, event: &str) -> Response {
269        append_hx_header(self, "hx-trigger-after-settle", event)
270    }
271
272    /// Triggers client-side events after the swap step (`HX-Trigger-After-Swap`).
273    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    /// Swap the element outerHTML-style by matching its own ID (`hx-swap-oob="true"`).
320    True,
321    /// Swap using a specific method, targeting the element matching the fragment's ID.
322    OuterHTML,
323    InnerHTML,
324    BeforeBegin,
325    AfterBegin,
326    BeforeEnd,
327    AfterEnd,
328    Delete,
329    /// Swap using a specific method targeting a custom CSS selector.
330    Target(OobMethod, String),
331    /// Already contains `hx-swap-oob` on the root element. Do not wrap in a `<template>` tag.
332    Raw,
333    /// Custom raw string for the `hx-swap-oob` attribute.
334    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    /// Whether htmx applies this swap by iterating the carrier element's own
379    /// child nodes — true for the *positional* swaps (`beforebegin` /
380    /// `afterbegin` / `beforeend` / `afterend`) **and** for `innerHTML`, which
381    /// all route through the same insert loop over `carrier.childNodes`. Only
382    /// the element-replacing swaps (`true` / `outerHTML` by id) unwrap the
383    /// carrier by id instead, so they return `false`.
384    ///
385    /// This matters for the carrier tag: htmx's insert loop iterates the
386    /// carrier's `childNodes`, but an `HTMLTemplateElement` keeps its children
387    /// in `.content` (a `DocumentFragment`), so `template.childNodes` is empty
388    /// and htmx would append nothing. Child-node-inserting swaps must therefore
389    /// use a plain `<div>` carrier — whose real children live in `childNodes` —
390    /// while element-replacing swaps keep the `<template>` carrier (#1688).
391    #[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/// A response builder for composing out-of-band multi-region swaps in htmx.
422///
423/// This builder allows a handler to return a primary HTML fragment plus one or
424/// more out-of-band (OOB) fragments that update other disjoint regions of the
425/// page.
426///
427/// # Example
428///
429/// ```rust
430/// use autumn_web::prelude::*;
431/// use autumn_web::htmx::{HtmxFragments, OobSwap};
432/// use maud::Render;
433///
434/// let primary = html! { div { "Task created successfully!" } };
435/// let flash = html! { div id="flash-message" class="alert alert-success" { "Succesfully saved!" } };
436/// let counter = html! { span { "5" } };
437///
438/// let response = HtmxFragments::new(primary)
439///     .oob("flash-message", flash)
440///     .oob_with_strategy("task-count", OobSwap::InnerHTML, counter);
441///
442/// // HtmxFragments implements `IntoResponse` and `maud::Render`
443/// let rendered = response.render().into_string();
444/// assert!(rendered.contains("<div>Task created successfully!</div>"));
445/// assert!(rendered.contains("<template hx-swap-oob=\"true\"><div id=\"flash-message\""));
446/// // htmx inserts the carrier's child nodes for `innerHTML`, so the carrier
447/// // must be a `<div>` — a `<template>`'s children live in `.content`, giving
448/// // empty `childNodes`, so nothing would land in the DOM.
449/// assert!(rendered.contains("<div hx-swap-oob=\"innerHTML:#task-count\"><span>5</span></div>"));
450/// ```
451#[derive(Debug, Clone)]
452pub struct HtmxFragments {
453    primary: Option<maud::Markup>,
454    oob: Vec<OobFragment>,
455}
456
457#[cfg(feature = "maud")]
458impl HtmxFragments {
459    /// Create a new builder with a primary fragment.
460    #[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    /// Create a new empty builder (only OOB fragments).
469    #[must_use]
470    pub const fn oob_only() -> Self {
471        Self {
472            primary: None,
473            oob: Vec::new(),
474        }
475    }
476
477    /// Attach an out-of-band fragment using the default `OobSwap::True` strategy.
478    #[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    /// Attach an out-of-band fragment with a specific swap strategy.
484    #[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("&amp;"),
505            '"' => w.push_str("&quot;"),
506            '<' => w.push_str("&lt;"),
507            '>' => w.push_str("&gt;"),
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                // Child-node-inserting swaps must use a `<div>` carrier: htmx
569                // inserts the carrier's `childNodes` for the positional swaps
570                // (beforebegin/afterbegin/beforeend/afterend) *and* for
571                // `innerHTML`, and a `<template>` keeps its children in
572                // `.content` (empty `childNodes`), so the fragment would never
573                // land in the DOM (#1688). Element-replacing swaps (`true` /
574                // `outerHTML` by id) keep the `<template>` carrier htmx unwraps.
575                //
576                // Because htmx inserts the carrier's direct `childNodes`, a
577                // fragment given to such a swap must be a valid direct child of
578                // a `<div>`. Do NOT pass a bare `<tr>`/`<td>`/`<tbody>`/
579                // `<option>`/`<col>`: the HTML parser foster-parents those out
580                // of a `<div>`, so they vanish before htmx sees them.
581                // Table-row live swaps instead go through the element-replacing
582                // (`outerHTML` / `inserts_child_nodes() == false`) path with the
583                // id on the row itself (see `channels.rs::sse_oob_envelope`); a
584                // positional table-row fragment API is a documented follow-up.
585                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/// Extracts the `id` attribute value from the root HTML element in the given HTML string.
701///
702/// Looks for an `id` attribute within the root start tag (before the first `>`).
703/// The attribute name must be preceded by a whitespace boundary.
704#[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        // Fallback open/close wiring for browsers without the Invoker
804        // Commands API (command/commandfor), used by autumn_web::widgets::modal
805        // and confirm_action (issue #1233).
806        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        // The whole point of this widget is to replace the native
814        // window.confirm() dialog with a server-rendered, testable one.
815        assert!(!js.contains("window.confirm"), "{js}");
816    }
817
818    #[test]
819    fn widgets_js_polyfills_light_dismiss_backdrop_click() {
820        // PR review (chatgpt-codex-connector): closedby="any" (see
821        // ModalConfig::light_dismiss) has limited browser support, so
822        // backdrop clicks must be polyfilled for browsers that don't honor
823        // it natively yet — mirroring the command/commandfor fallback above.
824        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        // This value is invalid because it contains a newline character.
901        // It should be gracefully ignored by the append_hx_header function.
902        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() // valid by default
909            .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        // hx_refresh is always set to "true" internally, so it will be present
922        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        // Positional swaps (#1688) use a <div> carrier — NOT a <template> —
980        // because htmx inserts the carrier's childNodes, which are empty for a
981        // <template> (its children live in .content).
982        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    /// Regression for #1688: a positional OOB swap must emit a carrier whose own
993    /// `childNodes` htmx can iterate and insert. This models htmx's positional
994    /// insert (which reads `carrier.childNodes`, empty for a `<template>` since
995    /// its children live in `.content`) rather than only asserting the string.
996    #[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        // Render each positional strategy and confirm the carrier is a <div>
1003        // (whose childNodes are the fragment) and never a <template>.
1004        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            // Model htmx's positional insert: the nodes it appends are the
1027            // carrier's *direct children* (the text between the carrier's open
1028            // and close tags). For this <div> carrier those children are the
1029            // rendered fragment; a <template> carrier would expose no childNodes
1030            // here and htmx would append nothing.
1031            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        // outerHTML/by-id (`true`) swaps still use the <template> carrier that
1042        // htmx unwraps — this is deliberately unchanged.
1043        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    /// Regression (same class as #1688): an `innerHTML` OOB swap must also emit
1057    /// a `<div>` carrier, not a `<template>`. htmx's `innerHTML` swap is
1058    /// non-inline and iterates the carrier's `childNodes` (only `outerHTML` /
1059    /// `true` unwrap the carrier by id), so a `<template>` carrier — whose
1060    /// children live in `.content` — would insert nothing at runtime. This
1061    /// mirrors `channels.rs::sse_oob_envelope`, which already wraps `innerHTML`
1062    /// in a `<div>`.
1063    #[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        // Both the shorthand `InnerHTML` and the targeted
1069        // `Target(InnerHTML, …)` strategy must use a <div> carrier.
1070        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            // Model htmx's innerHTML insert: the nodes it appends are the
1094            // carrier's *direct children* (the text between the carrier's open
1095            // and close tags). For this <div> carrier those children are the
1096            // rendered fragment; a <template> carrier would expose no childNodes
1097            // here and htmx would insert nothing.
1098            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        // Should not contain any stray wrapper or primary body, only the OOB fragment template
1124        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        // Already contains hx-swap-oob in markup
1136        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        // Should not be wrapped in template
1146        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        // True cases
1172        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        // False cases
1184        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        // Non-empty ID case
1215        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}