//! The htmx side of the contract, in one place. //! //! htmx is the webview transport, and it sits *below* the description: nothing //! in [`quasi_router`] names it, and the description never will. What is here //! is the small amount an HTTP host has to know to speak it correctly, which is //! a handful of response headers. //! //! # Which htmx this speaks //! //! htmx 4, and not 2.x. quasi is pre-release, so writing the transport //! against a model that is being replaced means writing it twice; 4.0 is in //! beta with no announced stable date and the bet is deliberate. Every header //! named below is read by 4.0.0-beta6, checked against its source rather than //! against the migration guide. //! //! One 4 behaviour is load-bearing here and is the reason a whole constant //! left this module: **every response swaps except 204 and 304**. Under 2.x //! the default `responseHandling` dropped a 4xx, so decision 9's classified //! errors (403 `Denied`, 404 `NotFound`) rendered nothing at all and a //! `htmx-config` meta tag turning the swap back on was a required piece of the //! webview adapter. Under 4 it is the default, `responseHandling` no longer //! exists as a config key, and a screen wanting per-status behaviour says so //! on the element with `hx-status:XXX`. //! //! This belonged to the webview transport rather than to axum specifically, //! and lived in `quasi-axum` while axum was the only caller. The Tauri //! custom-protocol adapter is the second, so it moved here as its own doc //! comment said it would. use quasi_router::layout; /// The header naming the element a response replaces. /// /// Set from [`Response::Fragment`](quasi_router::Response::Fragment), because /// the router is the only party that knows what it just changed. htmx wants a /// CSS selector, so a [`Slot::id`](quasi_router::Slot::id) of `detail` is sent /// as `#detail`, and the webview renderer owes every slot a matching `id`. pub const RETARGET: &str = "HX-Retarget"; /// The header sending the user to another route in this app. /// /// Set from [`Outcome::Goto`](quasi_router::Outcome::Goto) with a /// [`Destination::Route`](quasi_router::Destination::Route). htmx issues the /// request itself and swaps the body, so history and the back button work and /// the page is not torn down. /// /// Deliberately not a 303. A `fetch` follows a redirect before htmx sees the /// headers, so the client would swap the destination's body into whatever /// element the control targeted, which is a fragment-shaped swap of a whole /// screen. The status stays 200 with an empty body, and this header is the /// whole answer. pub const LOCATION: &str = "HX-Location"; /// The header handing the user to something that is not this app. /// /// Set from [`Outcome::Goto`](quasi_router::Outcome::Goto) with a /// [`Destination::External`](quasi_router::Destination::External). htmx assigns /// `window.location`, which is a real navigation and the only thing that can /// reach a `file://` or a `mailto:`. /// /// The split from [`LOCATION`] is the difference between going somewhere this /// router answers and leaving. Only the first can be a swap. pub const REDIRECT: &str = "HX-Redirect"; /// The header htmx sets on every request it makes. /// /// Read to tell an XHR from a navigation, which is the one fact about the /// envelope that changes what an answer may be: a navigation can be handed any /// bytes at all, and htmx reads a response as a UTF-8 string. See /// [`is_text`](crate::respond) -- the file arm of `respond` is the only place /// this is consulted. /// /// Presence is the whole test. htmx sets it to `true` and nothing sets it to /// anything else, and a client that lies about it gets a refusal it could have /// had by not lying. pub const REQUEST: &str = "HX-Request"; /// The header carrying a client-side event, used here for a notice. /// /// Set from [`Response::notice`](quasi_router::Response::notice). The value is /// JSON naming one event, `quasi:notice`, whose detail is the kind, tone and /// text. A client listens once and shows the message however that host shows /// messages. /// /// A header rather than markup in the body, because a notice is orthogonal to /// the outcome: it has to survive a redirect, which has no body at all, and it /// must not be mistaken for the content of the region being replaced. pub const TRIGGER: &str = "HX-Trigger"; /// The event name [`TRIGGER`] carries. pub const NOTICE_EVENT: &str = "quasi:notice"; /// The header saying this answer is a new place in history. /// /// Derived rather than described. A `GET` of a /// [`Destination::Route`](quasi_router::Destination::Route) answering with a /// whole screen is a place, and that is the whole of the common case: the /// address is the request's own, so nothing has to be computed and no control /// has to predict what its answer will be. /// /// [`Address`](quasi_router::Address) is the override, for the answers the /// derivation cannot reach. A fragment that is a place says /// `.at(url)`; a screen that is not says `.in_place()`. /// /// Never emitted as markup. `hx-push-url` on a control is the same fact decided /// a step too early, by the party that does not know it yet. /// /// htmx 4 keeps no snapshot in `localStorage`, so going back re-requests the /// address rather than restoring a cached body. Nothing here has to change for /// that -- the address was always the whole of what is pushed -- but a route /// answering one of these is answering it again on every back, which is a /// reason to keep such an answer cheap rather than a reason to push less. pub const PUSH_URL: &str = "HX-Push-Url"; /// The header saying this answer is a place that takes the current slot. /// /// [`PUSH_URL`]'s sibling, from /// [`Address::Replaces`](quasi_router::Address::Replaces). The address moves /// and history does not grow, which is what a filter over a list wants: the /// back button should leave the list, not walk back through the filters. pub const REPLACE_URL: &str = "HX-Replace-Url"; /// The header naming how a response is put in place. /// /// Not set by this adapter. How a swap happens is the renderer's business and /// travels with the element, and a host overriding it per response is how two /// parties end up deciding one thing. pub const RESWAP: &str = "HX-Reswap"; /// The [`TRIGGER`] value for one notice, as JSON. /// /// Hand-built rather than through a serialiser, because every part but the text /// and the undo's address is a fixed literal chosen here. That makes [`escape`] /// the whole of the correctness argument, and it is tested directly. /// /// # The undo, and why it is a route rather than an action /// /// [`Message::undo`] is a way back offered alongside the sentence, and this is /// the one path that carries a message without building a node out of it -- a /// client listens for the event and draws the message however that host draws /// messages, so it is the client that puts a control on it. /// /// `undo` is absent when there is none, so a client written before this reads /// the same object it always read. What it carries is the label and an address /// to `POST`, which is what an undo is: `Action::params` and `Action::carried` /// are not sent, because nothing in the tree raises an undo that has any and a /// half-serialised action is worse than an omitted one. A route that needs /// values in its way back says so with a screen rather than a notice. /// /// An [external destination](quasi_router::Destination::External) is dropped /// for the same reason: an undo is this router's own route, and handing a /// client somewhere else to send a write is not what the member means. /// /// [`Message::undo`]: quasi_router::Message::undo #[must_use] pub fn notice_trigger( kind: layout::Notice, tone: layout::Tone, text: &str, undo: Option<&quasi_router::Action>, ) -> String { let kind = match kind { layout::Notice::Toast => "toast", layout::Notice::Banner => "banner", }; let tone = match tone { layout::Tone::Neutral => "neutral", layout::Tone::Info => "info", layout::Tone::Success => "success", layout::Tone::Warning => "warning", layout::Tone::Danger => "danger", }; let undo = undo .and_then(|action| action.destination.route()) .map(|route| { format!( r#","undo":{{"label":"{}","route":"{}"}}"#, escape(quasi_router::Message::UNDO), escape(route) ) }) .unwrap_or_default(); format!( r#"{{"{NOTICE_EVENT}":{{"kind":"{kind}","tone":"{tone}","text":"{}"{undo}}}}}"#, escape(text) ) } /// A string as a JSON string body, without the quotes. /// /// A header value cannot hold a control character, so the escapes that exist to /// keep JSON parseable are also what keep the header legal. Anything below /// space goes to `\u00XX` rather than being dropped, because dropping it would /// silently change the message. fn escape(text: &str) -> String { let mut out = String::with_capacity(text.len()); for ch in text.chars() { match ch { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), c if (c as u32) < 0x20 => { use std::fmt::Write as _; let _ = write!(out, "\\u{:04x}", c as u32); } c => out.push(c), } } out } #[cfg(test)] mod tests { use super::*; #[test] fn a_quote_in_a_message_cannot_end_the_json_string() { // The one thing an app supplies, and the reason this is not a format! // with the text dropped straight in. let json = notice_trigger( layout::Notice::Toast, layout::Tone::Success, r#"Deleted "Q3 plan""#, None, ); assert!(json.contains(r#"Deleted \"Q3 plan\""#)); assert_eq!(json.matches(r#"","#).count(), 2); } #[test] fn a_backslash_does_not_escape_the_quote_after_it() { // `C:\` followed by the closing quote is the case a naive quote-only // escaper turns into `C:\"`, which ends the string one character early. let json = notice_trigger(layout::Notice::Toast, layout::Tone::Info, r"C:\", None); assert!(json.contains(r#""text":"C:\\""#)); } #[test] fn a_notice_with_a_way_back_carries_it_and_one_without_says_nothing() { // `bde35298`. This is the one path that hands a message over without // building a node out of it, so the undo has to ride in the payload or // the client has nothing to draw a control from. let with = notice_trigger( layout::Notice::Toast, layout::Tone::Success, "Deleted", Some(&quasi_router::Action::post("/tasks/7/restore")), ); assert!( with.contains(r#""undo":{"label":"Undo","route":"/tasks/7/restore"}"#), "{with}" ); // Absent rather than null, so a client written before this reads the // object it always read. let without = notice_trigger( layout::Notice::Toast, layout::Tone::Success, "Deleted", None, ); assert!(!without.contains("undo"), "{without}"); // Somewhere else is not a way back: an undo is this router's own route. let outside = notice_trigger( layout::Notice::Toast, layout::Tone::Success, "Deleted", Some(&quasi_router::Action::external("https://example.com/back")), ); assert!(!outside.contains("undo"), "{outside}"); } #[test] fn a_newline_cannot_reach_the_header_value() { // A raw newline is both invalid JSON and an illegal header value, which // is header injection if it survives. let json = notice_trigger( layout::Notice::Banner, layout::Tone::Danger, "one\r\ntwo", None, ); assert!(!json.contains('\n')); assert!(!json.contains('\r')); assert!(json.contains(r"one\r\ntwo")); } #[test] fn a_control_character_is_kept_rather_than_dropped() { let json = notice_trigger( layout::Notice::Toast, layout::Tone::Neutral, "a\u{1}b", None, ); assert!(json.contains(r"a\u0001b")); } #[test] fn every_tone_and_kind_has_a_spelling() { // A new Tone member added upstream fails to compile here rather than // reaching a client as a tone nothing styles. for tone in [ layout::Tone::Neutral, layout::Tone::Info, layout::Tone::Success, layout::Tone::Warning, layout::Tone::Danger, ] { for kind in [layout::Notice::Toast, layout::Notice::Banner] { let json = notice_trigger(kind, tone, "x", None); assert!(json.starts_with(&format!(r#"{{"{NOTICE_EVENT}":"#))); assert!(!json.contains(r#""""#), "{json} has an empty spelling"); } } } }