Skip to main content

max / quasi

79.7 KB · 1858 lines History Blame Raw
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 //! `hx-target` is emitted for one case and only one: [`Action::replaces`], set
17 //! when a control calls a route the description layer does not serve. Decision 7
18 //! puts the target on the response, where `quasi-http` sets `HX-Retarget` from
19 //! [`Response::Fragment`](quasi_router::Response::Fragment), because the router
20 //! is the only party that knows what it just changed, and a control that also
21 //! named a target would be a second party deciding one thing. That reasoning
22 //! assumes the responder is described. A plain API route is not, cannot name a
23 //! region, and leaves the answer to land wherever the transport defaults, which
24 //! for htmx is inside the pressed button. See `Action::replaces` for the whole
25 //! of it.
26
27 use std::collections::HashMap;
28 use std::fmt::Write as _;
29
30 use makeover_layout as layout;
31 // `Tone::token` is the trait method, and `data-tone` is spelled from it rather
32 // than from a match here, so a tone added upstream cannot be named two ways.
33 use makeover_layout::Intent as _;
34 use makeover_webview::figure::figure_html_into;
35 use makeover_webview::form::{Filling, Markup, Value, escape_into, field_html_into};
36 // `class`, `option_class` and the two part-class mappings below are makeover's,
37 // not copies of it. They were copies until makeover-webview 0.27.0 made them
38 // public: the prefix helper was byte-identical, and the row and cell part names
39 // were a second spelling of a list whose own doc comment carries an obligation
40 // to be grepped on upgrade. A second spelling is a second place to forget, and
41 // the selector names had already drifted.
42 use makeover_webview::{Emit, class, option_class};
43 // `Cell` is a name both crates use: makeover's is the emitted table cell, ours
44 // is the described one. Aliased rather than qualified at the call site, so the
45 // two never read as the same type.
46 use makeover_webview::list::{
47 Cell as Emitted, cell_part_class, cells_html_into, part_class, push_column_classes,
48 };
49 use makeover_webview::meter::meter_html_into;
50 use makeover_webview::placeholder::placeholder_html_into;
51 use quasi_router::screen::{Act, Cell, Cells, Destination, Field, Node, Row, Slot, Tag};
52 use quasi_router::{Action, Method, Params};
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
71 /// Write a `class="..."` attribute, prefixed.
72 fn class_attr(names: &[&str], opts: &Emit, out: &mut String) {
73 out.push_str(" class=\"");
74 for (i, name) in names.iter().enumerate() {
75 if i > 0 {
76 out.push(' ');
77 }
78 class_into(name, opts, out);
79 }
80 out.push('"');
81 }
82
83 /// Write the attribute naming a tone, if the tone is worth naming.
84 ///
85 /// [`Tone::Neutral`] writes nothing: ordinary content is the default, and an
86 /// attribute meaning "nothing unusual" is an attribute on every element in the
87 /// document.
88 ///
89 /// An attribute and not a class, which is the correction. This emitted
90 /// `tone-info`, `tone-success`, `tone-warning` and `tone-danger` as classes, and
91 /// makeover has never defined one of them: its whole vocabulary keys tone off
92 /// `data-tone`, from `.badge[data-tone="danger"]` to the progress fill to a
93 /// figure's value. So every toned thing a description produced arrived with a
94 /// class no stylesheet in the tree had heard of, which is why the SSH-keys tab's
95 /// Remove button came out the same colour as everything else.
96 fn tone_attr(tone: layout::Tone, out: &mut String) {
97 if matches!(tone, layout::Tone::Neutral) {
98 return;
99 }
100 out.push_str(" data-tone=\"");
101 out.push_str(tone.token());
102 out.push('"');
103 }
104
105 /// How a picture sits in its box, where it is not the default.
106 ///
107 /// `tone_attr`'s shape and for its reason: `Natural` is what an `<img>` does
108 /// with no rule at all, so saying it would be a stylesheet hook that changes
109 /// nothing. The two that need a rule get one.
110 fn fit_attr(fit: layout::Fit, out: &mut String) {
111 let value = match fit {
112 layout::Fit::Natural => return,
113 layout::Fit::Cover => "cover",
114 layout::Fit::Contain => "contain",
115 // `Fit` is `#[non_exhaustive]`, so a member added upstream lands here
116 // rather than failing the build. Drawing it as natural is the safe
117 // read: the picture is whole and its own shape, which is wrong about
118 // the box and never wrong about the content.
119 _ => return,
120 };
121 out.push_str(" data-fit=\"");
122 out.push_str(value);
123 out.push('"');
124 }
125
126 /// What goes back in the box, for a form being offered again after a refusal.
127 ///
128 /// `1c4a66a4`. The description carries the value as a string, because that is
129 /// what came off the wire; the kind is what says how to read it. A checkbox is
130 /// carried by presence the way HTML submits one, so any value means ticked and
131 /// nothing means not.
132 ///
133 /// A [`layout::FieldKind::Secret`] is emitted empty whatever it holds. That is
134 /// the third refusal of the same thing and none of the three is redundant:
135 /// `Field::value` will not store one, `makeover_webview::form` will not write
136 /// one into an `<input type="password">`, and this one stands between them
137 /// because `Field::value` is a public field that a struct literal reaches past.
138 fn refill(field: &Field) -> Value<'_> {
139 if field.kind == layout::FieldKind::Secret {
140 return Value::Absent;
141 }
142 match field.value.as_deref() {
143 None => Value::Absent,
144 Some(_) if field.kind == layout::FieldKind::Checkbox => Value::On(true),
145 Some(value) => Value::Text(value),
146 }
147 }
148
149 /// One field, with whatever `1c4a66a4` and `14612ed8` added around it.
150 ///
151 /// The field's own markup is makeover-webview's, unchanged. A second field
152 /// emitter here is the divergence phase A existed to end, and it would be the
153 /// same anatomy with a different escaping story.
154 ///
155 /// A [`Field::changes`] is a wrapper rather than attributes on the control,
156 /// because the control is emitted by makeover-webview and there is no seam to
157 /// put them through. That turns out to be the better shape anyway: the `change`
158 /// event bubbles, so one element around the group catches it whichever of the
159 /// input, select or textarea forms the field took, and `hx-include` finds the
160 /// control back without this having to know which it was.
161 fn field_group_html(field: &Field, morphs: bool, opts: &Emit, out: &mut String) {
162 let filling = Filling::of(refill(field));
163 let writes = field.changes.as_ref();
164
165 if let Some(action) = writes {
166 out.push_str("<div");
167 class_attr(&["field-writes"], opts, out);
168 action_attrs(action, Fires::ChangeInside, None, morphs, None, out);
169 out.push('>');
170 }
171
172 field.with_layout(|borrowed| {
173 field_html_into(&borrowed, &filling, opts, out);
174 });
175
176 if writes.is_some() {
177 out.push_str("</div>");
178 }
179 }
180
181 /// Markdown source into markup, for [`Node::Rich`].
182 ///
183 /// The strict preset, which is a deliberate difference from the
184 /// `render_standard` goingson's own JS calls: standard lets sanitised raw HTML
185 /// through, and a shared renderer taking text a user typed should be the safer
186 /// of the two by default. What it costs is angle brackets in a description
187 /// rendering as text rather than as markup, which is the outcome
188 /// [`Node::Text`] would have given anyway.
189 ///
190 /// Unconditional. This sat behind a default-on `rich` feature until markdown
191 /// was made standard, and turning the feature off did not remove a cost so much
192 /// as produce a renderer that draws `**bold**` at the user. Markdown is what
193 /// these descriptions are made of, so rendering it is part of being a renderer
194 /// rather than an extra somebody opts into.
195 fn rich_html(source: &str) -> String {
196 docengine::render_strict(source)
197 }
198
199 /// JSON-encode a string into an attribute value, both rules in one pass.
200 ///
201 /// Small enough to own. Pulling in a JSON crate to write object literals of
202 /// strings would be the larger decision, and the encoder a renderer needs is
203 /// this: the six characters JSON requires escaped, plus a `\u00XX` form for the
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;");
218 for ch in text.chars() {
219 match ch {
220 // JSON first, then the HTML form of what it produced.
221 '"' => out.push_str("\\&quot;"),
222 '\\' => out.push_str("\\\\"),
223 '\n' => out.push_str("\\n"),
224 '\r' => out.push_str("\\r"),
225 '\t' => out.push_str("\\t"),
226 c if (c as u32) < 0x20 => {
227 let _ = write!(out, "\\u{:04x}", c as u32);
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;"),
236 c => out.push(c),
237 }
238 }
239 out.push_str("&quot;");
240 }
241
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('{');
245 for (i, (name, value)) in params.iter().enumerate() {
246 if i > 0 {
247 out.push(',');
248 }
249 json_string_attr(name, out);
250 out.push(':');
251 json_string_attr(value, out);
252 }
253 out.push('}');
254 }
255
256 /// What makes a control call its action.
257 ///
258 /// Here rather than at the call sites because every `hx-` attribute this crate
259 /// emits has to come out of one function, which is decision 13's claim that the
260 /// transport is replaceable and is asserted by a test.
261 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
262 pub(crate) enum Fires<'a> {
263 /// The user activating the control. htmx's default for a button or a link.
264 Click,
265 /// The control's own value changing. What a checkbox that is itself the
266 /// write does.
267 Change,
268 /// The value of a control *inside* this element changing.
269 ///
270 /// A field group, whose control is emitted by makeover-webview and has no
271 /// seam to hang attributes on. The `change` event bubbles, so the wrapper
272 /// catches it whichever of input, select or textarea the field turned out to
273 /// be, and the value is found back rather than assumed.
274 ChangeInside,
275 /// A key pressed anywhere in the document.
276 ///
277 /// What a [`Chrome`](quasi_router::Chrome) binding is: it belongs to no
278 /// element, so it listens on the body rather than on itself. The string is
279 /// the filter over `KeyboardEvent`, built by `crate::chrome` because
280 /// reading a key name is the host's job.
281 Key(&'a str),
282 /// The user clicking the element, but not a control inside it.
283 ///
284 /// `022f0c59`. A table row carries its `activate` on the row itself, unlike
285 /// a list row, which hangs it on the primary text and so has never had this
286 /// problem. Once a cell can hold a control, a click on that control bubbles
287 /// to the row and htmx fires both: pressing Remove would delete the key and
288 /// open it. The filter is on the row rather than a `stopPropagation` on the
289 /// button because the row is the element making the wrong assumption, and a
290 /// button that swallows events breaks anything else listening above it.
291 ClickBeside,
292 }
293
294 /// The transport attributes for one action.
295 ///
296 /// # The two bags land in two places, and that is the point
297 ///
298 /// An action carries what the control sends (`params`) and the view it was
299 /// offered under (`carried`), and htmx has a slot for each: the address takes
300 /// the view, `hx-vals` takes the payload. So a write to a filtered list emits
301 /// `hx-post="/problems/{id}/status?status=Open"` with
302 /// `hx-vals='{"status":"Dismissed"}'`, and the two `status` values never meet.
303 ///
304 /// Until 2026-08-10 both bags were one and both went through `hx-vals`, which
305 /// could hold neither. `hx-vals` is a JSON object literal, so two entries under
306 /// one name emitted a duplicate key and every parser kept the last — inverting
307 /// [`Params::get`]'s first-wins rule the moment a value crossed the wire, and
308 /// silently dropping every repeat that [`Params::get_all`] exists to carry.
309 /// Folding the view into the address fixes both: a query string repeats a name
310 /// happily, and it is the half that wanted to be in the URL anyway.
311 ///
312 /// Nothing here concatenates a `?`. [`quasi_http::route_url`] does that, in one
313 /// place, with a real encoder, because hand-built query strings are where
314 /// escaping bugs live.
315 pub(crate) fn action_attrs(
316 action: &Action,
317 fires: Fires,
318 confirm: Option<&str>,
319 morphs: bool,
320 gathers: Option<&str>,
321 out: &mut String,
322 ) {
323 // An external destination is not htmx's business: nothing swaps, no route
324 // is called, and the browser follows a normal link. `rel` rather than
325 // trust: a new tab with `window.opener` left intact hands the other page a
326 // handle on this one.
327 if let Destination::External(url) = &action.destination {
328 out.push_str(" href=\"");
329 escape_into(url, out);
330 out.push_str("\" target=\"_blank\" rel=\"noopener noreferrer\"");
331 return;
332 }
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
340 // A read of a route this app answers is a link, and it gets the address as
341 // well as the transport. htmx uses `hx-get` and prevents the default, so
342 // the `href` is what everything else uses: middle-click, copy-link, a
343 // crawler, and the page with JS off. The parameters are folded into it
344 // because a link to a filtered list that drops the filter is a different
345 // place, and `hx-vals` below carries the same ones down htmx's path.
346 if matches!(action.destination, Destination::Route(_)) && !action.method.mutates() {
347 out.push_str(" href=\"");
348 escape_into(&url, out);
349 out.push('"');
350 }
351
352 // Everything the screen's selection has ticked, gathered by the selector
353 // the caller built. `5f2b8753`: this is the whole of what the per-app JS
354 // used to do, and it is declarative because a checkbox already submits its
355 // own name and value -- all that was missing was something saying which
356 // boxes belong together.
357 //
358 // Here rather than in `act_html` because it is htmx, and htmx entering this
359 // crate anywhere else is what the architectural test forbids.
360 if let Some(selector) = gathers {
361 out.push_str(" hx-include=\"");
362 escape_into(selector, out);
363 out.push('"');
364 }
365
366 // Where the answer goes, when the responder is not ours to ask. Emitted
367 // before the verb so the attributes read in the order they are reasoned
368 // about: where it lands, then what is sent.
369 if let Some(region) = &action.replaces {
370 out.push_str(" hx-target=\"#");
371 escape_into(region, out);
372 out.push('"');
373 }
374
375 // The answer is a file the reader keeps, not a view. On a link the browser
376 // does the whole job from the attribute, so nothing else is needed and the
377 // control still works with JS off. On a write it cannot: a response has to
378 // be performed before it can be saved, so this is a named hook the host
379 // acts on, in the same spirit as `data-act` and for the same reason it is an
380 // attribute rather than a class. The host handles one attribute instead of
381 // a per-button behaviour named by a class and two positional arguments.
382 if let Some(filename) = &action.saves {
383 if is_link(action) {
384 out.push_str(" download=\"");
385 } else {
386 out.push_str(" data-saves=\"");
387 }
388 escape_into(filename, out);
389 out.push('"');
390 }
391
392 let verb = match action.method {
393 Method::Get => " hx-get=\"",
394 Method::Post => " hx-post=\"",
395 Method::Delete => " hx-delete=\"",
396 Method::Put => " hx-put=\"",
397 };
398 out.push_str(verb);
399 escape_into(&url, out);
400 out.push('"');
401
402 if !action.params.is_empty() {
403 out.push_str(" hx-vals=\"");
404 json_object_attr(&action.params, out);
405 out.push('"');
406 }
407
408 // Named even where it matches htmx's own default for the element, so the
409 // markup says what it does rather than resting on a default holding.
410 match fires {
411 Fires::Click => {}
412 // `data-act` and not the class, so the filter does not depend on
413 // `Emit::class_prefix` and does not break when a host sets one. Same
414 // reasoning as `data-menu` on a row's menu.
415 Fires::ClickBeside => out.push_str(concat!(
416 " hx-trigger=\"click[!event.target.closest(",
417 "&#39;[data-act]&#39;)]\""
418 )),
419 Fires::Change => out.push_str(" hx-trigger=\"change\""),
420 Fires::Key(filter) => {
421 // `from:body`, because the element is hidden and never focused: a
422 // trigger on itself would wait for a keystroke it can never
423 // receive.
424 out.push_str(" hx-trigger=\"keydown[");
425 escape_into(filter, out);
426 out.push_str("] from:body\"");
427 }
428 Fires::ChangeInside => {
429 out.push_str(" hx-trigger=\"change\"");
430 out.push_str(" hx-include=\"find input, find select, find textarea\"");
431 }
432 }
433
434 // Asking before acting is transport here, same as the verb: htmx gates the
435 // request on it. That is also why it lands in this function rather than
436 // beside the label — every `hx-` attribute this crate emits comes from one
437 // place, or swapping htmx for fixi stops being a one-function change.
438 if let Some(prompt) = confirm {
439 out.push_str(" hx-confirm=\"");
440 escape_into(prompt, out);
441 out.push('"');
442 }
443
444 if morphs {
445 // Decision 7's slack: a morph preserves focus, scroll and input state
446 // through a swap, so a whole-Screen answer stops being destructive.
447 out.push_str(" hx-swap=\"morph\"");
448 }
449 }
450
451 /// Whether an action is somewhere to go rather than something to do.
452 ///
453 /// Two ways to be a link. An external destination leaves. A read of a route
454 /// this app answers is also a link: it has an address, it can be visited
455 /// directly, and nothing changes because it was.
456 ///
457 /// A write is never a link however it is spelled, which is the whole of the
458 /// other side. An anchor is something a browser may prefetch and a crawler will
459 /// follow, and neither is allowed to delete a task.
460 const fn is_link(action: &Action) -> bool {
461 action.destination.is_external() || !action.method.mutates()
462 }
463
464 /// The element a control becomes.
465 ///
466 /// A link is an anchor and a write is a button, and a button that navigates is
467 /// a button lying to everything that reads the page: middle-click, copy-link,
468 /// a crawler and a screen reader included. This keyed on external-or-not until
469 /// the read case was separated out, which made every internal navigation a
470 /// control that only worked by running JavaScript first.
471 ///
472 /// Branching on the [`Destination`] variant and never on the shape of the
473 /// string is the rule `Destination`'s own docs set. "Starts with https" is how a
474 /// route named `/https-setup` ends up opening a browser.
475 const fn control_tag(action: &Action) -> (&'static str, &'static str) {
476 if is_link(action) {
477 ("<a", "</a>")
478 } else {
479 ("<button type=\"button\"", "</button>")
480 }
481 }
482
483 /// A control that calls a route.
484 pub(crate) fn act_html(act: &Act, morphs: bool, opts: &Emit, out: &mut String) {
485 // The commit control for a staged selection gathers every tick on the
486 // screen. One selector rather than a name per box: a screen holds one set
487 // (`Screen::selection`), so the class the ticks already carry is what they
488 // have in common. Built here because it needs `Emit`, which the transport
489 // function does not take -- a host setting `class_prefix` moves the class
490 // and the selector together.
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)));
500 let gathers = gathers.as_deref();
501 // `button`, which is makeover's name for this and carries its whole
502 // interactive set: the raised bevel, the hover fill, the pressed inset, the
503 // focus ring and the disabled treatment. This emitted `act`, a second name
504 // for the same thing that no stylesheet in the tree defined, so a described
505 // control rendered as unstyled text. There was never a concept here that
506 // `button` was not already the word for.
507 let (open, close) = control_tag(&act.action);
508 out.push_str(open);
509 class_attr(&["button"], opts, out);
510 tone_attr(act.tone, out);
511 // Named rather than found by class, so anything binding to "this is a
512 // control" survives a host setting `Emit::class_prefix`. `Fires::ClickBeside`
513 // is the first reader; a table row uses it to tell its own click apart from
514 // a press on a button sitting inside one of its cells.
515 out.push_str(" data-act");
516
517 match act.state {
518 Some(layout::State::Disabled) => {
519 // Disabled and emitting no transport, rather than disabled and
520 // still carrying the address. A control that stops answering input
521 // should also stop being a request waiting to be re-enabled from
522 // the console.
523 //
524 // An anchor has no `disabled`, and omitting the href is what
525 // actually stops it: an `<a>` without one is not a link, so it
526 // drops out of the tab order on its own. `aria-disabled` is what
527 // says why, since a bare span-shaped anchor says nothing.
528 if is_link(&act.action) {
529 out.push_str(" aria-disabled=\"true\"");
530 } else {
531 out.push_str(" disabled");
532 }
533 }
534 // No `autofocus` arm. A description does not state focus: the browser
535 // owns reach and focus here, which is what `makeover-layout` 0.19.0
536 // settled by removing the member this used to read.
537 //
538 // `State` is `#[non_exhaustive]`, so a member added upstream lands
539 // here. Emitting the transport is the right default for anything that
540 // is not a suppression: a state this renderer has not learned yet
541 // should leave the control working, not silently inert.
542 _ => action_attrs(
543 &act.action,
544 Fires::Click,
545 act.confirm.as_deref(),
546 morphs,
547 gathers,
548 out,
549 ),
550 }
551
552 // The confirmation rides with the transport, in `action_attrs`. The key does
553 // not: `accesskey` is plain HTML and no part of htmx. It is emitted even
554 // though a browser makes little of it, because a key is the affordance in a
555 // terminal and this is the nearest honest thing a page has.
556 if let Some(key) = &act.key {
557 out.push_str(" accesskey=\"");
558 escape_into(key, out);
559 out.push('"');
560 }
561
562 out.push('>');
563 escape_into(&act.label, out);
564 out.push_str(close);
565 }
566
567 /// One row of a list.
568 fn row_html(row: &Row, morphs: bool, opts: &Emit, out: &mut String) {
569 let mut classes = vec!["row"];
570 if row.current {
571 classes.push("row-current");
572 }
573 if row.selected == Some(true) {
574 classes.push("row-selected");
575 }
576
577 out.push_str("<li");
578 class_attr(&classes, opts, out);
579 if row.current {
580 // `current` is what the detail side is showing: a current item within a
581 // set rather than a pressed control, and rather than anything the user
582 // ticked. That distinction is why the description carries two fields.
583 out.push_str(" aria-current=\"true\"");
584 }
585 out.push('>');
586
587 // The tick, when the row is selectable at all. A real checkbox rather than
588 // a styled span: it is the one control here the browser already gets right,
589 // including the label association, the space key and the mixed state a
590 // screen reader announces.
591 if let Some(ticked) = row.selected {
592 out.push_str("<input type=\"checkbox\"");
593 class_attr(&["row-select"], opts, out);
594 if ticked {
595 out.push_str(" checked");
596 }
597 // What the tick contributes to the screen's selection, under the one
598 // name a handler reads it back by. `5f2b8753`: the app used to bind
599 // this itself, gathering the checked boxes in JS, because nothing in
600 // the description said what the ticks were for.
601 //
602 // Named rather than left to the browser's `on`, which is what a
603 // checkbox with no value submits. `on` says a box was checked and not
604 // which one.
605 if let Some(value) = &row.value {
606 out.push_str(" name=\"");
607 escape_into(quasi_router::Node::TICKED, out);
608 out.push_str("\" value=\"");
609 escape_into(value, out);
610 out.push('"');
611 }
612 // A tick with no route is local state until something submits it, which
613 // is what a bulk checkbox is; the app binds those itself. A tick with
614 // one is the write, which is `14612ed8`, and htmx's own default trigger
615 // for an input is `change` — named anyway, so the markup says what it
616 // does rather than relying on a default holding.
617 if let Some(action) = &row.toggle {
618 action_attrs(action, Fires::Change, None, morphs, None, out);
619 }
620 out.push_str(" aria-label=\"Select\">");
621 }
622
623 // A span per run of consecutive parts sharing a role, rather than a fixed
624 // sequence of members. The old shape drew primary, secondary, meta, bar,
625 // tokens, actions in that order however the description was built; the run
626 // draws what it was given where it was put, and a row with a tag between
627 // two facts now says so.
628 //
629 // Consecutive same-role parts share one wrapping span for the reason a
630 // cell's tokens do: the part class carries the gap between siblings, so a
631 // span each would space two badges as though they were unrelated.
632 let mut rest = row.parts.as_slice();
633 while let Some(head) = rest.first() {
634 let role = head.role;
635 let taken = rest.iter().take_while(|part| part.role == role).count();
636 let (group, tail) = rest.split_at(taken);
637 row_part_html(row, role, group, morphs, opts, out);
638 rest = tail;
639 }
640
641 // After the run, because a menu is not on the line: it is the set of things
642 // that can be done to the row, and it renders as a container the host opens
643 // its own way. A webview hangs a context menu off it, a touch host an action
644 // sheet, a terminal a key-driven list; all three read the same acts.
645 if !row.menu.is_empty() {
646 out.push_str("<div");
647 class_attr(&["row-menu"], opts, out);
648 // Named rather than hidden by class, so the host's own menu code has
649 // something to bind to that does not depend on how it is styled.
650 out.push_str(" data-menu=\"row\" hidden>");
651 for act in &row.menu {
652 act_html(act, morphs, opts, out);
653 }
654 out.push_str("</div>");
655 }
656
657 out.push_str("</li>");
658 }
659
660 /// One run of consecutive row parts sharing a role.
661 fn row_part_html(
662 row: &Row,
663 role: layout::RowPart,
664 group: &[quasi_router::Part],
665 morphs: bool,
666 opts: &Emit,
667 out: &mut String,
668 ) {
669 // The primary is a control when selecting the row does something, and plain
670 // text when it does not. Emitting a button either way would give a screen
671 // reader an affordance that answers nothing. `activate` stayed a field
672 // through the run migration, so this is still the row's own answer rather
673 // than something recomputed from a part.
674 let activates = role == layout::RowPart::Primary && row.activate.is_some();
675
676 let close = if let (true, Some(action)) = (activates, row.activate.as_ref()) {
677 let (open, close) = control_tag(action);
678 out.push_str(open);
679 class_attr(&["row-activate"], opts, out);
680 action_attrs(action, Fires::Click, None, morphs, None, out);
681 out.push('>');
682 close
683 } else {
684 out.push_str("<span");
685 class_attr(&[part_class(role)], opts, out);
686 out.push('>');
687 "</span>"
688 };
689
690 for part in group {
691 row_inline_html(&part.node, morphs, opts, out);
692 }
693
694 out.push_str(close);
695 }
696
697 /// One leaf inside a row's run.
698 ///
699 /// Markdown is the one place a run entry is not just `node_html`. A
700 /// [`Node::Rich`] standing on its own is a block and renders as one; inside a
701 /// row it goes through docengine's `phrase` preset instead, which is markdown
702 /// with no block structure at all and no links, keeping the inline emphasis. A
703 /// heading, a list and a quote each contribute their words without claiming a
704 /// block of a row that has no room for one, and not even a paragraph survives.
705 ///
706 /// Links go because a row usually carries [`Row::activate`], so the row itself
707 /// is already a target and an anchor inside it is a second target inside the
708 /// first: ambiguous to click, worse to reach by keyboard, and pointing
709 /// somewhere a one-line summary cannot usefully send anyone. Their text stays.
710 ///
711 /// This is the renderer deciding, which is the point of the description
712 /// carrying the kind rather than a flattened string. A terminal renderer facing
713 /// the same source can emit bold instead, and one that wants neither can call
714 /// `docengine::render_plain`. None of them has to be told by the screen author
715 /// which to do.
716 fn row_inline_html(node: &Node, morphs: bool, opts: &Emit, out: &mut String) {
717 match node {
718 Node::Text { text, .. } => escape_into(text, out),
719 Node::Rich { source } => out.push_str(&docengine::render_phrase(source)),
720 Node::Token(tag) => tag_html(tag, morphs, opts, out),
721 Node::Act(act) => act_html(act, morphs, opts, out),
722 Node::Meter(meter) => meter_html_into(&meter.as_layout(), opts, out),
723 // Every other leaf the model admits into a run. A link in a row and a
724 // figure in a row were the two gaps the enumeration left open and could
725 // not close without a `RowPart` variant each; here they arrive by
726 // already being leaves.
727 //
728 // No fills: a bespoke region is a block and cannot reach a run, which
729 // is what the containment bound guarantees.
730 other => node_html(other, morphs, opts, &HashMap::new(), out),
731 }
732 }
733
734 /// One cell's inline run.
735 ///
736 /// A part per inline rather than a part per cell, which is the half of the
737 /// containment model this crate had to learn. Before it, a cell was four
738 /// members and this function was a fixed sequence: the value or the link, then
739 /// the tokens strip, then the actions strip. The run says the order itself, so
740 /// a cell holding a tag between two words draws that way instead of hoisting
741 /// the tag to the end.
742 ///
743 /// Consecutive tokens and consecutive acts still share one wrapping strip.
744 /// `cell-tokens` and `cell-actions` carry the gap between siblings, so a span
745 /// each would space them as though they were unrelated, and the common case --
746 /// a status column of three badges -- is exactly the consecutive one.
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) {
754 // A cell that is one piece of text says so on the container through
755 // `CellPart::Value`, so a wrapper span here would say nothing the container
756 // has not. Anything else names its parts inside, or the content colour on
757 // the cell reaches the tokens and the controls beside the text -- the drift
758 // makeover-layout 0.14.0 named and makeover-webview 0.25.0 stopped
759 // emitting.
760 if let [Node::Text { text, .. }] = cell.parts.as_slice() {
761 escape_into(text, out);
762 return;
763 }
764
765 let mut rest = cell.parts.as_slice();
766 while let Some((head, tail)) = rest.split_first() {
767 match head {
768 Node::Text { text, .. } => {
769 out.push_str("<span");
770 class_attr(&[cell_part_class(layout::CellPart::Value)], opts, out);
771 out.push('>');
772 escape_into(text, out);
773 out.push_str("</span>");
774 rest = tail;
775 }
776 // The row is a `div` and not an anchor even when it activates, so
777 // this nests nothing: the href lands on the row element as an
778 // attribute htmx reads, and the only `<a>` in the row is the one a
779 // cell asked for.
780 //
781 // `data-act` for the same reason a button carries it. The row's
782 // `ClickBeside` filter keys on that attribute, so without it a click
783 // on the title would follow the link and open the row underneath.
784 Node::Link { text, action } => {
785 let (open, close) = control_tag(action);
786 out.push_str(open);
787 class_attr(&[cell_part_class(layout::CellPart::Link)], opts, out);
788 out.push_str(" data-act");
789 action_attrs(action, Fires::Click, None, morphs, None, out);
790 out.push('>');
791 escape_into(text, out);
792 out.push_str(close);
793 rest = tail;
794 }
795 Node::Token(_) => {
796 let run = rest.iter().take_while(|p| matches!(p, Node::Token(_)));
797 out.push_str("<span");
798 class_attr(&[cell_part_class(layout::CellPart::Tokens)], opts, out);
799 out.push('>');
800 let mut taken = 0;
801 for part in run {
802 if let Node::Token(tag) = part {
803 tag_html(tag, morphs, opts, out);
804 }
805 taken += 1;
806 }
807 out.push_str("</span>");
808 rest = &rest[taken..];
809 }
810 // Deliberately not `row-actions`. That was a hover-reveal rule
811 // until makeover-webview 0.23.0 retired it, and it is a list row's
812 // class besides: `cell-actions` is the table's own, and it carries
813 // no colour so a button here is not painted as text.
814 Node::Act(_) => {
815 let run = rest.iter().take_while(|p| matches!(p, Node::Act(_)));
816 out.push_str("<span");
817 class_attr(&[cell_part_class(layout::CellPart::Actions)], opts, out);
818 out.push('>');
819 let mut taken = 0;
820 for part in run {
821 if let Node::Act(act) = part {
822 act_html(act, morphs, opts, out);
823 }
824 taken += 1;
825 }
826 out.push_str("</span>");
827 rest = &rest[taken..];
828 }
829 // Every other leaf the model now admits into a run. A meter and a
830 // figure in a cell were the two gaps the enumeration left open and
831 // could not close without a member each; here they arrive by
832 // already being leaves.
833 other => {
834 // No fills: a bespoke region is a block and cannot reach a run,
835 // which the containment bound is what guarantees.
836 node_html(other, morphs, opts, &HashMap::new(), out);
837 rest = tail;
838 }
839 }
840 }
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>>,
859 }
860
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.
866 fn cells_row_html(
867 cells: &Cells,
868 columns: &[layout::Column<'_>],
869 buffers: &mut RowBuffers,
870 morphs: bool,
871 opts: &Emit,
872 out: &mut String,
873 ) {
874 let mut classes = vec!["table-row"];
875 if cells.current {
876 // `table-row-current`, matching `row-current` on a list row. It was
877 // `table-row-selected` while the field was, so the class said one thing
878 // and the `aria-current` two lines down said the other.
879 classes.push("table-row-current");
880 }
881
882 out.push_str("<div role=\"row\"");
883 class_attr(&classes, opts, out);
884 if cells.current {
885 out.push_str(" aria-current=\"true\"");
886 }
887 if let Some(action) = &cells.activate {
888 // Any control in any cell, which is acts and links plus the chips that
889 // answer a click. A badge is not one and does not earn the filter. The
890 // walk moved onto `Cell` with the run, so this reads the description's
891 // answer rather than recomputing it from members.
892 let carries_control = cells.values.iter().any(Cell::carries_control);
893 let fires = if carries_control {
894 Fires::ClickBeside
895 } else {
896 Fires::Click
897 };
898 action_attrs(action, fires, None, morphs, None, out);
899 }
900 out.push('>');
901
902 // The cell contents are escaped here and handed over as Markup, which is
903 // makeover-webview's contract: it owns the structure, the caller owns what
904 // goes in. Ours is text from a description plus, since `022f0c59`, whatever
905 // controls the cell carries, and `cells_html` is what knows the column
906 // classes and the narrowing.
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 }
916 // The container says what the cell is only when the cell is nothing but one
917 // piece of text, which is the case where a wrapper span would say nothing
918 // the container has not already said. Anything else names its parts inside
919 // -- the anchor is a `cell-link`, the strips are `cell-tokens` and
920 // `cell-actions` -- because a colour on the container would reach all of
921 // them, and that is the drift makeover-layout 0.14.0 named.
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
927 .iter()
928 .zip(filled.iter())
929 .zip(parts.iter().copied())
930 .map(|((column, value), part)| Emitted {
931 column: column.name,
932 part,
933 content: Markup(value),
934 })
935 .collect();
936 cells_html_into(columns, &emitted, opts, out);
937
938 out.push_str("</div>");
939 }
940
941 /// One thing on a screen.
942 pub(crate) fn node_html(
943 node: &Node,
944 morphs: bool,
945 opts: &Emit,
946 fills: &HashMap<String, String>,
947 out: &mut String,
948 ) {
949 match node {
950 Node::Heading { level, text } => {
951 let tag = match level {
952 layout::Heading::Page => "h1",
953 layout::Heading::Section => "h2",
954 layout::Heading::Subsection => "h3",
955 };
956 out.push('<');
957 out.push_str(tag);
958 class_attr(&["heading"], opts, out);
959 out.push('>');
960 escape_into(text, out);
961 out.push_str("</");
962 out.push_str(tag);
963 out.push('>');
964 }
965
966 Node::Text { text, tone } => {
967 out.push_str("<p");
968 class_attr(&["text"], opts, out);
969 tone_attr(*tone, out);
970 out.push('>');
971 escape_into(text, out);
972 out.push_str("</p>");
973 }
974
975 Node::StandIn {
976 state,
977 message,
978 act,
979 } => {
980 // The markup is makeover-webview's, unchanged, for the reason every
981 // other emitter here defers to it: the CSS that has to match it is
982 // emitted there too. The way out arrives as `Markup` because a
983 // button is an address and no crate down there names one.
984 let mut way_out = String::new();
985 if let Some(act) = act {
986 act_html(act, morphs, opts, &mut way_out);
987 }
988 placeholder_html_into(
989 *state,
990 message,
991 (!way_out.is_empty()).then_some(Markup(way_out.as_str())),
992 opts,
993 out,
994 );
995 }
996
997 Node::Rich { source } => {
998 out.push_str("<div");
999 class_attr(&["rich"], opts, out);
1000 out.push('>');
1001 out.push_str(&rich_html(source));
1002 out.push_str("</div>");
1003 }
1004
1005 Node::Act(act) => act_html(act, morphs, opts, out),
1006
1007 // Text that goes somewhere. `control_tag` picks the anchor or the
1008 // button from the method, the same way a cell link already did, and
1009 // `link` is the class makeover names it with.
1010 Node::Link { text, action } => {
1011 let (open, close) = control_tag(action);
1012 out.push_str(open);
1013 class_attr(&["link"], opts, out);
1014 out.push_str(" data-act");
1015 action_attrs(action, Fires::Click, None, morphs, None, out);
1016 out.push('>');
1017 escape_into(text, out);
1018 out.push_str(close);
1019 }
1020
1021 Node::Token(tag) => tag_html(tag, morphs, opts, out),
1022
1023 // One figure, through the same emitter the strip uses. What differs is
1024 // that nothing wraps it: the run it sits in is already the grouping.
1025 Node::Figure(figure) => {
1026 figure_html_into(&figure.as_layout(), opts, out);
1027 }
1028
1029 Node::Image(picture) => {
1030 // A captioned picture is a `<figure>`, which is what the element is
1031 // for and what the shipped carousel already writes by hand. An
1032 // uncaptioned one is the bare `<img>`: wrapping it would put a
1033 // grouping element around a group of one.
1034 let captioned = picture.caption.is_some();
1035 if captioned {
1036 out.push_str("<figure");
1037 class_attr(&["picture"], opts, out);
1038 out.push('>');
1039 }
1040
1041 out.push_str("<img");
1042 class_attr(&["picture-img"], opts, out);
1043 out.push_str(" src=\"");
1044 // Escaped as an attribute and otherwise untouched, the treatment
1045 // every app-supplied string gets here. No scheme guard: quoting is
1046 // what stops an attribute breaking out, and unlike `href` an `src`
1047 // has no scheme that executes -- `javascript:` in an `<img src>` is
1048 // a broken picture, not a script.
1049 escape_into(&picture.src, out);
1050 out.push_str("\" alt=\"");
1051 escape_into(&picture.alt, out);
1052 out.push('"');
1053
1054 // The picture's own dimensions, which is how the browser holds its
1055 // place. With `height: auto` set by makeover, these two attributes
1056 // give the box an aspect ratio before a byte arrives, so the space
1057 // is right at any width and nothing below moves when the image
1058 // lands. Omitted when the app does not know them -- an invented
1059 // size would reserve the wrong room, which is worse than none.
1060 if let Some(e) = picture.intrinsic {
1061 let _ = write!(out, " width=\"{}\" height=\"{}\"", e.width, e.height);
1062 }
1063
1064 // 0.21.0 wrote `loading="lazy"` for every picture and that was
1065 // wrong for anything on screen at first paint: deferring what is
1066 // already needed saves nothing and lands its arrival later, so the
1067 // page moves more rather than less. The description says which it
1068 // is now, and eager is the default.
1069 if matches!(picture.loading, layout::Loading::Lazy) {
1070 out.push_str(" loading=\"lazy\"");
1071 }
1072 fit_attr(picture.fit, out);
1073 out.push('>');
1074
1075 if let Some(caption) = &picture.caption {
1076 out.push_str("<figcaption");
1077 class_attr(&["picture-caption"], opts, out);
1078 out.push('>');
1079 escape_into(caption, out);
1080 out.push_str("</figcaption></figure>");
1081 }
1082 }
1083
1084 Node::Notice { kind, tone, text } => {
1085 out.push_str("<div");
1086 class_attr(
1087 &[match kind {
1088 layout::Notice::Toast => "toast",
1089 layout::Notice::Banner => "banner",
1090 }],
1091 opts,
1092 out,
1093 );
1094 tone_attr(*tone, out);
1095 // A danger or warning notice interrupts; anything else waits for a
1096 // pause. The description already says which through its tone, so
1097 // the renderer does not need a second field to be told.
1098 let assertive = matches!(tone, layout::Tone::Danger | layout::Tone::Warning);
1099 if assertive {
1100 out.push_str(" role=\"alert\"");
1101 } else {
1102 out.push_str(" role=\"status\" aria-live=\"polite\"");
1103 }
1104 out.push('>');
1105 escape_into(text, out);
1106 out.push_str("</div>");
1107 }
1108
1109 Node::Field(field) => field_group_html(field, morphs, opts, out),
1110
1111 Node::Form {
1112 action,
1113 submit,
1114 fields,
1115 } => {
1116 out.push_str("<form");
1117 class_attr(&["form"], opts, out);
1118 action_attrs(action, Fires::Click, None, morphs, None, out);
1119 out.push('>');
1120 for field in fields {
1121 field_group_html(field, morphs, opts, out);
1122 }
1123 out.push_str("<button type=\"submit\"");
1124 class_attr(&["button", "act-submit"], opts, out);
1125 out.push('>');
1126 escape_into(submit, out);
1127 out.push_str("</button></form>");
1128 }
1129
1130 Node::List { rows, more } => {
1131 out.push_str("<ul");
1132 class_attr(&["list"], opts, out);
1133 out.push('>');
1134 for row in rows {
1135 row_html(row, morphs, opts, out);
1136 }
1137 out.push_str("</ul>");
1138
1139 // Outside the list, because it is not one of the things in it. A
1140 // renderer that wanted numbered pages instead would put them here
1141 // too; what the description said is that there is more and how to
1142 // ask, and this is one host's answer to that.
1143 if let Some(rest) = more {
1144 out.push_str("<div");
1145 class_attr(&["rest"], opts, out);
1146 out.push('>');
1147
1148 let (open, close) = control_tag(&rest.action);
1149 out.push_str(open);
1150 class_attr(&["button", "rest-more"], opts, out);
1151 action_attrs(&rest.action, Fires::Click, None, morphs, None, out);
1152 out.push('>');
1153 match rest.remaining {
1154 Some(n) => {
1155 out.push_str("Show more (");
1156 let _ = write!(out, "{n}");
1157 out.push_str(" remaining)");
1158 }
1159 None => out.push_str("Show more"),
1160 }
1161 out.push_str(close);
1162 out.push_str("</div>");
1163 }
1164 }
1165
1166 Node::Timeline {
1167 track,
1168 entries,
1169 focus,
1170 } => {
1171 // The axis first, then the things on it. Two children of one
1172 // positioned box, so the entries resolve their percentages against
1173 // the same height the slots fill.
1174 out.push_str("<div");
1175 class_attr(&["track"], opts, out);
1176 // The focus travels as data rather than as a scroll offset: the app
1177 // knows the interesting hour, the host decides how to get there.
1178 // goingson's JS hardcodes `targetHour = 9` inside its renderer,
1179 // which is the arrangement this replaces.
1180 if let Some(minute) = focus {
1181 let _ = write!(out, " data-focus=\"{minute}\"");
1182 }
1183 out.push('>');
1184
1185 // The ruler. Slots are the grid the eye reads against; a tick every
1186 // `tick` minutes carries the label. Both are the axis describing
1187 // itself, so neither is an entry and neither is addressable.
1188 let slots = track.slots();
1189 let tick_every = if track.slot == 0 || track.tick == 0 {
1190 0
1191 } else {
1192 track.tick / track.slot
1193 };
1194 for slot in 0..slots {
1195 out.push_str("<div");
1196 class_attr(&["track-slot"], opts, out);
1197 out.push('>');
1198 if tick_every > 0 && slot % tick_every == 0 {
1199 let minute = track.span.from() + slot * track.slot;
1200 out.push_str("<span");
1201 class_attr(&["track-tick"], opts, out);
1202 out.push('>');
1203 // Wall clock, wrapped, so a span running past midnight
1204 // labels 02:00 rather than 26:00. The wrap is presentation:
1205 // `Span` deliberately counts past 1440 so it needs no date,
1206 // and how that reads to a person is this renderer's call.
1207 let _ = write!(out, "{:02}:{:02}", (minute / 60) % 24, minute % 60);
1208 out.push_str("</span>");
1209 }
1210 out.push_str("</div>");
1211 }
1212
1213 // Lanes. Overlapping entries sit side by side, and which lane each
1214 // takes is worked out here rather than described, because it is a
1215 // fact about how wide the box is and not about the day. The
1216 // description said when things happen; `Placement::overlaps` turns
1217 // that into who collides.
1218 //
1219 // Greedy first-fit against the entries already placed, which is the
1220 // standard day-view packing: an entry takes the lowest lane no
1221 // occupant of which it overlaps. O(n^2) in the worst case and n is a
1222 // day's worth of appointments, so the clever interval graph is not
1223 // worth its own bugs here.
1224 let mut lanes: Vec<usize> = Vec::with_capacity(entries.len());
1225 for (i, entry) in entries.iter().enumerate() {
1226 let mut lane = 0;
1227 loop {
1228 let taken = entries[..i]
1229 .iter()
1230 .zip(&lanes)
1231 .any(|(other, &l)| l == lane && entry.overlaps(other));
1232 if !taken {
1233 break;
1234 }
1235 lane += 1;
1236 }
1237 lanes.push(lane);
1238 }
1239 // One width for the whole track rather than per collision cluster.
1240 // Per-cluster is denser and is a layout decision this renderer can
1241 // revisit without the description changing, which is the point of
1242 // it being here.
1243 let width = lanes.iter().copied().max().map_or(1, |m| m + 1);
1244
1245 for (entry, lane) in entries.iter().zip(&lanes) {
1246 let at = track.fraction(entry.placement.at());
1247 let end = track.fraction(entry.placement.end());
1248 out.push_str("<div");
1249 class_attr(&["track-entry"], opts, out);
1250 // The only inline style this crate writes, and it carries no
1251 // colour, no size and no opinion: four numbers makeover's rules
1252 // read. A stylesheet cannot hold these, because they are the
1253 // data.
1254 let _ = write!(
1255 out,
1256 " style=\"--track-at:{:.4}%;--track-for:{:.4}%;--track-lane:{lane};--track-lanes:{width}\"",
1257 at * 100.0,
1258 (end - at) * 100.0
1259 );
1260 out.push('>');
1261 row_html(&entry.row, morphs, opts, out);
1262 out.push_str("</div>");
1263 }
1264
1265 out.push_str("</div>");
1266 }
1267
1268 Node::Table { columns, rows } => {
1269 let borrowed: Vec<layout::Column<'_>> = columns
1270 .iter()
1271 .map(quasi_router::screen::Column::as_layout)
1272 .collect();
1273 // No CSS travels with the table, and none can. A described table's
1274 // columns are known here rather than at build time, so a track list
1275 // would have to be emitted per table: a `<style>` element beside it,
1276 // which needs `style-src 'unsafe-inline'`, or a head block, which a
1277 // fragment swap does not carry. makeover's stylesheet lays the table
1278 // out with `display: table` instead, aligning columns across rows
1279 // knowing nothing about how many there are, and hides a dropped
1280 // column by the drop class its cells carry. The markup owes the
1281 // cells and headings those classes and nothing more.
1282 out.push_str("<div role=\"table\"");
1283 class_attr(&["table"], opts, out);
1284 out.push('>');
1285
1286 out.push_str("<div role=\"row\"");
1287 class_attr(&["table-head"], opts, out);
1288 out.push('>');
1289 for (column, described) in borrowed.iter().zip(columns) {
1290 out.push_str("<span role=\"columnheader\"");
1291 // The heading carries the same classes its column's cells do,
1292 // or the header and the body disagree about which column just
1293 // dropped and every heading below the cut sits over the wrong
1294 // values. `column_classes` is makeover's, so the two lists
1295 // cannot be assembled differently in two places.
1296 out.push_str(" class=\"");
1297 class_into("table-heading", opts, out);
1298 out.push(' ');
1299 // Not escaped, unlike every other app-supplied string here.
1300 // makeover reduces a column name to identifier characters
1301 // rather than escaping it (0.41.0), because the same name is
1302 // written into a CSS selector by `narrowing_css` and an escaped
1303 // one would be safe in the attribute and unmatchable from the
1304 // stylesheet. Escaping the result again would encode nothing
1305 // and is the one way this heading could stop matching the cells
1306 // below it.
1307 push_column_classes(out, column, opts);
1308 out.push('"');
1309 // `aria-sort` is what a table actually says about its order;
1310 // the caret in `state_rules` is this renderer's expression of
1311 // the same fact for everyone not using a screen reader.
1312 if let Some(sort) = column.sorted {
1313 out.push_str(" aria-sort=\"");
1314 out.push_str(sort.as_str());
1315 out.push('"');
1316 }
1317 if column.sortable {
1318 out.push_str(" data-sortable");
1319 }
1320 out.push('>');
1321 match &described.reorder {
1322 // A button inside the header cell rather than attributes on
1323 // the cell itself: `role="columnheader"` is not a control,
1324 // and a screen reader offered a press on something that
1325 // announces itself as a heading has been lied to.
1326 Some(action) => {
1327 let (open, close) = control_tag(action);
1328 out.push_str(open);
1329 class_attr(&["table-sort"], opts, out);
1330 action_attrs(action, Fires::Click, None, morphs, None, out);
1331 out.push('>');
1332 escape_into(column.name, out);
1333 out.push_str(close);
1334 }
1335 None => escape_into(column.name, out),
1336 }
1337 out.push_str("</span>");
1338 }
1339 out.push_str("</div>");
1340
1341 let mut buffers = RowBuffers::default();
1342 for cells in rows {
1343 cells_row_html(cells, &borrowed, &mut buffers, morphs, opts, out);
1344 }
1345 out.push_str("</div>");
1346 }
1347
1348 Node::Select {
1349 kind,
1350 options,
1351 chosen,
1352 action,
1353 } => select_html(
1354 *kind,
1355 options,
1356 chosen.as_deref(),
1357 action.as_ref(),
1358 morphs,
1359 opts,
1360 out,
1361 ),
1362
1363 // The trough and its tones are makeover-webview's, unchanged, for the
1364 // same reason the field markup is: a second emitter here would be the
1365 // same anatomy with a different escaping story, and the CSS it has to
1366 // match is emitted there too.
1367 Node::Meter(meter) => meter_html_into(&meter.as_layout(), opts, out),
1368
1369 // The strip and its tones are makeover-webview's too, and the actions
1370 // are this crate's: a figure that answers a click becomes a control
1371 // wrapped round the emitted markup rather than a second figure emitter
1372 // that knows about routes.
1373 Node::Stats { figures } => {
1374 out.push_str("<div");
1375 class_attr(&["figures"], opts, out);
1376 out.push('>');
1377 for (figure, action) in figures {
1378 match action {
1379 Some(action) => {
1380 let (open, close) = control_tag(action);
1381 out.push_str(open);
1382 class_attr(&["figure-act"], opts, out);
1383 action_attrs(action, Fires::Click, None, morphs, None, out);
1384 out.push('>');
1385 figure_html_into(&figure.as_layout(), opts, out);
1386 out.push_str(close);
1387 }
1388 None => figure_html_into(&figure.as_layout(), opts, out),
1389 }
1390 }
1391 out.push_str("</div>");
1392 }
1393
1394 Node::Region(slot) => slot_html(slot, morphs, opts, fills, out),
1395 }
1396 }
1397
1398 /// A small labelled thing sitting inside something else.
1399 ///
1400 /// Took eight arguments, one per field of `Node::Token`, until the payload
1401 /// became [`Tag`] so a row could carry one. The `too_many_arguments` allow went
1402 /// with them.
1403 fn tag_html(tag: &Tag, morphs: bool, opts: &Emit, out: &mut String) {
1404 let Tag {
1405 kind,
1406 label,
1407 tone,
1408 latched,
1409 action,
1410 } = tag;
1411 let (kind, tone, latched) = (*kind, *tone, *latched);
1412 let action = action.as_ref();
1413
1414 let mut classes = vec![match kind {
1415 layout::Token::Badge => "badge",
1416 layout::Token::Chip { .. } => "chip",
1417 }];
1418 // `latched`, which is what makeover styles: `.chip.latched` is the pressed
1419 // depth a chip holds itself down with. This said `chip-latched`, a third
1420 // name for it, and a latched chip therefore looked exactly like an
1421 // unlatched one.
1422 if latched {
1423 classes.push("latched");
1424 }
1425
1426 // A badge answers no click, so it is not a button however it is styled.
1427 // The description says which through the kind, which is the whole reason
1428 // the two are separate members rather than one with a flag.
1429 let interactive = kind.interactive() && action.is_some();
1430 let close = if interactive {
1431 // An interactive tag whose destination leaves the app is an anchor, for
1432 // the reason `control_tag` gives. A tag that answers nothing stays a
1433 // span either way.
1434 let (open, close) = action.map_or(("<span", "</span>"), control_tag);
1435 out.push_str(open);
1436 close
1437 } else {
1438 out.push_str("<span");
1439 "</span>"
1440 };
1441 class_attr(&classes, opts, out);
1442 tone_attr(tone, out);
1443
1444 if interactive {
1445 // `data-act` for the same reason a button carries it: a chip that
1446 // answers a click is a control, and a table row filtering its own
1447 // trigger has to be able to tell one apart from its own text. A badge
1448 // never gets it, because a badge answers nothing.
1449 out.push_str(" data-act");
1450 if latched {
1451 // A chip standing for a filter is on or off, and its latched class
1452 // carries that fact visually through Depth::pressed either way.
1453 // Which word says it depends on what the chip turned out to be:
1454 // aria-pressed is a button's state and means nothing on an anchor,
1455 // and a link that is the view you are looking at is the one thing
1456 // aria-current exists to say.
1457 if action.is_some_and(is_link) {
1458 out.push_str(" aria-current=\"true\"");
1459 } else {
1460 out.push_str(" aria-pressed=\"true\"");
1461 }
1462 }
1463 if let Some(action) = action {
1464 action_attrs(action, Fires::Click, None, morphs, None, out);
1465 }
1466 }
1467
1468 out.push('>');
1469 escape_into(label, out);
1470 if matches!(kind, layout::Token::Chip { removable: true }) {
1471 out.push_str("<span");
1472 class_attr(&["chip-remove"], opts, out);
1473 out.push_str(" aria-hidden=\"true\"></span>");
1474 }
1475 out.push_str(close);
1476 }
1477
1478 /// A control that picks between things.
1479 ///
1480 /// # Which element carries the kind
1481 ///
1482 /// The option does, because that is what makeover styles: `selector_rules`
1483 /// writes the depth, the focus ring and the chosen state for `.tab`,
1484 /// `.segment` and `.toggle`, and each of those is the thing that gets picked
1485 /// rather than the thing holding them. This emitted `tabs` / `segmented` /
1486 /// `option` and put `toggle` on the wrapping div, so every described selector
1487 /// came out flat, and a toggle group took the bevel meant for its buttons. The
1488 /// fourth instance of the gap the SSH-keys tab found three of, and the reason
1489 /// `every_class_this_renderer_emits_is_one_makeover_defines` now enumerates
1490 /// instead of remembering.
1491 ///
1492 /// The group keeps one name, `selector`, and says which kind it is in
1493 /// `data-selector`. An attribute for the same reason `data-tone` is one: the
1494 /// group is a spacing question, spacing is `makeover-geometry`'s, and a class
1495 /// there would read as the styling hook the option's class actually is.
1496 fn select_html(
1497 kind: layout::Selector,
1498 options: &[(quasi_router::screen::Choice, Option<Action>)],
1499 chosen: Option<&str>,
1500 action: Option<&Action>,
1501 morphs: bool,
1502 opts: &Emit,
1503 out: &mut String,
1504 ) {
1505 out.push_str("<div");
1506 class_attr(&["selector"], opts, out);
1507 out.push_str(" data-selector=\"");
1508 out.push_str(option_class(kind));
1509 out.push('"');
1510 // Tabs are navigation between panes, which is a tablist. The other two pick
1511 // a value and are a group of buttons.
1512 if matches!(kind, layout::Selector::Tabs) {
1513 out.push_str(" role=\"tablist\"");
1514 } else {
1515 out.push_str(" role=\"group\"");
1516 }
1517 out.push('>');
1518
1519 for (option, own) in options {
1520 let picked = chosen.is_some_and(|value| value == option.value);
1521 // `chosen` is makeover's name for this, the way `latched` is on a chip.
1522 let mut classes = vec![option_class(kind)];
1523 if picked {
1524 classes.push("chosen");
1525 }
1526
1527 // An option naming its own route is a link when that route is a read,
1528 // for the reason every other read here is: middle-click, copy-link, a
1529 // crawler and the page with JS off. A tab panel is fetched with a GET,
1530 // so a tab strip is fifteen links rather than fifteen buttons.
1531 let (open, close) = own
1532 .as_ref()
1533 .map_or(("<button type=\"button\"", "</button>"), |action| {
1534 control_tag(action)
1535 });
1536 out.push_str(open);
1537 class_attr(&classes, opts, out);
1538 if matches!(kind, layout::Selector::Tabs) {
1539 out.push_str(" role=\"tab\" aria-selected=\"");
1540 out.push_str(if picked { "true\"" } else { "false\"" });
1541 } else if picked {
1542 out.push_str(" aria-pressed=\"true\"");
1543 }
1544
1545 // The option's own route wins, and there is nothing to substitute into
1546 // it: it already names the panel. The strip's action is the fallback,
1547 // and it is the one that needs the picked value, because it is one
1548 // route standing for all the options.
1549 if let Some(own) = own {
1550 action_attrs(own, Fires::Click, None, morphs, None, out);
1551 } else if let Some(action) = action {
1552 // The picked value travels under one name, decided once in
1553 // `Node::SELECTED`, rather than agreed per screen between a
1554 // renderer and a handler.
1555 let carrying = action.clone().with(Node::SELECTED, option.value.clone());
1556 action_attrs(&carrying, Fires::Click, None, morphs, None, out);
1557 }
1558
1559 out.push('>');
1560 escape_into(&option.label, out);
1561 out.push_str(close);
1562 }
1563
1564 out.push_str("</div>");
1565 }
1566
1567 /// A region the answer changed without being aimed at it.
1568 ///
1569 /// The second half of the transport, and the reason this file's htmx test
1570 /// names two functions rather than one. [`action_attrs`] says where a control
1571 /// sends and where the answer lands; this says where a piece of the answer
1572 /// lands that no control asked for. Both are the same fact — how htmx is told
1573 /// to put markup somewhere — and a transport swapped for fixi moves both, which
1574 /// is what decision 13's claim needs.
1575 ///
1576 /// # Why `innerHTML:` and not a bare `true`
1577 ///
1578 /// A bare `hx-swap-oob="true"` replaces the element carrying the matching id
1579 /// outright, and the element in the document is [`slot_html`]'s `<div>` with
1580 /// the region's classes on it. Replacing it with what is emitted here would
1581 /// strip them, so the region would keep its contents and lose its layout.
1582 /// Addressing the swap by selector instead puts the markup *inside* the slot
1583 /// and leaves the wrapper alone, which is what [`Serves::fragment`] already
1584 /// means for the targeted region. The wrapper emitted here is htmx's envelope
1585 /// and never reaches the document.
1586 ///
1587 /// [`Serves::fragment`]: quasi_http::Serves::fragment
1588 pub(crate) fn oob_html(
1589 region: &str,
1590 node: &Node,
1591 morphs: bool,
1592 opts: &Emit,
1593 fills: &HashMap<String, String>,
1594 out: &mut String,
1595 ) {
1596 out.push_str("<div hx-swap-oob=\"innerHTML:#");
1597 // Escaped as an attribute, and otherwise untouched for `slot_html`'s
1598 // reason: this has to match the id that function emitted, and a slot id is
1599 // the address the description chose.
1600 escape_into(region, out);
1601 out.push_str("\">");
1602 node_html(node, morphs, opts, fills, out);
1603 out.push_str("</div>");
1604 }
1605
1606 /// Write a `class="..."` attribute, prefixed, plus `current` if it is the one.
1607 ///
1608 /// `current` is written bare rather than through [`class_attr`], because
1609 /// `showing_rules` writes `.showing-frame:not(.current)` and the state half of
1610 /// that pair does not move under a prefix. Same shape as makeover's `chosen`
1611 /// and `latched`: the thing is prefixed, the state qualifying it is not.
1612 fn frame_class_attr(current: bool, opts: &Emit, out: &mut String) {
1613 out.push_str(" class=\"");
1614 class_into("showing-frame", opts, out);
1615 if current {
1616 out.push_str(" current");
1617 }
1618 out.push('"');
1619 }
1620
1621 /// The chrome for a region showing one child at a time.
1622 ///
1623 /// Derived, once, for every widget there will ever be. Nothing here reads
1624 /// [`quasi_router::RegionKind::Widget`]'s name, and that is the point of the
1625 /// whole design: a carousel, a tab group and a disclosure are one region that
1626 /// shows some of its children, and which idiom comes out falls out of what the
1627 /// children carry rather than out of what the assembly is called.
1628 ///
1629 /// # The three shapes, and what picks between them
1630 ///
1631 /// - Children carrying labels get a strip of them, which is
1632 /// [`layout::Selector::Tabs`] markup verbatim. A tab strip is already a
1633 /// described thing here; deriving a second spelling of one would be this
1634 /// renderer inventing a name makeover would then not style, which is the
1635 /// SSH-keys bug with extra steps.
1636 /// - One dismissible child with a name gets that name as a control, which is a
1637 /// summary line that opens.
1638 /// - Anything else gets previous, position, next.
1639 ///
1640 /// # Why these controls are not links
1641 ///
1642 /// A control here calls no route, because the description names none: what
1643 /// changes is which of several children already in the document is showing, and
1644 /// a round trip to reveal bytes the reader has downloaded is worse on every one
1645 /// of the three MNW galleries this was measured against. So the controls carry
1646 /// `data-shows` and whatever binds the region binds them, the same relationship
1647 /// `data-act` and `data-bespoke` already have with their host.
1648 ///
1649 /// That leaves route-bound movement available and unbuilt. A description that
1650 /// wanted a real fragment request would carry an [`Action`], the controls would
1651 /// become links through [`control_tag`] exactly as a selector's options do, and
1652 /// nothing here would have to change shape. Nothing asks for it today.
1653 fn showing_html(slot: &Slot, opts: &Emit, out: &mut String) {
1654 let labels = slot.labels();
1655 let current = slot.current();
1656 let total = slot.body.len();
1657
1658 // A named single child that can close is a disclosure, and the check comes
1659 // first because such a child is also a labelled one: a strip of one tab is
1660 // not what a summary line is.
1661 if slot.showing.dismissible()
1662 && total == 1
1663 && let [label] = labels.as_slice()
1664 {
1665 out.push_str("<button type=\"button\"");
1666 // `button`, which is makeover's name for a control and carries its whole
1667 // interactive set. `act_html` learned this the expensive way.
1668 class_attr(&["button"], opts, out);
1669 out.push_str(" data-shows=\"0\" aria-expanded=\"");
1670 out.push_str(if current.is_some() {
1671 "true\""
1672 } else {
1673 "false\""
1674 });
1675 out.push('>');
1676 escape_into(label, out);
1677 out.push_str("</button>");
1678 return;
1679 }
1680
1681 if !labels.is_empty() {
1682 let tab = option_class(layout::Selector::Tabs);
1683 out.push_str("<div");
1684 class_attr(&["selector"], opts, out);
1685 out.push_str(" data-selector=\"");
1686 out.push_str(tab);
1687 out.push_str("\" role=\"tablist\">");
1688 for (at, label) in labels.iter().enumerate() {
1689 let picked = current == Some(at);
1690 out.push_str("<button type=\"button\" class=\"");
1691 class_into(tab, opts, out);
1692 if picked {
1693 out.push_str(" chosen");
1694 }
1695 let _ = write!(out, "\" data-shows=\"{at}\" role=\"tab\" aria-selected=\"");
1696 out.push_str(if picked { "true\"" } else { "false\"" });
1697 out.push('>');
1698 escape_into(label, out);
1699 out.push_str("</button>");
1700 }
1701 out.push_str("</div>");
1702 return;
1703 }
1704
1705 // Previous, position, next. In flow, under the content, nothing overlaid:
1706 // a terminal cannot honestly overlay anything, and the dot strip this
1707 // replaces had no form at all past a handful of children -- two of the
1708 // three galleries it shipped on are creator uploads of arbitrary length.
1709 out.push_str("<div");
1710 class_attr(&["showing"], opts, out);
1711 out.push('>');
1712
1713 out.push_str("<button type=\"button\"");
1714 class_attr(&["button"], opts, out);
1715 out.push_str(" data-shows=\"previous\">Prev</button>");
1716
1717 out.push('<');
1718 out.push_str("span");
1719 class_attr(&["showing-position"], opts, out);
1720 // Zero when a dismissible region is closed, which is a true statement about
1721 // how many of its children are showing and needs no glyph of its own.
1722 let _ = write!(out, ">{} / {total}</span>", current.map_or(0, |at| at + 1));
1723
1724 out.push_str("<button type=\"button\"");
1725 class_attr(&["button"], opts, out);
1726 out.push_str(" data-shows=\"next\">Next</button>");
1727
1728 out.push_str("</div>");
1729 }
1730
1731 /// A region, and everything under it.
1732 pub(crate) fn slot_html(
1733 slot: &Slot,
1734 morphs: bool,
1735 opts: &Emit,
1736 fills: &HashMap<String, String>,
1737 out: &mut String,
1738 ) {
1739 let kind = match &slot.kind {
1740 quasi_router::RegionKind::Band => "band",
1741 quasi_router::RegionKind::Sidebar => "sidebar",
1742 quasi_router::RegionKind::Pane => "pane",
1743 quasi_router::RegionKind::Split => "split",
1744 quasi_router::RegionKind::Columns => "columns",
1745 quasi_router::RegionKind::TabGroup => "tabgroup",
1746 quasi_router::RegionKind::Modal => "modal",
1747 quasi_router::RegionKind::Bespoke { .. } => "bespoke",
1748 quasi_router::RegionKind::Widget { .. } => "widget",
1749 };
1750
1751 out.push_str("<div id=\"");
1752 // The id is the fragment's address. It is escaped and otherwise untouched:
1753 // rewriting it would break the HX-Retarget the router just sent, which
1754 // names the slot's own id.
1755 escape_into(&slot.id, out);
1756 out.push('"');
1757 class_attr(&["region", kind], opts, out);
1758
1759 if let quasi_router::RegionKind::Bespoke { name } = &slot.kind {
1760 // Never interpreted, per decision 4. Handed to the app under a name it
1761 // chose, which is the entire contract for a bespoke region.
1762 out.push_str(" data-bespoke=\"");
1763 escape_into(name, out);
1764 out.push('"');
1765 }
1766
1767 if let quasi_router::RegionKind::Widget { name } = &slot.kind {
1768 // Also never interpreted here, and for a different purpose: this is the
1769 // hook a stylesheet or a script attaches to in order to draw the
1770 // assembly the way a browser does it. It is an attribute rather than a
1771 // class because a widget name is app vocabulary and classes here are
1772 // this renderer's.
1773 //
1774 // What makes it safe to emit and ignore is that the body under it is
1775 // already the whole assembly in primitives. Nothing recognises
1776 // `data-widget` and the region still renders -- a carousel degrades to
1777 // its frames, which is the JS-off fallback the shipped partial has and
1778 // the reason the tier does not need three renderers to release in step.
1779 out.push_str(" data-widget=\"");
1780 escape_into(name, out);
1781 out.push('"');
1782 }
1783
1784 // What a binder looks for, and the one attribute that says this region has
1785 // chrome to bind. It is not the widget's name: a script that moved between
1786 // a carousel's frames by matching `data-widget="carousel"` would have to be
1787 // written again for the next assembly, which is the per-widget code the
1788 // whole derivation exists to stop.
1789 //
1790 // Nothing is emitted for `Showing::All`, so the hook is present exactly
1791 // when there is something to bind.
1792 match slot.showing {
1793 layout::Showing::One => out.push_str(" data-showing=\"one\""),
1794 layout::Showing::AtMostOne => out.push_str(" data-showing=\"at-most-one\""),
1795 _ => {}
1796 }
1797
1798 if matches!(slot.readiness, layout::Readiness::Pending) {
1799 out.push_str(" aria-busy=\"true\"");
1800 }
1801
1802 if matches!(slot.kind, quasi_router::RegionKind::Modal) {
1803 // A modal takes input until dismissed, which is what modal means, and
1804 // saying so is the renderer's job rather than the app's.
1805 out.push_str(" role=\"dialog\" aria-modal=\"true\"");
1806 }
1807
1808 out.push('>');
1809 if slot.showing.selective() {
1810 let current = slot.current();
1811 let labelled = !slot.labels().is_empty();
1812
1813 // A strip sits above the panes it opens and a counter row sits under
1814 // the content it counts. That is the only placement decision here, and
1815 // it is the folder semantic rather than a preference: a tab that came
1816 // after its pane would not read as the tab of it.
1817 if labelled {
1818 showing_html(slot, opts, out);
1819 }
1820
1821 // Each child is wrapped, because the rule that collapses the stack has
1822 // to have something to select and a described child emits whatever
1823 // element it is. The wrapper appears only here, so a region that shows
1824 // everything -- which is every region written before `Showing` existed
1825 // -- emits exactly the markup it always did.
1826 for (at, node) in slot.body.iter().enumerate() {
1827 out.push_str("<div");
1828 frame_class_attr(current == Some(at), opts, out);
1829 out.push('>');
1830 node_html(node, morphs, opts, fills, out);
1831 out.push_str("</div>");
1832 }
1833
1834 if !labelled {
1835 showing_html(slot, opts, out);
1836 }
1837 } else {
1838 for node in &slot.body {
1839 node_html(node, morphs, opts, fills, out);
1840 }
1841 }
1842
1843 // The host's markup for this region, after whatever the description put
1844 // here, and only for a bespoke one: a fill named against a pane is a host
1845 // reaching into a region the description already owns.
1846 //
1847 // Verbatim. See `Webview::fills` for why that is not the hole it looks
1848 // like: the string is host code's, never a description's, so the escaping
1849 // guarantee the whole vocabulary rests on is untouched.
1850 if matches!(slot.kind, quasi_router::RegionKind::Bespoke { .. })
1851 && let Some(fill) = fills.get(&slot.id)
1852 {
1853 out.push_str(fill);
1854 }
1855
1856 out.push_str("</div>");
1857 }
1858