//! The document head, emitted by quasi's renderer rather than by `base.html`. //! //! Wiki note `mnw-server-conversion-plan`, step S1. The dashboard is being //! converted to described screens one at a time, and a converted screen renders //! through [`quasi_webview::Shell`] while everything around it still renders //! through Askama. Two heads written by two hands drift, and the drift is //! silent: a layer statement that moves below a stylesheet, an htmx config that //! is stated below the script that reads it, a viewport that says one thing on //! half the site. So the head is the renderer's on both paths from here, //! before any screen is described. //! //! [`Shell::parts`] is what a host whose templating writes into the head takes: //! Askama renders the per-page `{% block title %}` and `{% block head %}` in //! place, inside `base.html`, and no caller ever holds them as a string. //! `base.html` writes the title, ``, the `` tag and the close; //! everything above is here. //! //! One shell for the process, not one per request. Nothing in it varies by //! viewer: the creator's theme block is per-request and unlayered on purpose, //! and it stays where it is, injected by the three templates that show a //! creator's work through `{% block head %}` so it lands after every sheet and //! outranks every named layer. use std::sync::OnceLock; use quasi_webview::Shell; /// The cache-busting suffix on the site's own assets. /// /// A content hash of every watched static file, computed in `build.rs` and /// handed over as an env var. It used to reach the head through a generated /// `_head_assets.html`; the head is the renderer's now, so the version is all /// that crosses. `_sheet.html` and `_island.html` are still generated with the /// same hash, for the per-page sheets and islands this module does not see. const V: &str = env!("STATIC_VERSION"); /// The vendored htmx release, and the cache-busting suffix on its extensions. /// /// Not the content hash above: an extension is a pinned file that changes only /// when htmx is bumped, so naming the release is both the version and the /// record of which one is on disk. `static/htmx.min.js` itself is under the /// content hash, because `build.rs` already watches it. const HTMX: &str = "4.0.0-beta6"; fn parts() -> &'static quasi_webview::Parts { static PARTS: OnceLock = OnceLock::new(); PARTS.get_or_init(|| { Shell::under("/static") // Earlier in the list = lower priority, and `makeover` is prepended // by the renderer. A layer's position is fixed where its name is // FIRST seen, so without the statement the generated sheets would // establish `makeover` simply by loading first and reordering two // links would silently reorder the cascade. `components` is where // the site's own sheets live, including the per-page wizard.css and // media-player.css that arrive later through `{% block head %}` and // that nothing here can order. `base` and `responsive` are still // empty; declaring an empty layer costs nothing and fixes its // position. .layered(["base", "components", "responsive"]) // Whole value is being early: a preload discovered after the sheets // it races bought nothing, and htmx reads its config once, when the // script runs, so the meta has to be above it. // // `noSwap` restores htmx 2's rule that a 4xx or 5xx response does // not swap. htmx 4 swaps everything but 204 and 304, and what this // server answers a failed fragment request with is a whole rendered // error page (`error.rs`), so the default would paint that page // inside whatever the request targeted. The error toast in // `htmx-glue.ts` reads the `HX-Error` header on the same response // and is what a user sees instead, unchanged from 2.x. // // The cost, worth knowing before turning a described screen on: a // blanket `noSwap` is checked before `hx-status:4xx`, so an element // cannot opt back in. Decision 9's classified errors (403 `Denied`, // 404 `NotFound`) therefore still render nothing here, which is the // gap `quasi-overview` expected htmx 4 to close for free. .with_head_first( "\ \ ", ) // First, and before the sheets that use the tokens it defines. The // `@font-face` rules take no part in the cascade so their position // buys nothing there; what it buys is discovery, since a face the // parser has not reached yet is a face the browser has not started // fetching. `style.css` still wins every contest it won before: // this file defines two tokens and matches no element. .styled(format!("/static/typography.css?v={V}")) .styled(format!("/static/geometry.css?v={V}")) .styled(format!("/static/timing.css?v={V}")) .styled(format!("/static/layout.css?v={V}")) .styled(format!("/static/style.css?v={V}")) // Last in the head, after htmx: the favicon has no order to keep, // and neither of the last two scripts reads htmx at load. // `upload.js` only defines `S3Uploader`, and the core module is // deferred by being a module, so it still runs after the deferred // htmx above it. // // `hx-history-cache` is the exception and is why it carries // `defer`: it calls `htmx.registerExtension` as it loads, so an // ordinary script would run during parsing and reach for an htmx // that has not executed yet. Deferred scripts run in document // order, and htmx's own init waits a tick past that. // // What it restores is htmx 2's history cache, which htmx 4 dropped: // without it every Back is a fresh request for the pushed URL, and // the URLs this site pushes are the wizard's step routes, which // answer a GET with a bare partial rather than a page. So a Back // out of a wizard step painted a chromeless fragment over the // document. The extension is first-party, keyed on sessionStorage // rather than localStorage, and defaults to the same 10 entries // htmx 2 kept. .with_head(format!( "\ \ \ " )) .parts() }) } /// `` through the head's contents, without ``. /// /// Called from `base.html`, which appends the title and the per-page head and /// closes the element. pub fn head() -> &'static str { &parts().head } /// The attributes the shell owns on ``, each one space-prefixed. /// /// `base.html` writes ``, so a /// page's own class attribute composes with these instead of replacing them. pub fn body_attrs() -> &'static str { &parts().body_attrs } pub use makeover_layout::Measure; /// The body class for a screen's measure. /// /// `0eccff0d`. 69 of 72 templates carried one of these three strings as a /// literal, which made how wide a page runs a fact about the template rather /// than about the screen. The strings are unchanged and the rules in /// `style.css` are untouched: what moved is where the choice is written down, /// from a class name in markup to a described property with a name in every /// renderer's vocabulary. /// /// The old names stay on the left-hand side of the rules because they are what /// `style.css` matches, and renaming them is a separate change with no /// description in it. `padded-page` is [`Measure::Wide`] because a padded page /// is the full width with gutters, which is what the class always meant. /// /// The four standalone tokens -- `health-page`, `purchase-page`, `buy-page`, /// `stripe-disclaimer-page` -- are screen identity rather than measure, and are /// deliberately not here. #[must_use] pub const fn measure(measure: Measure) -> &'static str { match measure { Measure::Contained => "centered-page", Measure::Reading => "article-page", // The default, and the arm a member added upstream lands in. A measure // this server has not learned yet should render at the width every // other page does rather than unstyled. _ => "padded-page", } } #[cfg(test)] mod tests { use super::*; #[test] fn the_layer_statement_precedes_every_stylesheet() { // The property the hand-written `` existed to // hold, now held by the renderer. It is the one that fails silently: // the CSS stays valid and buttons and badges look subtly wrong. let head = head(); let stmt = head .find("@layer makeover, base, components, responsive;") .expect("the order is stated"); for sheet in [ "typography.css", "geometry.css", "timing.css", "layout.css", "style.css", ] { assert!(stmt < head.find(sheet).expect("the sheet is linked")); } } #[test] fn the_head_is_not_closed_and_carries_no_title() { // Both are `base.html`'s, and emitting either here would produce a // second one rather than an error. assert!(!head().contains("")); assert!(!head().contains("")); assert!(head().starts_with("<!doctype html><html lang=\"en\">")); } #[test] fn the_fonts_are_preloaded_before_the_sheets_that_race_them() { let head = head(); assert!(head.find("QuasiBody.woff2") < head.find("style.css")); // The retired pair, checked by absence: a preload for a face nothing // declares is a download the browser makes and never uses. assert!(!head.contains("Lato")); assert!(!head.contains("IBMPlexMono")); } #[test] fn the_body_attributes_start_with_a_space_so_a_page_can_add_its_own() { // `base.html` writes them straight against `<body`, and a page's // `{% block body_attrs %}` straight after. Neither side puts a // separator in, so this one has to carry it. let attrs = body_attrs(); assert!(attrs.is_empty() || attrs.starts_with(' ')); // No class of its own, or a page's class attribute would be the second // on the tag and the browser would drop it. assert!(!attrs.contains("class=")); } #[test] fn every_measure_keeps_the_class_the_templates_used_to_write() { // `0eccff0d` moved where the choice is written down and changed no // rule in `style.css`, so the three strings have to come out exactly as // the 69 templates spelled them. A typo here renders 53 pages unstyled. assert_eq!(measure(Measure::Wide), "padded-page"); assert_eq!(measure(Measure::Contained), "centered-page"); assert_eq!(measure(Measure::Reading), "article-page"); } #[test] fn no_template_still_writes_a_layout_class_by_hand() { // The done-condition, checked rather than remembered: the layout axis // is derived from the described property. A new template pasted from an // old one fails here instead of quietly reintroducing the literal. // // The four standalone tokens are screen identity rather than measure // and are deliberately left alone, so they are not looked for. let mut offenders = Vec::new(); for entry in walk("templates") { let source = std::fs::read_to_string(&entry).expect("a template reads"); for (at, line) in source.lines().enumerate() { if !line.contains("block body_attrs") { continue; } // The literal, as distinct from the call that produces it: the // rendered class still says `padded-page`, and should. let derived = line.contains("crate::shell::measure("); let literal = ["padded-page", "centered-page", "article-page"] .iter() .any(|name| line.contains(name)); if literal && !derived { offenders.push(format!("{}:{}", entry.display(), at + 1)); } } } assert!(offenders.is_empty(), "{offenders:?}"); } /// Every `.html` under a directory. fn walk(root: &str) -> Vec<std::path::PathBuf> { let mut found = Vec::new(); let mut stack = vec![std::path::PathBuf::from(root)]; while let Some(at) = stack.pop() { let Ok(entries) = std::fs::read_dir(&at) else { continue; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { stack.push(path); } else if path.extension().is_some_and(|ext| ext == "html") { found.push(path); } } } found } #[test] fn the_transport_the_head_links_is_served_from_here() { // The shell links one script for the transport. Morphing is a swap // style in htmx 4 rather than something an extension supplies, so the // idiomorph registration and the extension script both left the head // when quasi-webview moved to 4 (`2246072d`, ruled 2026-08-18), and the // vendored file left the tree with `6b87e5ff`. assert!(head().contains("htmx.min.js")); assert!(!head().contains("idiomorph")); let vendored = concat!(env!("CARGO_MANIFEST_DIR"), "/static/htmx.min.js"); assert!(std::path::Path::new(vendored).exists()); // The served bundle and the markup the templates carry are one version // or the other, never a mix: 4 reads `hx-disable` as "disable while the // request runs" where 2 read it as "skip this subtree". let bundle = std::fs::read_to_string(vendored).expect("the bundle reads"); assert!(bundle.contains(HTMX), "the vendored bundle is {HTMX}"); } #[test] fn every_vendored_extension_is_busted_by_the_htmx_release_it_came_from() { // Two extensions are vendored out of the htmx release: the history // cache the shell links site-wide, and `hx-prompt`, which one admin // template links for itself. Both are pinned files, so the release is // their version, and a bump that leaves a `?v=` behind serves a browser // the old extension against the new core. let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); let mut linked = 0; let mut sources = vec![head().to_string()]; for entry in walk("templates") { sources.push(std::fs::read_to_string(&entry).expect("a template reads")); } for source in &sources { for (at, _) in source.match_indices("/static/hx-") { let tail = &source[at + "/static/".len()..]; // The URL alone, cut at whichever quote closes the attribute. let url = tail.split(['"', '\'']).next().expect("a quoted url"); let (name, suffix) = url.split_once("?v=").unwrap_or((url, "")); assert!( root.join("static").join(name).exists(), "{name} is linked but not vendored" ); assert_eq!(suffix, HTMX, "{name} is busted by the wrong version"); linked += 1; } } assert_eq!(linked, 2, "the vendored extension count changed"); } #[test] fn the_htmx_config_is_stated_above_the_script_that_reads_it() { // htmx reads `meta[name=htmx-config]` once, as the script runs. Below // it the tag is inert and 4xx responses start swapping a rendered error // page into whatever the request targeted. let head = head(); let meta = head .find("name=\"htmx-config\"") .expect("the config is stated"); assert!(meta < head.find("htmx.min.js").expect("htmx is linked")); assert!(head.contains("\"noSwap\"")); } }