Skip to main content

max / makenotwork

16.6 KB · 338 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/layout.css?v={V}"))
91 .styled(format!("/static/style.css?v={V}"))
92 // Last in the head, after htmx: the favicon has no order to keep,
93 // and neither of the last two scripts reads htmx at load.
94 // `upload.js` only defines `S3Uploader`, and the core module is
95 // deferred by being a module, so it still runs after the deferred
96 // htmx above it.
97 //
98 // `hx-history-cache` is the exception and is why it carries
99 // `defer`: it calls `htmx.registerExtension` as it loads, so an
100 // ordinary script would run during parsing and reach for an htmx
101 // that has not executed yet. Deferred scripts run in document
102 // order, and htmx's own init waits a tick past that.
103 //
104 // What it restores is htmx 2's history cache, which htmx 4 dropped:
105 // without it every Back is a fresh request for the pushed URL, and
106 // the URLs this site pushes are the wizard's step routes, which
107 // answer a GET with a bare partial rather than a page. So a Back
108 // out of a wizard step painted a chromeless fragment over the
109 // document. The extension is first-party, keyed on sessionStorage
110 // rather than localStorage, and defaults to the same 10 entries
111 // htmx 2 kept.
112 .with_head(format!(
113 "<link rel=\"icon\" href=\"/static/images/favicon.ico\" type=\"image/x-icon\">\
114 <script src=\"/static/hx-history-cache.min.js?v={HTMX}\" defer></script>\
115 <script src=\"/static/upload.js?v={V}\"></script>\
116 <script type=\"module\" src=\"/static/dist-{V}/core/index.js\"></script>"
117 ))
118 .parts()
119 })
120 }
121
122 /// `<!doctype>` through the head's contents, without `</head>`.
123 ///
124 /// Called from `base.html`, which appends the title and the per-page head and
125 /// closes the element.
126 pub fn head() -> &'static str {
127 &parts().head
128 }
129
130 /// The attributes the shell owns on `<body>`, each one space-prefixed.
131 ///
132 /// `base.html` writes `<body{{ body_attrs() }}{% block body_attrs %}>`, so a
133 /// page's own class attribute composes with these instead of replacing them.
134 pub fn body_attrs() -> &'static str {
135 &parts().body_attrs
136 }
137
138 pub use makeover_layout::Measure;
139
140 /// The body class for a screen's measure.
141 ///
142 /// `0eccff0d`. 69 of 72 templates carried one of these three strings as a
143 /// literal, which made how wide a page runs a fact about the template rather
144 /// than about the screen. The strings are unchanged and the rules in
145 /// `style.css` are untouched: what moved is where the choice is written down,
146 /// from a class name in markup to a described property with a name in every
147 /// renderer's vocabulary.
148 ///
149 /// The old names stay on the left-hand side of the rules because they are what
150 /// `style.css` matches, and renaming them is a separate change with no
151 /// description in it. `padded-page` is [`Measure::Wide`] because a padded page
152 /// is the full width with gutters, which is what the class always meant.
153 ///
154 /// The four standalone tokens -- `health-page`, `purchase-page`, `buy-page`,
155 /// `stripe-disclaimer-page` -- are screen identity rather than measure, and are
156 /// deliberately not here.
157 #[must_use]
158 pub const fn measure(measure: Measure) -> &'static str {
159 match measure {
160 Measure::Contained => "centered-page",
161 Measure::Reading => "article-page",
162 // The default, and the arm a member added upstream lands in. A measure
163 // this server has not learned yet should render at the width every
164 // other page does rather than unstyled.
165 _ => "padded-page",
166 }
167 }
168
169 #[cfg(test)]
170 mod tests {
171 use super::*;
172
173 #[test]
174 fn the_layer_statement_precedes_every_stylesheet() {
175 // The property the hand-written `<style>@layer ...</style>` existed to
176 // hold, now held by the renderer. It is the one that fails silently:
177 // the CSS stays valid and buttons and badges look subtly wrong.
178 let head = head();
179 let stmt = head
180 .find("@layer makeover, base, components, responsive;")
181 .expect("the order is stated");
182 for sheet in ["typography.css", "geometry.css", "layout.css", "style.css"] {
183 assert!(stmt < head.find(sheet).expect("the sheet is linked"));
184 }
185 }
186
187 #[test]
188 fn the_head_is_not_closed_and_carries_no_title() {
189 // Both are `base.html`'s, and emitting either here would produce a
190 // second one rather than an error.
191 assert!(!head().contains("</head>"));
192 assert!(!head().contains("<title>"));
193 assert!(head().starts_with("<!doctype html><html lang=\"en\">"));
194 }
195
196 #[test]
197 fn the_fonts_are_preloaded_before_the_sheets_that_race_them() {
198 let head = head();
199 assert!(head.find("QuasiBody.woff2") < head.find("style.css"));
200 // The retired pair, checked by absence: a preload for a face nothing
201 // declares is a download the browser makes and never uses.
202 assert!(!head.contains("Lato"));
203 assert!(!head.contains("IBMPlexMono"));
204 }
205
206 #[test]
207 fn the_body_attributes_start_with_a_space_so_a_page_can_add_its_own() {
208 // `base.html` writes them straight against `<body`, and a page's
209 // `{% block body_attrs %}` straight after. Neither side puts a
210 // separator in, so this one has to carry it.
211 let attrs = body_attrs();
212 assert!(attrs.is_empty() || attrs.starts_with(' '));
213 // No class of its own, or a page's class attribute would be the second
214 // on the tag and the browser would drop it.
215 assert!(!attrs.contains("class="));
216 }
217
218 #[test]
219 fn every_measure_keeps_the_class_the_templates_used_to_write() {
220 // `0eccff0d` moved where the choice is written down and changed no
221 // rule in `style.css`, so the three strings have to come out exactly as
222 // the 69 templates spelled them. A typo here renders 53 pages unstyled.
223 assert_eq!(measure(Measure::Wide), "padded-page");
224 assert_eq!(measure(Measure::Contained), "centered-page");
225 assert_eq!(measure(Measure::Reading), "article-page");
226 }
227
228 #[test]
229 fn no_template_still_writes_a_layout_class_by_hand() {
230 // The done-condition, checked rather than remembered: the layout axis
231 // is derived from the described property. A new template pasted from an
232 // old one fails here instead of quietly reintroducing the literal.
233 //
234 // The four standalone tokens are screen identity rather than measure
235 // and are deliberately left alone, so they are not looked for.
236 let mut offenders = Vec::new();
237 for entry in walk("templates") {
238 let source = std::fs::read_to_string(&entry).expect("a template reads");
239 for (at, line) in source.lines().enumerate() {
240 if !line.contains("block body_attrs") {
241 continue;
242 }
243 // The literal, as distinct from the call that produces it: the
244 // rendered class still says `padded-page`, and should.
245 let derived = line.contains("crate::shell::measure(");
246 let literal = ["padded-page", "centered-page", "article-page"]
247 .iter()
248 .any(|name| line.contains(name));
249 if literal && !derived {
250 offenders.push(format!("{}:{}", entry.display(), at + 1));
251 }
252 }
253 }
254 assert!(offenders.is_empty(), "{offenders:?}");
255 }
256
257 /// Every `.html` under a directory.
258 fn walk(root: &str) -> Vec<std::path::PathBuf> {
259 let mut found = Vec::new();
260 let mut stack = vec![std::path::PathBuf::from(root)];
261 while let Some(at) = stack.pop() {
262 let Ok(entries) = std::fs::read_dir(&at) else {
263 continue;
264 };
265 for entry in entries.flatten() {
266 let path = entry.path();
267 if path.is_dir() {
268 stack.push(path);
269 } else if path.extension().is_some_and(|ext| ext == "html") {
270 found.push(path);
271 }
272 }
273 }
274 found
275 }
276
277 #[test]
278 fn the_transport_the_head_links_is_served_from_here() {
279 // The shell links one script for the transport. Morphing is a swap
280 // style in htmx 4 rather than something an extension supplies, so the
281 // idiomorph registration and the extension script both left the head
282 // when quasi-webview moved to 4 (`2246072d`, ruled 2026-08-18), and the
283 // vendored file left the tree with `6b87e5ff`.
284 assert!(head().contains("htmx.min.js"));
285 assert!(!head().contains("idiomorph"));
286 let vendored = concat!(env!("CARGO_MANIFEST_DIR"), "/static/htmx.min.js");
287 assert!(std::path::Path::new(vendored).exists());
288 // The served bundle and the markup the templates carry are one version
289 // or the other, never a mix: 4 reads `hx-disable` as "disable while the
290 // request runs" where 2 read it as "skip this subtree".
291 let bundle = std::fs::read_to_string(vendored).expect("the bundle reads");
292 assert!(bundle.contains(HTMX), "the vendored bundle is {HTMX}");
293 }
294
295 #[test]
296 fn every_vendored_extension_is_busted_by_the_htmx_release_it_came_from() {
297 // Two extensions are vendored out of the htmx release: the history
298 // cache the shell links site-wide, and `hx-prompt`, which one admin
299 // template links for itself. Both are pinned files, so the release is
300 // their version, and a bump that leaves a `?v=` behind serves a browser
301 // the old extension against the new core.
302 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
303 let mut linked = 0;
304 let mut sources = vec![head().to_string()];
305 for entry in walk("templates") {
306 sources.push(std::fs::read_to_string(&entry).expect("a template reads"));
307 }
308 for source in &sources {
309 for (at, _) in source.match_indices("/static/hx-") {
310 let tail = &source[at + "/static/".len()..];
311 // The URL alone, cut at whichever quote closes the attribute.
312 let url = tail.split(['"', '\'']).next().expect("a quoted url");
313 let (name, suffix) = url.split_once("?v=").unwrap_or((url, ""));
314 assert!(
315 root.join("static").join(name).exists(),
316 "{name} is linked but not vendored"
317 );
318 assert_eq!(suffix, HTMX, "{name} is busted by the wrong version");
319 linked += 1;
320 }
321 }
322 assert_eq!(linked, 2, "the vendored extension count changed");
323 }
324
325 #[test]
326 fn the_htmx_config_is_stated_above_the_script_that_reads_it() {
327 // htmx reads `meta[name=htmx-config]` once, as the script runs. Below
328 // it the tag is inert and 4xx responses start swapping a rendered error
329 // page into whatever the request targeted.
330 let head = head();
331 let meta = head
332 .find("name=\"htmx-config\"")
333 .expect("the config is stated");
334 assert!(meta < head.find("htmx.min.js").expect("htmx is linked"));
335 assert!(head.contains("\"noSwap\""));
336 }
337 }
338