max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
7 files changed,
+732 insertions,
-105 deletions
| @@ -101,6 +101,48 @@ | |||
| 101 | 101 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 102 | 102 | checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" | |
| 103 | 103 | ||
| 104 | + | [[package]] | |
| 105 | + | name = "askama" | |
| 106 | + | version = "0.14.0" | |
| 107 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 108 | + | checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" | |
| 109 | + | dependencies = [ | |
| 110 | + | "askama_derive", | |
| 111 | + | "itoa", | |
| 112 | + | "percent-encoding", | |
| 113 | + | "serde", | |
| 114 | + | "serde_json", | |
| 115 | + | ] | |
| 116 | + | ||
| 117 | + | [[package]] | |
| 118 | + | name = "askama_derive" | |
| 119 | + | version = "0.14.0" | |
| 120 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 121 | + | checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" | |
| 122 | + | dependencies = [ | |
| 123 | + | "askama_parser", | |
| 124 | + | "basic-toml", | |
| 125 | + | "memchr", | |
| 126 | + | "proc-macro2", | |
| 127 | + | "quote", | |
| 128 | + | "rustc-hash 2.1.3", | |
| 129 | + | "serde", | |
| 130 | + | "serde_derive", | |
| 131 | + | "syn 2.0.119", | |
| 132 | + | ] | |
| 133 | + | ||
| 134 | + | [[package]] | |
| 135 | + | name = "askama_parser" | |
| 136 | + | version = "0.14.0" | |
| 137 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 138 | + | checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" | |
| 139 | + | dependencies = [ | |
| 140 | + | "memchr", | |
| 141 | + | "serde", | |
| 142 | + | "serde_derive", | |
| 143 | + | "winnow 0.7.15", | |
| 144 | + | ] | |
| 145 | + | ||
| 104 | 146 | [[package]] | |
| 105 | 147 | name = "async-broadcast" | |
| 106 | 148 | version = "0.7.2" | |
| @@ -346,6 +388,15 @@ | |||
| 346 | 388 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 347 | 389 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | |
| 348 | 390 | ||
| 391 | + | [[package]] | |
| 392 | + | name = "basic-toml" | |
| 393 | + | version = "0.1.10" | |
| 394 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 395 | + | checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" | |
| 396 | + | dependencies = [ | |
| 397 | + | "serde", | |
| 398 | + | ] | |
| 399 | + | ||
| 349 | 400 | [[package]] | |
| 350 | 401 | name = "bit-set" | |
| 351 | 402 | version = "0.8.0" | |
| @@ -3455,10 +3506,12 @@ | |||
| 3455 | 3506 | name = "quasi-bench" | |
| 3456 | 3507 | version = "0.94.0" | |
| 3457 | 3508 | dependencies = [ | |
| 3509 | + | "askama", | |
| 3458 | 3510 | "dhat", | |
| 3459 | 3511 | "makeover", | |
| 3460 | 3512 | "makeover-layout", | |
| 3461 | 3513 | "makeover-tui", | |
| 3514 | + | "makeover-webview", | |
| 3462 | 3515 | "quasi-http", | |
| 3463 | 3516 | "quasi-router", | |
| 3464 | 3517 | "quasi-tui", | |
| @@ -5900,6 +5953,9 @@ | |||
| 5900 | 5953 | version = "0.7.15" | |
| 5901 | 5954 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5902 | 5955 | checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" | |
| 5956 | + | dependencies = [ | |
| 5957 | + | "memchr", | |
| 5958 | + | ] | |
| 5903 | 5959 | ||
| 5904 | 5960 | [[package]] | |
| 5905 | 5961 | name = "winnow" | |
| @@ -6215,10 +6271,6 @@ | |||
| 6215 | 6271 | name = "synckit-client" | |
| 6216 | 6272 | version = "0.10.0" | |
| 6217 | 6273 | ||
| 6218 | - | [[patch.unused]] | |
| 6219 | - | name = "quasi-type" | |
| 6220 | - | version = "0.1.3" | |
| 6221 | - | ||
| 6222 | 6274 | [[patch.unused]] | |
| 6223 | 6275 | name = "kberg" | |
| 6224 | 6276 | version = "0.1.0" | |
| @@ -6234,3 +6286,7 @@ | |||
| 6234 | 6286 | [[patch.unused]] | |
| 6235 | 6287 | name = "tagtree" | |
| 6236 | 6288 | version = "0.4.1" | |
| 6289 | + | ||
| 6290 | + | [[patch.unused]] | |
| 6291 | + | name = "quasi-type" | |
| 6292 | + | version = "0.1.3" |
| @@ -20,6 +20,10 @@ | |||
| 20 | 20 | # two builds: `cargo run --release -p quasi-bench` times, and | |
| 21 | 21 | # `cargo run --release -p quasi-bench --features count` counts. | |
| 22 | 22 | count = ["dep:dhat"] | |
| 23 | + | # The generated template, compiled. Off by default so the ordinary bench does | |
| 24 | + | # not carry a templating engine; on, it adds the third column that says whether | |
| 25 | + | # a staged description reaches a compiled template. | |
| 26 | + | askama = ["dep:askama"] | |
| 23 | 27 | ||
| 24 | 28 | [dependencies] | |
| 25 | 29 | quasi-router = { path = "../quasi-router", version = "0.94.0" } | |
| @@ -30,6 +34,9 @@ | |||
| 30 | 34 | # because a bench calling a trait method should name the trait it calls. | |
| 31 | 35 | quasi-http = { path = "../quasi-http", version = "0.94.0" } | |
| 32 | 36 | makeover-layout = "0.41.0" | |
| 37 | + | # The renderer's own escaper, so the staged path fills a hole exactly the way | |
| 38 | + | # `Node::Text` does and the guarantee is not quietly reimplemented. | |
| 39 | + | makeover-webview = "0.70.0" | |
| 33 | 40 | makeover-tui = { version = "0.41.0", features = ["theme"] } | |
| 34 | 41 | # Only to load a bundled theme file. `makeover_tui::Theme` is `#[non_exhaustive]`, | |
| 35 | 42 | # so `Theme::from_theme` is the one way to get one, the same reason quasi-tui's | |
| @@ -41,3 +48,6 @@ | |||
| 41 | 48 | # `unsafe_code` and `forbid` cannot be overridden per crate; see | |
| 42 | 49 | # `src/counting.rs`. | |
| 43 | 50 | dhat = { version = "0.3", optional = true } | |
| 51 | + | # Compiles `templates/contacts.html`, which is emitted from the description by | |
| 52 | + | # `QUASI_EMIT_TEMPLATE=... cargo run -p quasi-bench` and never hand-edited. | |
| 53 | + | askama = { version = "0.14", optional = true } |
| @@ -14,12 +14,9 @@ | |||
| 14 | 14 | //! | |
| 15 | 15 | //! The row counts are the bench's axis, not the screen's: see [`SIZES`]. | |
| 16 | 16 | ||
| 17 | - | use makeover_layout as layout; | |
| 18 | - | use quasi_router::screen::{Act, Cell, Cells, Column}; | |
| 19 | - | use quasi_router::{Action, Node, RegionKind, Slot}; | |
| 17 | + | use quasi_router::Node; | |
| 20 | 18 | ||
| 21 | - | /// The region id, matching the original screen's. | |
| 22 | - | const REGION: &str = "tab-content"; | |
| 19 | + | use crate::staging; | |
| 23 | 20 | ||
| 24 | 21 | /// Row counts, and why these three. | |
| 25 | 22 | /// | |
| @@ -30,19 +27,19 @@ | |||
| 30 | 27 | pub const SIZES: [usize; 3] = [5, 25, 200]; | |
| 31 | 28 | ||
| 32 | 29 | /// One buyer row's worth of strings. | |
| 33 | - | struct Buyer { | |
| 34 | - | username: String, | |
| 35 | - | email: String, | |
| 36 | - | purchases: String, | |
| 37 | - | spent: String, | |
| 38 | - | last_purchase: String, | |
| 30 | + | pub(crate) struct Buyer { | |
| 31 | + | pub(crate) username: String, | |
| 32 | + | pub(crate) email: String, | |
| 33 | + | pub(crate) purchases: String, | |
| 34 | + | pub(crate) spent: String, | |
| 35 | + | pub(crate) last_purchase: String, | |
| 39 | 36 | } | |
| 40 | 37 | ||
| 41 | 38 | /// One shared-with row's worth of strings. | |
| 42 | - | struct Shared { | |
| 43 | - | seller_id: String, | |
| 44 | - | username: String, | |
| 45 | - | name: String, | |
| 39 | + | pub(crate) struct Shared { | |
| 40 | + | pub(crate) seller_id: String, | |
| 41 | + | pub(crate) username: String, | |
| 42 | + | pub(crate) name: String, | |
| 46 | 43 | } | |
| 47 | 44 | ||
| 48 | 45 | /// Rows built once, outside the timed region. | |
| @@ -52,8 +49,8 @@ | |||
| 52 | 49 | /// same string, which keeps an allocator from making a repeated value look | |
| 53 | 50 | /// cheaper than a real one. | |
| 54 | 51 | pub struct Rows { | |
| 55 | - | buyers: Vec<Buyer>, | |
| 56 | - | shared: Vec<Shared>, | |
| 52 | + | pub(crate) buyers: Vec<Buyer>, | |
| 53 | + | pub(crate) shared: Vec<Shared>, | |
| 57 | 54 | } | |
| 58 | 55 | ||
| 59 | 56 | impl Rows { | |
| @@ -85,87 +82,11 @@ | |||
| 85 | 82 | /// Rebuilt per iteration on purpose. Building the description is part of what a | |
| 86 | 83 | /// request pays, and a bench that hoisted it out would measure the renderer | |
| 87 | 84 | /// against a tree the handler never actually has to make. | |
| 85 | + | /// | |
| 86 | + | /// The shape itself lives in [`crate::staging`], written once against the | |
| 87 | + | /// `Data` seam so that the runtime path and the staged path cannot drift. This | |
| 88 | + | /// is that one description evaluated over real strings. | |
| 88 | 89 | #[must_use] | |
| 89 | 90 | pub fn pane(rows: &Rows) -> Node { | |
| 90 | - | let slot = Slot::new(REGION, RegionKind::Pane) | |
| 91 | - | .with(Node::section(format!( | |
| 92 | - | "Your Buyers ({})", | |
| 93 | - | rows.buyers.len() | |
| 94 | - | ))) | |
| 95 | - | .with(Node::text( | |
| 96 | - | "Buyers who opted to share their email with you at purchase time.", | |
| 97 | - | )) | |
| 98 | - | .with(buyers_table(&rows.buyers)) | |
| 99 | - | .with(Node::section("Shared With")) | |
| 100 | - | .with(Node::text( | |
| 101 | - | "You've shared your email with these creators. You can revoke sharing at any time.", | |
| 102 | - | )) | |
| 103 | - | .with(shared_table(&rows.shared)); | |
| 104 | - | ||
| 105 | - | Node::Region(slot) | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | /// Five columns, three widths, two priorities, two flavours of linked value. | |
| 109 | - | fn buyers_table(buyers: &[Buyer]) -> Node { | |
| 110 | - | Node::Table { | |
| 111 | - | columns: vec![ | |
| 112 | - | Column::new("Username") | |
| 113 | - | .width(layout::Width::Content) | |
| 114 | - | .priority(layout::Priority::Essential), | |
| 115 | - | Column::new("Email") | |
| 116 | - | .width(layout::Width::Fill) | |
| 117 | - | .priority(layout::Priority::Essential), | |
| 118 | - | Column::new("Purchases").width(layout::Width::Content), | |
| 119 | - | Column::new("Total Spent").width(layout::Width::Content), | |
| 120 | - | Column::new("Last Purchase") | |
| 121 | - | .width(layout::Width::Content) | |
| 122 | - | .priority(layout::Priority::Optional), | |
| 123 | - | ], | |
| 124 | - | rows: buyers | |
| 125 | - | .iter() | |
| 126 | - | .map(|buyer| { | |
| 127 | - | Cells::new([ | |
| 128 | - | Cell::new(buyer.username.clone()) | |
| 129 | - | .activate(Action::get(format!("/u/{}", buyer.username))), | |
| 130 | - | Cell::new(buyer.email.clone()) | |
| 131 | - | .activate(Action::external(format!("mailto:{}", buyer.email))), | |
| 132 | - | Cell::new(buyer.purchases.clone()), | |
| 133 | - | Cell::new(buyer.spent.clone()), | |
| 134 | - | Cell::new(buyer.last_purchase.clone()), | |
| 135 | - | ]) | |
| 136 | - | }) | |
| 137 | - | .collect(), | |
| 138 | - | more: None, | |
| 139 | - | } | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | /// Two columns, one of them a destructive act. | |
| 143 | - | fn shared_table(shared: &[Shared]) -> Node { | |
| 144 | - | Node::Table { | |
| 145 | - | columns: vec![ | |
| 146 | - | Column::new("Creator") | |
| 147 | - | .width(layout::Width::Fill) | |
| 148 | - | .priority(layout::Priority::Essential), | |
| 149 | - | Column::new("") | |
| 150 | - | .width(layout::Width::Content) | |
| 151 | - | .priority(layout::Priority::Essential), | |
| 152 | - | ], | |
| 153 | - | rows: shared | |
| 154 | - | .iter() | |
| 155 | - | .map(|creator| { | |
| 156 | - | Cells::new([ | |
| 157 | - | Cell::new(creator.name.clone()) | |
| 158 | - | .activate(Action::get(format!("/u/{}", creator.username))), | |
| 159 | - | Cell::acts([Act::new( | |
| 160 | - | "Revoke", | |
| 161 | - | Action::delete(format!( | |
| 162 | - | "/library/tabs/contacts/revoke/{}", | |
| 163 | - | creator.seller_id | |
| 164 | - | )), | |
| 165 | - | )]), | |
| 166 | - | ]) | |
| 167 | - | }) | |
| 168 | - | .collect(), | |
| 169 | - | more: None, | |
| 170 | - | } | |
| 91 | + | staging::describe(&staging::Live(rows)) | |
| 171 | 92 | } |
| @@ -39,6 +39,10 @@ | |||
| 39 | 39 | //! those are the two things a route actually answers and the shell is not free. | |
| 40 | 40 | ||
| 41 | 41 | mod fixture; | |
| 42 | + | mod staging; | |
| 43 | + | ||
| 44 | + | #[cfg(feature = "askama")] | |
| 45 | + | mod compiled; | |
| 42 | 46 | ||
| 43 | 47 | #[cfg(feature = "count")] | |
| 44 | 48 | mod counting; | |
| @@ -109,13 +113,48 @@ | |||
| 109 | 113 | println!("nanoseconds per render, best of {PASSES} passes of {ITERATIONS}\n"); | |
| 110 | 114 | ||
| 111 | 115 | println!( | |
| 112 | - | "{:>5} {:>22} {:>22} {:>22}", | |
| 113 | - | "rows", "webview fragment", "webview screen", "terminal" | |
| 116 | + | "{:>5} {:>13} {:>13} {:>13} {:>13} {:>13} {:>13}", | |
| 117 | + | "rows", "build tree", "render tree", "build+render", "staged", COMPILED_HEAD, "terminal" | |
| 114 | 118 | ); | |
| 115 | 119 | ||
| 120 | + | // The residual, emitted once. A build-time artifact in a real host; emitted | |
| 121 | + | // here at startup so the spike needs no build script to answer the | |
| 122 | + | // question it exists to answer. | |
| 123 | + | let program = staging::emit(); | |
| 124 | + | ||
| 125 | + | // The proposal, written to disk: the description compiled to an Askama | |
| 126 | + | // template. Not on the timing path; this is how the file that the `askama` | |
| 127 | + | // feature compiles gets regenerated when the description changes. | |
| 128 | + | if let Ok(path) = std::env::var("QUASI_EMIT_TEMPLATE") { | |
| 129 | + | std::fs::write(&path, staging::as_askama_template(&program)) | |
| 130 | + | .expect("the emitted template is writable"); | |
| 131 | + | println!("wrote {path}"); | |
| 132 | + | return; | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | check_staged_matches(&webview, &program); | |
| 136 | + | ||
| 116 | 137 | for size in fixture::SIZES { | |
| 117 | 138 | let rows = fixture::Rows::new(size); | |
| 118 | 139 | ||
| 140 | + | // The tree on its own: what a description costs to construct before | |
| 141 | + | // anything has been rendered. This is the half staging deletes. | |
| 142 | + | let build = measure(|| { | |
| 143 | + | black_box(fixture::pane(black_box(&rows))); | |
| 144 | + | }); | |
| 145 | + | // The renderer on its own, over a tree it did not have to build. The | |
| 146 | + | // closest thing in this bench to what a compiled template does, and | |
| 147 | + | // therefore the floor the staged column is trying to reach. | |
| 148 | + | let prebuilt = fixture::pane(&rows); | |
| 149 | + | let render = measure(|| { | |
| 150 | + | black_box(webview.fragment(black_box(&prebuilt))); | |
| 151 | + | }); | |
| 152 | + | let staged = measure(|| { | |
| 153 | + | let mut out = String::new(); | |
| 154 | + | staging::exec(&program, black_box(&rows), &mut out); | |
| 155 | + | black_box(out); | |
| 156 | + | }); | |
| 157 | + | ||
| 119 | 158 | let fragment = measure(|| { | |
| 120 | 159 | let node = fixture::pane(&rows); | |
| 121 | 160 | black_box(webview.fragment(black_box(&node))); | |
| @@ -137,7 +176,11 @@ | |||
| 137 | 176 | black_box(&buf); | |
| 138 | 177 | }); | |
| 139 | 178 | ||
| 140 | - | println!("{size:>5} {fragment:>22} {screen:>22} {terminal:>22}"); | |
| 179 | + | let compiled = measure_compiled(&webview, &rows, &screen); | |
| 180 | + | ||
| 181 | + | println!( | |
| 182 | + | "{size:>5} {build:>13} {render:>13} {fragment:>13} {staged:>13} {compiled:>13} {terminal:>13}" | |
| 183 | + | ); | |
| 141 | 184 | } | |
| 142 | 185 | ||
| 143 | 186 | #[cfg(not(feature = "count"))] | |
| @@ -213,3 +256,54 @@ | |||
| 213 | 256 | bytes_after - bytes_before | |
| 214 | 257 | ) | |
| 215 | 258 | } | |
| 259 | + | ||
| 260 | + | /// The residual must produce what the renderer produces. | |
| 261 | + | /// | |
| 262 | + | /// Byte-identity is the right assertion *here* and nowhere else in this design: | |
| 263 | + | /// it is not an acceptance test for staging, it is the check that the emitter | |
| 264 | + | /// dropped nothing. The staged path wins by deleting the tree, not by changing | |
| 265 | + | /// the markup, so at this stage the bytes should still agree. Every further | |
| 266 | + | /// optimisation available to an emitter (fusing adjacent literals, hoisting | |
| 267 | + | /// per-row constants, dropping wrappers whose region name is resolved at emit | |
| 268 | + | /// time) changes the bytes on purpose, and this assertion is what would be | |
| 269 | + | /// relaxed to take them. | |
| 270 | + | fn check_staged_matches(webview: &Webview, program: &[staging::Op]) { | |
| 271 | + | for size in fixture::SIZES { | |
| 272 | + | let rows = fixture::Rows::new(size); | |
| 273 | + | let direct = webview.fragment(&fixture::pane(&rows)); | |
| 274 | + | let mut staged = String::new(); | |
| 275 | + | staging::exec(program, &rows, &mut staged); | |
| 276 | + | assert_eq!( | |
| 277 | + | direct.len(), | |
| 278 | + | staged.len(), | |
| 279 | + | "staged output differs in length at {size} rows" | |
| 280 | + | ); | |
| 281 | + | assert!( | |
| 282 | + | direct == staged, | |
| 283 | + | "staged output differs from the renderer at {size} rows" | |
| 284 | + | ); | |
| 285 | + | } | |
| 286 | + | println!("residual verified against the renderer at every row count\n"); | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | /// The heading of the sixth column, which depends on what was compiled in. | |
| 290 | + | #[cfg(feature = "askama")] | |
| 291 | + | const COMPILED_HEAD: &str = "askama"; | |
| 292 | + | #[cfg(not(feature = "askama"))] | |
| 293 | + | const COMPILED_HEAD: &str = "webview screen"; | |
| 294 | + | ||
| 295 | + | /// The generated template, rendered. | |
| 296 | + | #[cfg(feature = "askama")] | |
| 297 | + | fn measure_compiled(_webview: &Webview, rows: &fixture::Rows, _screen: &str) -> String { | |
| 298 | + | use askama::Template as _; | |
| 299 | + | measure(|| { | |
| 300 | + | let page = compiled::Contacts::new(black_box(rows)); | |
| 301 | + | black_box(page.render().expect("the generated template renders")); | |
| 302 | + | }) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | /// Without the feature this column stays what it was: the whole-document cost. | |
| 306 | + | #[cfg(not(feature = "askama"))] | |
| 307 | + | fn measure_compiled(_webview: &Webview, _rows: &fixture::Rows, screen: &str) -> String { | |
| 308 | + | screen.to_string() | |
| 309 | + | } |
| @@ -1,0 +1,45 @@ | |||
| 1 | + | //! The emitted template, compiled. | |
| 2 | + | //! | |
| 3 | + | //! `templates/contacts.html` is generated from the description by | |
| 4 | + | //! [`crate::staging::as_askama_template`] and is never hand-edited. Regenerate | |
| 5 | + | //! it with: | |
| 6 | + | //! | |
| 7 | + | //! ```text | |
| 8 | + | //! QUASI_EMIT_TEMPLATE=crates/quasi-bench/templates/contacts.html \ | |
| 9 | + | //! cargo run --release -p quasi-bench --features askama | |
| 10 | + | //! ``` | |
| 11 | + | //! | |
| 12 | + | //! This column is the point of the whole spike. The description is the source, | |
| 13 | + | //! the template is an intermediate nobody writes, and Askama compiles that | |
| 14 | + | //! intermediate to straight-line Rust. If this and the staged column are close, | |
| 15 | + | //! then a semantic starting point costs nothing over a compiled template and | |
| 16 | + | //! the residual can stay a data program; if this is much faster, the gap is | |
| 17 | + | //! enum dispatch and the answer is to generate code rather than a table. | |
| 18 | + | ||
| 19 | + | use askama::Template; | |
| 20 | + | ||
| 21 | + | use crate::fixture::{Buyer, Rows, Shared}; | |
| 22 | + | ||
| 23 | + | /// The context the generated template reads. | |
| 24 | + | /// | |
| 25 | + | /// Field names are the ones [`crate::staging::expr`] emits, so the template and | |
| 26 | + | /// this struct move together or the build fails, which is the failure mode to | |
| 27 | + | /// want. | |
| 28 | + | #[derive(Template)] | |
| 29 | + | #[template(path = "contacts.html")] | |
| 30 | + | pub(crate) struct Contacts<'a> { | |
| 31 | + | pub(crate) count: usize, | |
| 32 | + | pub(crate) buyers: &'a [Buyer], | |
| 33 | + | pub(crate) shared: &'a [Shared], | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | impl<'a> Contacts<'a> { | |
| 37 | + | #[must_use] | |
| 38 | + | pub(crate) fn new(rows: &'a Rows) -> Self { | |
| 39 | + | Self { | |
| 40 | + | count: rows.buyers.len(), | |
| 41 | + | buyers: &rows.buyers, | |
| 42 | + | shared: &rows.shared, | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | } |
| @@ -1,0 +1,500 @@ | |||
| 1 | + | //! Staging: the same description, evaluated twice. | |
| 2 | + | //! | |
| 3 | + | //! A spike, measuring one claim: that a semantic description can be partially | |
| 4 | + | //! evaluated into a residual program, and that the residual reaches the speed | |
| 5 | + | //! of a compiled template while the description stays the only thing anyone | |
| 6 | + | //! writes. | |
| 7 | + | //! | |
| 8 | + | //! # The mechanism | |
| 9 | + | //! | |
| 10 | + | //! [`describe`] is generic over [`Data`]. Two implementations exist: | |
| 11 | + | //! | |
| 12 | + | //! - [`Live`] answers with the real strings a request has. | |
| 13 | + | //! - [`Symbolic`] answers with a fixed-width sentinel per field, and reports | |
| 14 | + | //! whatever row count the emitter asks for. | |
| 15 | + | //! | |
| 16 | + | //! Running `describe` over `Symbolic` and handing the result to the ordinary | |
| 17 | + | //! [`Webview`] renderer produces the screen's markup with sentinels where the | |
| 18 | + | //! data would be. That string is the residual. Splitting it on the sentinels | |
| 19 | + | //! gives a program of literals and holes, and the loop bodies are recovered by | |
| 20 | + | //! rendering twice at different row counts and taking the difference. | |
| 21 | + | //! | |
| 22 | + | //! The renderer is not reimplemented anywhere here. The emitted literals are | |
| 23 | + | //! the renderer's own output, which is what makes this a staging of the | |
| 24 | + | //! existing description layer rather than a second one. | |
| 25 | + | //! | |
| 26 | + | //! # What it does not cover, stated rather than implied | |
| 27 | + | //! | |
| 28 | + | //! `library_contacts` has no conditional in its shape, so nothing here | |
| 29 | + | //! exercises the case where a branch changes the tree. That is the constraint | |
| 30 | + | //! this approach really carries: a Rust `if` inside a shape function has no | |
| 31 | + | //! value for a symbolic pass to branch on, and would have to be expressed as a | |
| 32 | + | //! combinator the emitter can see. 56 `if` sites and 3 `match` sites across the | |
| 33 | + | //! server's shape functions are the size of that. | |
| 34 | + | //! | |
| 35 | + | //! Escaping is not reimplemented either: [`exec`] fills every hole through | |
| 36 | + | //! `makeover_webview::form::escape_into`, the same function the renderer uses, | |
| 37 | + | //! so the guarantee `Node::Text` carries survives into the residual. | |
| 38 | + | ||
| 39 | + | use std::fmt::Write as _; | |
| 40 | + | ||
| 41 | + | use makeover_layout as layout; | |
| 42 | + | use makeover_webview::form::escape_into; | |
| 43 | + | use quasi_http::Serves; | |
| 44 | + | use quasi_router::screen::{Act, Cell, Cells, Column}; | |
| 45 | + | use quasi_router::{Action, Node, RegionKind, Slot}; | |
| 46 | + | use quasi_webview::Webview; | |
| 47 | + | ||
| 48 | + | use crate::fixture::Rows; | |
| 49 | + | ||
| 50 | + | /// The region id, matching the original screen's. | |
| 51 | + | const REGION: &str = "tab-content"; | |
| 52 | + | ||
| 53 | + | /// A field of the buyers table. | |
| 54 | + | #[derive(Clone, Copy)] | |
| 55 | + | pub enum B { | |
| 56 | + | Username, | |
| 57 | + | Email, | |
| 58 | + | Purchases, | |
| 59 | + | Spent, | |
| 60 | + | Last, | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// A field of the shared-with table. | |
| 64 | + | #[derive(Clone, Copy)] | |
| 65 | + | pub enum S { | |
| 66 | + | Name, | |
| 67 | + | Username, | |
| 68 | + | Id, | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// Hole ids. One number per field, plus the scalar in the heading. | |
| 72 | + | const H_COUNT: u16 = 0; | |
| 73 | + | ||
| 74 | + | impl B { | |
| 75 | + | fn hole(self) -> u16 { | |
| 76 | + | match self { | |
| 77 | + | B::Username => 1, | |
| 78 | + | B::Email => 2, | |
| 79 | + | B::Purchases => 3, | |
| 80 | + | B::Spent => 4, | |
| 81 | + | B::Last => 5, | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | impl S { | |
| 87 | + | fn hole(self) -> u16 { | |
| 88 | + | match self { | |
| 89 | + | S::Name => 6, | |
| 90 | + | S::Username => 7, | |
| 91 | + | S::Id => 8, | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// Where a description gets its values. | |
| 97 | + | /// | |
| 98 | + | /// The whole staging story is that this is the only seam. A shape function | |
| 99 | + | /// takes one of these and never touches a database, a request or a clock. | |
| 100 | + | pub trait Data { | |
| 101 | + | fn buyer_rows(&self) -> usize; | |
| 102 | + | fn shared_rows(&self) -> usize; | |
| 103 | + | fn count_label(&self) -> String; | |
| 104 | + | fn buyer(&self, row: usize, field: B) -> String; | |
| 105 | + | fn shared(&self, row: usize, field: S) -> String; | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// The real data, as a request has it. | |
| 109 | + | pub struct Live<'a>(pub &'a Rows); | |
| 110 | + | ||
| 111 | + | impl Data for Live<'_> { | |
| 112 | + | fn buyer_rows(&self) -> usize { | |
| 113 | + | self.0.buyers.len() | |
| 114 | + | } | |
| 115 | + | fn shared_rows(&self) -> usize { | |
| 116 | + | self.0.shared.len() | |
| 117 | + | } | |
| 118 | + | fn count_label(&self) -> String { | |
| 119 | + | self.0.buyers.len().to_string() | |
| 120 | + | } | |
| 121 | + | fn buyer(&self, row: usize, field: B) -> String { | |
| 122 | + | let b = &self.0.buyers[row]; | |
| 123 | + | match field { | |
| 124 | + | B::Username => b.username.clone(), | |
| 125 | + | B::Email => b.email.clone(), | |
| 126 | + | B::Purchases => b.purchases.clone(), | |
| 127 | + | B::Spent => b.spent.clone(), | |
| 128 | + | B::Last => b.last_purchase.clone(), | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | fn shared(&self, row: usize, field: S) -> String { | |
| 132 | + | let s = &self.0.shared[row]; | |
| 133 | + | match field { | |
| 134 | + | S::Name => s.name.clone(), | |
| 135 | + | S::Username => s.username.clone(), | |
| 136 | + | S::Id => s.seller_id.clone(), | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// A sentinel, fixed width and alphanumeric so nothing escapes it and nothing | |
| 142 | + | /// in real markup collides with it. | |
| 143 | + | fn sentinel(hole: u16) -> String { | |
| 144 | + | format!("ZQH{hole:04}HQZ") | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// The width of one sentinel, which the scanner relies on. | |
| 148 | + | const SENTINEL_LEN: usize = 10; | |
| 149 | + | ||
| 150 | + | /// Values replaced by sentinels, and row counts the emitter chooses. | |
| 151 | + | pub struct Symbolic { | |
| 152 | + | pub buyers: usize, | |
| 153 | + | pub shared: usize, | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | impl Data for Symbolic { | |
| 157 | + | fn buyer_rows(&self) -> usize { | |
| 158 | + | self.buyers | |
| 159 | + | } | |
| 160 | + | fn shared_rows(&self) -> usize { | |
| 161 | + | self.shared | |
| 162 | + | } | |
| 163 | + | fn count_label(&self) -> String { | |
| 164 | + | sentinel(H_COUNT) | |
| 165 | + | } | |
| 166 | + | fn buyer(&self, _row: usize, field: B) -> String { | |
| 167 | + | sentinel(field.hole()) | |
| 168 | + | } | |
| 169 | + | fn shared(&self, _row: usize, field: S) -> String { | |
| 170 | + | sentinel(field.hole()) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// The pane, written once, evaluated by either implementation. | |
| 175 | + | /// | |
| 176 | + | /// This is `fixture::pane` with its data reads behind [`Data`] and nothing else | |
| 177 | + | /// changed. Every column width, priority and action is the original screen's. | |
| 178 | + | #[must_use] | |
| 179 | + | pub fn describe<D: Data>(d: &D) -> Node { | |
| 180 | + | let slot = Slot::new(REGION, RegionKind::Pane) | |
| 181 | + | .with(Node::section(format!("Your Buyers ({})", d.count_label()))) | |
| 182 | + | .with(Node::text( | |
| 183 | + | "Buyers who opted to share their email with you at purchase time.", | |
| 184 | + | )) | |
| 185 | + | .with(buyers_table(d)) | |
| 186 | + | .with(Node::section("Shared With")) | |
| 187 | + | .with(Node::text( | |
| 188 | + | "You've shared your email with these creators. You can revoke sharing at any time.", | |
| 189 | + | )) | |
| 190 | + | .with(shared_table(d)); | |
| 191 | + | ||
| 192 | + | Node::Region(slot) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | fn buyers_table<D: Data>(d: &D) -> Node { | |
| 196 | + | Node::Table { | |
| 197 | + | columns: vec![ | |
| 198 | + | Column::new("Username") | |
| 199 | + | .width(layout::Width::Content) | |
| 200 | + | .priority(layout::Priority::Essential), | |
| 201 | + | Column::new("Email") | |
| 202 | + | .width(layout::Width::Fill) | |
| 203 | + | .priority(layout::Priority::Essential), | |
| 204 | + | Column::new("Purchases").width(layout::Width::Content), | |
| 205 | + | Column::new("Total Spent").width(layout::Width::Content), | |
| 206 | + | Column::new("Last Purchase") | |
| 207 | + | .width(layout::Width::Content) | |
| 208 | + | .priority(layout::Priority::Optional), | |
| 209 | + | ], | |
| 210 | + | rows: (0..d.buyer_rows()) | |
| 211 | + | .map(|r| { | |
| 212 | + | Cells::new([ | |
| 213 | + | Cell::new(d.buyer(r, B::Username)) | |
| 214 | + | .activate(Action::get(format!("/u/{}", d.buyer(r, B::Username)))), | |
| 215 | + | Cell::new(d.buyer(r, B::Email)) | |
| 216 | + | .activate(Action::external(format!("mailto:{}", d.buyer(r, B::Email)))), | |
| 217 | + | Cell::new(d.buyer(r, B::Purchases)), | |
| 218 | + | Cell::new(d.buyer(r, B::Spent)), | |
| 219 | + | Cell::new(d.buyer(r, B::Last)), | |
| 220 | + | ]) | |
| 221 | + | }) | |
| 222 | + | .collect(), | |
| 223 | + | more: None, | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | fn shared_table<D: Data>(d: &D) -> Node { | |
| 228 | + | Node::Table { | |
| 229 | + | columns: vec![ | |
| 230 | + | Column::new("Creator") | |
| 231 | + | .width(layout::Width::Fill) | |
| 232 | + | .priority(layout::Priority::Essential), | |
| 233 | + | Column::new("") | |
| 234 | + | .width(layout::Width::Content) | |
| 235 | + | .priority(layout::Priority::Essential), | |
| 236 | + | ], | |
| 237 | + | rows: (0..d.shared_rows()) | |
| 238 | + | .map(|r| { | |
| 239 | + | Cells::new([ | |
| 240 | + | Cell::new(d.shared(r, S::Name)) | |
| 241 | + | .activate(Action::get(format!("/u/{}", d.shared(r, S::Username)))), | |
| 242 | + | Cell::acts([Act::new( | |
| 243 | + | "Revoke", | |
| 244 | + | Action::delete(format!( | |
| 245 | + | "/library/tabs/contacts/revoke/{}", | |
| 246 | + | d.shared(r, S::Id) | |
| 247 | + | )), | |
| 248 | + | )]), | |
| 249 | + | ]) | |
| 250 | + | }) | |
| 251 | + | .collect(), | |
| 252 | + | more: None, | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | /// One instruction of the residual program. | |
| 257 | + | pub enum Op { | |
| 258 | + | /// Markup the renderer produced that no data reaches. | |
| 259 | + | Lit(String), | |
| 260 | + | /// A value, escaped on the way in. | |
| 261 | + | Hole(u16), | |
| 262 | + | /// The buyers table body, once per row. | |
| 263 | + | BuyerRows(Vec<Op>), | |
| 264 | + | /// The shared-with table body, once per row. | |
| 265 | + | SharedRows(Vec<Op>), | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | /// The emitter: run the description symbolically and keep what comes back. | |
| 269 | + | /// | |
| 270 | + | /// Three renders rather than one. A single symbolic render gives the markup but | |
| 271 | + | /// not where a row begins and ends, so the loop bodies are recovered by | |
| 272 | + | /// rendering again with one more row in one table at a time and taking the | |
| 273 | + | /// difference. The renderer is the same one a request uses. | |
| 274 | + | /// | |
| 275 | + | /// # Panics | |
| 276 | + | /// | |
| 277 | + | /// If a loop body cannot be recovered, which would mean the renderer's output | |
| 278 | + | /// is not `prefix + row * n + suffix` for this screen. Loud on purpose: a | |
| 279 | + | /// silent fallback here would report a staged number for an unstaged program. | |
| 280 | + | #[must_use] | |
| 281 | + | pub fn emit() -> Vec<Op> { | |
| 282 | + | let webview = Webview::new(); | |
| 283 | + | let render = |buyers, shared| webview.fragment(&describe(&Symbolic { buyers, shared })); | |
| 284 | + | ||
| 285 | + | let base = render(1, 1); | |
| 286 | + | let more_buyers = render(2, 1); | |
| 287 | + | let more_shared = render(1, 2); | |
| 288 | + | ||
| 289 | + | // The three symbolic renders, for when a shape stops being `P R Q` and the | |
| 290 | + | // recovery below starts refusing. Diffing them is how the one bug this | |
| 291 | + | // emitter has had was found. | |
| 292 | + | if let Ok(dir) = std::env::var("QUASI_DUMP") { | |
| 293 | + | let dir = std::path::Path::new(&dir); | |
| 294 | + | std::fs::write(dir.join("emit-base.html"), &base).ok(); | |
| 295 | + | std::fs::write(dir.join("emit-buyers-2.html"), &more_buyers).ok(); | |
| 296 | + | std::fs::write(dir.join("emit-shared-2.html"), &more_shared).ok(); | |
| 297 | + | } | |
| 298 | + | let buyer_row = repeated_unit(&base, &more_buyers).expect("a buyers row repeats"); | |
| 299 | + | let shared_row = repeated_unit(&base, &more_shared).expect("a shared row repeats"); | |
| 300 | + | ||
| 301 | + | let at_buyer = base | |
| 302 | + | .find(&buyer_row) | |
| 303 | + | .expect("the buyers row is in the page"); | |
| 304 | + | let prefix = &base[..at_buyer]; | |
| 305 | + | let rest = &base[at_buyer + buyer_row.len()..]; | |
| 306 | + | ||
| 307 | + | let at_shared = rest | |
| 308 | + | .find(&shared_row) | |
| 309 | + | .expect("the shared row is in the page"); | |
| 310 | + | let middle = &rest[..at_shared]; | |
| 311 | + | let suffix = &rest[at_shared + shared_row.len()..]; | |
| 312 | + | ||
| 313 | + | let mut ops = compile(prefix); | |
| 314 | + | ops.push(Op::BuyerRows(compile(&buyer_row))); | |
| 315 | + | ops.extend(compile(middle)); | |
| 316 | + | ops.push(Op::SharedRows(compile(&shared_row))); | |
| 317 | + | ops.extend(compile(suffix)); | |
| 318 | + | ops | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | /// The block `wide` holds one more time than `narrow` does. | |
| 322 | + | /// | |
| 323 | + | /// `narrow` is `P R Q` and `wide` is `P R R Q`. The length difference is `R` | |
| 324 | + | /// outright, since the symbolic rows are the same string, and the common prefix | |
| 325 | + | /// runs to the end of the first `R`. So `R` is the span ending where the two | |
| 326 | + | /// first disagree. | |
| 327 | + | /// | |
| 328 | + | /// The prefix and the suffix deliberately overlap inside `narrow`, by exactly | |
| 329 | + | /// `R`. An earlier version of this forbade that overlap and could therefore | |
| 330 | + | /// never find a repeat at all. | |
| 331 | + | fn repeated_unit(narrow: &str, wide: &str) -> Option<String> { | |
| 332 | + | let unit = wide.len().checked_sub(narrow.len())?; | |
| 333 | + | if unit == 0 { | |
| 334 | + | return None; | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | let n = narrow.as_bytes(); | |
| 338 | + | let w = wide.as_bytes(); | |
| 339 | + | let mut head = 0; | |
| 340 | + | while head < n.len() && head < w.len() && n[head] == w[head] { | |
| 341 | + | head += 1; | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | let start = head.checked_sub(unit)?; | |
| 345 | + | if !narrow.is_char_boundary(start) || !narrow.is_char_boundary(head) { | |
| 346 | + | return None; | |
| 347 | + | } | |
| 348 | + | let unit_text = &narrow[start..head]; | |
| 349 | + | ||
| 350 | + | // Removing one copy from `wide` has to give `narrow` back, or the shape is | |
| 351 | + | // not `P R Q` and the emitter must not pretend it is. | |
| 352 | + | let mut rebuilt = String::with_capacity(narrow.len()); | |
| 353 | + | rebuilt.push_str(&wide[..start]); | |
| 354 | + | rebuilt.push_str(&wide[start + unit..]); | |
| 355 | + | if rebuilt != narrow { | |
| 356 | + | return None; | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | Some(unit_text.to_string()) | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | /// Split a chunk of emitted markup into literals and holes. | |
| 363 | + | fn compile(chunk: &str) -> Vec<Op> { | |
| 364 | + | let mut ops = Vec::new(); | |
| 365 | + | let mut rest = chunk; | |
| 366 | + | while let Some(at) = rest.find("ZQH") { | |
| 367 | + | let candidate = &rest[at..]; | |
| 368 | + | let parsed = candidate | |
| 369 | + | .get(..SENTINEL_LEN) | |
| 370 | + | .filter(|s| s.ends_with("HQZ")) | |
| 371 | + | .and_then(|s| s[3..7].parse::<u16>().ok()); | |
| 372 | + | match parsed { | |
| 373 | + | Some(hole) => { | |
| 374 | + | if at > 0 { | |
| 375 | + | ops.push(Op::Lit(rest[..at].to_string())); | |
| 376 | + | } | |
| 377 | + | ops.push(Op::Hole(hole)); | |
| 378 | + | rest = &rest[at + SENTINEL_LEN..]; | |
| 379 | + | } | |
| 380 | + | // "ZQH" that is not a sentinel. Keep it and carry on past it. | |
| 381 | + | None => { | |
| 382 | + | ops.push(Op::Lit(rest[..at + 3].to_string())); | |
| 383 | + | rest = &rest[at + 3..]; | |
| 384 | + | } | |
| 385 | + | } | |
| 386 | + | } | |
| 387 | + | if !rest.is_empty() { | |
| 388 | + | ops.push(Op::Lit(rest.to_string())); | |
| 389 | + | } | |
| 390 | + | ops | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | /// Run the residual against real data. | |
| 394 | + | pub fn exec(ops: &[Op], rows: &Rows, out: &mut String) { | |
| 395 | + | run(ops, rows, 0, out); | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | fn run(ops: &[Op], rows: &Rows, row: usize, out: &mut String) { | |
| 399 | + | for op in ops { | |
| 400 | + | match op { | |
| 401 | + | Op::Lit(s) => out.push_str(s), | |
| 402 | + | Op::Hole(h) => fill(*h, rows, row, out), | |
| 403 | + | Op::BuyerRows(body) => { | |
| 404 | + | for r in 0..rows.buyers.len() { | |
| 405 | + | run(body, rows, r, out); | |
| 406 | + | } | |
| 407 | + | } | |
| 408 | + | Op::SharedRows(body) => { | |
| 409 | + | for r in 0..rows.shared.len() { | |
| 410 | + | run(body, rows, r, out); | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | /// One value into the buffer, through the renderer's own escaper. | |
| 418 | + | fn fill(hole: u16, rows: &Rows, row: usize, out: &mut String) { | |
| 419 | + | match hole { | |
| 420 | + | // A length: no escaping to do, since a decimal integer has nothing to | |
| 421 | + | // escape. | |
| 422 | + | 0 => { | |
| 423 | + | let _ = write!(out, "{}", rows.buyers.len()); | |
| 424 | + | } | |
| 425 | + | 1 => escape_into(&rows.buyers[row].username, out), | |
| 426 | + | 2 => escape_into(&rows.buyers[row].email, out), | |
| 427 | + | 3 => escape_into(&rows.buyers[row].purchases, out), | |
| 428 | + | 4 => escape_into(&rows.buyers[row].spent, out), | |
| 429 | + | 5 => escape_into(&rows.buyers[row].last_purchase, out), | |
| 430 | + | 6 => escape_into(&rows.shared[row].name, out), | |
| 431 | + | 7 => escape_into(&rows.shared[row].username, out), | |
| 432 | + | 8 => escape_into(&rows.shared[row].seller_id, out), | |
| 433 | + | _ => {} | |
| 434 | + | } | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | /// The residual, written out as an Askama template. | |
| 438 | + | /// | |
| 439 | + | /// This is the proposal stated exactly: the description is the source, and the | |
| 440 | + | /// template is a generated intermediate rather than something anyone authors. | |
| 441 | + | /// Holes become `{{ }}` and the two loop bodies become `{% for %}`, so what | |
| 442 | + | /// comes out is an ordinary Askama file that compiles to straight-line Rust. | |
| 443 | + | /// | |
| 444 | + | /// # Panics | |
| 445 | + | /// | |
| 446 | + | /// If the emitted markup contains an Askama opening delimiter, which would make | |
| 447 | + | /// the generated file mean something other than the markup it came from. It | |
| 448 | + | /// does not today; the check is here so that it cannot start silently. | |
| 449 | + | #[must_use] | |
| 450 | + | pub fn as_askama_template(ops: &[Op]) -> String { | |
| 451 | + | let mut out = String::new(); | |
| 452 | + | write_template(ops, None, &mut out); | |
| 453 | + | out | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | fn write_template(ops: &[Op], binding: Option<&str>, out: &mut String) { | |
| 457 | + | for op in ops { | |
| 458 | + | match op { | |
| 459 | + | Op::Lit(s) => { | |
| 460 | + | assert!( | |
| 461 | + | !s.contains("{{") && !s.contains("{%") && !s.contains("{#"), | |
| 462 | + | "emitted markup carries an Askama delimiter" | |
| 463 | + | ); | |
| 464 | + | out.push_str(s); | |
| 465 | + | } | |
| 466 | + | Op::Hole(h) => { | |
| 467 | + | out.push_str("{{ "); | |
| 468 | + | out.push_str(&expr(*h, binding)); | |
| 469 | + | out.push_str(" }}"); | |
| 470 | + | } | |
| 471 | + | Op::BuyerRows(body) => { | |
| 472 | + | out.push_str("{% for b in buyers %}"); | |
| 473 | + | write_template(body, Some("b"), out); | |
| 474 | + | out.push_str("{% endfor %}"); | |
| 475 | + | } | |
| 476 | + | Op::SharedRows(body) => { | |
| 477 | + | out.push_str("{% for s in shared %}"); | |
| 478 | + | write_template(body, Some("s"), out); | |
| 479 | + | out.push_str("{% endfor %}"); | |
| 480 | + | } | |
| 481 | + | } | |
| 482 | + | } | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | /// The template expression one hole becomes. | |
| 486 | + | fn expr(hole: u16, binding: Option<&str>) -> String { | |
| 487 | + | let field = match hole { | |
| 488 | + | 0 => return "count".to_string(), | |
| 489 | + | 1 => "username", | |
| 490 | + | 2 => "email", | |
| 491 | + | 3 => "purchases", | |
| 492 | + | 4 => "spent", | |
| 493 | + | 5 => "last_purchase", | |
| 494 | + | 6 => "name", | |
| 495 | + | 7 => "username", | |
| 496 | + | 8 => "seller_id", | |
| 497 | + | _ => "unknown", | |
| 498 | + | }; | |
| 499 | + | format!("{}.{field}", binding.unwrap_or("self")) | |
| 500 | + | } |
| @@ -1,0 +1,1 @@ | |||
| 1 | + | <div id="tab-content" class="region pane"><h2 class="heading">Your Buyers ({{ count }})</h2><p class="text">Buyers who opted to share their email with you at purchase time.</p><div role="table" class="table"><div role="row" class="table-head"><span role="columnheader" class="table-heading col-Username cell-content cell-keeps">Username</span><span role="columnheader" class="table-heading col-Email cell-fill cell-keeps">Email</span><span role="columnheader" class="table-heading col-Purchases cell-content cell-drops-next">Purchases</span><span role="columnheader" class="table-heading col-Total-Spent cell-content cell-drops-next">Total Spent</span><span role="columnheader" class="table-heading col-Last-Purchase cell-content cell-drops-first">Last Purchase</span></div><{% for b in buyers %}div role="row" class="table-row" data-row><div class="cell col-Username cell-content cell-keeps"><a class="cell-link" data-act href="/u/{{ b.username }}" hx-get="/u/{{ b.username }}" hx-swap="outerMorph">{{ b.username }}</a></div><div class="cell col-Email cell-fill cell-keeps"><a class="cell-link" data-act href="mailto:{{ b.email }}" target="_blank" rel="noopener noreferrer">{{ b.email }}</a></div><div class="cell col-Purchases cell-content cell-drops-next cell-value">{{ b.purchases }}</div><div class="cell col-Total-Spent cell-content cell-drops-next cell-value">{{ b.spent }}</div><div class="cell col-Last-Purchase cell-content cell-drops-first cell-value">{{ b.last_purchase }}</div></div><{% endfor %}/div><h2 class="heading">Shared With</h2><p class="text">You've shared your email with these creators. You can revoke sharing at any time.</p><div role="table" class="table"><div role="row" class="table-head"><span role="columnheader" class="table-heading col-Creator cell-fill cell-keeps">Creator</span><span role="columnheader" class="table-heading col- cell-content cell-keeps"></span></div><{% for s in shared %}div role="row" class="table-row" data-row><div class="cell col-Creator cell-fill cell-keeps"><a class="cell-link" data-act href="/u/{{ s.username }}" hx-get="/u/{{ s.username }}" hx-swap="outerMorph">{{ s.name }}</a></div><div class="cell col- cell-content cell-keeps"><span class="cell-actions"><button type="button" class="button" data-act hx-delete="/library/tabs/contacts/revoke/{{ s.seller_id }}" hx-swap="outerMorph">Revoke</button></span></div></div><{% endfor %}/div><div class="anchored" id="tab-content-anchored" data-menu="anchored" hidden></div></div> |