//! Nodes to markup. //! //! Every function here takes a piece of [`quasi_router`]'s screen tree and //! pushes markup onto a buffer. Nothing returns a `Result`: a description that //! exists is renderable by construction, which is the property the owned mirror //! in `quasi-router` was built to have. //! //! # Where the htmx goes in //! //! In exactly one function, [`action_attrs`]. An [`Action`] is a method, a path //! and some params, and turning that into `hx-get` / `hx-post` / `hx-vals` is //! the whole of what "htmx is the transport" means in code. Nothing else in //! this file knows the word htmx, so decision 13's claim that the transport is //! replaceable is a claim about one function rather than about the crate. //! //! `hx-target` is emitted for one case and only one: [`Action::replaces`], set //! when a control calls a route the description layer does not serve. Decision 7 //! puts the target on the response, where `quasi-http` sets `HX-Retarget` from //! [`Response::Fragment`](quasi_router::Response::Fragment), because the router //! is the only party that knows what it just changed, and a control that also //! named a target would be a second party deciding one thing. That reasoning //! assumes the responder is described. A plain API route is not, cannot name a //! region, and leaves the answer to land wherever the transport defaults, which //! for htmx is inside the pressed button. See `Action::replaces` for the whole //! of it. use std::collections::HashMap; use std::fmt::Write as _; use makeover_layout as layout; // `Tone::token` is the trait method, and `data-tone` is spelled from it rather // than from a match here, so a tone added upstream cannot be named two ways. use makeover_layout::Intent as _; use makeover_webview::figure::figure_html_into; use makeover_webview::form::{Filling, Markup, Value, escape_into, field_html_into}; // `class`, `option_class` and the two part-class mappings below are makeover's, // not copies of it. They were copies until makeover-webview 0.27.0 made them // public: the prefix helper was byte-identical, and the row and cell part names // were a second spelling of a list whose own doc comment carries an obligation // to be grepped on upgrade. A second spelling is a second place to forget, and // the selector names had already drifted. use makeover_webview::{Emit, class, option_class}; // `Cell` is a name both crates use: makeover's is the emitted table cell, ours // is the described one. Aliased rather than qualified at the call site, so the // two never read as the same type. use makeover_webview::list::{ Cell as Emitted, cell_part_class, cells_html_into, part_class, push_column_classes, }; use makeover_webview::meter::meter_html_into; use makeover_webview::placeholder::placeholder_html_into; use quasi_router::screen::{Act, Cell, Cells, Destination, Field, Node, Row, Slot, Tag}; use quasi_router::{Action, Method, Params}; /// Write one prefixed class name onto a buffer the caller already has. /// /// The two halves are escaped separately rather than joined and escaped once. /// That is the same bytes -- escaping is per character and has no context to /// carry across the seam -- for none of the allocations. Every class of every /// element went through a `format!` and then a second `String` before this /// existed, which measured as most of the emitter's allocation count. /// /// Escaped at all because a prefix is host configuration reaching an attribute /// value. It is a `&'static str` and every real one is identity under this, so /// the cost is a scan; what it buys is that the one string here that did not /// come from this crate cannot end the attribute. pub(crate) fn class_into(name: &str, opts: &Emit, out: &mut String) { escape_into(opts.class_prefix, out); escape_into(name, out); } /// Write a `class="..."` attribute, prefixed. fn class_attr(names: &[&str], opts: &Emit, out: &mut String) { out.push_str(" class=\""); for (i, name) in names.iter().enumerate() { if i > 0 { out.push(' '); } class_into(name, opts, out); } out.push('"'); } /// Write the attribute naming a tone, if the tone is worth naming. /// /// [`Tone::Neutral`] writes nothing: ordinary content is the default, and an /// attribute meaning "nothing unusual" is an attribute on every element in the /// document. /// /// An attribute and not a class, which is the correction. This emitted /// `tone-info`, `tone-success`, `tone-warning` and `tone-danger` as classes, and /// makeover has never defined one of them: its whole vocabulary keys tone off /// `data-tone`, from `.badge[data-tone="danger"]` to the progress fill to a /// figure's value. So every toned thing a description produced arrived with a /// class no stylesheet in the tree had heard of, which is why the SSH-keys tab's /// Remove button came out the same colour as everything else. fn tone_attr(tone: layout::Tone, out: &mut String) { if matches!(tone, layout::Tone::Neutral) { return; } out.push_str(" data-tone=\""); out.push_str(tone.token()); out.push('"'); } /// How a picture sits in its box, where it is not the default. /// /// `tone_attr`'s shape and for its reason: `Natural` is what an `` does /// with no rule at all, so saying it would be a stylesheet hook that changes /// nothing. The two that need a rule get one. fn fit_attr(fit: layout::Fit, out: &mut String) { let value = match fit { layout::Fit::Natural => return, layout::Fit::Cover => "cover", layout::Fit::Contain => "contain", // `Fit` is `#[non_exhaustive]`, so a member added upstream lands here // rather than failing the build. Drawing it as natural is the safe // read: the picture is whole and its own shape, which is wrong about // the box and never wrong about the content. _ => return, }; out.push_str(" data-fit=\""); out.push_str(value); out.push('"'); } /// What goes back in the box, for a form being offered again after a refusal. /// /// `1c4a66a4`. The description carries the value as a string, because that is /// what came off the wire; the kind is what says how to read it. A checkbox is /// carried by presence the way HTML submits one, so any value means ticked and /// nothing means not. /// /// A [`layout::FieldKind::Secret`] is emitted empty whatever it holds. That is /// the third refusal of the same thing and none of the three is redundant: /// `Field::value` will not store one, `makeover_webview::form` will not write /// one into an ``, and this one stands between them /// because `Field::value` is a public field that a struct literal reaches past. fn refill(field: &Field) -> Value<'_> { if field.kind == layout::FieldKind::Secret { return Value::Absent; } match field.value.as_deref() { None => Value::Absent, Some(_) if field.kind == layout::FieldKind::Checkbox => Value::On(true), Some(value) => Value::Text(value), } } /// One field, with whatever `1c4a66a4` and `14612ed8` added around it. /// /// The field's own markup is makeover-webview's, unchanged. A second field /// emitter here is the divergence phase A existed to end, and it would be the /// same anatomy with a different escaping story. /// /// A [`Field::changes`] is a wrapper rather than attributes on the control, /// because the control is emitted by makeover-webview and there is no seam to /// put them through. That turns out to be the better shape anyway: the `change` /// event bubbles, so one element around the group catches it whichever of the /// input, select or textarea forms the field took, and `hx-include` finds the /// control back without this having to know which it was. fn field_group_html(field: &Field, morphs: bool, opts: &Emit, out: &mut String) { let filling = Filling::of(refill(field)); let writes = field.changes.as_ref(); if let Some(action) = writes { out.push_str("'); } field.with_layout(|borrowed| { field_html_into(&borrowed, &filling, opts, out); }); if writes.is_some() { out.push_str(""); } } /// Markdown source into markup, for [`Node::Rich`]. /// /// The strict preset, which is a deliberate difference from the /// `render_standard` goingson's own JS calls: standard lets sanitised raw HTML /// through, and a shared renderer taking text a user typed should be the safer /// of the two by default. What it costs is angle brackets in a description /// rendering as text rather than as markup, which is the outcome /// [`Node::Text`] would have given anyway. /// /// Unconditional. This sat behind a default-on `rich` feature until markdown /// was made standard, and turning the feature off did not remove a cost so much /// as produce a renderer that draws `**bold**` at the user. Markdown is what /// these descriptions are made of, so rendering it is part of being a renderer /// rather than an extra somebody opts into. fn rich_html(source: &str) -> String { docengine::render_strict(source) } /// JSON-encode a string into an attribute value, both rules in one pass. /// /// Small enough to own. Pulling in a JSON crate to write object literals of /// strings would be the larger decision, and the encoder a renderer needs is /// this: the six characters JSON requires escaped, plus a `\u00XX` form for the /// rest of the C0 range. /// /// The HTML escaping is applied here rather than by the caller, which is what /// lets the payload go straight into `out`. Building the object and then /// escaping the whole of it allocated a `String` for each, per action, and /// there was nothing in between to look at. /// /// The two rules compose in this order and only this order. JSON runs first, so /// a quote in the text becomes `\"` and then `\"`; the backslash JSON adds /// is not a character HTML encodes, and the quote HTML encodes is not one JSON /// would look at twice. The structural quotes the object needs are written as /// `"` directly, because they are markup rather than content. fn json_string_attr(text: &str, out: &mut String) { out.push_str("""); for ch in text.chars() { match ch { // JSON first, then the HTML form of what it produced. '"' => out.push_str("\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), c if (c as u32) < 0x20 => { let _ = write!(out, "\\u{:04x}", c as u32); } // The rest of what an attribute value cannot carry. JSON has no // opinion on any of these, so this arm is HTML's alone and matches // `escape_into` character for character. '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '\'' => out.push_str("'"), c => out.push(c), } } out.push_str("""); } /// The params as an `hx-vals` object, written into the attribute they land in. fn json_object_attr(params: &Params, out: &mut String) { out.push('{'); for (i, (name, value)) in params.iter().enumerate() { if i > 0 { out.push(','); } json_string_attr(name, out); out.push(':'); json_string_attr(value, out); } out.push('}'); } /// What makes a control call its action. /// /// Here rather than at the call sites because every `hx-` attribute this crate /// emits has to come out of one function, which is decision 13's claim that the /// transport is replaceable and is asserted by a test. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Fires<'a> { /// The user activating the control. htmx's default for a button or a link. Click, /// The control's own value changing. What a checkbox that is itself the /// write does. Change, /// The value of a control *inside* this element changing. /// /// A field group, whose control is emitted by makeover-webview and has no /// seam to hang attributes on. The `change` event bubbles, so the wrapper /// catches it whichever of input, select or textarea the field turned out to /// be, and the value is found back rather than assumed. ChangeInside, /// A key pressed anywhere in the document. /// /// What a [`Chrome`](quasi_router::Chrome) binding is: it belongs to no /// element, so it listens on the body rather than on itself. The string is /// the filter over `KeyboardEvent`, built by `crate::chrome` because /// reading a key name is the host's job. Key(&'a str), /// The user clicking the element, but not a control inside it. /// /// `022f0c59`. A table row carries its `activate` on the row itself, unlike /// a list row, which hangs it on the primary text and so has never had this /// problem. Once a cell can hold a control, a click on that control bubbles /// to the row and htmx fires both: pressing Remove would delete the key and /// open it. The filter is on the row rather than a `stopPropagation` on the /// button because the row is the element making the wrong assumption, and a /// button that swallows events breaks anything else listening above it. ClickBeside, } /// The transport attributes for one action. /// /// # The two bags land in two places, and that is the point /// /// An action carries what the control sends (`params`) and the view it was /// offered under (`carried`), and htmx has a slot for each: the address takes /// the view, `hx-vals` takes the payload. So a write to a filtered list emits /// `hx-post="/problems/{id}/status?status=Open"` with /// `hx-vals='{"status":"Dismissed"}'`, and the two `status` values never meet. /// /// Until 2026-08-10 both bags were one and both went through `hx-vals`, which /// could hold neither. `hx-vals` is a JSON object literal, so two entries under /// one name emitted a duplicate key and every parser kept the last — inverting /// [`Params::get`]'s first-wins rule the moment a value crossed the wire, and /// silently dropping every repeat that [`Params::get_all`] exists to carry. /// Folding the view into the address fixes both: a query string repeats a name /// happily, and it is the half that wanted to be in the URL anyway. /// /// Nothing here concatenates a `?`. [`quasi_http::route_url`] does that, in one /// place, with a real encoder, because hand-built query strings are where /// escaping bugs live. pub(crate) fn action_attrs( action: &Action, fires: Fires, confirm: Option<&str>, morphs: bool, gathers: Option<&str>, out: &mut String, ) { // An external destination is not htmx's business: nothing swaps, no route // is called, and the browser follows a normal link. `rel` rather than // trust: a new tab with `window.opener` left intact hands the other page a // handle on this one. if let Destination::External(url) = &action.destination { out.push_str(" href=\""); escape_into(url, out); out.push_str("\" target=\"_blank\" rel=\"noopener noreferrer\""); return; } // The address, built once. The `href` below and the verb further down are // the same string whenever both are emitted -- a read of a route names one // place -- and building it twice was a `String` per link on every screen // made mostly of links. let url = quasi_http::route_url(action.destination.as_str(), &action.carried); // A read of a route this app answers is a link, and it gets the address as // well as the transport. htmx uses `hx-get` and prevents the default, so // the `href` is what everything else uses: middle-click, copy-link, a // crawler, and the page with JS off. The parameters are folded into it // because a link to a filtered list that drops the filter is a different // place, and `hx-vals` below carries the same ones down htmx's path. if matches!(action.destination, Destination::Route(_)) && !action.method.mutates() { out.push_str(" href=\""); escape_into(&url, out); out.push('"'); } // Everything the screen's selection has ticked, gathered by the selector // the caller built. `5f2b8753`: this is the whole of what the per-app JS // used to do, and it is declarative because a checkbox already submits its // own name and value -- all that was missing was something saying which // boxes belong together. // // Here rather than in `act_html` because it is htmx, and htmx entering this // crate anywhere else is what the architectural test forbids. if let Some(selector) = gathers { out.push_str(" hx-include=\""); escape_into(selector, out); out.push('"'); } // Where the answer goes, when the responder is not ours to ask. Emitted // before the verb so the attributes read in the order they are reasoned // about: where it lands, then what is sent. if let Some(region) = &action.replaces { out.push_str(" hx-target=\"#"); escape_into(region, out); out.push('"'); } // The answer is a file the reader keeps, not a view. On a link the browser // does the whole job from the attribute, so nothing else is needed and the // control still works with JS off. On a write it cannot: a response has to // be performed before it can be saved, so this is a named hook the host // acts on, in the same spirit as `data-act` and for the same reason it is an // attribute rather than a class. The host handles one attribute instead of // a per-button behaviour named by a class and two positional arguments. if let Some(filename) = &action.saves { if is_link(action) { out.push_str(" download=\""); } else { out.push_str(" data-saves=\""); } escape_into(filename, out); out.push('"'); } let verb = match action.method { Method::Get => " hx-get=\"", Method::Post => " hx-post=\"", Method::Delete => " hx-delete=\"", Method::Put => " hx-put=\"", }; out.push_str(verb); escape_into(&url, out); out.push('"'); if !action.params.is_empty() { out.push_str(" hx-vals=\""); json_object_attr(&action.params, out); out.push('"'); } // Named even where it matches htmx's own default for the element, so the // markup says what it does rather than resting on a default holding. match fires { Fires::Click => {} // `data-act` and not the class, so the filter does not depend on // `Emit::class_prefix` and does not break when a host sets one. Same // reasoning as `data-menu` on a row's menu. Fires::ClickBeside => out.push_str(concat!( " hx-trigger=\"click[!event.target.closest(", "'[data-act]')]\"" )), Fires::Change => out.push_str(" hx-trigger=\"change\""), Fires::Key(filter) => { // `from:body`, because the element is hidden and never focused: a // trigger on itself would wait for a keystroke it can never // receive. out.push_str(" hx-trigger=\"keydown["); escape_into(filter, out); out.push_str("] from:body\""); } Fires::ChangeInside => { out.push_str(" hx-trigger=\"change\""); out.push_str(" hx-include=\"find input, find select, find textarea\""); } } // Asking before acting is transport here, same as the verb: htmx gates the // request on it. That is also why it lands in this function rather than // beside the label — every `hx-` attribute this crate emits comes from one // place, or swapping htmx for fixi stops being a one-function change. if let Some(prompt) = confirm { out.push_str(" hx-confirm=\""); escape_into(prompt, out); out.push('"'); } if morphs { // Decision 7's slack: a morph preserves focus, scroll and input state // through a swap, so a whole-Screen answer stops being destructive. out.push_str(" hx-swap=\"morph\""); } } /// Whether an action is somewhere to go rather than something to do. /// /// Two ways to be a link. An external destination leaves. A read of a route /// this app answers is also a link: it has an address, it can be visited /// directly, and nothing changes because it was. /// /// A write is never a link however it is spelled, which is the whole of the /// other side. An anchor is something a browser may prefetch and a crawler will /// follow, and neither is allowed to delete a task. const fn is_link(action: &Action) -> bool { action.destination.is_external() || !action.method.mutates() } /// The element a control becomes. /// /// A link is an anchor and a write is a button, and a button that navigates is /// a button lying to everything that reads the page: middle-click, copy-link, /// a crawler and a screen reader included. This keyed on external-or-not until /// the read case was separated out, which made every internal navigation a /// control that only worked by running JavaScript first. /// /// Branching on the [`Destination`] variant and never on the shape of the /// string is the rule `Destination`'s own docs set. "Starts with https" is how a /// route named `/https-setup` ends up opening a browser. const fn control_tag(action: &Action) -> (&'static str, &'static str) { if is_link(action) { ("") } else { (""); } Node::List { rows, more } => { out.push_str("'); for row in rows { row_html(row, morphs, opts, out); } out.push_str(""); // Outside the list, because it is not one of the things in it. A // renderer that wanted numbered pages instead would put them here // too; what the description said is that there is more and how to // ask, and this is one host's answer to that. if let Some(rest) = more { out.push_str("'); let (open, close) = control_tag(&rest.action); out.push_str(open); class_attr(&["button", "rest-more"], opts, out); action_attrs(&rest.action, Fires::Click, None, morphs, None, out); out.push('>'); match rest.remaining { Some(n) => { out.push_str("Show more ("); let _ = write!(out, "{n}"); out.push_str(" remaining)"); } None => out.push_str("Show more"), } out.push_str(close); out.push_str(""); } } Node::Timeline { track, entries, focus, } => { // The axis first, then the things on it. Two children of one // positioned box, so the entries resolve their percentages against // the same height the slots fill. out.push_str("'); // The ruler. Slots are the grid the eye reads against; a tick every // `tick` minutes carries the label. Both are the axis describing // itself, so neither is an entry and neither is addressable. let slots = track.slots(); let tick_every = if track.slot == 0 || track.tick == 0 { 0 } else { track.tick / track.slot }; for slot in 0..slots { out.push_str("'); if tick_every > 0 && slot % tick_every == 0 { let minute = track.span.from() + slot * track.slot; out.push_str("'); // Wall clock, wrapped, so a span running past midnight // labels 02:00 rather than 26:00. The wrap is presentation: // `Span` deliberately counts past 1440 so it needs no date, // and how that reads to a person is this renderer's call. let _ = write!(out, "{:02}:{:02}", (minute / 60) % 24, minute % 60); out.push_str(""); } out.push_str(""); } // Lanes. Overlapping entries sit side by side, and which lane each // takes is worked out here rather than described, because it is a // fact about how wide the box is and not about the day. The // description said when things happen; `Placement::overlaps` turns // that into who collides. // // Greedy first-fit against the entries already placed, which is the // standard day-view packing: an entry takes the lowest lane no // occupant of which it overlaps. O(n^2) in the worst case and n is a // day's worth of appointments, so the clever interval graph is not // worth its own bugs here. let mut lanes: Vec = Vec::with_capacity(entries.len()); for (i, entry) in entries.iter().enumerate() { let mut lane = 0; loop { let taken = entries[..i] .iter() .zip(&lanes) .any(|(other, &l)| l == lane && entry.overlaps(other)); if !taken { break; } lane += 1; } lanes.push(lane); } // One width for the whole track rather than per collision cluster. // Per-cluster is denser and is a layout decision this renderer can // revisit without the description changing, which is the point of // it being here. let width = lanes.iter().copied().max().map_or(1, |m| m + 1); for (entry, lane) in entries.iter().zip(&lanes) { let at = track.fraction(entry.placement.at()); let end = track.fraction(entry.placement.end()); out.push_str("'); row_html(&entry.row, morphs, opts, out); out.push_str(""); } out.push_str(""); } Node::Table { columns, rows } => { let borrowed: Vec> = columns .iter() .map(quasi_router::screen::Column::as_layout) .collect(); // No CSS travels with the table, and none can. A described table's // columns are known here rather than at build time, so a track list // would have to be emitted per table: a `