max / quasi
| 1 | //! The htmx side of the contract, in one place. |
| 2 | //! |
| 3 | //! htmx is the webview transport, and it sits *below* the description: nothing |
| 4 | //! in [`quasi_router`] names it, and the description never will. What is here |
| 5 | //! is the small amount an HTTP host has to know to speak it correctly, which is |
| 6 | //! a handful of response headers. |
| 7 | //! |
| 8 | //! # Which htmx this speaks |
| 9 | //! |
| 10 | //! htmx 4, and not 2.x. quasi is pre-release, so writing the transport |
| 11 | //! against a model that is being replaced means writing it twice; 4.0 is in |
| 12 | //! beta with no announced stable date and the bet is deliberate. Every header |
| 13 | //! named below is read by 4.0.0-beta6, checked against its source rather than |
| 14 | //! against the migration guide. |
| 15 | //! |
| 16 | //! One 4 behaviour is load-bearing here and is the reason a whole constant |
| 17 | //! left this module: **every response swaps except 204 and 304**. Under 2.x |
| 18 | //! the default `responseHandling` dropped a 4xx, so decision 9's classified |
| 19 | //! errors (403 `Denied`, 404 `NotFound`) rendered nothing at all and a |
| 20 | //! `htmx-config` meta tag turning the swap back on was a required piece of the |
| 21 | //! webview adapter. Under 4 it is the default, `responseHandling` no longer |
| 22 | //! exists as a config key, and a screen wanting per-status behaviour says so |
| 23 | //! on the element with `hx-status:XXX`. |
| 24 | //! |
| 25 | //! This belonged to the webview transport rather than to axum specifically, |
| 26 | //! and lived in `quasi-axum` while axum was the only caller. The Tauri |
| 27 | //! custom-protocol adapter is the second, so it moved here as its own doc |
| 28 | //! comment said it would. |
| 29 | |
| 30 | use layout; |
| 31 | |
| 32 | /// The header naming the element a response replaces. |
| 33 | /// |
| 34 | /// Set from [`Response::Fragment`](quasi_router::Response::Fragment), because |
| 35 | /// the router is the only party that knows what it just changed. htmx wants a |
| 36 | /// CSS selector, so a [`Slot::id`](quasi_router::Slot::id) of `detail` is sent |
| 37 | /// as `#detail`, and the webview renderer owes every slot a matching `id`. |
| 38 | pub const RETARGET: &str = "HX-Retarget"; |
| 39 | |
| 40 | /// The header sending the user to another route in this app. |
| 41 | /// |
| 42 | /// Set from [`Outcome::Goto`](quasi_router::Outcome::Goto) with a |
| 43 | /// [`Destination::Route`](quasi_router::Destination::Route). htmx issues the |
| 44 | /// request itself and swaps the body, so history and the back button work and |
| 45 | /// the page is not torn down. |
| 46 | /// |
| 47 | /// Deliberately not a 303. A `fetch` follows a redirect before htmx sees the |
| 48 | /// headers, so the client would swap the destination's body into whatever |
| 49 | /// element the control targeted, which is a fragment-shaped swap of a whole |
| 50 | /// screen. The status stays 200 with an empty body, and this header is the |
| 51 | /// whole answer. |
| 52 | pub const LOCATION: &str = "HX-Location"; |
| 53 | |
| 54 | /// The header handing the user to something that is not this app. |
| 55 | /// |
| 56 | /// Set from [`Outcome::Goto`](quasi_router::Outcome::Goto) with a |
| 57 | /// [`Destination::External`](quasi_router::Destination::External). htmx assigns |
| 58 | /// `window.location`, which is a real navigation and the only thing that can |
| 59 | /// reach a `file://` or a `mailto:`. |
| 60 | /// |
| 61 | /// The split from [`LOCATION`] is the difference between going somewhere this |
| 62 | /// router answers and leaving. Only the first can be a swap. |
| 63 | pub const REDIRECT: &str = "HX-Redirect"; |
| 64 | |
| 65 | /// The header htmx sets on every request it makes. |
| 66 | /// |
| 67 | /// Read to tell an XHR from a navigation, which is the one fact about the |
| 68 | /// envelope that changes what an answer may be: a navigation can be handed any |
| 69 | /// bytes at all, and htmx reads a response as a UTF-8 string. See |
| 70 | /// [`is_text`](crate::respond) -- the file arm of `respond` is the only place |
| 71 | /// this is consulted. |
| 72 | /// |
| 73 | /// Presence is the whole test. htmx sets it to `true` and nothing sets it to |
| 74 | /// anything else, and a client that lies about it gets a refusal it could have |
| 75 | /// had by not lying. |
| 76 | pub const REQUEST: &str = "HX-Request"; |
| 77 | |
| 78 | /// The header carrying a client-side event, used here for a notice. |
| 79 | /// |
| 80 | /// Set from [`Response::notice`](quasi_router::Response::notice). The value is |
| 81 | /// JSON naming one event, `quasi:notice`, whose detail is the kind, tone and |
| 82 | /// text. A client listens once and shows the message however that host shows |
| 83 | /// messages. |
| 84 | /// |
| 85 | /// A header rather than markup in the body, because a notice is orthogonal to |
| 86 | /// the outcome: it has to survive a redirect, which has no body at all, and it |
| 87 | /// must not be mistaken for the content of the region being replaced. |
| 88 | pub const TRIGGER: &str = "HX-Trigger"; |
| 89 | |
| 90 | /// The event name [`TRIGGER`] carries. |
| 91 | pub const NOTICE_EVENT: &str = "quasi:notice"; |
| 92 | |
| 93 | /// The header saying this answer is a new place in history. |
| 94 | /// |
| 95 | /// Derived rather than described. A `GET` of a |
| 96 | /// [`Destination::Route`](quasi_router::Destination::Route) answering with a |
| 97 | /// whole screen is a place, and that is the whole of the common case: the |
| 98 | /// address is the request's own, so nothing has to be computed and no control |
| 99 | /// has to predict what its answer will be. |
| 100 | /// |
| 101 | /// [`Address`](quasi_router::Address) is the override, for the answers the |
| 102 | /// derivation cannot reach. A fragment that is a place says |
| 103 | /// `.at(url)`; a screen that is not says `.in_place()`. |
| 104 | /// |
| 105 | /// Never emitted as markup. `hx-push-url` on a control is the same fact decided |
| 106 | /// a step too early, by the party that does not know it yet. |
| 107 | /// |
| 108 | /// htmx 4 keeps no snapshot in `localStorage`, so going back re-requests the |
| 109 | /// address rather than restoring a cached body. Nothing here has to change for |
| 110 | /// that -- the address was always the whole of what is pushed -- but a route |
| 111 | /// answering one of these is answering it again on every back, which is a |
| 112 | /// reason to keep such an answer cheap rather than a reason to push less. |
| 113 | pub const PUSH_URL: &str = "HX-Push-Url"; |
| 114 | |
| 115 | /// The header saying this answer is a place that takes the current slot. |
| 116 | /// |
| 117 | /// [`PUSH_URL`]'s sibling, from |
| 118 | /// [`Address::Replaces`](quasi_router::Address::Replaces). The address moves |
| 119 | /// and history does not grow, which is what a filter over a list wants: the |
| 120 | /// back button should leave the list, not walk back through the filters. |
| 121 | pub const REPLACE_URL: &str = "HX-Replace-Url"; |
| 122 | |
| 123 | /// The header naming how a response is put in place. |
| 124 | /// |
| 125 | /// Not set by this adapter. How a swap happens is the renderer's business and |
| 126 | /// travels with the element, and a host overriding it per response is how two |
| 127 | /// parties end up deciding one thing. |
| 128 | pub const RESWAP: &str = "HX-Reswap"; |
| 129 | |
| 130 | /// The [`TRIGGER`] value for one notice, as JSON. |
| 131 | /// |
| 132 | /// Hand-built rather than through a serialiser, because every part but the text |
| 133 | /// and the undo's address is a fixed literal chosen here. That makes [`escape`] |
| 134 | /// the whole of the correctness argument, and it is tested directly. |
| 135 | /// |
| 136 | /// # The undo, and why it is a route rather than an action |
| 137 | /// |
| 138 | /// [`Message::undo`] is a way back offered alongside the sentence, and this is |
| 139 | /// the one path that carries a message without building a node out of it -- a |
| 140 | /// client listens for the event and draws the message however that host draws |
| 141 | /// messages, so it is the client that puts a control on it. |
| 142 | /// |
| 143 | /// `undo` is absent when there is none, so a client written before this reads |
| 144 | /// the same object it always read. What it carries is the label and an address |
| 145 | /// to `POST`, which is what an undo is: `Action::params` and `Action::carried` |
| 146 | /// are not sent, because nothing in the tree raises an undo that has any and a |
| 147 | /// half-serialised action is worse than an omitted one. A route that needs |
| 148 | /// values in its way back says so with a screen rather than a notice. |
| 149 | /// |
| 150 | /// An [external destination](quasi_router::Destination::External) is dropped |
| 151 | /// for the same reason: an undo is this router's own route, and handing a |
| 152 | /// client somewhere else to send a write is not what the member means. |
| 153 | /// |
| 154 | /// [`Message::undo`]: quasi_router::Message::undo |
| 155 | |
| 156 | |
| 157 | kind: Notice, |
| 158 | tone: Tone, |
| 159 | text: &str, |
| 160 | undo: , |
| 161 | |
| 162 | let kind = match kind |
| 163 | Toast => "toast", |
| 164 | Banner => "banner", |
| 165 | ; |
| 166 | let tone = match tone |
| 167 | Neutral => "neutral", |
| 168 | Info => "info", |
| 169 | Success => "success", |
| 170 | Warning => "warning", |
| 171 | Danger => "danger", |
| 172 | ; |
| 173 | let undo = undo |
| 174 | .and_then |
| 175 | .map |
| 176 | format! |
| 177 | r#","undo":{{"label":"{}","route":"{}"}}"# |
| 178 | escape, |
| 179 | escape |
| 180 | ) |
| 181 | |
| 182 | .unwrap_or_default; |
| 183 | format! |
| 184 | r#"{{"{NOTICE_EVENT}":{{"kind":"{kind}","tone":"{tone}","text":"{}"{undo}}}}}"# |
| 185 | escape |
| 186 | ) |
| 187 | |
| 188 | |
| 189 | /// A string as a JSON string body, without the quotes. |
| 190 | /// |
| 191 | /// A header value cannot hold a control character, so the escapes that exist to |
| 192 | /// keep JSON parseable are also what keep the header legal. Anything below |
| 193 | /// space goes to `\u00XX` rather than being dropped, because dropping it would |
| 194 | /// silently change the message. |
| 195 | |
| 196 | let mut out = Stringwith_capacity; |
| 197 | for ch in text.chars |
| 198 | match ch |
| 199 | '"' => out.push_str, |
| 200 | '\\' => out.push_str, |
| 201 | '\n' => out.push_str, |
| 202 | '\r' => out.push_str, |
| 203 | '\t' => out.push_str, |
| 204 | c if < 0x20 => |
| 205 | use Write as _; |
| 206 | let _ = write!; |
| 207 | |
| 208 | c => out.push, |
| 209 | |
| 210 | |
| 211 | out |
| 212 | |
| 213 | |
| 214 | |
| 215 | |
| 216 | use *; |
| 217 | |
| 218 | |
| 219 | |
| 220 | // The one thing an app supplies, and the reason this is not a format! |
| 221 | // with the text dropped straight in. |
| 222 | let json = notice_trigger |
| 223 | Toast, |
| 224 | Success, |
| 225 | r#"Deleted "Q3 plan""#, |
| 226 | None, |
| 227 | ; |
| 228 | assert!; |
| 229 | assert_eq!; |
| 230 | |
| 231 | |
| 232 | |
| 233 | |
| 234 | // `C:\` followed by the closing quote is the case a naive quote-only |
| 235 | // escaper turns into `C:\"`, which ends the string one character early. |
| 236 | let json = notice_trigger; |
| 237 | assert!; |
| 238 | |
| 239 | |
| 240 | |
| 241 | |
| 242 | // `bde35298`. This is the one path that hands a message over without |
| 243 | // building a node out of it, so the undo has to ride in the payload or |
| 244 | // the client has nothing to draw a control from. |
| 245 | let with = notice_trigger |
| 246 | Toast, |
| 247 | Success, |
| 248 | "Deleted", |
| 249 | Some, |
| 250 | ; |
| 251 | assert! |
| 252 | with.contains, |
| 253 | "{with}" |
| 254 | ; |
| 255 | |
| 256 | // Absent rather than null, so a client written before this reads the |
| 257 | // object it always read. |
| 258 | let without = notice_trigger |
| 259 | Toast, |
| 260 | Success, |
| 261 | "Deleted", |
| 262 | None, |
| 263 | ; |
| 264 | assert!; |
| 265 | |
| 266 | // Somewhere else is not a way back: an undo is this router's own route. |
| 267 | let outside = notice_trigger |
| 268 | Toast, |
| 269 | Success, |
| 270 | "Deleted", |
| 271 | Some, |
| 272 | ; |
| 273 | assert!; |
| 274 | |
| 275 | |
| 276 | |
| 277 | |
| 278 | // A raw newline is both invalid JSON and an illegal header value, which |
| 279 | // is header injection if it survives. |
| 280 | let json = notice_trigger |
| 281 | Banner, |
| 282 | Danger, |
| 283 | "one\r\ntwo", |
| 284 | None, |
| 285 | ; |
| 286 | assert!; |
| 287 | assert!; |
| 288 | assert!; |
| 289 | |
| 290 | |
| 291 | |
| 292 | |
| 293 | let json = notice_trigger |
| 294 | Toast, |
| 295 | Neutral, |
| 296 | "a\u{1}b", |
| 297 | None, |
| 298 | ; |
| 299 | assert!; |
| 300 | |
| 301 | |
| 302 | |
| 303 | |
| 304 | // A new Tone member added upstream fails to compile here rather than |
| 305 | // reaching a client as a tone nothing styles. |
| 306 | for tone in |
| 307 | Neutral, |
| 308 | Info, |
| 309 | Success, |
| 310 | Warning, |
| 311 | Danger, |
| 312 | ] |
| 313 | for kind in |
| 314 | let json = notice_trigger; |
| 315 | assert!; |
| 316 | assert!; |
| 317 | |
| 318 | |
| 319 | |
| 320 | |
| 321 |