//! The document around a screen. //! //! Settled 2026-08-08 (Max): the renderer owns the document and the host //! injects what it knows. The alternative was each adapter supplying its own //! `` and the renderer filling the inside, which is honest about axum and //! Tauri resolving assets differently and costs two heads to keep in step — //! the divergence the stack exists to end. A served page and a //! custom-protocol page differ in where their assets live and in nothing else, //! so where assets live is the parameter and the rest is emitted once. //! //! What the host owes is small enough to list: three asset URLs, a language, //! and whatever else belongs in its own head. Everything with an opinion in it //! — the htmx config from [`quasi_http::htmx`], the morph extension, the //! viewport, where the body's classes come from — is here, because a host that //! could get those wrong is a host that can diverge. use makeover_webview::form::escape_into; use quasi_router::{Chrome, Discovery}; /// The parts of a document only the host knows. /// /// A [`Default`] shell is a valid one: the asset paths are what a server /// mounting its static directory at `/static` already serves, which is what /// both MNW and multithreaded do today, and the Tauri adapter overrides them /// with its own scheme. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Shell { /// The document language, for ``. pub lang: String, /// Where htmx is served from. pub htmx_src: String, /// Where the idiomorph htmx extension is served from. /// /// `None` drops both the script and the `hx-ext` attribute, which is the /// honest way to run without it: emitting `hx-swap="morph"` with no /// extension loaded makes htmx fall back to `innerHTML` silently, and a /// silent fallback to the destructive behaviour is the one outcome /// decision 7 was avoiding. pub morph_src: Option, /// Stylesheets, in link order. pub stylesheets: Vec, /// The app's own cascade layer names, in priority order, lowest first. /// /// The renderer always emits the layer statement, with `makeover` first and /// these after it, before any stylesheet link: /// /// ```css /// @layer makeover, base, components, responsive; /// ``` /// /// It is emitted even when this is empty, because the point is not the /// app's names but `makeover`'s position. A layer's place in the cascade is /// fixed where its name is FIRST seen, so with no statement the generated /// stylesheets establish `makeover` simply by loading first, and reordering /// two link tags silently reorders the cascade. /// /// This is a field rather than something the host writes into /// [`head_first`](Self::head_first) because it is exactly the class of /// thing the module header says belongs here: a host that could get it /// wrong is a host that can diverge, and getting it wrong is silent. The /// CSS stays valid, the minifier stays happy, and buttons and badges look /// subtly wrong. Only the app's own names are the host's to supply, since /// the renderer cannot know them. /// /// Names are filtered to CSS identifier characters. A name is markup inside /// a `"); for href in &self.stylesheets { out.push_str(""); } // Deferred, so the parser is never blocked and the extension is // registered before htmx processes the body either way. out.push_str(""); if let Some(src) = &self.morph_src { out.push_str(""); } if let Some(head) = &self.head { out.push_str(head); } } /// What a link preview and a crawler read, from the screen itself. /// /// Every value is escaped. These are user-authored strings — an item /// description, a bio — going into attribute values, and this is the one /// place in the head where that is true. Same `escape` the node emitter /// uses; a second one here would be a second thing to get wrong. /// /// A `None` emits nothing at all. An empty `og:description` is worse than /// no tag: a preview showing a blank line reads as a broken page rather /// than as a page that said nothing. /// /// The Twitter tags mirror the OG ones, which is what the server's 28 /// templates do by hand today. fn discovery_head(title: Option<&str>, discovery: &Discovery, out: &mut String) { let mut meta = |property: &str, content: &str| { out.push_str(""); }; if let Some(title) = title { meta("og:title", title); } if let Some(summary) = &discovery.summary { meta("og:description", summary); } if let Some(image) = &discovery.image { meta("og:image", image); } meta("og:type", discovery.kind.as_str()); if let Some(url) = &discovery.canonical { meta("og:url", url); } // `name`, not `property`: the Twitter tags were never part of RDFa, and // a card written with `property` is a card the crawler skips. let mut named = |name: &str, content: &str| { out.push_str(""); }; named( "twitter:card", if discovery.image.is_some() { "summary_large_image" } else { "summary" }, ); if let Some(title) = title { named("twitter:title", title); } if let Some(summary) = &discovery.summary { named("twitter:description", summary); } if let Some(image) = &discovery.image { named("twitter:image", image); } if !discovery.indexable { named("robots", "noindex"); } // The canonical link, beside `og:url` rather than instead of it: one is // what a crawler dedupes on and the other is what a share sheet shows, // and the six purchased-content screens need both to agree. if let Some(url) = &discovery.canonical { out.push_str(""); } } /// The attributes the shell owns on ``, each one space-prefixed so /// they compose with whatever else the host puts on the tag. /// /// [`Parts`] wants these as a value and the emitted document does not, so /// the buffer-writing form is the one with the code in it. On the described /// path this was a `String` per render for two attributes, one of which is /// a constant. fn push_body_attrs(&self, out: &mut String) { if self.morphs() { // Registered once on the body rather than per element: the // extension is inherited, and an app that has to remember it per // control is an app that will forget it. out.push_str(" hx-ext=\"morph\""); } if let Some(class) = &self.body_class { out.push_str(" class=\""); escape_into(class, out); out.push('"'); } } /// The same attributes as a value, for a host writing the `` tag. fn body_attrs(&self) -> String { let mut out = String::new(); self.push_body_attrs(&mut out); out } /// The close of what [`open`](Self::open) opened. pub(crate) fn close(out: &mut String) { out.push_str(""); } } /// The shell's half of a host-assembled document. See [`Shell::parts`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Parts { /// `` through the head's contents, without ``. The host /// appends its own head markup and closes the element. pub head: String, /// The attributes the shell owns on ``, space-prefixed, for a host /// writing the tag itself: ``. pub body_attrs: String, /// The chrome's markup, for the end of the body the host is writing. /// /// Empty when the app declares no chrome, which is why this does not make /// the chrome unconditional: a host assembling its own document appends /// this before closing the body, and appending an empty string is what a /// document with no chrome already does. pub body_chrome: String, }