max / quasi
7 files changed,
+1344 insertions,
-5 deletions
| @@ -1718,12 +1718,39 @@ | |||
| 1718 | 1718 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1719 | 1719 | checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" | |
| 1720 | 1720 | ||
| 1721 | + | [[package]] | |
| 1722 | + | name = "makeover-geometry" | |
| 1723 | + | version = "0.6.2" | |
| 1724 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1725 | + | checksum = "c4e0cbec6b86c225f5d4a793437fee457b9bf8e93786ee3af0212d668ad8f132" | |
| 1726 | + | ||
| 1721 | 1727 | [[package]] | |
| 1722 | 1728 | name = "makeover-layout" | |
| 1723 | 1729 | version = "0.8.2" | |
| 1724 | 1730 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1725 | 1731 | checksum = "9883c75a9d26fce10be2b979c01a74f8c07513800a70ec6bf598de1e7d411f1b" | |
| 1726 | 1732 | ||
| 1733 | + | [[package]] | |
| 1734 | + | name = "makeover-touch" | |
| 1735 | + | version = "0.4.0" | |
| 1736 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1737 | + | checksum = "369083d008157d6d95cd2ac0b8dbfee8689d80eb4c2883b48c2ef627a0116fdb" | |
| 1738 | + | dependencies = [ | |
| 1739 | + | "makeover-geometry", | |
| 1740 | + | "makeover-layout", | |
| 1741 | + | ] | |
| 1742 | + | ||
| 1743 | + | [[package]] | |
| 1744 | + | name = "makeover-webview" | |
| 1745 | + | version = "0.17.1" | |
| 1746 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1747 | + | checksum = "c8cfb1a75dcdafc4eab17ca9d1e2ae59f66dea218832c604c1cde5c01a1a799c" | |
| 1748 | + | dependencies = [ | |
| 1749 | + | "makeover-geometry", | |
| 1750 | + | "makeover-layout", | |
| 1751 | + | "makeover-touch", | |
| 1752 | + | ] | |
| 1753 | + | ||
| 1727 | 1754 | [[package]] | |
| 1728 | 1755 | name = "markup5ever" | |
| 1729 | 1756 | version = "0.38.0" | |
| @@ -1865,7 +1892,7 @@ | |||
| 1865 | 1892 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1866 | 1893 | checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" | |
| 1867 | 1894 | dependencies = [ | |
| 1868 | - | "proc-macro-crate 1.3.1", | |
| 1895 | + | "proc-macro-crate 2.0.2", | |
| 1869 | 1896 | "proc-macro2", | |
| 1870 | 1897 | "quote", | |
| 1871 | 1898 | "syn 2.0.119", | |
| @@ -2353,6 +2380,16 @@ | |||
| 2353 | 2380 | "tauri", | |
| 2354 | 2381 | ] | |
| 2355 | 2382 | ||
| 2383 | + | [[package]] | |
| 2384 | + | name = "quasi-webview" | |
| 2385 | + | version = "0.1.0" | |
| 2386 | + | dependencies = [ | |
| 2387 | + | "makeover-layout", | |
| 2388 | + | "makeover-webview", | |
| 2389 | + | "quasi-http", | |
| 2390 | + | "quasi-router", | |
| 2391 | + | ] | |
| 2392 | + | ||
| 2356 | 2393 | [[package]] | |
| 2357 | 2394 | name = "quick-xml" | |
| 2358 | 2395 | version = "0.38.4" | |
| @@ -4503,10 +4540,6 @@ | |||
| 4503 | 4540 | name = "tagtree" | |
| 4504 | 4541 | version = "0.4.0" | |
| 4505 | 4542 | ||
| 4506 | - | [[patch.unused]] | |
| 4507 | - | name = "docengine" | |
| 4508 | - | version = "0.4.0" | |
| 4509 | - | ||
| 4510 | 4543 | [[patch.unused]] | |
| 4511 | 4544 | name = "synckit-client" | |
| 4512 | 4545 | version = "0.8.0" | |
| @@ -4514,3 +4547,7 @@ | |||
| 4514 | 4547 | [[patch.unused]] | |
| 4515 | 4548 | name = "synckit-config" | |
| 4516 | 4549 | version = "0.2.0" | |
| 4550 | + | ||
| 4551 | + | [[patch.unused]] | |
| 4552 | + | name = "docengine" | |
| 4553 | + | version = "0.4.0" |
| @@ -6,6 +6,7 @@ | |||
| 6 | 6 | "crates/quasi-http", | |
| 7 | 7 | "crates/quasi-router", | |
| 8 | 8 | "crates/quasi-tauri", | |
| 9 | + | "crates/quasi-webview", | |
| 9 | 10 | ] | |
| 10 | 11 | ||
| 11 | 12 | [workspace.dependencies] |
| @@ -1,0 +1,19 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "quasi-webview" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | description = "The webview renderer for quasi: a screen description in, an htmx document out" | |
| 5 | + | edition.workspace = true | |
| 6 | + | rust-version.workspace = true | |
| 7 | + | authors.workspace = true | |
| 8 | + | repository.workspace = true | |
| 9 | + | license.workspace = true | |
| 10 | + | publish = false | |
| 11 | + | ||
| 12 | + | [lints] | |
| 13 | + | workspace = true | |
| 14 | + | ||
| 15 | + | [dependencies] | |
| 16 | + | quasi-router = { path = "../quasi-router", version = "0.1.0" } | |
| 17 | + | quasi-http = { path = "../quasi-http", version = "0.1.0" } | |
| 18 | + | makeover-layout = "0.8.2" | |
| 19 | + | makeover-webview = "0.17.1" |
| @@ -1,0 +1,152 @@ | |||
| 1 | + | //! The webview renderer for [`quasi_router`]. | |
| 2 | + | //! | |
| 3 | + | //! <!-- wiki: quasi-overview --> | |
| 4 | + | //! | |
| 5 | + | //! # Why this is not in `makeover-webview` | |
| 6 | + | //! | |
| 7 | + | //! A [`Screen`](quasi_router::Screen) renderer has to import `quasi-router`, | |
| 8 | + | //! and the makeover/quasi boundary settled 2026-08-08 is audience: a makeover | |
| 9 | + | //! crate is something another developer might use on its own, and quasi is what | |
| 10 | + | //! you use once you are committed to the whole stack. A `makeover-webview` that | |
| 11 | + | //! depended on quasi would stop passing its own test. | |
| 12 | + | //! | |
| 13 | + | //! So the split runs along that line and not along "who emits markup". | |
| 14 | + | //! `makeover-webview` owns the pieces whose description is settled and shared — | |
| 15 | + | //! fields, cell containers, the narrowing rules, the escaping — and this crate | |
| 16 | + | //! owns screen assembly, regions, and the transport. Every piece it can borrow | |
| 17 | + | //! from over there, it does: there is no second field emitter here. | |
| 18 | + | //! | |
| 19 | + | //! # What phase B is, once the pieces exist | |
| 20 | + | //! | |
| 21 | + | //! Assembly, mostly. The vocabulary is closed at two arrangements, seven region | |
| 22 | + | //! kinds and twelve node variants, and none of them is a widget: the admission | |
| 23 | + | //! test in [`quasi_router::screen`] is that a node composes something | |
| 24 | + | //! `makeover-layout` already names. So this crate has no opinions to hold, and | |
| 25 | + | //! the file to read for the interesting ones is [`node`], where htmx enters in | |
| 26 | + | //! a single function. | |
| 27 | + | //! | |
| 28 | + | //! # The transport is replaceable, and that is measurable here | |
| 29 | + | //! | |
| 30 | + | //! Nobody hand-writes `hx-post`. Decision 13 parks the fixi question on the | |
| 31 | + | //! grounds that a transport nothing authors by hand is a transport that can be | |
| 32 | + | //! swapped, and the check on that claim is that | |
| 33 | + | //! [`node::action_attrs`](node) is the only place in this crate naming htmx at | |
| 34 | + | //! all. A test asserts it. | |
| 35 | + | ||
| 36 | + | mod node; | |
| 37 | + | mod shell; | |
| 38 | + | ||
| 39 | + | #[cfg(test)] | |
| 40 | + | mod tests; | |
| 41 | + | ||
| 42 | + | pub use crate::shell::Shell; | |
| 43 | + | pub use makeover_webview::Emit; | |
| 44 | + | ||
| 45 | + | use makeover_layout::Arrangement; | |
| 46 | + | use quasi_http::Render; | |
| 47 | + | use quasi_router::{Node, Screen}; | |
| 48 | + | ||
| 49 | + | /// A renderer that answers HTML. | |
| 50 | + | /// | |
| 51 | + | /// Holds the two things a webview needs and a description never carries: where | |
| 52 | + | /// the host's assets live, and what to prefix class names with. Both are | |
| 53 | + | /// values rather than constants because they are the parts that genuinely | |
| 54 | + | /// differ between an axum route and a Tauri custom-protocol handler, and | |
| 55 | + | /// neither is anything the router can know. | |
| 56 | + | #[derive(Debug, Clone, Default)] | |
| 57 | + | pub struct Webview { | |
| 58 | + | /// The document around a screen. | |
| 59 | + | pub shell: Shell, | |
| 60 | + | /// Class naming, shared with `makeover-webview`'s stylesheet half so the | |
| 61 | + | /// emitted markup and the emitted CSS agree on every name. | |
| 62 | + | pub emit: Emit, | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | impl Webview { | |
| 66 | + | /// A renderer with the default shell and class naming. | |
| 67 | + | #[must_use] | |
| 68 | + | pub fn new() -> Self { | |
| 69 | + | Self::default() | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// A renderer serving its assets from under this prefix. | |
| 73 | + | #[must_use] | |
| 74 | + | pub fn under(prefix: &str) -> Self { | |
| 75 | + | Self { | |
| 76 | + | shell: Shell::under(prefix), | |
| 77 | + | emit: Emit::default(), | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// Use this shell, chaining. | |
| 82 | + | #[must_use] | |
| 83 | + | pub fn with_shell(mut self, shell: Shell) -> Self { | |
| 84 | + | self.shell = shell; | |
| 85 | + | self | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Use this class naming, chaining. | |
| 89 | + | #[must_use] | |
| 90 | + | pub fn with_emit(mut self, emit: Emit) -> Self { | |
| 91 | + | self.emit = emit; | |
| 92 | + | self | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// The class naming the arrangement of a screen's regions. | |
| 96 | + | /// | |
| 97 | + | /// Two, because our apps have two. A third arrives when an app has one, | |
| 98 | + | /// and not before: naming arrangements an app has not asked for is how a | |
| 99 | + | /// description becomes a framework. | |
| 100 | + | fn arrangement_class(arrangement: Arrangement) -> &'static str { | |
| 101 | + | match arrangement { | |
| 102 | + | Arrangement::ListDetail { tabbed: false } => "list-detail", | |
| 103 | + | Arrangement::ListDetail { tabbed: true } => "list-detail-tabbed", | |
| 104 | + | Arrangement::SidebarContent => "sidebar-content", | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | impl Render for Webview { | |
| 110 | + | fn screen(&self, screen: &Screen) -> String { | |
| 111 | + | let mut out = String::with_capacity(1024); | |
| 112 | + | self.shell.open(&screen.title, &mut out); | |
| 113 | + | ||
| 114 | + | out.push_str("<main class=\""); | |
| 115 | + | out.push_str(&node::class( | |
| 116 | + | Self::arrangement_class(screen.arrangement), | |
| 117 | + | &self.emit, | |
| 118 | + | )); | |
| 119 | + | out.push_str("\">"); | |
| 120 | + | ||
| 121 | + | // Notices before the regions, because a notice belongs to the screen | |
| 122 | + | // rather than to a place in it, and the first thing in the document is | |
| 123 | + | // the one place that is true of. Where they visually land is the | |
| 124 | + | // stylesheet's answer. | |
| 125 | + | if !screen.notices.is_empty() { | |
| 126 | + | out.push_str("<div class=\""); | |
| 127 | + | out.push_str(&node::class("notices", &self.emit)); | |
| 128 | + | out.push_str("\">"); | |
| 129 | + | for notice in &screen.notices { | |
| 130 | + | node::node_html(notice, self.shell.morphs(), &self.emit, &mut out); | |
| 131 | + | } | |
| 132 | + | out.push_str("</div>"); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | for slot in &screen.slots { | |
| 136 | + | node::slot_html(slot, self.shell.morphs(), &self.emit, &mut out); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | out.push_str("</main>"); | |
| 140 | + | Shell::close(&mut out); | |
| 141 | + | out | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | fn fragment(&self, node: &Node) -> String { | |
| 145 | + | // No shell, by definition: a fragment is the inside of one element and | |
| 146 | + | // htmx puts it there. The router already said which element through | |
| 147 | + | // `HX-Retarget`, so nothing here needs to know. | |
| 148 | + | let mut out = String::with_capacity(256); | |
| 149 | + | node::node_html(node, self.shell.morphs(), &self.emit, &mut out); | |
| 150 | + | out | |
| 151 | + | } | |
| 152 | + | } |
| @@ -1,0 +1,579 @@ | |||
| 1 | + | //! Nodes to markup. | |
| 2 | + | //! | |
| 3 | + | //! Every function here takes a piece of [`quasi_router`]'s screen tree and | |
| 4 | + | //! pushes markup onto a buffer. Nothing returns a `Result`: a description that | |
| 5 | + | //! exists is renderable by construction, which is the property the owned mirror | |
| 6 | + | //! in `quasi-router` was built to have. | |
| 7 | + | //! | |
| 8 | + | //! # Where the htmx goes in | |
| 9 | + | //! | |
| 10 | + | //! In exactly one function, [`action_attrs`]. An [`Action`] is a method, a path | |
| 11 | + | //! and some params, and turning that into `hx-get` / `hx-post` / `hx-vals` is | |
| 12 | + | //! the whole of what "htmx is the transport" means in code. Nothing else in | |
| 13 | + | //! this file knows the word htmx, so decision 13's claim that the transport is | |
| 14 | + | //! replaceable is a claim about one function rather than about the crate. | |
| 15 | + | //! | |
| 16 | + | //! No `hx-target` is ever emitted. Decision 7 puts the target on the response, | |
| 17 | + | //! where `quasi-http` sets `HX-Retarget` from | |
| 18 | + | //! [`Response::Fragment`](quasi_router::Response::Fragment), because the router | |
| 19 | + | //! is the only party that knows what it just changed. A control that also named | |
| 20 | + | //! a target would be a second party deciding one thing. | |
| 21 | + | ||
| 22 | + | use std::fmt::Write as _; | |
| 23 | + | ||
| 24 | + | use makeover_layout as layout; | |
| 25 | + | use makeover_webview::Emit; | |
| 26 | + | use makeover_webview::form::{Filling, Markup, Value, escape, field_html}; | |
| 27 | + | use makeover_webview::list::{Cell, cells_html}; | |
| 28 | + | use quasi_router::screen::{Act, Cells, Node, Row, Slot}; | |
| 29 | + | use quasi_router::{Action, Method, Params}; | |
| 30 | + | ||
| 31 | + | /// The class-name prefix, applied through [`Emit::class_prefix`]. | |
| 32 | + | pub(crate) fn class(name: &str, opts: &Emit) -> String { | |
| 33 | + | format!("{}{name}", opts.class_prefix) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// Write a `class="..."` attribute, prefixed. | |
| 37 | + | fn class_attr(names: &[&str], opts: &Emit, out: &mut String) { | |
| 38 | + | out.push_str(" class=\""); | |
| 39 | + | for (i, name) in names.iter().enumerate() { | |
| 40 | + | if i > 0 { | |
| 41 | + | out.push(' '); | |
| 42 | + | } | |
| 43 | + | out.push_str(&escape(&class(name, opts))); | |
| 44 | + | } | |
| 45 | + | out.push('"'); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// The class suffix naming a tone. | |
| 49 | + | /// | |
| 50 | + | /// [`Tone::Neutral`] has none: ordinary content is the default, and a class | |
| 51 | + | /// meaning "nothing unusual" is a class on every element in the document. | |
| 52 | + | fn tone_class(tone: layout::Tone) -> Option<&'static str> { | |
| 53 | + | match tone { | |
| 54 | + | layout::Tone::Neutral => None, | |
| 55 | + | layout::Tone::Info => Some("tone-info"), | |
| 56 | + | layout::Tone::Success => Some("tone-success"), | |
| 57 | + | layout::Tone::Warning => Some("tone-warning"), | |
| 58 | + | layout::Tone::Danger => Some("tone-danger"), | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// JSON-encode a string, for an `hx-vals` payload. | |
| 63 | + | /// | |
| 64 | + | /// Small enough to own. Pulling in a JSON crate to write object literals of | |
| 65 | + | /// strings would be the larger decision, and the encoder a renderer needs is | |
| 66 | + | /// this: the six characters JSON requires escaped, plus a `\u00XX` form for the | |
| 67 | + | /// rest of the C0 range. The result is then HTML-escaped by the caller, because | |
| 68 | + | /// it lands in an attribute. | |
| 69 | + | fn json_string(text: &str, out: &mut String) { | |
| 70 | + | out.push('"'); | |
| 71 | + | for ch in text.chars() { | |
| 72 | + | match ch { | |
| 73 | + | '"' => out.push_str("\\\""), | |
| 74 | + | '\\' => out.push_str("\\\\"), | |
| 75 | + | '\n' => out.push_str("\\n"), | |
| 76 | + | '\r' => out.push_str("\\r"), | |
| 77 | + | '\t' => out.push_str("\\t"), | |
| 78 | + | c if (c as u32) < 0x20 => { | |
| 79 | + | let _ = write!(out, "\\u{:04x}", c as u32); | |
| 80 | + | } | |
| 81 | + | c => out.push(c), | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | out.push('"'); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// The params as an `hx-vals` object. | |
| 88 | + | fn json_object(params: &Params) -> String { | |
| 89 | + | let mut json = String::from("{"); | |
| 90 | + | for (i, (name, value)) in params.iter().enumerate() { | |
| 91 | + | if i > 0 { | |
| 92 | + | json.push(','); | |
| 93 | + | } | |
| 94 | + | json_string(name, &mut json); | |
| 95 | + | json.push(':'); | |
| 96 | + | json_string(value, &mut json); | |
| 97 | + | } | |
| 98 | + | json.push('}'); | |
| 99 | + | json | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /// The transport attributes for one action. | |
| 103 | + | /// | |
| 104 | + | /// `hx-vals` rather than a hand-built query string, for both methods. htmx | |
| 105 | + | /// folds them into the query string for a GET and into the body for a POST, so | |
| 106 | + | /// one code path covers both, and no part of this stack concatenates a `?`. | |
| 107 | + | /// That is where escaping bugs live, and the description layer's own reason for | |
| 108 | + | /// carrying [`Action::params`] as values rather than as text. | |
| 109 | + | pub(crate) fn action_attrs(action: &Action, morphs: bool, out: &mut String) { | |
| 110 | + | let verb = match action.method { | |
| 111 | + | Method::Get => " hx-get=\"", | |
| 112 | + | Method::Post => " hx-post=\"", | |
| 113 | + | }; | |
| 114 | + | out.push_str(verb); | |
| 115 | + | out.push_str(&escape(&action.path)); | |
| 116 | + | out.push('"'); | |
| 117 | + | ||
| 118 | + | if !action.params.is_empty() { | |
| 119 | + | out.push_str(" hx-vals=\""); | |
| 120 | + | out.push_str(&escape(&json_object(&action.params))); | |
| 121 | + | out.push('"'); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | if morphs { | |
| 125 | + | // Decision 7's slack: a morph preserves focus, scroll and input state | |
| 126 | + | // through a swap, so a whole-Screen answer stops being destructive. | |
| 127 | + | out.push_str(" hx-swap=\"morph\""); | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// A control that calls a route. | |
| 132 | + | pub(crate) fn act_html(act: &Act, morphs: bool, opts: &Emit, out: &mut String) { | |
| 133 | + | let mut classes = vec!["act"]; | |
| 134 | + | if let Some(tone) = tone_class(act.tone) { | |
| 135 | + | classes.push(tone); | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | out.push_str("<button type=\"button\""); | |
| 139 | + | class_attr(&classes, opts, out); | |
| 140 | + | ||
| 141 | + | match act.state { | |
| 142 | + | Some(layout::State::Disabled) => { | |
| 143 | + | // Disabled and emitting no transport, rather than disabled and | |
| 144 | + | // still carrying the address. A control that stops answering input | |
| 145 | + | // should also stop being a request waiting to be re-enabled from | |
| 146 | + | // the console. | |
| 147 | + | out.push_str(" disabled"); | |
| 148 | + | } | |
| 149 | + | Some(layout::State::Focus) => { | |
| 150 | + | out.push_str(" autofocus"); | |
| 151 | + | action_attrs(&act.action, morphs, out); | |
| 152 | + | } | |
| 153 | + | // `State` is `#[non_exhaustive]`, so a member added upstream lands | |
| 154 | + | // here. Emitting the transport is the right default for anything that | |
| 155 | + | // is not a suppression: a state this renderer has not learned yet | |
| 156 | + | // should leave the control working, not silently inert. | |
| 157 | + | _ => action_attrs(&act.action, morphs, out), | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | out.push('>'); | |
| 161 | + | out.push_str(&escape(&act.label)); | |
| 162 | + | out.push_str("</button>"); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | /// One row of a list. | |
| 166 | + | fn row_html(row: &Row, morphs: bool, opts: &Emit, out: &mut String) { | |
| 167 | + | let mut classes = vec!["row"]; | |
| 168 | + | if row.selected { | |
| 169 | + | classes.push("row-selected"); | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | out.push_str("<li"); | |
| 173 | + | class_attr(&classes, opts, out); | |
| 174 | + | if row.selected { | |
| 175 | + | // The description's `selected` is what the detail side is showing, so | |
| 176 | + | // it is a current item within a set rather than a pressed control. | |
| 177 | + | out.push_str(" aria-current=\"true\""); | |
| 178 | + | } | |
| 179 | + | out.push('>'); | |
| 180 | + | ||
| 181 | + | // The primary is a control when selecting the row does something, and plain | |
| 182 | + | // text when it does not. Emitting a button either way would give a screen | |
| 183 | + | // reader an affordance that answers nothing. | |
| 184 | + | match &row.activate { | |
| 185 | + | Some(action) => { | |
| 186 | + | out.push_str("<button type=\"button\""); | |
| 187 | + | class_attr(&["row-activate"], opts, out); | |
| 188 | + | action_attrs(action, morphs, out); | |
| 189 | + | out.push('>'); | |
| 190 | + | out.push_str(&escape(&row.primary)); | |
| 191 | + | out.push_str("</button>"); | |
| 192 | + | } | |
| 193 | + | None => { | |
| 194 | + | out.push_str("<span"); | |
| 195 | + | class_attr(&["row-primary"], opts, out); | |
| 196 | + | out.push('>'); | |
| 197 | + | out.push_str(&escape(&row.primary)); | |
| 198 | + | out.push_str("</span>"); | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | if let Some(secondary) = &row.secondary { | |
| 203 | + | out.push_str("<span"); | |
| 204 | + | class_attr(&["row-secondary"], opts, out); | |
| 205 | + | out.push('>'); | |
| 206 | + | out.push_str(&escape(secondary)); | |
| 207 | + | out.push_str("</span>"); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | if let Some(meta) = &row.meta { | |
| 211 | + | out.push_str("<span"); | |
| 212 | + | class_attr(&["row-meta"], opts, out); | |
| 213 | + | out.push('>'); | |
| 214 | + | out.push_str(&escape(meta)); | |
| 215 | + | out.push_str("</span>"); | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | if !row.actions.is_empty() { | |
| 219 | + | out.push_str("<span"); | |
| 220 | + | class_attr(&["row-actions"], opts, out); | |
| 221 | + | out.push('>'); | |
| 222 | + | for act in &row.actions { | |
| 223 | + | act_html(act, morphs, opts, out); | |
| 224 | + | } | |
| 225 | + | out.push_str("</span>"); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | out.push_str("</li>"); | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | /// One row of a table. | |
| 232 | + | fn cells_row_html( | |
| 233 | + | cells: &Cells, | |
| 234 | + | columns: &[quasi_router::screen::Column], | |
| 235 | + | morphs: bool, | |
| 236 | + | opts: &Emit, | |
| 237 | + | out: &mut String, | |
| 238 | + | ) { | |
| 239 | + | let mut classes = vec!["table-row"]; | |
| 240 | + | if cells.selected { | |
| 241 | + | classes.push("table-row-selected"); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | out.push_str("<div role=\"row\""); | |
| 245 | + | class_attr(&classes, opts, out); | |
| 246 | + | if cells.selected { | |
| 247 | + | out.push_str(" aria-current=\"true\""); | |
| 248 | + | } | |
| 249 | + | if let Some(action) = &cells.activate { | |
| 250 | + | action_attrs(action, morphs, out); | |
| 251 | + | } | |
| 252 | + | out.push('>'); | |
| 253 | + | ||
| 254 | + | // The cell contents are escaped here and handed over as Markup, which is | |
| 255 | + | // makeover-webview's contract: it owns the structure, the caller owns what | |
| 256 | + | // goes in. Ours is always text from a description, so it is always escaped, | |
| 257 | + | // and `cells_html` is what knows the column classes and the narrowing. | |
| 258 | + | let escaped: Vec<String> = cells.values.iter().map(|value| escape(value)).collect(); | |
| 259 | + | let borrowed: Vec<layout::Column<'_>> = columns | |
| 260 | + | .iter() | |
| 261 | + | .map(quasi_router::screen::Column::as_layout) | |
| 262 | + | .collect(); | |
| 263 | + | let cells: Vec<Cell<'_>> = borrowed | |
| 264 | + | .iter() | |
| 265 | + | .zip(escaped.iter()) | |
| 266 | + | .map(|(column, value)| Cell::new(column.name, Markup(value))) | |
| 267 | + | .collect(); | |
| 268 | + | out.push_str(&cells_html(&borrowed, &cells, opts)); | |
| 269 | + | ||
| 270 | + | out.push_str("</div>"); | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | /// One thing on a screen. | |
| 274 | + | pub(crate) fn node_html(node: &Node, morphs: bool, opts: &Emit, out: &mut String) { | |
| 275 | + | match node { | |
| 276 | + | Node::Heading { level, text } => { | |
| 277 | + | let tag = match level { | |
| 278 | + | layout::Heading::Page => "h1", | |
| 279 | + | layout::Heading::Section => "h2", | |
| 280 | + | layout::Heading::Subsection => "h3", | |
| 281 | + | }; | |
| 282 | + | let _ = write!(out, "<{tag}"); | |
| 283 | + | class_attr(&["heading"], opts, out); | |
| 284 | + | out.push('>'); | |
| 285 | + | out.push_str(&escape(text)); | |
| 286 | + | let _ = write!(out, "</{tag}>"); | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | Node::Text { text, tone } => { | |
| 290 | + | let mut classes = vec!["text"]; | |
| 291 | + | if let Some(tone) = tone_class(*tone) { | |
| 292 | + | classes.push(tone); | |
| 293 | + | } | |
| 294 | + | out.push_str("<p"); | |
| 295 | + | class_attr(&classes, opts, out); | |
| 296 | + | out.push('>'); | |
| 297 | + | out.push_str(&escape(text)); | |
| 298 | + | out.push_str("</p>"); | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | Node::Act(act) => act_html(act, morphs, opts, out), | |
| 302 | + | ||
| 303 | + | Node::Token { | |
| 304 | + | kind, | |
| 305 | + | label, | |
| 306 | + | tone, | |
| 307 | + | latched, | |
| 308 | + | action, | |
| 309 | + | } => token_html(*kind, label, *tone, *latched, action.as_ref(), morphs, opts, out), | |
| 310 | + | ||
| 311 | + | Node::Notice { kind, tone, text } => { | |
| 312 | + | let mut classes = vec![match kind { | |
| 313 | + | layout::Notice::Toast => "toast", | |
| 314 | + | layout::Notice::Banner => "banner", | |
| 315 | + | }]; | |
| 316 | + | if let Some(tone) = tone_class(*tone) { | |
| 317 | + | classes.push(tone); | |
| 318 | + | } | |
| 319 | + | out.push_str("<div"); | |
| 320 | + | class_attr(&classes, opts, out); | |
| 321 | + | // A danger or warning notice interrupts; anything else waits for a | |
| 322 | + | // pause. The description already says which through its tone, so | |
| 323 | + | // the renderer does not need a second field to be told. | |
| 324 | + | let assertive = matches!(tone, layout::Tone::Danger | layout::Tone::Warning); | |
| 325 | + | if assertive { | |
| 326 | + | out.push_str(" role=\"alert\""); | |
| 327 | + | } else { | |
| 328 | + | out.push_str(" role=\"status\" aria-live=\"polite\""); | |
| 329 | + | } | |
| 330 | + | out.push('>'); | |
| 331 | + | out.push_str(&escape(text)); | |
| 332 | + | out.push_str("</div>"); | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | Node::Form { | |
| 336 | + | action, | |
| 337 | + | submit, | |
| 338 | + | fields, | |
| 339 | + | } => { | |
| 340 | + | out.push_str("<form"); | |
| 341 | + | class_attr(&["form"], opts, out); | |
| 342 | + | action_attrs(action, morphs, out); | |
| 343 | + | out.push('>'); | |
| 344 | + | for field in fields { | |
| 345 | + | // The field's own markup is makeover-webview's, unchanged. A | |
| 346 | + | // second field emitter here is the divergence phase A existed | |
| 347 | + | // to end, and it would be the same anatomy with a different | |
| 348 | + | // escaping story. | |
| 349 | + | field.with_layout(|borrowed| { | |
| 350 | + | out.push_str(&field_html(&borrowed, &Filling::of(Value::Absent), opts)); | |
| 351 | + | }); | |
| 352 | + | } | |
| 353 | + | out.push_str("<button type=\"submit\""); | |
| 354 | + | class_attr(&["act", "act-submit"], opts, out); | |
| 355 | + | out.push('>'); | |
| 356 | + | out.push_str(&escape(submit)); | |
| 357 | + | out.push_str("</button></form>"); | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | Node::List { rows } => { | |
| 361 | + | out.push_str("<ul"); | |
| 362 | + | class_attr(&["list"], opts, out); | |
| 363 | + | out.push('>'); | |
| 364 | + | for row in rows { | |
| 365 | + | row_html(row, morphs, opts, out); | |
| 366 | + | } | |
| 367 | + | out.push_str("</ul>"); | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | Node::Table { columns, rows } => { | |
| 371 | + | let borrowed: Vec<layout::Column<'_>> = columns | |
| 372 | + | .iter() | |
| 373 | + | .map(quasi_router::screen::Column::as_layout) | |
| 374 | + | .collect(); | |
| 375 | + | // No inline grid tracks. `makeover_webview::list::narrowing_css` | |
| 376 | + | // emits the track list and the per-column hiding together, per | |
| 377 | + | // breakpoint, from these same columns; writing tracks here as well | |
| 378 | + | // would put the widest layout in the markup where it outranks the | |
| 379 | + | // narrow rules, which is goingson's mobile bug reintroduced from | |
| 380 | + | // the other side. The markup owes the cells their column classes | |
| 381 | + | // and nothing more, and `cells_html` is what knows those. | |
| 382 | + | out.push_str("<div role=\"table\""); | |
| 383 | + | class_attr(&["table"], opts, out); | |
| 384 | + | out.push('>'); | |
| 385 | + | ||
| 386 | + | out.push_str("<div role=\"row\""); | |
| 387 | + | class_attr(&["table-head"], opts, out); | |
| 388 | + | out.push('>'); | |
| 389 | + | for column in &borrowed { | |
| 390 | + | out.push_str("<span role=\"columnheader\""); | |
| 391 | + | class_attr(&["table-heading"], opts, out); | |
| 392 | + | out.push('>'); | |
| 393 | + | out.push_str(&escape(column.name)); | |
| 394 | + | out.push_str("</span>"); | |
| 395 | + | } | |
| 396 | + | out.push_str("</div>"); | |
| 397 | + | ||
| 398 | + | for cells in rows { | |
| 399 | + | cells_row_html(cells, columns, morphs, opts, out); | |
| 400 | + | } | |
| 401 | + | out.push_str("</div>"); | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | Node::Select { | |
| 405 | + | kind, | |
| 406 | + | options, | |
| 407 | + | chosen, | |
| 408 | + | action, | |
| 409 | + | } => select_html(*kind, options, chosen.as_deref(), action.as_ref(), morphs, opts, out), | |
| 410 | + | ||
| 411 | + | Node::Region(slot) => slot_html(slot, morphs, opts, out), | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | /// A small labelled thing sitting inside something else. | |
| 416 | + | #[expect(clippy::too_many_arguments, reason = "one per field of Node::Token")] | |
| 417 | + | fn token_html( | |
| 418 | + | kind: layout::Token, | |
| 419 | + | label: &str, | |
| 420 | + | tone: layout::Tone, | |
| 421 | + | latched: bool, | |
| 422 | + | action: Option<&Action>, | |
| 423 | + | morphs: bool, | |
| 424 | + | opts: &Emit, | |
| 425 | + | out: &mut String, | |
| 426 | + | ) { | |
| 427 | + | let mut classes = vec![match kind { | |
| 428 | + | layout::Token::Badge => "badge", | |
| 429 | + | layout::Token::Chip { .. } => "chip", | |
| 430 | + | }]; | |
| 431 | + | if let Some(tone) = tone_class(tone) { | |
| 432 | + | classes.push(tone); | |
| 433 | + | } | |
| 434 | + | if latched { | |
| 435 | + | classes.push("chip-latched"); | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | // A badge answers no click, so it is not a button however it is styled. | |
| 439 | + | // The description says which through the kind, which is the whole reason | |
| 440 | + | // the two are separate members rather than one with a flag. | |
| 441 | + | let interactive = kind.interactive() && action.is_some(); | |
| 442 | + | if interactive { | |
| 443 | + | out.push_str("<button type=\"button\""); | |
| 444 | + | } else { | |
| 445 | + | out.push_str("<span"); | |
| 446 | + | } | |
| 447 | + | class_attr(&classes, opts, out); | |
| 448 | + | ||
| 449 | + | if interactive { | |
| 450 | + | if latched { | |
| 451 | + | // A chip standing for a filter is on or off, which is what | |
| 452 | + | // aria-pressed means. Its latched class carries the same fact | |
| 453 | + | // visually through Depth::pressed. | |
| 454 | + | out.push_str(" aria-pressed=\"true\""); | |
| 455 | + | } | |
| 456 | + | if let Some(action) = action { | |
| 457 | + | action_attrs(action, morphs, out); | |
| 458 | + | } | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | out.push('>'); | |
| 462 | + | out.push_str(&escape(label)); | |
| 463 | + | if matches!(kind, layout::Token::Chip { removable: true }) { | |
| 464 | + | out.push_str("<span"); | |
| 465 | + | class_attr(&["chip-remove"], opts, out); | |
| 466 | + | out.push_str(" aria-hidden=\"true\"></span>"); | |
| 467 | + | } | |
| 468 | + | if interactive { | |
| 469 | + | out.push_str("</button>"); | |
| 470 | + | } else { | |
| 471 | + | out.push_str("</span>"); | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | /// A control that picks between things. | |
| 476 | + | fn select_html( | |
| 477 | + | kind: layout::Selector, | |
| 478 | + | options: &[quasi_router::screen::Choice], | |
| 479 | + | chosen: Option<&str>, | |
| 480 | + | action: Option<&Action>, | |
| 481 | + | morphs: bool, | |
| 482 | + | opts: &Emit, | |
| 483 | + | out: &mut String, | |
| 484 | + | ) { | |
| 485 | + | let group = match kind { | |
| 486 | + | layout::Selector::Segmented => "segmented", | |
| 487 | + | layout::Selector::Toggle => "toggle", | |
| 488 | + | layout::Selector::Tabs => "tabs", | |
| 489 | + | }; | |
| 490 | + | ||
| 491 | + | out.push_str("<div"); | |
| 492 | + | class_attr(&[group], opts, out); | |
| 493 | + | // Tabs are navigation between panes, which is a tablist. The other two pick | |
| 494 | + | // a value and are a group of buttons. | |
| 495 | + | if matches!(kind, layout::Selector::Tabs) { | |
| 496 | + | out.push_str(" role=\"tablist\""); | |
| 497 | + | } else { | |
| 498 | + | out.push_str(" role=\"group\""); | |
| 499 | + | } | |
| 500 | + | out.push('>'); |
Lines truncated
| @@ -1,0 +1,169 @@ | |||
| 1 | + | //! The document around a screen. | |
| 2 | + | //! | |
| 3 | + | //! Settled 2026-08-08 (Max): the renderer owns the document and the host | |
| 4 | + | //! injects what it knows. The alternative was each adapter supplying its own | |
| 5 | + | //! `<html>` and the renderer filling the inside, which is honest about axum and | |
| 6 | + | //! Tauri resolving assets differently and costs two heads to keep in step — | |
| 7 | + | //! the divergence the stack exists to end. A served page and a | |
| 8 | + | //! custom-protocol page differ in where their assets live and in nothing else, | |
| 9 | + | //! so where assets live is the parameter and the rest is emitted once. | |
| 10 | + | //! | |
| 11 | + | //! What the host owes is small enough to list: three asset URLs, a language, | |
| 12 | + | //! and whatever else belongs in its own head. Everything with an opinion in it | |
| 13 | + | //! — the htmx config from [`quasi_http::htmx`], the morph extension, the | |
| 14 | + | //! viewport, where the body's classes come from — is here, because a host that | |
| 15 | + | //! could get those wrong is a host that can diverge. | |
| 16 | + | ||
| 17 | + | use makeover_webview::form::escape; | |
| 18 | + | ||
| 19 | + | /// The parts of a document only the host knows. | |
| 20 | + | /// | |
| 21 | + | /// A [`Default`] shell is a valid one: the asset paths are what a server | |
| 22 | + | /// mounting its static directory at `/static` already serves, which is what | |
| 23 | + | /// both MNW and multithreaded do today, and the Tauri adapter overrides them | |
| 24 | + | /// with its own scheme. | |
| 25 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 26 | + | pub struct Shell { | |
| 27 | + | /// The document language, for `<html lang>`. | |
| 28 | + | pub lang: String, | |
| 29 | + | /// Where htmx is served from. | |
| 30 | + | pub htmx_src: String, | |
| 31 | + | /// Where the idiomorph htmx extension is served from. | |
| 32 | + | /// | |
| 33 | + | /// `None` drops both the script and the `hx-ext` attribute, which is the | |
| 34 | + | /// honest way to run without it: emitting `hx-swap="morph"` with no | |
| 35 | + | /// extension loaded makes htmx fall back to `innerHTML` silently, and a | |
| 36 | + | /// silent fallback to the destructive behaviour is the one outcome | |
| 37 | + | /// decision 7 was avoiding. | |
| 38 | + | pub morph_src: Option<String>, | |
| 39 | + | /// Stylesheets, in link order. | |
| 40 | + | pub stylesheets: Vec<String>, | |
| 41 | + | /// Markup appended to the head verbatim. Not escaped. | |
| 42 | + | /// | |
| 43 | + | /// The escape hatch for what no description will ever name: a favicon, a | |
| 44 | + | /// preconnect, a theme bootstrap that has to run before first paint. It is | |
| 45 | + | /// last in the head so it can override anything above it. | |
| 46 | + | pub head: Option<String>, | |
| 47 | + | /// Classes added to `<body>`, space-separated. | |
| 48 | + | pub body_class: Option<String>, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | impl Default for Shell { | |
| 52 | + | fn default() -> Self { | |
| 53 | + | Self { | |
| 54 | + | lang: "en".into(), | |
| 55 | + | htmx_src: "/static/htmx.min.js".into(), | |
| 56 | + | morph_src: Some("/static/idiomorph-ext.min.js".into()), | |
| 57 | + | stylesheets: Vec::new(), | |
| 58 | + | head: None, | |
| 59 | + | body_class: None, | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | impl Shell { | |
| 65 | + | /// A shell serving its assets from under this prefix. | |
| 66 | + | /// | |
| 67 | + | /// The common case said once: `Shell::under("/assets")` rather than three | |
| 68 | + | /// paths written out, each of which could disagree with the others. | |
| 69 | + | #[must_use] | |
| 70 | + | pub fn under(prefix: &str) -> Self { | |
| 71 | + | let prefix = prefix.trim_end_matches('/'); | |
| 72 | + | Self { | |
| 73 | + | htmx_src: format!("{prefix}/htmx.min.js"), | |
| 74 | + | morph_src: Some(format!("{prefix}/idiomorph-ext.min.js")), | |
| 75 | + | ..Self::default() | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// Add a stylesheet, chaining. | |
| 80 | + | #[must_use] | |
| 81 | + | pub fn styled(mut self, href: impl Into<String>) -> Self { | |
| 82 | + | self.stylesheets.push(href.into()); | |
| 83 | + | self | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// Append markup to the head, chaining. Not escaped. | |
| 87 | + | #[must_use] | |
| 88 | + | pub fn with_head(mut self, markup: impl Into<String>) -> Self { | |
| 89 | + | self.head = Some(match self.head.take() { | |
| 90 | + | Some(existing) => format!("{existing}{}", markup.into()), | |
| 91 | + | None => markup.into(), | |
| 92 | + | }); | |
| 93 | + | self | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// Run without idiomorph, chaining. | |
| 97 | + | #[must_use] | |
| 98 | + | pub fn without_morph(mut self) -> Self { | |
| 99 | + | self.morph_src = None; | |
| 100 | + | self | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /// Whether responses may ask for a morph swap. | |
| 104 | + | /// | |
| 105 | + | /// Read by the node emitter rather than assumed, so that turning the | |
| 106 | + | /// extension off changes what is emitted instead of leaving an attribute | |
| 107 | + | /// naming a swap nothing implements. | |
| 108 | + | #[must_use] | |
| 109 | + | pub fn morphs(&self) -> bool { | |
| 110 | + | self.morph_src.is_some() | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// Everything from `<!doctype>` to the open `<body>` tag. | |
| 114 | + | pub(crate) fn open(&self, title: &str, out: &mut String) { | |
| 115 | + | out.push_str("<!doctype html><html lang=\""); | |
| 116 | + | out.push_str(&escape(&self.lang)); | |
| 117 | + | out.push_str("\"><head><meta charset=\"utf-8\">"); | |
| 118 | + | out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"); | |
| 119 | + | out.push_str("<title>"); | |
| 120 | + | out.push_str(&escape(title)); | |
| 121 | + | out.push_str("</title>"); | |
| 122 | + | ||
| 123 | + | // Decision 9's gap, closed in one place. A 4xx that does not swap is a | |
| 124 | + | // banner the user never sees, so this tag is required rather than a | |
| 125 | + | // refinement, and it is a tag rather than a script so it survives a | |
| 126 | + | // `script-src` with no `unsafe-inline`. | |
| 127 | + | out.push_str(quasi_http::htmx::CONFIG_META); | |
| 128 | + | ||
| 129 | + | for href in &self.stylesheets { | |
| 130 | + | out.push_str("<link rel=\"stylesheet\" href=\""); | |
| 131 | + | out.push_str(&escape(href)); | |
| 132 | + | out.push_str("\">"); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // Deferred, so the parser is never blocked and the extension is | |
| 136 | + | // registered before htmx processes the body either way. | |
| 137 | + | out.push_str("<script src=\""); | |
| 138 | + | out.push_str(&escape(&self.htmx_src)); | |
| 139 | + | out.push_str("\" defer></script>"); | |
| 140 | + | if let Some(src) = &self.morph_src { | |
| 141 | + | out.push_str("<script src=\""); | |
| 142 | + | out.push_str(&escape(src)); | |
| 143 | + | out.push_str("\" defer></script>"); | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | if let Some(head) = &self.head { | |
| 147 | + | out.push_str(head); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | out.push_str("</head><body"); | |
| 151 | + | if self.morphs() { | |
| 152 | + | // Registered once on the body rather than per element: the | |
| 153 | + | // extension is inherited, and an app that has to remember it per | |
| 154 | + | // control is an app that will forget it. | |
| 155 | + | out.push_str(" hx-ext=\"morph\""); | |
| 156 | + | } | |
| 157 | + | if let Some(class) = &self.body_class { | |
| 158 | + | out.push_str(" class=\""); | |
| 159 | + | out.push_str(&escape(class)); | |
| 160 | + | out.push('"'); | |
| 161 | + | } | |
| 162 | + | out.push('>'); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | /// The close of what [`open`](Self::open) opened. | |
| 166 | + | pub(crate) fn close(out: &mut String) { | |
| 167 | + | out.push_str("</body></html>"); | |
| 168 | + | } | |
| 169 | + | } |
| @@ -1,0 +1,461 @@ | |||
| 1 | + | //! What the renderer promises, asserted. | |
| 2 | + | //! | |
| 3 | + | //! Two kinds of test here, and the second is the interesting one. The first | |
| 4 | + | //! checks that a description comes out as the markup it should. The second | |
| 5 | + | //! checks the architectural claims the design rests on — that no `hx-target` | |
| 6 | + | //! is ever emitted, that htmx appears in exactly one function, that a | |
| 7 | + | //! description's text cannot become markup — because those are the properties | |
| 8 | + | //! that would decay silently, one convenient exception at a time. | |
| 9 | + | ||
| 10 | + | use makeover_layout as layout; | |
| 11 | + | use quasi_http::Render; | |
| 12 | + | use quasi_router::screen::{Act, Cells, Choice, Column, Field, Row}; | |
| 13 | + | use quasi_router::{Action, Node, RegionKind, Screen, Slot}; | |
| 14 | + | ||
| 15 | + | use crate::{Shell, Webview}; | |
| 16 | + | ||
| 17 | + | fn render(screen: &Screen) -> String { | |
| 18 | + | Webview::new().screen(screen) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | fn fragment(node: &Node) -> String { | |
| 22 | + | Webview::new().fragment(node) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | #[test] | |
| 26 | + | fn a_screen_is_a_whole_document() { | |
| 27 | + | let html = render(&Screen::list_detail("Tasks", false)); | |
| 28 | + | assert!(html.starts_with("<!doctype html><html lang=\"en\">")); | |
| 29 | + | assert!(html.contains("<title>Tasks</title>")); | |
| 30 | + | assert!(html.ends_with("</body></html>")); | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | #[test] | |
| 34 | + | fn a_fragment_is_not() { | |
| 35 | + | let html = fragment(&Node::text("hello")); | |
| 36 | + | assert!(!html.contains("<html")); | |
| 37 | + | assert!(!html.contains("<body")); | |
| 38 | + | assert_eq!(html, "<p class=\"text\">hello</p>"); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | #[test] | |
| 42 | + | fn the_document_carries_the_response_handling_config() { | |
| 43 | + | // Decision 9's gap. Without this a 4xx does not swap and the notice the | |
| 44 | + | // adapter carefully classified reaches nobody. | |
| 45 | + | let html = render(&Screen::list_detail("Tasks", false)); | |
| 46 | + | assert!(html.contains(quasi_http::htmx::CONFIG_META)); | |
| 47 | + | assert!(html.contains(r#"{"code":"[45]..","swap":true,"error":true}"#)); | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | #[test] | |
| 51 | + | fn the_title_is_escaped_into_the_head() { | |
| 52 | + | let html = render(&Screen::list_detail("</title><script>x()</script>", false)); | |
| 53 | + | assert!(!html.contains("<script>x()")); | |
| 54 | + | assert!(html.contains("</title>")); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | #[test] | |
| 58 | + | fn morph_is_emitted_only_when_the_extension_is_loaded() { | |
| 59 | + | let with = render(&Screen::list_detail("Tasks", false)); | |
| 60 | + | assert!(with.contains("hx-ext=\"morph\"")); | |
| 61 | + | ||
| 62 | + | let without = Webview::new() | |
| 63 | + | .with_shell(Shell::default().without_morph()) | |
| 64 | + | .screen(&Screen::list_detail("Tasks", false)); | |
| 65 | + | assert!(!without.contains("hx-ext=\"morph\"")); | |
| 66 | + | assert!(!without.contains("idiomorph")); | |
| 67 | + | ||
| 68 | + | // The pair that matters: no element may ask for a swap the page cannot | |
| 69 | + | // perform. htmx falls back to innerHTML silently, and silently destructive | |
| 70 | + | // is the outcome decision 7 exists to avoid. | |
| 71 | + | let screen = Screen::list_detail("Tasks", false) | |
| 72 | + | .with(Slot::new("main", RegionKind::Pane).with(Node::act("Go", Action::get("/go")))); | |
| 73 | + | let quiet = Webview::new() | |
| 74 | + | .with_shell(Shell::default().without_morph()) | |
| 75 | + | .screen(&screen); | |
| 76 | + | assert!(!quiet.contains("hx-swap")); | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | #[test] | |
| 80 | + | fn an_action_becomes_the_verb_it_names() { | |
| 81 | + | let get = fragment(&Node::act("Open", Action::get("/tasks/1"))); | |
| 82 | + | assert!(get.contains("hx-get=\"/tasks/1\"")); | |
| 83 | + | assert!(!get.contains("hx-post")); | |
| 84 | + | ||
| 85 | + | let post = fragment(&Node::act("Delete", Action::post("/tasks/1/delete"))); | |
| 86 | + | assert!(post.contains("hx-post=\"/tasks/1/delete\"")); | |
| 87 | + | assert!(!post.contains("hx-get")); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | #[test] | |
| 91 | + | fn params_travel_as_hx_vals_not_as_a_query_string() { | |
| 92 | + | let action = Action::get("/tasks").with("filter", "open").with("sort", "due"); | |
| 93 | + | let html = fragment(&Node::act("Filter", action)); | |
| 94 | + | ||
| 95 | + | assert!(html.contains("hx-get=\"/tasks\"")); | |
| 96 | + | assert!(!html.contains('?')); | |
| 97 | + | assert!(html.contains("hx-vals=\"{"filter":"open","sort":"due"}\"")); | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | #[test] | |
| 101 | + | fn a_param_value_cannot_escape_the_attribute_or_the_json() { | |
| 102 | + | let action = Action::post("/search").with("q", "\" onload=\"steal()"); | |
| 103 | + | let html = fragment(&Node::act("Search", action)); | |
| 104 | + | ||
| 105 | + | assert!(!html.contains("onload=\"steal()")); | |
| 106 | + | assert!(html.contains("\\"")); | |
| 107 | + | ||
| 108 | + | let action = Action::post("/search").with("q", "a\\b\nc"); | |
| 109 | + | let html = fragment(&Node::act("Search", action)); | |
| 110 | + | assert!(html.contains("a\\\\b\\nc")); | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | #[test] | |
| 114 | + | fn no_control_ever_names_its_own_target() { | |
| 115 | + | // Decision 7: the response says what it replaces, through HX-Retarget, | |
| 116 | + | // because the router is the only party that knows what it just changed. | |
| 117 | + | let screen = Screen::list_detail("Tasks", false) | |
| 118 | + | .with( | |
| 119 | + | Slot::new("list", RegionKind::Pane) | |
| 120 | + | .with(Node::list([Row::new("One").activate(Action::get("/tasks/1"))])) | |
| 121 | + | .with(Node::act("New", Action::post("/tasks"))), | |
| 122 | + | ) | |
| 123 | + | .with(Slot::new("detail", RegionKind::Pane).with(Node::text("Nothing selected"))); | |
| 124 | + | ||
| 125 | + | let html = render(&screen); | |
| 126 | + | assert!(!html.contains("hx-target")); | |
| 127 | + | assert!(!html.contains("hx-retarget")); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | #[test] | |
| 131 | + | fn htmx_enters_in_exactly_one_function() { | |
| 132 | + | // The check on decision 13's claim that the transport is replaceable. If | |
| 133 | + | // this fails, `hx-` has leaked out of `action_attrs` and swapping htmx for | |
| 134 | + | // fixi stopped being a one-function change. | |
| 135 | + | let source = include_str!("node.rs"); | |
| 136 | + | let emitted = source | |
| 137 | + | .lines() | |
| 138 | + | .filter(|line| line.contains("\" hx-") || line.contains("hx-ext")) | |
| 139 | + | .count(); | |
| 140 | + | let in_action_attrs = source | |
| 141 | + | .split("pub(crate) fn action_attrs") | |
| 142 | + | .nth(1) | |
| 143 | + | .expect("action_attrs exists") | |
| 144 | + | .split("\n/// ") | |
| 145 | + | .next() | |
| 146 | + | .expect("the function has a body") | |
| 147 | + | .lines() | |
| 148 | + | .filter(|line| line.contains("\" hx-")) | |
| 149 | + | .count(); | |
| 150 | + | assert_eq!( | |
| 151 | + | emitted, in_action_attrs, | |
| 152 | + | "every emitted hx- attribute should come from action_attrs" | |
| 153 | + | ); | |
| 154 | + | assert!(in_action_attrs >= 3, "verb, vals and swap at least"); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | #[test] | |
| 158 | + | fn a_disabled_control_carries_no_address() { | |
| 159 | + | let act = Act::new("Delete", Action::post("/tasks/1/delete")).disabled(); | |
| 160 | + | let html = fragment(&Node::Act(act)); | |
| 161 | + | ||
| 162 | + | assert!(html.contains("disabled")); | |
| 163 | + | assert!(!html.contains("hx-post")); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | #[test] | |
| 167 | + | fn a_row_is_a_control_only_when_selecting_it_does_something() { | |
| 168 | + | let live = fragment(&Node::list([Row::new("One").activate(Action::get("/1"))])); | |
| 169 | + | assert!(live.contains("<button type=\"button\" class=\"row-activate\"")); | |
| 170 | + | ||
| 171 | + | let inert = fragment(&Node::list([Row::new("One")])); | |
| 172 | + | assert!(!inert.contains("<button")); | |
| 173 | + | assert!(inert.contains("<span class=\"row-primary\">One</span>")); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | #[test] | |
| 177 | + | fn a_selected_row_says_so_to_a_screen_reader() { | |
| 178 | + | let html = fragment(&Node::list([Row { | |
| 179 | + | selected: true, | |
| 180 | + | ..Row::new("One") | |
| 181 | + | }])); | |
| 182 | + | assert!(html.contains("aria-current=\"true\"")); | |
| 183 | + | assert!(html.contains("row-selected")); | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | #[test] | |
| 187 | + | fn a_badge_is_not_a_button_and_a_chip_is() { | |
| 188 | + | let badge = fragment(&Node::Token { | |
| 189 | + | kind: layout::Token::Badge, | |
| 190 | + | label: "3".into(), | |
| 191 | + | tone: layout::Tone::Info, | |
| 192 | + | latched: false, | |
| 193 | + | action: Some(Action::get("/x")), | |
| 194 | + | }); | |
| 195 | + | // A badge answers no click however it is dressed, so it emits no transport | |
| 196 | + | // even when a description hands it an action. | |
| 197 | + | assert!(!badge.contains("<button")); | |
| 198 | + | assert!(!badge.contains("hx-get")); | |
| 199 | + | ||
| 200 | + | let chip = fragment(&Node::Token { | |
| 201 | + | kind: layout::Token::Chip { removable: false }, | |
| 202 | + | label: "open".into(), | |
| 203 | + | tone: layout::Tone::Neutral, | |
| 204 | + | latched: true, | |
| 205 | + | action: Some(Action::get("/x")), | |
| 206 | + | }); | |
| 207 | + | assert!(chip.contains("<button")); | |
| 208 | + | assert!(chip.contains("aria-pressed=\"true\"")); | |
| 209 | + | assert!(chip.contains("hx-get=\"/x\"")); | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | #[test] | |
| 213 | + | fn a_select_sends_its_value_under_the_one_agreed_name() { | |
| 214 | + | let html = fragment(&Node::Select { | |
| 215 | + | kind: layout::Selector::Tabs, | |
| 216 | + | options: vec![ | |
| 217 | + | Choice { | |
| 218 | + | value: "open".into(), | |
| 219 | + | label: "Open".into(), | |
| 220 | + | }, | |
| 221 | + | Choice { | |
| 222 | + | value: "done".into(), | |
| 223 | + | label: "Done".into(), | |
| 224 | + | }, | |
| 225 | + | ], | |
| 226 | + | chosen: Some("open".into()), | |
| 227 | + | action: Some(Action::get("/tasks")), | |
| 228 | + | }); | |
| 229 | + | ||
| 230 | + | assert!(html.contains("role=\"tablist\"")); | |
| 231 | + | assert!(html.contains("aria-selected=\"true\"")); | |
| 232 | + | assert!(html.contains("aria-selected=\"false\"")); | |
| 233 | + | assert!(html.contains(&format!(""{}":"open"", Node::SELECTED))); | |
| 234 | + | assert!(html.contains(&format!(""{}":"done"", Node::SELECTED))); | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | #[test] | |
| 238 | + | fn a_pending_region_says_it_is_waiting() { | |
| 239 | + | let screen = Screen::list_detail("Tasks", false) | |
| 240 | + | .with(Slot::new("detail", RegionKind::Pane).pending()); | |
| 241 | + | assert!(render(&screen).contains("aria-busy=\"true\"")); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | #[test] | |
| 245 | + | fn a_bespoke_region_is_a_place_and_nothing_else() { | |
| 246 | + | // Decision 4: the renderer hands the space over under the name the app | |
| 247 | + | // chose and never interprets it. | |
| 248 | + | let screen = Screen::list_detail("Tasks", false) | |
| 249 | + | .with(Slot::bespoke("player", "media-player")); | |
| 250 | + | let html = render(&screen); | |
| 251 | + | ||
| 252 | + | assert!(html.contains("id=\"player\"")); | |
| 253 | + | assert!(html.contains("data-bespoke=\"media-player\"")); | |
| 254 | + | // Empty. Whatever fills it is the app's, per host. | |
| 255 | + | assert!(html.contains("data-bespoke=\"media-player\"></div>")); | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | #[test] | |
| 259 | + | fn a_slot_id_survives_intact_because_a_fragment_is_aimed_at_it() { | |
| 260 | + | let screen = | |
| 261 | + | Screen::sidebar_content("Feeds").with(Slot::new("feed-list", RegionKind::Sidebar)); | |
| 262 | + | assert!(render(&screen).contains("id=\"feed-list\"")); | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | #[test] | |
| 266 | + | fn a_modal_says_it_takes_input_until_dismissed() { | |
| 267 | + | let screen = | |
| 268 | + | Screen::list_detail("Tasks", false).with(Slot::new("confirm", RegionKind::Modal)); | |
| 269 | + | let html = render(&screen); | |
| 270 | + | assert!(html.contains("role=\"dialog\"")); | |
| 271 | + | assert!(html.contains("aria-modal=\"true\"")); | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | #[test] | |
| 275 | + | fn a_table_addresses_its_cells_by_column_never_by_position() { | |
| 276 | + | let columns = vec![ | |
| 277 | + | Column::new("Name").width(layout::Width::Fill), | |
| 278 | + | Column::new("Size").width(layout::Width::Content), | |
| 279 | + | ]; | |
| 280 | + | let html = fragment(&Node::Table { | |
| 281 | + | columns, | |
| 282 | + | rows: vec![Cells::new(["kick.wav", "2.1 MB"])], | |
| 283 | + | }); | |
| 284 | + | ||
| 285 | + | assert!(html.contains("role=\"table\"")); | |
| 286 | + | assert!(html.contains("role=\"columnheader\"")); | |
| 287 | + | assert!(html.contains("kick.wav")); | |
| 288 | + | ||
| 289 | + | // The tracks are the stylesheet's, emitted by `narrowing_css` alongside the | |
| 290 | + | // hiding rules. Inline tracks here would be the widest layout outranking | |
| 291 | + | // the narrow ones from inside the markup. | |
| 292 | + | assert!(!html.contains("grid-template-columns")); | |
| 293 | + | assert!(!html.contains("style=")); | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | #[test] | |
| 297 | + | fn a_cell_value_is_text_and_cannot_become_markup() { | |
| 298 | + | let html = fragment(&Node::Table { | |
| 299 | + | columns: vec![Column::new("Name").width(layout::Width::Fill)], | |
| 300 | + | rows: vec![Cells::new(["<img src=x onerror=alert(1)>"])], | |
| 301 | + | }); | |
| 302 | + | assert!(!html.contains("<img")); | |
| 303 | + | assert!(html.contains("<img")); | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | #[test] | |
| 307 | + | fn a_form_borrows_its_fields_rather_than_emitting_them_twice() { | |
| 308 | + | let html = fragment(&Node::Form { | |
| 309 | + | action: Action::post("/tasks"), | |
| 310 | + | submit: "Save".into(), | |
| 311 | + | fields: vec![Field::new(layout::FieldKind::Text, "title", "Title").required()], | |
| 312 | + | }); | |
| 313 | + | ||
| 314 | + | assert!(html.contains("hx-post=\"/tasks\"")); | |
| 315 | + | assert!(html.contains("name=\"title\"")); | |
| 316 | + | assert!(html.contains("required")); | |
| 317 | + | assert!(html.contains("<button type=\"submit\"")); | |
| 318 | + | // The anatomy is makeover-webview's, so the label association it emits is | |
| 319 | + | // the one every app already gets. | |
| 320 | + | assert!(html.contains("<label")); | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | #[test] | |
| 324 | + | fn a_notice_interrupts_only_when_its_tone_says_to() { | |
| 325 | + | let danger = fragment(&Node::banner(layout::Tone::Danger, "Disk full")); | |
| 326 | + | assert!(danger.contains("role=\"alert\"")); | |
| 327 | + | ||
| 328 | + | let info = fragment(&Node::toast(layout::Tone::Info, "Saved")); | |
| 329 | + | assert!(info.contains("role=\"status\"")); | |
| 330 | + | assert!(info.contains("aria-live=\"polite\"")); | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | #[test] | |
| 334 | + | fn a_screens_notices_come_before_its_regions() { | |
| 335 | + | let screen = Screen::list_detail("Tasks", false) | |
| 336 | + | .with(Slot::new("list", RegionKind::Pane)) | |
| 337 | + | .saying(Node::toast(layout::Tone::Success, "Saved")); | |
| 338 | + | let html = render(&screen); | |
| 339 | + | ||
| 340 | + | let notice = html.find("Saved").expect("the notice is rendered"); | |
| 341 | + | let region = html.find("id=\"list\"").expect("the region is rendered"); | |
| 342 | + | assert!(notice < region); | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | #[test] | |
| 346 | + | fn every_arrangement_has_a_class_and_they_differ() { | |
| 347 | + | let plain = render(&Screen::list_detail("A", false)); | |
| 348 | + | let tabbed = render(&Screen::list_detail("A", true)); | |
| 349 | + | let sidebar = render(&Screen::sidebar_content("A")); | |
| 350 | + | ||
| 351 | + | assert!(plain.contains("class=\"list-detail\"")); | |
| 352 | + | assert!(tabbed.contains("class=\"list-detail-tabbed\"")); | |
| 353 | + | assert!(sidebar.contains("class=\"sidebar-content\"")); | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | #[test] | |
| 357 | + | fn a_class_prefix_reaches_every_emitted_name() { | |
| 358 | + | let emit = crate::Emit { | |
| 359 | + | class_prefix: "qs-", | |
| 360 | + | ..crate::Emit::default() | |
| 361 | + | }; | |
| 362 | + | ||
| 363 | + | let screen = Screen::list_detail("Tasks", false).with( | |
| 364 | + | Slot::new("list", RegionKind::Pane) | |
| 365 | + | .with(Node::page("Tasks")) | |
| 366 | + | .with(Node::list([Row::new("One")])), | |
| 367 | + | ); | |
| 368 | + | let html = Webview::new().with_emit(emit).screen(&screen); | |
| 369 | + | ||
| 370 | + | assert!(html.contains("class=\"qs-list-detail\"")); | |
| 371 | + | assert!(html.contains("qs-region qs-pane")); | |
| 372 | + | assert!(html.contains("qs-heading")); | |
| 373 | + | assert!(html.contains("qs-list")); | |
| 374 | + | // No unprefixed leftovers: a name that missed the prefix is a rule in the | |
| 375 | + | // generated stylesheet that matches nothing. | |
| 376 | + | assert!(!html.contains("class=\"list\"")); | |
| 377 | + | assert!(!html.contains("class=\"heading\"")); | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | #[test] | |
| 381 | + | fn the_shell_serves_its_assets_from_where_the_host_says() { | |
| 382 | + | let html = Webview::under("/assets").screen(&Screen::list_detail("A", false)); | |
| 383 | + | assert!(html.contains("src=\"/assets/htmx.min.js\"")); | |
| 384 | + | assert!(html.contains("src=\"/assets/idiomorph-ext.min.js\"")); | |
| 385 | + | ||
| 386 | + | // The Tauri case: a custom scheme, which is the whole reason this is a | |
| 387 | + | // parameter and not a constant. | |
| 388 | + | let tauri = Webview::under("quasi://localhost/assets").screen(&Screen::list_detail("A", false)); | |
| 389 | + | assert!(tauri.contains("src=\"quasi://localhost/assets/htmx.min.js\"")); | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | #[test] | |
| 393 | + | fn stylesheets_link_in_the_order_they_were_added() { | |
| 394 | + | let shell = Shell::default().styled("/a.css").styled("/b.css"); | |
| 395 | + | let html = Webview::new().with_shell(shell).screen(&Screen::list_detail("A", false)); | |
| 396 | + | ||
| 397 | + | let a = html.find("/a.css").expect("a is linked"); | |
| 398 | + | let b = html.find("/b.css").expect("b is linked"); | |
| 399 | + | assert!(a < b); | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | #[test] | |
| 403 | + | fn injected_head_markup_lands_last_so_it_can_override() { | |
| 404 | + | let shell = Shell::default().with_head("<link rel=\"icon\" href=\"/f.png\">"); | |
| 405 | + | let html = Webview::new().with_shell(shell).screen(&Screen::list_detail("A", false)); | |
| 406 | + | ||
| 407 | + | let icon = html.find("/f.png").expect("the icon is linked"); | |
| 408 | + | let htmx = html.find("htmx.min.js").expect("htmx is linked"); | |
| 409 | + | assert!(htmx < icon); | |
| 410 | + | assert!(icon < html.find("</head>").expect("the head closes")); | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | #[test] | |
| 414 | + | fn a_nested_region_renders_inside_its_parent() { | |
| 415 | + | let screen = Screen::list_detail("Tasks", false).with( | |
| 416 | + | Slot::new("outer", RegionKind::Split) | |
| 417 | + | .with(Node::Region(Slot::new("inner", RegionKind::Pane).with(Node::text("in")))), | |
| 418 | + | ); | |
| 419 | + | let html = render(&screen); | |
| 420 | + | ||
| 421 | + | let outer = html.find("id=\"outer\"").expect("outer renders"); | |
| 422 | + | let inner = html.find("id=\"inner\"").expect("inner renders"); | |
| 423 | + | assert!(outer < inner); | |
| 424 | + | assert!(html.contains("in</p></div></div>")); | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | #[test] | |
| 428 | + | fn text_from_a_description_can_never_become_markup() { | |
| 429 | + | // The property that has to hold across every variant, because a | |
| 430 | + | // description's strings come from application state. Checked over the whole | |
| 431 | + | // tree rather than per node, so a variant added without escaping fails | |
| 432 | + | // here rather than in production. | |
| 433 | + | let hostile = "<script>alert(1)</script>"; | |
| 434 | + | let screen = Screen::list_detail(hostile, false) | |
| 435 | + | .saying(Node::banner(layout::Tone::Danger, hostile)) | |
| 436 | + | .with( | |
| 437 | + | Slot::new("s", RegionKind::Pane) | |
| 438 | + | .with(Node::page(hostile)) | |
| 439 | + | .with(Node::text(hostile)) | |
| 440 | + | .with(Node::act(hostile, Action::get("/x"))) | |
| 441 | + | .with(Node::list([Row::new(hostile) | |
| 442 | + | .secondary(hostile) | |
| 443 | + | .meta(hostile) | |
| 444 | + | .act(Act::new(hostile, Action::post("/y")))])) | |
| 445 | + | .with(Node::Token { | |
| 446 | + | kind: layout::Token::Chip { removable: true }, | |
| 447 | + | label: hostile.into(), | |
| 448 | + | tone: layout::Tone::Neutral, | |
| 449 | + | latched: false, | |
| 450 | + | action: Some(Action::get("/z")), | |
| 451 | + | }), | |
| 452 | + | ); | |
| 453 | + | ||
| 454 | + | let html = render(&screen); | |
| 455 | + | assert!(!html.contains("<script>")); | |
| 456 | + | // Ten sinks: the title, the notice, the heading, the prose, the act's | |
| 457 | + | // label, the row's four parts, and the chip's label. Counted rather than | |
| 458 | + | // merely checked for absence, so a variant that silently stops rendering | |
| 459 | + | // its text fails here too. | |
| 460 | + | assert_eq!(html.matches("<script>").count(), 10); | |
| 461 | + | } |