max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+136 insertions,
-29 deletions
| @@ -203,17 +203,7 @@ | |||
| 203 | 203 | /// written: a `mailto:` or a `file://` has no query string this router built. | |
| 204 | 204 | fn redirect(action: &Action) -> http::Response<Vec<u8>> { | |
| 205 | 205 | let (header, address) = match &action.destination { | |
| 206 | - | Destination::Route(path) if action.params.is_empty() => (htmx::LOCATION, path.clone()), | |
| 207 | - | Destination::Route(path) => { | |
| 208 | - | // Encoded here rather than in the router, for the reason the router | |
| 209 | - | // does not decode: the host has this code already and a second | |
| 210 | - | // implementation is a second place for an escaping bug. | |
| 211 | - | let query = form_urlencoded::Serializer::new(String::new()) | |
| 212 | - | .extend_pairs(action.params.iter()) | |
| 213 | - | .finish(); | |
| 214 | - | let joiner = if path.contains('?') { '&' } else { '?' }; | |
| 215 | - | (htmx::LOCATION, format!("{path}{joiner}{query}")) | |
| 216 | - | } | |
| 206 | + | Destination::Route(path) => (htmx::LOCATION, route_url(path, &action.params)), | |
| 217 | 207 | Destination::External(address) => (htmx::REDIRECT, address.clone()), | |
| 218 | 208 | }; | |
| 219 | 209 | let mut builder = http::Response::builder().status(200); | |
| @@ -225,6 +215,29 @@ | |||
| 225 | 215 | .expect("a response with no body and one checked header is always valid") | |
| 226 | 216 | } | |
| 227 | 217 | ||
| 218 | + | /// A route with its parameters folded into a query string. | |
| 219 | + | /// | |
| 220 | + | /// Encoded here rather than in the router, for the reason the router does not | |
| 221 | + | /// decode: the host has this code already and a second implementation is a | |
| 222 | + | /// second place for an escaping bug. Public because the renderers need the same | |
| 223 | + | /// answer the redirect does. A read that a renderer writes into an `href` and a | |
| 224 | + | /// redirect back to a filtered list are the same address, and two functions | |
| 225 | + | /// building it is how they stop being. | |
| 226 | + | /// | |
| 227 | + | /// A route with no parameters is returned as written, so nothing gains a | |
| 228 | + | /// trailing `?` it did not have. | |
| 229 | + | #[must_use] | |
| 230 | + | pub fn route_url(path: &str, params: &Params) -> String { | |
| 231 | + | if params.is_empty() { | |
| 232 | + | return path.to_owned(); | |
| 233 | + | } | |
| 234 | + | let query = form_urlencoded::Serializer::new(String::new()) | |
| 235 | + | .extend_pairs(params.iter()) | |
| 236 | + | .finish(); | |
| 237 | + | let joiner = if path.contains('?') { '&' } else { '?' }; | |
| 238 | + | format!("{path}{joiner}{query}") | |
| 239 | + | } | |
| 240 | + | ||
| 228 | 241 | /// Turn the adapter's own refusal into a response. | |
| 229 | 242 | /// | |
| 230 | 243 | /// No body, because there is nothing to say that the status does not already |
| @@ -222,6 +222,20 @@ | |||
| 222 | 222 | return; | |
| 223 | 223 | } | |
| 224 | 224 | ||
| 225 | + | // A read of a route this app answers is a link, and it gets the address as | |
| 226 | + | // well as the transport. htmx uses `hx-get` and prevents the default, so | |
| 227 | + | // the `href` is what everything else uses: middle-click, copy-link, a | |
| 228 | + | // crawler, and the page with JS off. The parameters are folded into it | |
| 229 | + | // because a link to a filtered list that drops the filter is a different | |
| 230 | + | // place, and `hx-vals` below carries the same ones down htmx's path. | |
| 231 | + | if let Destination::Route(path) = &action.destination | |
| 232 | + | && !action.method.mutates() | |
| 233 | + | { | |
| 234 | + | out.push_str(" href=\""); | |
| 235 | + | out.push_str(&escape(&quasi_http::route_url(path, &action.params))); | |
| 236 | + | out.push('"'); | |
| 237 | + | } | |
| 238 | + | ||
| 225 | 239 | let verb = match action.method { | |
| 226 | 240 | Method::Get => " hx-get=\"", | |
| 227 | 241 | Method::Post => " hx-post=\"", | |
| @@ -264,18 +278,32 @@ | |||
| 264 | 278 | } | |
| 265 | 279 | } | |
| 266 | 280 | ||
| 281 | + | /// Whether an action is somewhere to go rather than something to do. | |
| 282 | + | /// | |
| 283 | + | /// Two ways to be a link. An external destination leaves. A read of a route | |
| 284 | + | /// this app answers is also a link: it has an address, it can be visited | |
| 285 | + | /// directly, and nothing changes because it was. | |
| 286 | + | /// | |
| 287 | + | /// A write is never a link however it is spelled, which is the whole of the | |
| 288 | + | /// other side. An anchor is something a browser may prefetch and a crawler will | |
| 289 | + | /// follow, and neither is allowed to delete a task. | |
| 290 | + | const fn is_link(action: &Action) -> bool { | |
| 291 | + | action.destination.is_external() || !action.method.mutates() | |
| 292 | + | } | |
| 293 | + | ||
| 267 | 294 | /// The element a control becomes. | |
| 268 | 295 | /// | |
| 269 | - | /// A route is a button: it calls this app and something here answers. An | |
| 270 | - | /// external destination is an anchor: it leaves, and a button that navigates | |
| 271 | - | /// away is a button lying to everything that reads the page, middle-click and | |
| 272 | - | /// screen readers included. | |
| 296 | + | /// A link is an anchor and a write is a button, and a button that navigates is | |
| 297 | + | /// a button lying to everything that reads the page: middle-click, copy-link, | |
| 298 | + | /// a crawler and a screen reader included. This keyed on external-or-not until | |
| 299 | + | /// the read case was separated out, which made every internal navigation a | |
| 300 | + | /// control that only worked by running JavaScript first. | |
| 273 | 301 | /// | |
| 274 | 302 | /// Branching on the [`Destination`] variant and never on the shape of the | |
| 275 | 303 | /// string is the rule `Destination`'s own docs set. "Starts with https" is how a | |
| 276 | 304 | /// route named `/https-setup` ends up opening a browser. | |
| 277 | 305 | const fn control_tag(action: &Action) -> (&'static str, &'static str) { | |
| 278 | - | if action.destination.is_external() { | |
| 306 | + | if is_link(action) { | |
| 279 | 307 | ("<a", "</a>") | |
| 280 | 308 | } else { | |
| 281 | 309 | ("<button type=\"button\"", "</button>") | |
| @@ -304,7 +332,7 @@ | |||
| 304 | 332 | // actually stops it: an `<a>` without one is not a link, so it | |
| 305 | 333 | // drops out of the tab order on its own. `aria-disabled` is what | |
| 306 | 334 | // says why, since a bare span-shaped anchor says nothing. | |
| 307 | - | if act.action.destination.is_external() { | |
| 335 | + | if is_link(&act.action) { | |
| 308 | 336 | out.push_str(" aria-disabled=\"true\""); | |
| 309 | 337 | } else { | |
| 310 | 338 | out.push_str(" disabled"); | |
| @@ -832,10 +860,17 @@ | |||
| 832 | 860 | ||
| 833 | 861 | if interactive { | |
| 834 | 862 | if latched { | |
| 835 | - | // A chip standing for a filter is on or off, which is what | |
| 836 | - | // aria-pressed means. Its latched class carries the same fact | |
| 837 | - | // visually through Depth::pressed. | |
| 838 | - | out.push_str(" aria-pressed=\"true\""); | |
| 863 | + | // A chip standing for a filter is on or off, and its latched class | |
| 864 | + | // carries that fact visually through Depth::pressed either way. | |
| 865 | + | // Which word says it depends on what the chip turned out to be: | |
| 866 | + | // aria-pressed is a button's state and means nothing on an anchor, | |
| 867 | + | // and a link that is the view you are looking at is the one thing | |
| 868 | + | // aria-current exists to say. | |
| 869 | + | if action.is_some_and(is_link) { | |
| 870 | + | out.push_str(" aria-current=\"true\""); | |
| 871 | + | } else { | |
| 872 | + | out.push_str(" aria-pressed=\"true\""); | |
| 873 | + | } | |
| 839 | 874 | } | |
| 840 | 875 | if let Some(action) = action { | |
| 841 | 876 | action_attrs(action, Fires::Click, None, morphs, out); |
| @@ -87,6 +87,35 @@ | |||
| 87 | 87 | assert!(!post.contains("hx-get")); | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | + | #[test] | |
| 91 | + | fn a_read_of_a_route_is_a_link_and_a_write_is_not() { | |
| 92 | + | // A GET to a route this app answers is a link in every host, and it was a | |
| 93 | + | // `<button hx-get>` until 2026-08-10: a control that only worked once | |
| 94 | + | // JavaScript had run, with no middle-click, no copy-link and nothing for a | |
| 95 | + | // crawler. The anchor costs the webview host nothing, because htmx still | |
| 96 | + | // has its own attributes and prevents the default. | |
| 97 | + | let read = fragment(&Node::act("Open", Action::get("/tasks/1"))); | |
| 98 | + | assert!(read.contains("<a ")); | |
| 99 | + | assert!(read.contains("href=\"/tasks/1\"")); | |
| 100 | + | assert!(read.contains("hx-get=\"/tasks/1\"")); | |
| 101 | + | assert!(!read.contains("<button")); | |
| 102 | + | ||
| 103 | + | // The other half, and the reason this is not simply "GET is an anchor | |
| 104 | + | // everywhere": a write is never a link however it is spelled. An anchor is | |
| 105 | + | // something a browser may prefetch and a crawler will follow, and neither | |
| 106 | + | // is allowed to delete a task. | |
| 107 | + | let write = fragment(&Node::act("Delete", Action::post("/tasks/1/delete"))); | |
| 108 | + | assert!(write.contains("<button")); | |
| 109 | + | assert!(!write.contains("href")); | |
| 110 | + | ||
| 111 | + | // An external address is still a plain link that leaves, with no transport | |
| 112 | + | // on it at all. | |
| 113 | + | let away = fragment(&Node::act("Docs", Action::external("https://example.com"))); | |
| 114 | + | assert!(away.contains("href=\"https://example.com\"")); | |
| 115 | + | assert!(away.contains("rel=\"noopener noreferrer\"")); | |
| 116 | + | assert!(!away.contains("hx-get")); | |
| 117 | + | } | |
| 118 | + | ||
| 90 | 119 | #[test] | |
| 91 | 120 | fn params_travel_as_hx_vals_not_as_a_query_string() { | |
| 92 | 121 | let action = Action::get("/tasks") | |
| @@ -94,11 +123,17 @@ | |||
| 94 | 123 | .with("sort", "due"); | |
| 95 | 124 | let html = fragment(&Node::act("Filter", action)); | |
| 96 | 125 | ||
| 126 | + | // The transport carries them as values and htmx folds them in itself, so | |
| 127 | + | // nothing here concatenates a `?` into what is sent. | |
| 97 | 128 | assert!(html.contains("hx-get=\"/tasks\"")); | |
| 98 | - | assert!(!html.contains('?')); | |
| 99 | 129 | assert!(html.contains( | |
| 100 | 130 | "hx-vals=\"{"filter":"open","sort":"due"}\"" | |
| 101 | 131 | )); | |
| 132 | + | ||
| 133 | + | // The href is the same address for the reader who is not htmx, and it | |
| 134 | + | // keeps the parameters: a link to a filtered list that drops the filter is | |
| 135 | + | // a different place. | |
| 136 | + | assert!(html.contains("href=\"/tasks?filter=open&sort=due\"")); | |
| 102 | 137 | } | |
| 103 | 138 | ||
| 104 | 139 | #[test] | |
| @@ -171,11 +206,14 @@ | |||
| 171 | 206 | ||
| 172 | 207 | #[test] | |
| 173 | 208 | fn a_row_is_a_control_only_when_selecting_it_does_something() { | |
| 209 | + | // Opening a row is a read of a route, so it is a link and has an address. | |
| 174 | 210 | let live = fragment(&Node::list([Row::new("One").activate(Action::get("/1"))])); | |
| 175 | - | assert!(live.contains("<button type=\"button\" class=\"row-activate\"")); | |
| 211 | + | assert!(live.contains("<a class=\"row-activate\"")); | |
| 212 | + | assert!(live.contains("href=\"/1\"")); | |
| 176 | 213 | ||
| 177 | 214 | let inert = fragment(&Node::list([Row::new("One")])); | |
| 178 | 215 | assert!(!inert.contains("<button")); | |
| 216 | + | assert!(!inert.contains("<a ")); | |
| 179 | 217 | assert!(inert.contains("<span class=\"row-primary\">One</span>")); | |
| 180 | 218 | } | |
| 181 | 219 | ||
| @@ -330,7 +368,8 @@ | |||
| 330 | 368 | )], | |
| 331 | 369 | }); | |
| 332 | 370 | ||
| 333 | - | assert!(html.contains("<button type=\"button\" class=\"figure-act\"")); | |
| 371 | + | assert!(html.contains("<a class=\"figure-act\"")); | |
| 372 | + | assert!(html.contains("href=\"/settings/held\"")); | |
| 334 | 373 | assert!(html.contains("hx-get=\"/settings/held\"")); | |
| 335 | 374 | // And the figure inside it is the same markup an inert one would be. | |
| 336 | 375 | assert!(html.contains(&makeover_webview::figure::figure_html( | |
| @@ -365,7 +404,9 @@ | |||
| 365 | 404 | // A badge answers no click however it is dressed, so it emits no transport | |
| 366 | 405 | // even when a description hands it an action. | |
| 367 | 406 | assert!(!badge.contains("<button")); | |
| 407 | + | assert!(!badge.contains("<a ")); | |
| 368 | 408 | assert!(!badge.contains("hx-get")); | |
| 409 | + | assert!(!badge.contains("href")); | |
| 369 | 410 | ||
| 370 | 411 | let chip = fragment(&Node::Token(Tag { | |
| 371 | 412 | kind: layout::Token::Chip { removable: false }, | |
| @@ -374,9 +415,25 @@ | |||
| 374 | 415 | latched: true, | |
| 375 | 416 | action: Some(Action::get("/x")), | |
| 376 | 417 | })); | |
| 377 | - | assert!(chip.contains("<button")); | |
| 378 | - | assert!(chip.contains("aria-pressed=\"true\"")); | |
| 418 | + | // A read of a route, so the chip is a link and says its state the way a | |
| 419 | + | // link says it. aria-pressed is a button's word and means nothing here. | |
| 420 | + | assert!(chip.contains("<a ")); | |
| 421 | + | assert!(chip.contains("aria-current=\"true\"")); | |
| 422 | + | assert!(!chip.contains("aria-pressed")); | |
| 423 | + | assert!(chip.contains("href=\"/x\"")); | |
| 379 | 424 | assert!(chip.contains("hx-get=\"/x\"")); | |
| 425 | + | ||
| 426 | + | // A chip whose activation writes is still a button, and still says so. | |
| 427 | + | let toggle = fragment(&Node::Token(Tag { | |
| 428 | + | kind: layout::Token::Chip { removable: false }, | |
| 429 | + | label: "open".into(), | |
| 430 | + | tone: layout::Tone::Neutral, | |
| 431 | + | latched: true, | |
| 432 | + | action: Some(Action::post("/x/toggle")), | |
| 433 | + | })); | |
| 434 | + | assert!(toggle.contains("<button")); | |
| 435 | + | assert!(toggle.contains("aria-pressed=\"true\"")); | |
| 436 | + | assert!(!toggle.contains("href")); | |
| 380 | 437 | } | |
| 381 | 438 | ||
| 382 | 439 | #[test] | |
| @@ -835,9 +892,11 @@ | |||
| 835 | 892 | ||
| 836 | 893 | assert!(html.contains(r#"aria-sort="ascending""#)); | |
| 837 | 894 | assert_eq!(html.matches("data-sortable").count(), 2); | |
| 838 | - | // The press is a button inside the header cell, never the cell itself: a | |
| 839 | - | // `columnheader` is not a control and must not announce itself as one. | |
| 840 | - | assert!(html.contains(r#"<button type="button" class="table-sort""#)); | |
| 895 | + | // The press is its own control inside the header cell, never the cell | |
| 896 | + | // itself: a `columnheader` is not a control and must not announce itself as | |
| 897 | + | // one. Reordering is a read, so the control is a link and is addressable. | |
| 898 | + | assert!(html.contains(r#"<a class="table-sort""#)); | |
| 899 | + | assert!(html.contains("href=\"/tasks?sort=due\"")); | |
| 841 | 900 | assert!(html.contains("hx-get=\"/tasks?sort=due\"")); | |
| 842 | 901 | } | |
| 843 | 902 |