//! The webview renderer for [`quasi_router`]. //! //! //! //! # Why this is not in `makeover-webview` //! //! A [`Screen`](quasi_router::Screen) renderer has to import `quasi-router`, //! and the makeover/quasi boundary settled 2026-08-08 is audience: a makeover //! crate is something another developer might use on its own, and quasi is what //! you use once you are committed to the whole stack. A `makeover-webview` that //! depended on quasi would stop passing its own test. //! //! So the split runs along that line and not along "who emits markup". //! `makeover-webview` owns the pieces whose description is settled and shared — //! fields, cell containers, the narrowing rules, the escaping — and this crate //! owns screen assembly, regions, and the transport. Every piece it can borrow //! from over there, it does: there is no second field emitter here. //! //! # What phase B is, once the pieces exist //! //! Assembly, mostly. The vocabulary is closed at two arrangements, seven region //! kinds and twelve node variants, and none of them is a widget: the admission //! test in [`quasi_router::screen`] is that a node composes something //! `makeover-layout` already names. So this crate has no opinions to hold, and //! the file to read for the interesting ones is [`node`], where htmx enters in //! a single function. //! //! # The transport is replaceable, and that is measurable here //! //! Nobody hand-writes `hx-post`. Decision 13 parks the fixi question on the //! grounds that a transport nothing authors by hand is a transport that can be //! swapped, and the check on that claim is that //! [`node::action_attrs`](node) is the only place in this crate naming htmx at //! all. A test asserts it. pub mod chrome; mod node; mod shell; #[cfg(test)] mod tests; pub use crate::shell::{Parts, Shell}; pub use makeover_webview::Emit; use std::collections::HashMap; use makeover_layout::Arrangement; use quasi_http::Serves; use quasi_router::{Node, Screen}; /// A renderer that answers HTML. /// /// Holds the things a webview needs and a description never carries: where the /// host's assets live, what to prefix class names with, and what goes inside a /// bespoke region. All values rather than constants because they are the parts /// that genuinely differ between an axum route and a Tauri custom-protocol /// handler, and none of them is anything the router can know. /// /// # Why a renderer is cheap /// /// Three fields, two of them usually shared configuration. A host with /// something per-request to say builds one per request — that is what /// [`fills`](Self::fills) is for, and it is an allocation rather than a /// rebuild. `quasi-axum` takes a factory for exactly this. #[derive(Debug, Clone, Default)] pub struct Webview { /// The document around a screen. pub shell: Shell, /// Class naming, shared with `makeover-webview`'s stylesheet half so the /// emitted markup and the emitted CSS agree on every name. pub emit: Emit, /// What to put inside a bespoke region, by [`Slot::id`](quasi_router::Slot). /// /// Markup, inserted verbatim and unescaped, exactly as /// [`Shell::head`] is. It is host code's string: the description never sees /// it, never carries it and cannot be made to produce one. That is what /// keeps decision 4 intact while giving a server-rendered page something to /// serve, and it is why `Node::Html` is still refused. /// /// Keyed by slot id and not by the bespoke name, because a page of N rows /// each carrying a fill shares one name and has N ids. The emitted div /// carries both. /// /// A slot with no entry here renders empty, which is what every client host /// relies on. An entry naming an id the screen does not have is ignored /// rather than appended anywhere. pub fills: HashMap, } impl Webview { /// A renderer with the default shell and class naming. #[must_use] pub fn new() -> Self { Self::default() } /// A renderer serving its assets from under this prefix. #[must_use] pub fn under(prefix: &str) -> Self { Self { shell: Shell::under(prefix), ..Self::default() } } /// Use this shell, chaining. #[must_use] pub fn with_shell(mut self, shell: Shell) -> Self { self.shell = shell; self } /// Use this class naming, chaining. #[must_use] pub fn with_emit(mut self, emit: Emit) -> Self { self.emit = emit; self } /// Fill the bespoke region with this slot id, chaining. Not escaped. /// /// Repeated calls for one id replace, rather than appending the way /// [`Shell::with_head`] does: a head accumulates unrelated tags, and a fill /// is one region's whole contents. #[must_use] pub fn with_fill(mut self, slot_id: impl Into, markup: impl Into) -> Self { self.fills.insert(slot_id.into(), markup.into()); self } /// The class naming the arrangement of a screen's regions. /// /// Two, because our apps have two. A third arrives when an app has one, /// and not before: naming arrangements an app has not asked for is how a /// description becomes a framework. fn arrangement_class(arrangement: Arrangement) -> &'static str { match arrangement { Arrangement::ListDetail { tabbed: false, .. } => "list-detail", Arrangement::ListDetail { tabbed: true, .. } => "list-detail-tabbed", Arrangement::SidebarContent { .. } => "sidebar-content", } } /// The share, as the grid that honours it. /// /// `e0fd485e`. An inline style rather than a class, because the share is a /// number the description carries and a class can only name a number some /// stylesheet already fixed. Nothing in `makeover-webview` styled these /// classes at all before this, so no shipped rule is being overridden: the /// check the finding asked for was whether adopting the description changes /// what the webview draws, and there was nothing there to change. /// /// `fr` rather than a percentage, so the gap between the regions comes out /// of the whole rather than out of the second one. fn share_style(arrangement: Arrangement) -> String { let first = u16::from(arrangement.share().as_percent()); format!( " style=\"--region-share:{first}fr;--region-rest:{}fr\"", 100 - first ) } } impl Serves for Webview { fn screen(&self, screen: &Screen) -> String { let mut out = String::with_capacity(1024); self.shell .open(&screen.title, Some(&screen.discovery), &mut out); out.push_str("
'); // Notices before the regions, because a notice belongs to the screen // rather than to a place in it, and the first thing in the document is // the one place that is true of. Where they visually land is the // stylesheet's answer. if !screen.notices.is_empty() { out.push_str("
"); for notice in &screen.notices { node::node_html( notice, self.shell.morphs(), &self.emit, &self.fills, &mut out, ); } out.push_str("
"); } for slot in &screen.slots { node::slot_html(slot, self.shell.morphs(), &self.emit, &self.fills, &mut out); } out.push_str("
"); // After the main content: the chrome belongs to the app rather than to // the screen, so it sits outside what a screen's markup is. crate::chrome::chrome_html(&self.shell.chrome, self.shell.morphs(), &mut out); Shell::close(&mut out); out } fn overlay(&self, screen: &Screen) -> String { // No shell and no `
`: this is the inside of the overlay // container, which the document already has. Notices ride along, // because an answer that raises one while opening an overlay is // raising it about the overlay. let mut out = String::with_capacity(512); for notice in &screen.notices { node::node_html( notice, self.shell.morphs(), &self.emit, &self.fills, &mut out, ); } for slot in &screen.slots { node::slot_html(slot, self.shell.morphs(), &self.emit, &self.fills, &mut out); } out } fn overlay_target(&self) -> Option<&str> { Some(crate::chrome::OVERLAY_ID) } fn fragment(&self, node: &Node) -> String { // No shell, by definition: a fragment is the inside of one element and // htmx puts it there. The router already said which element through // `HX-Retarget`, so nothing here needs to know. let mut out = String::with_capacity(256); node::node_html(node, self.shell.morphs(), &self.emit, &self.fills, &mut out); out } fn invalidated(&self, region: &str, node: &Node) -> String { // The one thing this does that `fragment` does not is carry its own // address, because nothing aimed at it. See `node::oob_html`. let mut out = String::with_capacity(256); node::oob_html( region, node, self.shell.morphs(), &self.emit, &self.fills, &mut out, ); out } }