Skip to main content

max / makenotwork

16.7 KB · 345 lines History Blame Raw
1 //! The document head, emitted by quasi's renderer rather than by `base.html`.
2 //!
3 //! Wiki note `mnw-server-conversion-plan`, step S1. The dashboard is being
4 //! converted to described screens one at a time, and a converted screen renders
5 //! through [`quasi_webview::Shell`] while everything around it still renders
6 //! through Askama. Two heads written by two hands drift, and the drift is
7 //! silent: a layer statement that moves below a stylesheet, an htmx config that
8 //! is stated below the script that reads it, a viewport that says one thing on
9 //! half the site. So the head is the renderer's on both paths from here,
10 //! before any screen is described.
11 //!
12 //! [`Shell::parts`] is what a host whose templating writes into the head takes:
13 //! Askama renders the per-page `{% block title %}` and `{% block head %}` in
14 //! place, inside `base.html`, and no caller ever holds them as a string.
15 //! `base.html` writes the title, `</head>`, the `<body>` tag and the close;
16 //! everything above is here.
17 //!
18 //! One shell for the process, not one per request. Nothing in it varies by
19 //! viewer: the creator's theme block is per-request and unlayered on purpose,
20 //! and it stays where it is, injected by the three templates that show a
21 //! creator's work through `{% block head %}` so it lands after every sheet and
22 //! outranks every named layer.
23
24 use std::sync::OnceLock;
25
26 use quasi_webview::Shell;
27
28 /// The cache-busting suffix on the site's own assets.
29 ///
30 /// A content hash of every watched static file, computed in `build.rs` and
31 /// handed over as an env var. It used to reach the head through a generated
32 /// `_head_assets.html`; the head is the renderer's now, so the version is all
33 /// that crosses. `_sheet.html` and `_island.html` are still generated with the
34 /// same hash, for the per-page sheets and islands this module does not see.
35 const V: &str = env!("STATIC_VERSION");
36
37 /// The vendored htmx release, and the cache-busting suffix on its extensions.
38 ///
39 /// Not the content hash above: an extension is a pinned file that changes only
40 /// when htmx is bumped, so naming the release is both the version and the
41 /// record of which one is on disk. `static/htmx.min.js` itself is under the
42 /// content hash, because `build.rs` already watches it.
43 const HTMX: &str = "4.0.0-beta6";
44
45 fn parts() -> &'static quasi_webview::Parts {
46 static PARTS: OnceLock<quasi_webview::Parts> = OnceLock::new();
47 PARTS.get_or_init(|| {
48 Shell::under("/static")
49 // Earlier in the list = lower priority, and `makeover` is prepended
50 // by the renderer. A layer's position is fixed where its name is
51 // FIRST seen, so without the statement the generated sheets would
52 // establish `makeover` simply by loading first and reordering two
53 // links would silently reorder the cascade. `components` is where
54 // the site's own sheets live, including the per-page wizard.css and
55 // media-player.css that arrive later through `{% block head %}` and
56 // that nothing here can order. `base` and `responsive` are still
57 // empty; declaring an empty layer costs nothing and fixes its
58 // position.
59 .layered(["base", "components", "responsive"])
60 // Whole value is being early: a preload discovered after the sheets
61 // it races bought nothing, and htmx reads its config once, when the
62 // script runs, so the meta has to be above it.
63 //
64 // `noSwap` restores htmx 2's rule that a 4xx or 5xx response does
65 // not swap. htmx 4 swaps everything but 204 and 304, and what this
66 // server answers a failed fragment request with is a whole rendered
67 // error page (`error.rs`), so the default would paint that page
68 // inside whatever the request targeted. The error toast in
69 // `htmx-glue.ts` reads the `HX-Error` header on the same response
70 // and is what a user sees instead, unchanged from 2.x.
71 //
72 // The cost, worth knowing before turning a described screen on: a
73 // blanket `noSwap` is checked before `hx-status:4xx`, so an element
74 // cannot opt back in. Decision 9's classified errors (403 `Denied`,
75 // 404 `NotFound`) therefore still render nothing here, which is the
76 // gap `quasi-overview` expected htmx 4 to close for free.
77 .with_head_first(
78 "<meta name=\"htmx-config\" content='{\"noSwap\":[204,304,\"4xx\",\"5xx\"]}'>\
79 <link rel=\"preload\" href=\"/static/fonts/QuasiBody.woff2\" as=\"font\" type=\"font/woff2\" crossorigin>\
80 <link rel=\"preload\" href=\"/static/fonts/ysrf.woff2\" as=\"font\" type=\"font/woff2\" crossorigin>",
81 )
82 // First, and before the sheets that use the tokens it defines. The
83 // `@font-face` rules take no part in the cascade so their position
84 // buys nothing there; what it buys is discovery, since a face the
85 // parser has not reached yet is a face the browser has not started
86 // fetching. `style.css` still wins every contest it won before:
87 // this file defines two tokens and matches no element.
88 .styled(format!("/static/typography.css?v={V}"))
89 .styled(format!("/static/geometry.css?v={V}"))
90 .styled(format!("/static/timing.css?v={V}"))
91 .styled(format!("/static/layout.css?v={V}"))
92 .styled(format!("/static/style.css?v={V}"))
93 // Last in the head, after htmx: the favicon has no order to keep,
94 // and neither of the last two scripts reads htmx at load.
95 // `upload.js` only defines `S3Uploader`, and the core module is
96 // deferred by being a module, so it still runs after the deferred
97 // htmx above it.
98 //
99 // `hx-history-cache` is the exception and is why it carries
100 // `defer`: it calls `htmx.registerExtension` as it loads, so an
101 // ordinary script would run during parsing and reach for an htmx
102 // that has not executed yet. Deferred scripts run in document
103 // order, and htmx's own init waits a tick past that.
104 //
105 // What it restores is htmx 2's history cache, which htmx 4 dropped:
106 // without it every Back is a fresh request for the pushed URL, and
107 // the URLs this site pushes are the wizard's step routes, which
108 // answer a GET with a bare partial rather than a page. So a Back
109 // out of a wizard step painted a chromeless fragment over the
110 // document. The extension is first-party, keyed on sessionStorage
111 // rather than localStorage, and defaults to the same 10 entries
112 // htmx 2 kept.
113 .with_head(format!(
114 "<link rel=\"icon\" href=\"/static/images/favicon.ico\" type=\"image/x-icon\">\
115 <script src=\"/static/hx-history-cache.min.js?v={HTMX}\" defer></script>\
116 <script src=\"/static/upload.js?v={V}\"></script>\
117 <script type=\"module\" src=\"/static/dist-{V}/core/index.js\"></script>"
118 ))
119 .parts()
120 })
121 }
122
123 /// `<!doctype>` through the head's contents, without `</head>`.
124 ///
125 /// Called from `base.html`, which appends the title and the per-page head and
126 /// closes the element.
127 pub fn head() -> &'static str {
128 &parts().head
129 }
130
131 /// The attributes the shell owns on `<body>`, each one space-prefixed.
132 ///
133 /// `base.html` writes `<body{{ body_attrs() }}{% block body_attrs %}>`, so a
134 /// page's own class attribute composes with these instead of replacing them.
135 pub fn body_attrs() -> &'static str {
136 &parts().body_attrs
137 }
138
139 pub use makeover_layout::Measure;
140
141 /// The body class for a screen's measure.
142 ///
143 /// `0eccff0d`. 69 of 72 templates carried one of these three strings as a
144 /// literal, which made how wide a page runs a fact about the template rather
145 /// than about the screen. The strings are unchanged and the rules in
146 /// `style.css` are untouched: what moved is where the choice is written down,
147 /// from a class name in markup to a described property with a name in every
148 /// renderer's vocabulary.
149 ///
150 /// The old names stay on the left-hand side of the rules because they are what
151 /// `style.css` matches, and renaming them is a separate change with no
152 /// description in it. `padded-page` is [`Measure::Wide`] because a padded page
153 /// is the full width with gutters, which is what the class always meant.
154 ///
155 /// The four standalone tokens -- `health-page`, `purchase-page`, `buy-page`,
156 /// `stripe-disclaimer-page` -- are screen identity rather than measure, and are
157 /// deliberately not here.
158 #[must_use]
159 pub const fn measure(measure: Measure) -> &'static str {
160 match measure {
161 Measure::Contained => "centered-page",
162 Measure::Reading => "article-page",
163 // The default, and the arm a member added upstream lands in. A measure
164 // this server has not learned yet should render at the width every
165 // other page does rather than unstyled.
166 _ => "padded-page",
167 }
168 }
169
170 #[cfg(test)]
171 mod tests {
172 use super::*;
173
174 #[test]
175 fn the_layer_statement_precedes_every_stylesheet() {
176 // The property the hand-written `<style>@layer ...</style>` existed to
177 // hold, now held by the renderer. It is the one that fails silently:
178 // the CSS stays valid and buttons and badges look subtly wrong.
179 let head = head();
180 let stmt = head
181 .find("@layer makeover, base, components, responsive;")
182 .expect("the order is stated");
183 for sheet in [
184 "typography.css",
185 "geometry.css",
186 "timing.css",
187 "layout.css",
188 "style.css",
189 ] {
190 assert!(stmt < head.find(sheet).expect("the sheet is linked"));
191 }
192 }
193
194 #[test]
195 fn the_head_is_not_closed_and_carries_no_title() {
196 // Both are `base.html`'s, and emitting either here would produce a
197 // second one rather than an error.
198 assert!(!head().contains("</head>"));
199 assert!(!head().contains("<title>"));
200 assert!(head().starts_with("<!doctype html><html lang=\"en\">"));
201 }
202
203 #[test]
204 fn the_fonts_are_preloaded_before_the_sheets_that_race_them() {
205 let head = head();
206 assert!(head.find("QuasiBody.woff2") < head.find("style.css"));
207 // The retired pair, checked by absence: a preload for a face nothing
208 // declares is a download the browser makes and never uses.
209 assert!(!head.contains("Lato"));
210 assert!(!head.contains("IBMPlexMono"));
211 }
212
213 #[test]
214 fn the_body_attributes_start_with_a_space_so_a_page_can_add_its_own() {
215 // `base.html` writes them straight against `<body`, and a page's
216 // `{% block body_attrs %}` straight after. Neither side puts a
217 // separator in, so this one has to carry it.
218 let attrs = body_attrs();
219 assert!(attrs.is_empty() || attrs.starts_with(' '));
220 // No class of its own, or a page's class attribute would be the second
221 // on the tag and the browser would drop it.
222 assert!(!attrs.contains("class="));
223 }
224
225 #[test]
226 fn every_measure_keeps_the_class_the_templates_used_to_write() {
227 // `0eccff0d` moved where the choice is written down and changed no
228 // rule in `style.css`, so the three strings have to come out exactly as
229 // the 69 templates spelled them. A typo here renders 53 pages unstyled.
230 assert_eq!(measure(Measure::Wide), "padded-page");
231 assert_eq!(measure(Measure::Contained), "centered-page");
232 assert_eq!(measure(Measure::Reading), "article-page");
233 }
234
235 #[test]
236 fn no_template_still_writes_a_layout_class_by_hand() {
237 // The done-condition, checked rather than remembered: the layout axis
238 // is derived from the described property. A new template pasted from an
239 // old one fails here instead of quietly reintroducing the literal.
240 //
241 // The four standalone tokens are screen identity rather than measure
242 // and are deliberately left alone, so they are not looked for.
243 let mut offenders = Vec::new();
244 for entry in walk("templates") {
245 let source = std::fs::read_to_string(&entry).expect("a template reads");
246 for (at, line) in source.lines().enumerate() {
247 if !line.contains("block body_attrs") {
248 continue;
249 }
250 // The literal, as distinct from the call that produces it: the
251 // rendered class still says `padded-page`, and should.
252 let derived = line.contains("crate::shell::measure(");
253 let literal = ["padded-page", "centered-page", "article-page"]
254 .iter()
255 .any(|name| line.contains(name));
256 if literal && !derived {
257 offenders.push(format!("{}:{}", entry.display(), at + 1));
258 }
259 }
260 }
261 assert!(offenders.is_empty(), "{offenders:?}");
262 }
263
264 /// Every `.html` under a directory.
265 fn walk(root: &str) -> Vec<std::path::PathBuf> {
266 let mut found = Vec::new();
267 let mut stack = vec![std::path::PathBuf::from(root)];
268 while let Some(at) = stack.pop() {
269 let Ok(entries) = std::fs::read_dir(&at) else {
270 continue;
271 };
272 for entry in entries.flatten() {
273 let path = entry.path();
274 if path.is_dir() {
275 stack.push(path);
276 } else if path.extension().is_some_and(|ext| ext == "html") {
277 found.push(path);
278 }
279 }
280 }
281 found
282 }
283
284 #[test]
285 fn the_transport_the_head_links_is_served_from_here() {
286 // The shell links one script for the transport. Morphing is a swap
287 // style in htmx 4 rather than something an extension supplies, so the
288 // idiomorph registration and the extension script both left the head
289 // when quasi-webview moved to 4 (`2246072d`, ruled 2026-08-18), and the
290 // vendored file left the tree with `6b87e5ff`.
291 assert!(head().contains("htmx.min.js"));
292 assert!(!head().contains("idiomorph"));
293 let vendored = concat!(env!("CARGO_MANIFEST_DIR"), "/static/htmx.min.js");
294 assert!(std::path::Path::new(vendored).exists());
295 // The served bundle and the markup the templates carry are one version
296 // or the other, never a mix: 4 reads `hx-disable` as "disable while the
297 // request runs" where 2 read it as "skip this subtree".
298 let bundle = std::fs::read_to_string(vendored).expect("the bundle reads");
299 assert!(bundle.contains(HTMX), "the vendored bundle is {HTMX}");
300 }
301
302 #[test]
303 fn every_vendored_extension_is_busted_by_the_htmx_release_it_came_from() {
304 // Two extensions are vendored out of the htmx release: the history
305 // cache the shell links site-wide, and `hx-prompt`, which one admin
306 // template links for itself. Both are pinned files, so the release is
307 // their version, and a bump that leaves a `?v=` behind serves a browser
308 // the old extension against the new core.
309 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
310 let mut linked = 0;
311 let mut sources = vec![head().to_string()];
312 for entry in walk("templates") {
313 sources.push(std::fs::read_to_string(&entry).expect("a template reads"));
314 }
315 for source in &sources {
316 for (at, _) in source.match_indices("/static/hx-") {
317 let tail = &source[at + "/static/".len()..];
318 // The URL alone, cut at whichever quote closes the attribute.
319 let url = tail.split(['"', '\'']).next().expect("a quoted url");
320 let (name, suffix) = url.split_once("?v=").unwrap_or((url, ""));
321 assert!(
322 root.join("static").join(name).exists(),
323 "{name} is linked but not vendored"
324 );
325 assert_eq!(suffix, HTMX, "{name} is busted by the wrong version");
326 linked += 1;
327 }
328 }
329 assert_eq!(linked, 2, "the vendored extension count changed");
330 }
331
332 #[test]
333 fn the_htmx_config_is_stated_above_the_script_that_reads_it() {
334 // htmx reads `meta[name=htmx-config]` once, as the script runs. Below
335 // it the tag is inert and 4xx responses start swapping a rendered error
336 // page into whatever the request targeted.
337 let head = head();
338 let meta = head
339 .find("name=\"htmx-config\"")
340 .expect("the config is stated");
341 assert!(meta < head.find("htmx.min.js").expect("htmx is linked"));
342 assert!(head.contains("\"noSwap\""));
343 }
344 }
345