//! 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 that loads //! without the morph extension, 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"); 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. .with_head_first( "\ ", ) .styled(format!("/static/geometry.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 script 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. .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 ["geometry.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("Lato-Regular.woff2") < head.find("style.css")); } #[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.starts_with(' ')); assert!(attrs.contains("hx-ext=\"morph\"")); // 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 morph_is_loaded_wherever_a_swap_can_ask_for_it() { // The shell writes `hx-ext="morph"` onto every page's body, so the // extension has to be served. It is: `static/idiomorph-ext.min.js`. assert!(head().contains("idiomorph-ext.min.js")); assert!( std::path::Path::new(concat!( env!("CARGO_MANIFEST_DIR"), "/static/idiomorph-ext.min.js" )) .exists() ); } }