Skip to main content

autumn_web/
htmx.rs

1//! Embedded htmx JavaScript.
2//!
3//! htmx is embedded directly in the Autumn binary via [`include_bytes!`]
4//! and served at [`HTMX_JS_PATH`]. A small CSRF helper is also served at
5//! [`HTMX_CSRF_JS_PATH`] so htmx forms can work with Autumn's default
6//! `script-src 'self'` Content Security Policy. No CDN, no npm, no build step
7//! required.
8//!
9//! The framework automatically mounts a route handler that serves this
10//! file with immutable caching headers. Reference it in your HTML
11//! templates:
12//!
13//! ```html
14//! <script src="/static/js/htmx.min.js"></script>
15//! <script src="/static/js/autumn-htmx-csrf.js"></script>
16//! ```
17
18use axum::extract::FromRequestParts;
19use axum::http::request::Parts;
20use axum::response::{IntoResponse, Response};
21use http::header::{HeaderName, HeaderValue};
22use std::convert::Infallible;
23
24/// htmx 2.x minified JavaScript, embedded at compile time.
25///
26/// This is the raw byte content of the minified htmx library. It is
27/// served automatically by the framework at `/static/js/htmx.min.js`
28/// with `Cache-Control: public, max-age=31536000, immutable`.
29pub const HTMX_JS: &[u8] = include_bytes!("../vendor/htmx.min.js");
30
31/// Same-origin path where Autumn serves embedded htmx.
32pub const HTMX_JS_PATH: &str = "/static/js/htmx.min.js";
33
34/// htmx SSE extension JavaScript, embedded at compile time.
35pub const HTMX_SSE_JS: &[u8] = include_bytes!("../vendor/sse.js");
36
37/// Same-origin path where Autumn serves embedded htmx SSE extension.
38pub const HTMX_SSE_JS_PATH: &str = "/static/js/sse.js";
39
40/// Autumn widget runtime JavaScript, embedded at compile time.
41///
42/// Provides CSP-compatible event-listener wiring for built-in widgets
43/// (autocomplete selection, min-length enforcement). Served automatically
44/// at [`AUTUMN_WIDGETS_JS_PATH`] with immutable cache headers.
45///
46/// Reference it once in your layout template:
47///
48/// ```html
49/// <script src="/static/js/autumn-widgets.js" defer></script>
50/// ```
51pub const AUTUMN_WIDGETS_JS: &[u8] = include_bytes!("../vendor/autumn-widgets.js");
52
53/// Same-origin path where Autumn serves the widget runtime script.
54pub const AUTUMN_WIDGETS_JS_PATH: &str = "/static/js/autumn-widgets.js";
55
56/// Same-origin path where Autumn serves the htmx CSRF helper.
57///
58/// The helper reads a CSRF token from either:
59/// - `<meta name="csrf-token" content="...">`
60/// - `<meta name="autumn-csrf-token" content="...">`
61///
62/// The request header defaults to `X-CSRF-Token`; override it with
63/// `data-header="..."` on the meta tag when using a custom CSRF header name.
64pub const HTMX_CSRF_JS_PATH: &str = "/static/js/autumn-htmx-csrf.js";
65
66/// Idiomorph DOM-morphing library, embedded at compile time.
67///
68/// Serves at [`IDIOMORPH_JS_PATH`] with immutable caching headers.
69/// Enables `hx-swap="morph"` on htmx requests for smooth DOM updates.
70///
71/// Reference in your layout:
72/// ```html
73/// <script src="/static/js/idiomorph.min.js"></script>
74/// ```
75#[cfg(feature = "htmx")]
76pub const IDIOMORPH_JS: &[u8] = include_bytes!("../vendor/idiomorph.min.js");
77
78/// Same-origin path where Autumn serves the idiomorph library.
79#[cfg(feature = "htmx")]
80pub const IDIOMORPH_JS_PATH: &str = "/static/js/idiomorph.min.js";
81
82/// CSP-compatible htmx CSRF helper JavaScript.
83///
84/// Served as an external same-origin script so applications do not need inline
85/// JavaScript under Autumn's default `script-src 'self'` policy.
86pub 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
100/// htmx version string for diagnostics and cache busting.
101///
102/// Corresponds to the version of the embedded htmx JS file.
103/// Re-exported at the crate root as [`HTMX_VERSION`].
104pub const HTMX_VERSION: &str = "2.0.4";
105
106/// Extractor for htmx request headers.
107///
108/// Extracts the standard `hx-*` headers sent by htmx requests, enabling
109/// conditional rendering (e.g. sending back partials instead of full pages).
110///
111/// See <https://htmx.org/reference/#request_headers> for more details.
112#[allow(dead_code)]
113#[derive(Debug, Clone, Default)]
114pub struct HxRequest {
115    /// Indicates that the request is via htmx (`HX-Request`)
116    pub is_htmx: bool,
117    /// The id of the target element if provided (`HX-Target`)
118    pub target: Option<String>,
119    /// The id of the triggered element if provided (`HX-Trigger`)
120    pub trigger: Option<String>,
121    /// The name of the triggered element if provided (`HX-Trigger-Name`)
122    pub trigger_name: Option<String>,
123    /// The current URL of the browser (`HX-Current-URL`)
124    pub current_url: Option<String>,
125    /// `true` if the request is for history restoration after a miss (`HX-History-Restore-Request`)
126    pub history_restore_request: bool,
127    /// The user response to an hx-prompt (`HX-Prompt`)
128    pub prompt: Option<String>,
129    /// `true` if the request is via an element using hx-boost (`HX-Boosted`)
130    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
163/// Extension trait for adding htmx response headers to any `IntoResponse` type.
164///
165/// This trait provides a fluent API for controlling htmx behavior from the server.
166/// If an invalid header value is provided, it is gracefully ignored.
167///
168/// See <https://htmx.org/reference/#response_headers> for more details.
169pub trait HxResponseExt: IntoResponse + Sized {
170    /// Allows you to do a client-side redirect that does not do a full page reload (`HX-Location`).
171    fn hx_location(self, url: &str) -> Response {
172        append_hx_header(self, "hx-location", url)
173    }
174
175    /// Pushes a new URL into the history stack (`HX-Push-Url`).
176    fn hx_push_url(self, url: &str) -> Response {
177        append_hx_header(self, "hx-push-url", url)
178    }
179
180    /// Triggers a client-side redirect (`HX-Redirect`).
181    fn hx_redirect(self, url: &str) -> Response {
182        append_hx_header(self, "hx-redirect", url)
183    }
184
185    /// Tells the client to do a full page refresh (`HX-Refresh`).
186    fn hx_refresh(self) -> Response {
187        append_hx_header(self, "hx-refresh", "true")
188    }
189
190    /// Replaces the current URL in the location bar (`HX-Replace-Url`).
191    fn hx_replace_url(self, url: &str) -> Response {
192        append_hx_header(self, "hx-replace-url", url)
193    }
194
195    /// Specifies how the response will be swapped (`HX-Reswap`).
196    fn hx_reswap(self, swap: &str) -> Response {
197        append_hx_header(self, "hx-reswap", swap)
198    }
199
200    /// Specifies the target element to update (`HX-Retarget`).
201    fn hx_retarget(self, target: &str) -> Response {
202        append_hx_header(self, "hx-retarget", target)
203    }
204
205    /// Triggers client-side events (`HX-Trigger`).
206    fn hx_trigger(self, event: &str) -> Response {
207        append_hx_header(self, "hx-trigger", event)
208    }
209
210    /// Triggers client-side events after the settle step (`HX-Trigger-After-Settle`).
211    fn hx_trigger_after_settle(self, event: &str) -> Response {
212        append_hx_header(self, "hx-trigger-after-settle", event)
213    }
214
215    /// Triggers client-side events after the swap step (`HX-Trigger-After-Swap`).
216    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    /// Swap the element outerHTML-style by matching its own ID (`hx-swap-oob="true"`).
263    True,
264    /// Swap using a specific method, targeting the element matching the fragment's ID.
265    OuterHTML,
266    InnerHTML,
267    BeforeBegin,
268    AfterBegin,
269    BeforeEnd,
270    AfterEnd,
271    Delete,
272    /// Swap using a specific method targeting a custom CSS selector.
273    Target(OobMethod, String),
274    /// Already contains `hx-swap-oob` on the root element. Do not wrap in a `<template>` tag.
275    Raw,
276    /// Custom raw string for the `hx-swap-oob` attribute.
277    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    /// Whether htmx applies this swap by iterating the carrier element's own
322    /// child nodes — true for the *positional* swaps (`beforebegin` /
323    /// `afterbegin` / `beforeend` / `afterend`) **and** for `innerHTML`, which
324    /// all route through the same insert loop over `carrier.childNodes`. Only
325    /// the element-replacing swaps (`true` / `outerHTML` by id) unwrap the
326    /// carrier by id instead, so they return `false`.
327    ///
328    /// This matters for the carrier tag: htmx's insert loop iterates the
329    /// carrier's `childNodes`, but an `HTMLTemplateElement` keeps its children
330    /// in `.content` (a `DocumentFragment`), so `template.childNodes` is empty
331    /// and htmx would append nothing. Child-node-inserting swaps must therefore
332    /// use a plain `<div>` carrier — whose real children live in `childNodes` —
333    /// while element-replacing swaps keep the `<template>` carrier (#1688).
334    #[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/// A response builder for composing out-of-band multi-region swaps in htmx.
365///
366/// This builder allows a handler to return a primary HTML fragment plus one or
367/// more out-of-band (OOB) fragments that update other disjoint regions of the
368/// page.
369///
370/// # Example
371///
372/// ```rust
373/// use autumn_web::prelude::*;
374/// use autumn_web::htmx::{HtmxFragments, OobSwap};
375/// use maud::Render;
376///
377/// let primary = html! { div { "Task created successfully!" } };
378/// let flash = html! { div id="flash-message" class="alert alert-success" { "Succesfully saved!" } };
379/// let counter = html! { span { "5" } };
380///
381/// let response = HtmxFragments::new(primary)
382///     .oob("flash-message", flash)
383///     .oob_with_strategy("task-count", OobSwap::InnerHTML, counter);
384///
385/// // HtmxFragments implements `IntoResponse` and `maud::Render`
386/// let rendered = response.render().into_string();
387/// assert!(rendered.contains("<div>Task created successfully!</div>"));
388/// assert!(rendered.contains("<template hx-swap-oob=\"true\"><div id=\"flash-message\""));
389/// // htmx inserts the carrier's child nodes for `innerHTML`, so the carrier
390/// // must be a `<div>` — a `<template>`'s children live in `.content`, giving
391/// // empty `childNodes`, so nothing would land in the DOM.
392/// assert!(rendered.contains("<div hx-swap-oob=\"innerHTML:#task-count\"><span>5</span></div>"));
393/// ```
394#[derive(Debug, Clone)]
395pub struct HtmxFragments {
396    primary: Option<maud::Markup>,
397    oob: Vec<OobFragment>,
398}
399
400#[cfg(feature = "maud")]
401impl HtmxFragments {
402    /// Create a new builder with a primary fragment.
403    #[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    /// Create a new empty builder (only OOB fragments).
412    #[must_use]
413    pub const fn oob_only() -> Self {
414        Self {
415            primary: None,
416            oob: Vec::new(),
417        }
418    }
419
420    /// Attach an out-of-band fragment using the default `OobSwap::True` strategy.
421    #[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    /// Attach an out-of-band fragment with a specific swap strategy.
427    #[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("&amp;"),
448            '"' => w.push_str("&quot;"),
449            '<' => w.push_str("&lt;"),
450            '>' => w.push_str("&gt;"),
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                // Child-node-inserting swaps must use a `<div>` carrier: htmx
512                // inserts the carrier's `childNodes` for the positional swaps
513                // (beforebegin/afterbegin/beforeend/afterend) *and* for
514                // `innerHTML`, and a `<template>` keeps its children in
515                // `.content` (empty `childNodes`), so the fragment would never
516                // land in the DOM (#1688). Element-replacing swaps (`true` /
517                // `outerHTML` by id) keep the `<template>` carrier htmx unwraps.
518                //
519                // Because htmx inserts the carrier's direct `childNodes`, a
520                // fragment given to such a swap must be a valid direct child of
521                // a `<div>`. Do NOT pass a bare `<tr>`/`<td>`/`<tbody>`/
522                // `<option>`/`<col>`: the HTML parser foster-parents those out
523                // of a `<div>`, so they vanish before htmx sees them.
524                // Table-row live swaps instead go through the element-replacing
525                // (`outerHTML` / `inserts_child_nodes() == false`) path with the
526                // id on the row itself (see `channels.rs::sse_oob_envelope`); a
527                // positional table-row fragment API is a documented follow-up.
528                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/// Extracts the `id` attribute value from the root HTML element in the given HTML string.
644///
645/// Looks for an `id` attribute within the root start tag (before the first `>`).
646/// The attribute name must be preceded by a whitespace boundary.
647#[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        // Fallback open/close wiring for browsers without the Invoker
747        // Commands API (command/commandfor), used by autumn_web::widgets::modal
748        // and confirm_action (issue #1233).
749        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        // The whole point of this widget is to replace the native
757        // window.confirm() dialog with a server-rendered, testable one.
758        assert!(!js.contains("window.confirm"), "{js}");
759    }
760
761    #[test]
762    fn widgets_js_polyfills_light_dismiss_backdrop_click() {
763        // PR review (chatgpt-codex-connector): closedby="any" (see
764        // ModalConfig::light_dismiss) has limited browser support, so
765        // backdrop clicks must be polyfilled for browsers that don't honor
766        // it natively yet — mirroring the command/commandfor fallback above.
767        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        // This value is invalid because it contains a newline character.
844        // It should be gracefully ignored by the append_hx_header function.
845        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() // valid by default
852            .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        // hx_refresh is always set to "true" internally, so it will be present
865        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        // Positional swaps (#1688) use a <div> carrier — NOT a <template> —
923        // because htmx inserts the carrier's childNodes, which are empty for a
924        // <template> (its children live in .content).
925        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    /// Regression for #1688: a positional OOB swap must emit a carrier whose own
936    /// `childNodes` htmx can iterate and insert. This models htmx's positional
937    /// insert (which reads `carrier.childNodes`, empty for a `<template>` since
938    /// its children live in `.content`) rather than only asserting the string.
939    #[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        // Render each positional strategy and confirm the carrier is a <div>
946        // (whose childNodes are the fragment) and never a <template>.
947        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            // Model htmx's positional insert: the nodes it appends are the
970            // carrier's *direct children* (the text between the carrier's open
971            // and close tags). For this <div> carrier those children are the
972            // rendered fragment; a <template> carrier would expose no childNodes
973            // here and htmx would append nothing.
974            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        // outerHTML/by-id (`true`) swaps still use the <template> carrier that
985        // htmx unwraps — this is deliberately unchanged.
986        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    /// Regression (same class as #1688): an `innerHTML` OOB swap must also emit
1000    /// a `<div>` carrier, not a `<template>`. htmx's `innerHTML` swap is
1001    /// non-inline and iterates the carrier's `childNodes` (only `outerHTML` /
1002    /// `true` unwrap the carrier by id), so a `<template>` carrier — whose
1003    /// children live in `.content` — would insert nothing at runtime. This
1004    /// mirrors `channels.rs::sse_oob_envelope`, which already wraps `innerHTML`
1005    /// in a `<div>`.
1006    #[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        // Both the shorthand `InnerHTML` and the targeted
1012        // `Target(InnerHTML, …)` strategy must use a <div> carrier.
1013        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            // Model htmx's innerHTML insert: the nodes it appends are the
1037            // carrier's *direct children* (the text between the carrier's open
1038            // and close tags). For this <div> carrier those children are the
1039            // rendered fragment; a <template> carrier would expose no childNodes
1040            // here and htmx would insert nothing.
1041            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        // Should not contain any stray wrapper or primary body, only the OOB fragment template
1067        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        // Already contains hx-swap-oob in markup
1079        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        // Should not be wrapped in template
1089        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        // True cases
1115        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        // False cases
1127        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        // Non-empty ID case
1158        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}