//! Phase B, the forms half: [`makeover_layout::Field`] rendered to HTML. //! //! # Why this emits strings //! //! Both webview apps build their markup as strings and hand it to `innerHTML`: //! goingson's `renderFormField` returns a template literal that fifteen call //! sites interpolate into larger literals, and Balanced Breakfast's builds //! nodes but appends them into the same string-built forms. Returning nodes //! would rewrite the surrounding templates as well, which makes it a migration //! rather than an adoption. So: strings, and the escaping comes with them. //! //! # Why one escaper is enough here //! //! goingson carries four escapers and 543 call sites that must pick between //! them, because `escapeHtml` is built on `textContent` serialization and //! **`textContent` refuses to encode `"`**. That is what makes it unsound in an //! attribute, and it is the whole reason the choice exists. Its `escape.js` //! records the finding as the CHRONIC-XSS seal, and its test suite has a gate //! keeping the unsafe one off the namespace. //! //! [`escape`] here is not built on that, so it encodes the quote along with //! everything else, which makes one function sound in both sinks. The four-way //! choice does not move into Rust: it disappears. Nothing in this module hands //! an unescaped value to the output except through [`Markup`], which a caller //! has to name. //! //! # What the description does not carry //! //! One thing: the **current value**, which arrives in [`Filling`]. //! //! It used to be three. Writing this emitter is what found them, and the other //! two turned out not to be renderer state at all — the placeholder is //! user-facing text that sits with `label` and `hint`, and a select's options //! are needed by every renderer, which is how each of them ends up inventing a //! near-miss of the same struct. Both moved down into `makeover-layout` 0.8.0, //! `Choice` included, and this crate reads them off [`Field`] now. //! //! The value stays, and it is not a leftover. A webview reads it back out of //! the DOM, an immediate-mode renderer writes through a `&mut`, and a terminal //! keeps an edit buffer; a description carrying it would have to carry a way to //! write it back, at which point it is a form model. use crate::{Emit, class, push_class}; use makeover_layout::{Choice, Depth, Field, FieldKind, Selector}; use std::fmt::Write as _; /// A string that is already markup, and is emitted without escaping. /// /// The one hole in the escaping, and it has to be named to be used. goingson /// has two live callers that need it, both passing a recurrence-config block /// built elsewhere, and both would otherwise have their markup rendered as /// visible angle brackets. A caller constructing this is stating that the /// contents are trusted; nothing here can check that for them. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Markup<'a>(pub &'a str); /// What the field currently holds. /// /// An enum rather than a bag of optional fields, on the same reasoning /// [`makeover_layout::Depth`] is one: a checkbox holding a string is unsayable /// here, where a struct would let it be said and then have to cope. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Value<'a> { /// Nothing yet. #[default] Absent, /// The value of anything that takes typed text, a select included: what a /// select holds is the `value` of one of [`Field::options`]'s /// [`Choice`]s. /// /// It carried the options too until makeover-layout 0.8.0 moved them onto /// the field, which collapsed a `Chosen { options, value }` variant into /// this one. `makeover-immediate` arrived at the same single-variant shape /// on its own, from the other direction. Text(&'a str), /// A checkbox, on or off. On(bool), } impl<'a> Value<'a> { /// The value as text, for the kinds that submit one. const fn as_text(&self) -> &'a str { match self { Self::Text(text) => text, Self::Absent | Self::On(_) => "", } } } /// Everything about the field that the description does not carry. #[derive(Debug, Clone, Copy, Default)] pub struct Filling<'a> { /// What the field holds now. pub value: Value<'a>, /// Markup appended inside the group, after the hint. Not escaped. pub trailing: Option>, /// Scopes the `id` attributes to one instance of the form. /// /// The field's `name` is what the value submits under and is the same /// wherever the form appears; its `id` has to be unique in the document, /// and those two facts stop agreeing the moment a form appears twice. /// goingson hits this directly: its new-task and edit-task modals are the /// same field set, so it prefixes `form-modal-task-new` or `-edit` to keep /// `label for` and `aria-describedby` pointing at the right control. /// /// Applies to `id`, `for` and the `-hint` / `-error` associations. Never to /// `name`, which would change what the form submits. pub id_prefix: Option<&'a str>, } impl<'a> Filling<'a> { /// A filling that carries a value and nothing else. #[must_use] pub const fn of(value: Value<'a>) -> Self { Self { value, trailing: None, id_prefix: None, } } /// The document-unique id for a field of this name. fn id_for(&self, name: &str) -> String { let mut id = String::new(); if let Some(prefix) = self.id_prefix { escape_into(prefix, &mut id); id.push('-'); } escape_into(name, &mut id); id } } /// Encode the five characters that let a value stop being a value, into a /// buffer the caller already has. /// /// The form the emitters use. [`escape`] is this with a `String` allocated /// around it, and the allocation is the whole difference: a described screen /// escapes once per attribute and once per run of text, so a function that /// returns a `String` allocates a few thousand times to produce one page, where /// a template engine writes its escaped bytes straight into the output buffer. /// Measured 2026-08-14 against a real pane, that gap was 85% of a 42x rendering /// cost, and this is the half of the fix that lives in this crate. /// /// Sound in element text and in a double-quoted attribute alike, which is the /// property `textContent`-based escaping cannot have. Both sinks are covered by /// one function so that no call site has to choose, here or downstream. /// /// Copies in runs rather than per character. All five encoded characters are /// ASCII, so a byte scan cannot land inside a multi-byte character and the /// slice between two of them is always a valid `&str`. Text with nothing to /// encode — which is most text — is one `push_str` of the whole thing. pub fn escape_into(text: &str, out: &mut String) { let mut start = 0; for (index, byte) in text.bytes().enumerate() { let encoded = match byte { b'&' => "&", b'<' => "<", b'>' => ">", b'"' => """, b'\'' => "'", _ => continue, }; out.push_str(&text[start..index]); out.push_str(encoded); start = index + 1; } out.push_str(&text[start..]); } /// Encode the five characters that let a value stop being a value. /// /// [`escape_into`] with a buffer of its own, for the callers that want a value /// rather than an append: a caller assembling an attribute out of several /// pieces, and everything outside this crate that took this function before the /// buffer-writing form existed. Emitting into a buffer you already hold is the /// cheaper path and the one this crate's own emitters take. #[must_use] pub fn escape(text: &str) -> String { let mut out = String::with_capacity(text.len()); escape_into(text, &mut out); out } /// The `type` an input takes for a kind. /// /// [`FieldKind::Secret`] is `password`, which both apps already map by hand. const fn input_type(kind: FieldKind) -> &'static str { match kind { FieldKind::Secret => "password", FieldKind::Number => "number", FieldKind::Checkbox => "checkbox", FieldKind::File => "file", FieldKind::Hidden => "hidden", // Not decoration. Each of these changes the keyboard a touch device // offers and turns on the platform's own validation, which is why the // description names them apart from text rather than letting the app // pass an HTML type through. FieldKind::Email => "email", FieldKind::Url => "url", FieldKind::Tel => "tel", // The same argument, and it buys more here than anywhere else in this // list: a native picker as well as the keyboard and the validation. // Both submit the format `makeover-layout` names, `DATE_FORMAT` and // `DATETIME_FORMAT`, so honouring it costs this renderer nothing. FieldKind::Date => "date", FieldKind::DateTime => "datetime-local", FieldKind::Radio => "radio", // The clearest case in this list that a kind is not decoration: a // number and a range submit the same value and are different controls, // and the browser is the one drawing the difference. FieldKind::Range => "range", // Select and Textarea are not inputs at all; they never reach here. // Radio is one, but it is emitted once per option by `radio_html` and // so does not reach here either. FieldKind::Text | FieldKind::Select | FieldKind::Textarea | FieldKind::Rich => "text", // A kind added to the description since this renderer was built. Text // accepts any value the others would, so it degrades rather than // dropping the field. _ => "text", } } /// The attributes every visible control carries, error state included. /// /// `aria-invalid` is the whole reason the error state is readable at all: the /// generated stylesheet keys the danger ring on `[aria-invalid="true"]` rather /// than on a class, so a control rendered already-invalid without it is styled /// as if nothing were wrong. goingson's runtime validation path sets the /// attribute and its initial render does not, which is exactly the drift one /// emitter removes. /// `id` and `name` arrive separately because they are not the same fact. The /// name is what submits and is fixed by the description; the id has to be /// unique in the document and so carries [`Filling::id_prefix`] when a form /// appears more than once. /// The `accept` attribute, from the description's accept list. /// /// makeover-layout 0.31.0. The list is comma-joined because that is the /// attribute's own format, and each entry writes itself: a family is its /// wildcard media type, a media type is itself, a suffix is itself with its /// leading dot. Nothing is normalised on the way through -- `.tar.gz` is two /// dots and the browser is fine with it. /// /// An empty list emits no attribute at all, which is the browser's own "any /// file" and is what the description means by listing nothing. Emitting /// `accept=""` instead would be a filter that matches nothing on some browsers /// and everything on others. /// /// It is a filter and not a guarantee, on the browser's side as much as here: /// the picker keeps an "All Files" escape and the user may take it. Whoever /// validated still validates. fn push_accept(out: &mut String, field: &Field<'_>) { if field.accept.is_empty() { return; } out.push_str(" accept=\""); for (index, one) in field.accept.iter().enumerate() { if index > 0 { out.push(','); } escape_into(one.as_str(), out); } out.push('"'); } fn push_control_attributes(out: &mut String, field: &Field<'_>, id: &str, name: &str) { let _ = write!(out, " id=\"{id}\" name=\""); escape_into(name, out); out.push('"'); if field.required { out.push_str(" required"); } // makeover-layout 0.11.0's constraints. The description carries the rule and // this emits the browser's idiom for it, which is the model `required` has // been using since before the crate wrote down that it carried none. // Enforcement is still whoever validated's, and arrives back as `error`. if let Some(limit) = field.max_length { let _ = write!(out, " maxlength=\"{limit}\""); } if let Some(min) = field.min { out.push_str(" min=\""); escape_into(min, out); out.push('"'); } if let Some(max) = field.max { out.push_str(" max=\""); escape_into(max, out); out.push('"'); } // The browser's own default is `step="1"`, which turns a 0-to-1 threshold // into a two-position control. That is the granularity the description // means when it says nothing, so this is emitted only when an app has said // otherwise rather than defaulted here. if let Some(step) = field.step { out.push_str(" step=\""); escape_into(step, out); out.push('"'); } if field.invalid() { out.push_str(" aria-invalid=\"true\""); } push_described_by(out, field, id); } /// The `aria-describedby` naming whatever of the hint and the error exist. /// /// Both associations, in the order they are useful: the standing help, then /// what is currently wrong. goingson's runtime path points describedby at the /// error alone and drops the hint association it never made in the first place; /// naming both here means the hint survives an error appearing. /// /// Its own function because a radio group carries it on the group rather than /// on a control, and one reading of "what describes this field" is the point. fn push_described_by(out: &mut String, field: &Field<'_>, id: &str) { if field.hint.is_none() && field.error.is_none() { return; } out.push_str(" aria-describedby=\""); if field.hint.is_some() { let _ = write!(out, "{id}-hint"); } if field.error.is_some() { if field.hint.is_some() { out.push(' '); } let _ = write!(out, "{id}-error"); } out.push('"'); } /// Whether the field's control is a set of elements rather than one. /// /// A DOM concern rather than a description one, which is why it is decided here /// and not in `makeover-layout`: `for` and `id` are an HTML association and /// egui has no counterpart to get wrong. A `