Skip to main content

max / quasi

Emit into the buffer instead of allocating per fragment Rendering a described screen was building a String for almost every fragment it wrote. `class_attr` allocated twice for one attribute -- a format! for the prefixed name, then a second String to escape it -- on every class of every element; the hx-vals payload was built and then escaped whole; a read's address was built twice, once for href and once for the verb; and a table rebuilt its borrowed column list and its cell buffers once per row. makeover-webview 0.40.0 added escape_into and push_class for this. Every call site here now writes into the buffer it was going to land in: - class_into, one prefixed name onto a buffer the caller holds. Both halves escaped separately, which is the same bytes for none of the allocations. lib.rs went through it too, so the markup and the stylesheet cannot disagree about a prefix that needed escaping. - json_object_attr applies the JSON and HTML rules in one pass rather than one after the other. The composition is stated where it happens. - The address is built once per action. - measure_class spells its three names instead of assembling one from measure- and a value, which was a String per render of every screen. - A table borrows its columns once and reuses its cell buffers down the rows, so the second row onwards writes into memory that exists. No output byte changes. Verified beyond the suite by rendering one screen carrying every node kind with hostile strings, under both an empty and a set class prefix, and diffing 35,088 bytes against the parent commit. Measured on fw13, release, one render of a 25-row list plus a 25-row three-column table, against a hand-written push_str renderer emitting the same shape: allocations 1,043 -> 326 time/render 15.9us -> 8.7us ratio 9.7x -> 5.4x Short of the under-5x target, and the remainder is not here: of the ~10 allocations a table row still costs, ~8 are makeover-webview's element emitters answering with a String -- cells_html, column_classes and the figure, meter, placeholder and field emitters beside them. Giving those the same buffer-writing treatment is the second half and belongs over there. tests/vocabulary.rs reads class_into, or the names moved out of class() would have stopped being checked silently. Its header now also records what it has never read: a name returned by a helper rather than handed to one, which is both arrangement_class and measure_class.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-15 15:18 UTC
Signed with PGP, not checked
Commit: 9c7a81a3492789764ce645bafb1ea6f17a34a4b3
Parent: 52d0f34
5 files changed, +247 insertions, -122 deletions
@@ -20,7 +20,7 @@
20 20 //! rule `Act::key` already states for a key one host wants and another has
21 21 //! never heard of.
22 22
23 - use makeover_webview::form::escape;
23 + use makeover_webview::form::escape_into;
24 24 use quasi_router::{Binding, Chrome};
25 25
26 26 use crate::node::{Fires, action_attrs};
@@ -62,7 +62,7 @@
62 62 };
63 63
64 64 out.push_str("<button type=\"button\" hidden data-chrome aria-label=\"");
65 - out.push_str(&escape(&binding.label));
65 + escape_into(&binding.label, out);
66 66 out.push('"');
67 67
68 68 // The answer goes into the overlay container. A binding whose route
@@ -44,8 +44,9 @@
44 44 pub use makeover_webview::Emit;
45 45
46 46 use std::collections::HashMap;
47 + use std::fmt::Write as _;
47 48
48 - use makeover_layout::Arrangement;
49 + use makeover_layout::{Arrangement, Measure};
49 50 use quasi_http::Serves;
50 51 use quasi_router::{Node, Screen};
51 52
@@ -142,6 +143,31 @@
142 143 }
143 144 }
144 145
146 + /// The class naming how wide a screen runs.
147 + ///
148 + /// Spelled out per measure rather than assembled from `measure-` and
149 + /// [`Measure::as_str`], which is what this did until the emitter's
150 + /// allocations were counted: that form built a `String` on every render of
151 + /// every screen to reach a name from a set of three.
152 + ///
153 + /// These are this renderer's own names, the same way
154 + /// [`arrangement_class`](Self::arrangement_class)'s are. makeover has no
155 + /// word for how wide a screen runs, because the answer is a page-level
156 + /// arrangement rather than anything it styles.
157 + fn measure_class(measure: Measure) -> &'static str {
158 + match measure {
159 + Measure::Wide => "measure-wide",
160 + Measure::Contained => "measure-contained",
161 + Measure::Reading => "measure-reading",
162 + // `Measure` is `#[non_exhaustive]`, so a member added upstream
163 + // lands here rather than failing the build. `tone_attr`'s reading:
164 + // the widest is the default and the one 53 of the 69 screens want,
165 + // so an unlearned measure runs full width rather than carrying a
166 + // class no stylesheet defines.
167 + _ => "measure-wide",
168 + }
169 + }
170 +
145 171 /// The share, as the grid that honours it.
146 172 ///
147 173 /// `e0fd485e`. An inline style rather than a class, because the share is a
@@ -153,12 +179,13 @@
153 179 ///
154 180 /// `fr` rather than a percentage, so the gap between the regions comes out
155 181 /// of the whole rather than out of the second one.
156 - fn share_style(arrangement: Arrangement) -> String {
182 + fn share_style(arrangement: Arrangement, out: &mut String) {
157 183 let first = u16::from(arrangement.share().as_percent());
158 - format!(
184 + let _ = write!(
185 + out,
159 186 " style=\"--region-share:{first}fr;--region-rest:{}fr\"",
160 187 100 - first
161 - )
188 + );
162 189 }
163 190 }
164 191
@@ -169,21 +196,19 @@
169 196 .open(&screen.title, Some(&screen.discovery), &mut out);
170 197
171 198 out.push_str("<main class=\"");
172 - out.push_str(&makeover_webview::class(
199 + node::class_into(
173 200 Self::arrangement_class(screen.arrangement),
174 201 &self.emit,
175 - ));
202 + &mut out,
203 + );
176 204 // How wide the screen runs, beside how its width is divided. Two
177 205 // classes rather than one compound name: they vary independently, and a
178 206 // `wide-list-detail` class per pairing is the enumeration `1786cb94`
179 207 // settled against one level down.
180 208 out.push(' ');
181 - out.push_str(&makeover_webview::class(
182 - &format!("measure-{}", screen.measure.as_str()),
183 - &self.emit,
184 - ));
209 + node::class_into(Self::measure_class(screen.measure), &self.emit, &mut out);
185 210 out.push('"');
186 - out.push_str(&Self::share_style(screen.arrangement));
211 + Self::share_style(screen.arrangement, &mut out);
187 212 out.push('>');
188 213
189 214 // Notices before the regions, because a notice belongs to the screen
@@ -192,7 +217,7 @@
192 217 // stylesheet's answer.
193 218 if !screen.notices.is_empty() {
194 219 out.push_str("<div class=\"");
195 - out.push_str(&makeover_webview::class("notices", &self.emit));
220 + node::class_into("notices", &self.emit, &mut out);
196 221 out.push_str("\">");
197 222 for notice in &screen.notices {
198 223 node::node_html(
@@ -32,7 +32,7 @@
32 32 // than from a match here, so a tone added upstream cannot be named two ways.
33 33 use makeover_layout::Intent as _;
34 34 use makeover_webview::figure::figure_html;
35 - use makeover_webview::form::{Filling, Markup, Value, escape, field_html};
35 + use makeover_webview::form::{Filling, Markup, Value, escape_into, field_html};
36 36 // `class`, `option_class` and the two part-class mappings below are makeover's,
37 37 // not copies of it. They were copies until makeover-webview 0.27.0 made them
38 38 // public: the prefix helper was byte-identical, and the row and cell part names
@@ -51,6 +51,23 @@
51 51 use quasi_router::screen::{Act, Cell, Cells, Destination, Field, Node, Row, Slot, Tag};
52 52 use quasi_router::{Action, Method, Params};
53 53
54 + /// Write one prefixed class name onto a buffer the caller already has.
55 + ///
56 + /// The two halves are escaped separately rather than joined and escaped once.
57 + /// That is the same bytes -- escaping is per character and has no context to
58 + /// carry across the seam -- for none of the allocations. Every class of every
59 + /// element went through a `format!` and then a second `String` before this
60 + /// existed, which measured as most of the emitter's allocation count.
61 + ///
62 + /// Escaped at all because a prefix is host configuration reaching an attribute
63 + /// value. It is a `&'static str` and every real one is identity under this, so
64 + /// the cost is a scan; what it buys is that the one string here that did not
65 + /// come from this crate cannot end the attribute.
66 + pub(crate) fn class_into(name: &str, opts: &Emit, out: &mut String) {
67 + escape_into(opts.class_prefix, out);
68 + escape_into(name, out);
69 + }
70 +
54 71 /// Write a `class="..."` attribute, prefixed.
55 72 fn class_attr(names: &[&str], opts: &Emit, out: &mut String) {
56 73 out.push_str(" class=\"");
@@ -58,7 +75,7 @@
58 75 if i > 0 {
59 76 out.push(' ');
60 77 }
61 - out.push_str(&escape(&class(name, opts)));
78 + class_into(name, opts, out);
62 79 }
63 80 out.push('"');
64 81 }
@@ -179,18 +196,29 @@
179 196 docengine::render_strict(source)
180 197 }
181 198
182 - /// JSON-encode a string, for an `hx-vals` payload.
199 + /// JSON-encode a string into an attribute value, both rules in one pass.
183 200 ///
184 201 /// Small enough to own. Pulling in a JSON crate to write object literals of
185 202 /// strings would be the larger decision, and the encoder a renderer needs is
186 203 /// this: the six characters JSON requires escaped, plus a `\u00XX` form for the
187 - /// rest of the C0 range. The result is then HTML-escaped by the caller, because
188 - /// it lands in an attribute.
189 - fn json_string(text: &str, out: &mut String) {
190 - out.push('"');
204 + /// rest of the C0 range.
205 + ///
206 + /// The HTML escaping is applied here rather than by the caller, which is what
207 + /// lets the payload go straight into `out`. Building the object and then
208 + /// escaping the whole of it allocated a `String` for each, per action, and
209 + /// there was nothing in between to look at.
210 + ///
211 + /// The two rules compose in this order and only this order. JSON runs first, so
212 + /// a quote in the text becomes `\"` and then `\&quot;`; the backslash JSON adds
213 + /// is not a character HTML encodes, and the quote HTML encodes is not one JSON
214 + /// would look at twice. The structural quotes the object needs are written as
215 + /// `&quot;` directly, because they are markup rather than content.
216 + fn json_string_attr(text: &str, out: &mut String) {
217 + out.push_str("&quot;");
191 218 for ch in text.chars() {
192 219 match ch {
193 - '"' => out.push_str("\\\""),
220 + // JSON first, then the HTML form of what it produced.
221 + '"' => out.push_str("\\&quot;"),
194 222 '\\' => out.push_str("\\\\"),
195 223 '\n' => out.push_str("\\n"),
196 224 '\r' => out.push_str("\\r"),
@@ -198,25 +226,31 @@
198 226 c if (c as u32) < 0x20 => {
199 227 let _ = write!(out, "\\u{:04x}", c as u32);
200 228 }
229 + // The rest of what an attribute value cannot carry. JSON has no
230 + // opinion on any of these, so this arm is HTML's alone and matches
231 + // `escape_into` character for character.
232 + '&' => out.push_str("&amp;"),
233 + '<' => out.push_str("&lt;"),
234 + '>' => out.push_str("&gt;"),
235 + '\'' => out.push_str("&#39;"),
201 236 c => out.push(c),
202 237 }
203 238 }
204 - out.push('"');
239 + out.push_str("&quot;");
205 240 }
206 241
207 - /// The params as an `hx-vals` object.
208 - fn json_object(params: &Params) -> String {
209 - let mut json = String::from("{");
242 + /// The params as an `hx-vals` object, written into the attribute they land in.
243 + fn json_object_attr(params: &Params, out: &mut String) {
244 + out.push('{');
210 245 for (i, (name, value)) in params.iter().enumerate() {
211 246 if i > 0 {
212 - json.push(',');
247 + out.push(',');
213 248 }
214 - json_string(name, &mut json);
215 - json.push(':');
216 - json_string(value, &mut json);
249 + json_string_attr(name, out);
250 + out.push(':');
251 + json_string_attr(value, out);
217 252 }
218 - json.push('}');
219 - json
253 + out.push('}');
220 254 }
221 255
222 256 /// What makes a control call its action.
@@ -292,22 +326,26 @@
292 326 // handle on this one.
293 327 if let Destination::External(url) = &action.destination {
294 328 out.push_str(" href=\"");
295 - out.push_str(&escape(url));
329 + escape_into(url, out);
296 330 out.push_str("\" target=\"_blank\" rel=\"noopener noreferrer\"");
297 331 return;
298 332 }
299 333
334 + // The address, built once. The `href` below and the verb further down are
335 + // the same string whenever both are emitted -- a read of a route names one
336 + // place -- and building it twice was a `String` per link on every screen
337 + // made mostly of links.
338 + let url = quasi_http::route_url(action.destination.as_str(), &action.carried);
339 +
300 340 // A read of a route this app answers is a link, and it gets the address as
301 341 // well as the transport. htmx uses `hx-get` and prevents the default, so
302 342 // the `href` is what everything else uses: middle-click, copy-link, a
303 343 // crawler, and the page with JS off. The parameters are folded into it
304 344 // because a link to a filtered list that drops the filter is a different
305 345 // place, and `hx-vals` below carries the same ones down htmx's path.
306 - if let Destination::Route(path) = &action.destination
307 - && !action.method.mutates()
308 - {
346 + if matches!(action.destination, Destination::Route(_)) && !action.method.mutates() {
309 347 out.push_str(" href=\"");
310 - out.push_str(&escape(&quasi_http::route_url(path, &action.carried)));
348 + escape_into(&url, out);
311 349 out.push('"');
312 350 }
313 351
@@ -321,7 +359,7 @@
321 359 // crate anywhere else is what the architectural test forbids.
322 360 if let Some(selector) = gathers {
323 361 out.push_str(" hx-include=\"");
324 - out.push_str(&escape(selector));
362 + escape_into(selector, out);
325 363 out.push('"');
326 364 }
327 365
@@ -330,7 +368,7 @@
330 368 // about: where it lands, then what is sent.
331 369 if let Some(region) = &action.replaces {
332 370 out.push_str(" hx-target=\"#");
333 - out.push_str(&escape(region));
371 + escape_into(region, out);
334 372 out.push('"');
335 373 }
336 374
@@ -347,7 +385,7 @@
347 385 } else {
348 386 out.push_str(" data-saves=\"");
349 387 }
350 - out.push_str(&escape(filename));
388 + escape_into(filename, out);
351 389 out.push('"');
352 390 }
353 391
@@ -358,15 +396,12 @@
358 396 Method::Put => " hx-put=\"",
359 397 };
360 398 out.push_str(verb);
361 - out.push_str(&escape(&quasi_http::route_url(
362 - action.destination.as_str(),
363 - &action.carried,
364 - )));
399 + escape_into(&url, out);
365 400 out.push('"');
366 401
367 402 if !action.params.is_empty() {
368 403 out.push_str(" hx-vals=\"");
369 - out.push_str(&escape(&json_object(&action.params)));
404 + json_object_attr(&action.params, out);
370 405 out.push('"');
371 406 }
372 407
@@ -387,7 +422,7 @@
387 422 // trigger on itself would wait for a keystroke it can never
388 423 // receive.
389 424 out.push_str(" hx-trigger=\"keydown[");
390 - out.push_str(&escape(filter));
425 + escape_into(filter, out);
391 426 out.push_str("] from:body\"");
392 427 }
393 428 Fires::ChangeInside => {
@@ -402,7 +437,7 @@
402 437 // place, or swapping htmx for fixi stops being a one-function change.
403 438 if let Some(prompt) = confirm {
404 439 out.push_str(" hx-confirm=\"");
405 - out.push_str(&escape(prompt));
440 + escape_into(prompt, out);
406 441 out.push('"');
407 442 }
408 443
@@ -453,8 +488,15 @@
453 488 // have in common. Built here because it needs `Emit`, which the transport
454 489 // function does not take -- a host setting `class_prefix` moves the class
455 490 // and the selector together.
456 - let gathered = class("row-select", opts);
457 - let gathers = act.over.as_ref().map(|_| format!(".{gathered}"));
491 + //
492 + // Built inside the `map` and not before it: staged selections are rare and
493 + // every other control on the screen was paying for two `String`s it then
494 + // dropped. `class` rather than `class_into` because what is wanted here is
495 + // a value, and a selector is the one place this crate holds one.
496 + let gathers = act
497 + .over
498 + .as_ref()
499 + .map(|_| format!(".{}", class("row-select", opts)));
458 500 let gathers = gathers.as_deref();
459 501 // `button`, which is makeover's name for this and carries its whole
460 502 // interactive set: the raised bevel, the hover fill, the pressed inset, the
@@ -513,12 +555,12 @@
513 555 // terminal and this is the nearest honest thing a page has.
514 556 if let Some(key) = &act.key {
515 557 out.push_str(" accesskey=\"");
516 - out.push_str(&escape(key));
558 + escape_into(key, out);
517 559 out.push('"');
518 560 }
519 561
520 562 out.push('>');
521 - out.push_str(&escape(&act.label));
563 + escape_into(&act.label, out);
522 564 out.push_str(close);
523 565 }
524 566
@@ -562,9 +604,9 @@
562 604 // which one.
563 605 if let Some(value) = &row.value {
564 606 out.push_str(" name=\"");
565 - out.push_str(&escape(quasi_router::Node::TICKED));
607 + escape_into(quasi_router::Node::TICKED, out);
566 608 out.push_str("\" value=\"");
567 - out.push_str(&escape(value));
609 + escape_into(value, out);
568 610 out.push('"');
569 611 }
570 612 // A tick with no route is local state until something submits it, which
@@ -673,7 +715,7 @@
673 715 /// which to do.
674 716 fn row_inline_html(node: &Node, morphs: bool, opts: &Emit, out: &mut String) {
675 717 match node {
676 - Node::Text { text, .. } => out.push_str(&escape(text)),
718 + Node::Text { text, .. } => escape_into(text, out),
677 719 Node::Rich { source } => out.push_str(&docengine::render_phrase(source)),
678 720 Node::Token(tag) => tag_html(tag, morphs, opts, out),
679 721 Node::Act(act) => act_html(act, morphs, opts, out),
@@ -702,7 +744,13 @@
702 744 /// `cell-tokens` and `cell-actions` carry the gap between siblings, so a span
703 745 /// each would space them as though they were unrelated, and the common case --
704 746 /// a status column of three badges -- is exactly the consecutive one.
705 - fn cell_run_html(cell: &Cell, morphs: bool, opts: &Emit) -> String {
747 + ///
748 + /// Writes into a buffer the caller owns rather than answering with one. The
749 + /// caller is [`cells_row_html`], which needs every cell of a row to exist at
750 + /// once because `cells_html` takes them together -- but needs that only within
751 + /// the row, so the buffers are reused down the table and a fresh `String` per
752 + /// cell per row was the emitter's largest remaining cost.
753 + fn cell_run_html(cell: &Cell, morphs: bool, opts: &Emit, out: &mut String) {
706 754 // A cell that is one piece of text says so on the container through
707 755 // `CellPart::Value`, so a wrapper span here would say nothing the container
708 756 // has not. Anything else names its parts inside, or the content colour on
@@ -710,18 +758,18 @@
710 758 // makeover-layout 0.14.0 named and makeover-webview 0.25.0 stopped
711 759 // emitting.
712 760 if let [Node::Text { text, .. }] = cell.parts.as_slice() {
713 - return escape(text);
761 + escape_into(text, out);
762 + return;
714 763 }
715 764
716 - let mut out = String::new();
717 765 let mut rest = cell.parts.as_slice();
718 766 while let Some((head, tail)) = rest.split_first() {
719 767 match head {
720 768 Node::Text { text, .. } => {
721 769 out.push_str("<span");
722 - class_attr(&[cell_part_class(layout::CellPart::Value)], opts, &mut out);
770 + class_attr(&[cell_part_class(layout::CellPart::Value)], opts, out);
723 771 out.push('>');
724 - out.push_str(&escape(text));
772 + escape_into(text, out);
725 773 out.push_str("</span>");
726 774 rest = tail;
727 775 }
@@ -736,23 +784,23 @@
736 784 Node::Link { text, action } => {
737 785 let (open, close) = control_tag(action);
738 786 out.push_str(open);
739 - class_attr(&[cell_part_class(layout::CellPart::Link)], opts, &mut out);
787 + class_attr(&[cell_part_class(layout::CellPart::Link)], opts, out);
740 788 out.push_str(" data-act");
741 - action_attrs(action, Fires::Click, None, morphs, None, &mut out);
789 + action_attrs(action, Fires::Click, None, morphs, None, out);
742 790 out.push('>');
743 - out.push_str(&escape(text));
791 + escape_into(text, out);
744 792 out.push_str(close);
745 793 rest = tail;
746 794 }
747 795 Node::Token(_) => {
748 796 let run = rest.iter().take_while(|p| matches!(p, Node::Token(_)));
749 797 out.push_str("<span");
750 - class_attr(&[cell_part_class(layout::CellPart::Tokens)], opts, &mut out);
798 + class_attr(&[cell_part_class(layout::CellPart::Tokens)], opts, out);
751 799 out.push('>');
752 800 let mut taken = 0;
753 801 for part in run {
754 802 if let Node::Token(tag) = part {
755 - tag_html(tag, morphs, opts, &mut out);
803 + tag_html(tag, morphs, opts, out);
756 804 }
757 805 taken += 1;
758 806 }
@@ -766,16 +814,12 @@
766 814 Node::Act(_) => {
767 815 let run = rest.iter().take_while(|p| matches!(p, Node::Act(_)));
768 816 out.push_str("<span");
769 - class_attr(
770 - &[cell_part_class(layout::CellPart::Actions)],
771 - opts,
772 - &mut out,
773 - );
817 + class_attr(&[cell_part_class(layout::CellPart::Actions)], opts, out);
774 818 out.push('>');
775 819 let mut taken = 0;
776 820 for part in run {
777 821 if let Node::Act(act) = part {
778 - act_html(act, morphs, opts, &mut out);
822 + act_html(act, morphs, opts, out);
779 823 }
780 824 taken += 1;
781 825 }
@@ -789,18 +833,40 @@
789 833 other => {
790 834 // No fills: a bespoke region is a block and cannot reach a run,
791 835 // which the containment bound is what guarantees.
792 - node_html(other, morphs, opts, &HashMap::new(), &mut out);
836 + node_html(other, morphs, opts, &HashMap::new(), out);
793 837 rest = tail;
794 838 }
795 839 }
796 840 }
797 - out
841 + }
842 +
843 + /// The buffers a table's rows take turns in.
844 + ///
845 + /// `cells_html` takes a row's cells together, so the markup of every cell in one
846 + /// row has to exist at once. Nothing says it has to be new: cleared and
847 + /// refilled, these keep the capacity the first row bought, and a table's second
848 + /// row onwards writes into memory that already exists.
849 + ///
850 + /// [`Emitted`] is not here, because it borrows from `filled` and a struct
851 + /// holding both would be self-referential. It is a `Vec` per row and stays one.
852 + #[derive(Default)]
853 + struct RowBuffers {
854 + /// One cell's markup each, in column order.
855 + filled: Vec<String>,
856 + /// Which cells are a single piece of text, which is what earns the
857 + /// container the part class.
858 + parts: Vec<Option<layout::CellPart>>,
798 859 }
799 860
800 861 /// One row of a table.
862 + ///
863 + /// Takes the columns already borrowed rather than the described ones. They are
864 + /// the same for every row of the table, and this built the borrowed list again
865 + /// per row until the emitter's allocations were counted.
801 866 fn cells_row_html(
802 867 cells: &Cells,
803 - columns: &[quasi_router::screen::Column],
868 + columns: &[layout::Column<'_>],
869 + buffers: &mut RowBuffers,
804 870 morphs: bool,
805 871 opts: &Emit,
806 872 out: &mut String,
@@ -838,39 +904,36 @@
838 904 // goes in. Ours is text from a description plus, since `022f0c59`, whatever
839 905 // controls the cell carries, and `cells_html` is what knows the column
840 906 // classes and the narrowing.
841 - let filled: Vec<String> = cells
842 - .values
843 - .iter()
844 - .map(|cell| cell_run_html(cell, morphs, opts))
845 - .collect();
907 + let RowBuffers { filled, parts } = buffers;
908 + filled.truncate(cells.values.len());
909 + for (at, cell) in cells.values.iter().enumerate() {
910 + match filled.get_mut(at) {
911 + Some(buffer) => buffer.clear(),
912 + None => filled.push(String::new()),
913 + }
914 + cell_run_html(cell, morphs, opts, &mut filled[at]);
915 + }
846 916 // The container says what the cell is only when the cell is nothing but one
847 917 // piece of text, which is the case where a wrapper span would say nothing
848 918 // the container has not already said. Anything else names its parts inside
849 919 // -- the anchor is a `cell-link`, the strips are `cell-tokens` and
850 920 // `cell-actions` -- because a colour on the container would reach all of
851 921 // them, and that is the drift makeover-layout 0.14.0 named.
852 - let parts: Vec<Option<layout::CellPart>> = cells
853 - .values
854 - .iter()
855 - .map(|cell| {
856 - matches!(cell.parts.as_slice(), [Node::Text { .. }]).then_some(layout::CellPart::Value)
857 - })
858 - .collect();
859 - let borrowed: Vec<layout::Column<'_>> = columns
860 - .iter()
861 - .map(quasi_router::screen::Column::as_layout)
862 - .collect();
863 - let cells: Vec<Emitted<'_>> = borrowed
922 + parts.clear();
923 + parts.extend(cells.values.iter().map(|cell| {
924 + matches!(cell.parts.as_slice(), [Node::Text { .. }]).then_some(layout::CellPart::Value)
925 + }));
926 + let emitted: Vec<Emitted<'_>> = columns
864 927 .iter()
865 928 .zip(filled.iter())
866 - .zip(parts)
929 + .zip(parts.iter().copied())
867 930 .map(|((column, value), part)| Emitted {
868 931 column: column.name,
869 932 part,
870 933 content: Markup(value),
871 934 })
872 935 .collect();
873 - out.push_str(&cells_html(&borrowed, &cells, opts));
936 + out.push_str(&cells_html(columns, &emitted, opts));
874 937
875 938 out.push_str("</div>");
876 939 }
@@ -890,11 +953,14 @@
890 953 layout::Heading::Section => "h2",
891 954 layout::Heading::Subsection => "h3",
892 955 };
893 - let _ = write!(out, "<{tag}");
956 + out.push('<');
957 + out.push_str(tag);
894 958 class_attr(&["heading"], opts, out);
895 959 out.push('>');
896 - out.push_str(&escape(text));
897 - let _ = write!(out, "</{tag}>");
960 + escape_into(text, out);
961 + out.push_str("</");
962 + out.push_str(tag);
963 + out.push('>');
898 964 }
899 965
900 966 Node::Text { text, tone } => {
@@ -902,7 +968,7 @@
902 968 class_attr(&["text"], opts, out);
903 969 tone_attr(*tone, out);
904 970 out.push('>');
905 - out.push_str(&escape(text));
971 + escape_into(text, out);
906 972 out.push_str("</p>");
907 973 }
908 974
@@ -947,7 +1013,7 @@
947 1013 out.push_str(" data-act");
948 1014 action_attrs(action, Fires::Click, None, morphs, None, out);
949 1015 out.push('>');
950 - out.push_str(&escape(text));
1016 + escape_into(text, out);
951 1017 out.push_str(close);
952 1018 }
953 1019
@@ -979,9 +1045,9 @@
979 1045 // what stops an attribute breaking out, and unlike `href` an `src`
980 1046 // has no scheme that executes -- `javascript:` in an `<img src>` is
981 1047 // a broken picture, not a script.
982 - out.push_str(&escape(&picture.src));
1048 + escape_into(&picture.src, out);
983 1049 out.push_str("\" alt=\"");
984 - out.push_str(&escape(&picture.alt));
1050 + escape_into(&picture.alt, out);
985 1051 out.push('"');
986 1052
987 1053 // The picture's own dimensions, which is how the browser holds its
@@ -1009,7 +1075,7 @@
1009 1075 out.push_str("<figcaption");
1010 1076 class_attr(&["picture-caption"], opts, out);
1011 1077 out.push('>');
1012 - out.push_str(&escape(caption));
1078 + escape_into(caption, out);
1013 1079 out.push_str("</figcaption></figure>");
1014 1080 }
1015 1081 }
@@ -1035,7 +1101,7 @@
1035 1101 out.push_str(" role=\"status\" aria-live=\"polite\"");
1036 1102 }
1037 1103 out.push('>');
1038 - out.push_str(&escape(text));
1104 + escape_into(text, out);
1039 1105 out.push_str("</div>");
1040 1106 }
1041 1107
@@ -1056,7 +1122,7 @@
1056 1122 out.push_str("<button type=\"submit\"");
1057 1123 class_attr(&["button", "act-submit"], opts, out);
1058 1124 out.push('>');
1059 - out.push_str(&escape(submit));
1125 + escape_into(submit, out);
1060 1126 out.push_str("</button></form>");
1061 1127 }
Lines truncated
@@ -14,7 +14,7 @@
14 14 //! viewport, where the body's classes come from — is here, because a host that
15 15 //! could get those wrong is a host that can diverge.
16 16
17 - use makeover_webview::form::escape;
17 + use makeover_webview::form::escape_into;
18 18 use quasi_router::{Chrome, Discovery};
19 19
20 20 /// The parts of a document only the host knows.
@@ -256,19 +256,19 @@
256 256 pub(crate) fn open(&self, title: &str, discovery: Option<&Discovery>, out: &mut String) {
257 257 self.open_head(Some(title), discovery, out);
258 258 out.push_str("</head><body");
259 - out.push_str(&self.body_attrs());
259 + self.push_body_attrs(out);
260 260 out.push('>');
261 261 }
262 262
263 263 /// The head, less its close. `None` leaves the `<title>` to the caller.
264 264 fn open_head(&self, title: Option<&str>, discovery: Option<&Discovery>, out: &mut String) {
265 265 out.push_str("<!doctype html><html lang=\"");
266 - out.push_str(&escape(&self.lang));
266 + escape_into(&self.lang, out);
267 267 out.push_str("\"><head><meta charset=\"utf-8\">");
268 268 out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
269 269 if let Some(title) = title {
270 270 out.push_str("<title>");
271 - out.push_str(&escape(title));
271 + escape_into(title, out);
272 272 out.push_str("</title>");
273 273 }
274 274
@@ -305,18 +305,18 @@
305 305
306 306 for href in &self.stylesheets {
307 307 out.push_str("<link rel=\"stylesheet\" href=\"");
308 - out.push_str(&escape(href));
308 + escape_into(href, out);
309 309 out.push_str("\">");
310 310 }
311 311
312 312 // Deferred, so the parser is never blocked and the extension is
313 313 // registered before htmx processes the body either way.
314 314 out.push_str("<script src=\"");
315 - out.push_str(&escape(&self.htmx_src));
315 + escape_into(&self.htmx_src, out);
316 316 out.push_str("\" defer></script>");
317 317 if let Some(src) = &self.morph_src {
318 318 out.push_str("<script src=\"");
319 - out.push_str(&escape(src));
319 + escape_into(src, out);
320 320 out.push_str("\" defer></script>");
321 321 }
322 322
@@ -343,7 +343,7 @@
343 343 out.push_str("<meta property=\"");
344 344 out.push_str(property);
345 345 out.push_str("\" content=\"");
346 - out.push_str(&escape(content));
346 + escape_into(content, out);
347 347 out.push_str("\">");
348 348 };
349 349
@@ -367,7 +367,7 @@
367 367 out.push_str("<meta name=\"");
368 368 out.push_str(name);
369 369 out.push_str("\" content=\"");
370 - out.push_str(&escape(content));
370 + escape_into(content, out);
371 371 out.push_str("\">");
372 372 };
373 373
@@ -398,15 +398,19 @@
398 398 // and the six purchased-content screens need both to agree.
399 399 if let Some(url) = &discovery.canonical {
400 400 out.push_str("<link rel=\"canonical\" href=\"");
401 - out.push_str(&escape(url));
401 + escape_into(url, out);
402 402 out.push_str("\">");
403 403 }
404 404 }
405 405
406 406 /// The attributes the shell owns on `<body>`, each one space-prefixed so
407 407 /// they compose with whatever else the host puts on the tag.
408 - fn body_attrs(&self) -> String {
409 - let mut out = String::new();
408 + ///
409 + /// [`Parts`] wants these as a value and the emitted document does not, so
410 + /// the buffer-writing form is the one with the code in it. On the described
411 + /// path this was a `String` per render for two attributes, one of which is
412 + /// a constant.
413 + fn push_body_attrs(&self, out: &mut String) {
410 414 if self.morphs() {
411 415 // Registered once on the body rather than per element: the
412 416 // extension is inherited, and an app that has to remember it per
@@ -415,9 +419,15 @@
415 419 }
416 420 if let Some(class) = &self.body_class {
417 421 out.push_str(" class=\"");
418 - out.push_str(&escape(class));
422 + escape_into(class, out);
419 423 out.push('"');
420 424 }
425 + }
426 +
427 + /// The same attributes as a value, for a host writing the `<body>` tag.
428 + fn body_attrs(&self) -> String {
429 + let mut out = String::new();
430 + self.push_body_attrs(&mut out);
421 431 out
422 432 }
423 433
@@ -19,13 +19,28 @@
19 19 //!
20 20 //! # How it reads
21 21 //!
22 - //! Class names reach the output through exactly two places -- `class_attr`,
23 - //! which writes the attribute, and `makeover_webview::class`, which prefixes one
24 - //! name -- so the literals handed to those two are the whole surface. This test
22 + //! Class names reach the output through exactly three places -- `class_attr`,
23 + //! which writes the attribute, `class_into`, which writes one prefixed name
24 + //! into a buffer, and `makeover_webview::class`, which returns one as a value
25 + //! -- so the literals handed to those three are the whole surface. This test
25 26 //! reads them out of the source rather than out of rendered HTML: rendering
26 27 //! covers what the test author remembered to describe, and the failure being
27 28 //! guarded against is a name nobody thought about.
28 29 //!
30 + //! `class_into` is read and `makeover_webview::push_class` is not, which is why
31 + //! this crate calls the former. A name spelled at a `push_class` call would be
32 + //! skipped silently: the reader drops `class(` preceded by an identifier
33 + //! character, because `option_class(`, `part_class(` and `cell_part_class(` all
34 + //! end that way and are makeover answering rather than a literal.
35 + //!
36 + //! What it does not read: a name returned by a helper rather than handed to one
37 + //! of the three. `Webview::arrangement_class` and `Webview::measure_class` each
38 + //! match a description value to a `&'static str`, and those names -- the
39 + //! `list-detail` and `measure-wide` sets -- reach the output through a
40 + //! `class_into` call whose argument is the helper. Both sets are this
41 + //! renderer's, neither has ever been checked here, and closing that is its own
42 + //! change rather than a line in RENDERER_OWN.
43 + //!
29 44 //! A literal that is neither makeover's nor declared below fails. Adding one to
30 45 //! [`RENDERER_OWN`] is the deliberate act the 0.27.0 defect skipped.
31 46
@@ -141,20 +156,23 @@
141 156 }
142 157
143 158 #[test]
144 - fn the_reader_finds_both_call_shapes_and_ignores_prose() {
159 + fn the_reader_finds_every_call_shape_and_ignores_prose() {
145 160 let src = r#"
146 161 // class_attr(&["not-a-real-one"]) in a comment
147 162 class_attr(&["alpha"], opts, out);
148 163 class_attr(&["beta", "gamma"], opts, out);
149 164 out.push_str(&escape(&class("delta", opts)));
165 + class_into("epsilon", opts, out);
150 166 class_attr(&[part_class(layout::RowPart::Primary)], opts, out);
167 + class_into(option_class(kind), opts, out);
151 168 "#;
152 169 let found: BTreeSet<String> = class_literals(src).into_iter().map(|(_, l)| l).collect();
153 - let expected: BTreeSet<String> = ["alpha", "beta", "gamma", "delta"]
170 + let expected: BTreeSet<String> = ["alpha", "beta", "gamma", "delta", "epsilon"]
154 171 .into_iter()
155 172 .map(String::from)
156 173 .collect();
157 - // `part_class(...)` is makeover answering, not a literal, so it is not here.
174 + // `part_class(...)` and `option_class(...)` are makeover answering, not
175 + // literals, so neither is here.
158 176 assert_eq!(found, expected);
159 177 }
160 178
@@ -171,7 +189,13 @@
171 189 if code.starts_with("//") {
172 190 continue;
173 191 }
174 - for (call, open) in [("class_attr(&[", ']'), ("class(", ')')] {
192 + // `class_into(` before `class(`, and the two cannot both match: there
193 + // is no `class(` inside `class_into(`.
194 + for (call, open) in [
195 + ("class_attr(&[", ']'),
196 + ("class_into(", ')'),
197 + ("class(", ')'),
198 + ] {
175 199 let mut at = 0;
176 200 while let Some(found) = line[at..].find(call) {
177 201 let start = at + found + call.len();