Skip to main content

max / makenotwork

10.6 KB · 240 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 that loads
8 //! without the morph extension, a viewport that says one thing on half the
9 //! site. So the head is the renderer's on both paths from here, before any
10 //! 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 fn parts() -> &'static quasi_webview::Parts {
38 static PARTS: OnceLock<quasi_webview::Parts> = OnceLock::new();
39 PARTS.get_or_init(|| {
40 Shell::under("/static")
41 // Earlier in the list = lower priority, and `makeover` is prepended
42 // by the renderer. A layer's position is fixed where its name is
43 // FIRST seen, so without the statement the generated sheets would
44 // establish `makeover` simply by loading first and reordering two
45 // links would silently reorder the cascade. `components` is where
46 // the site's own sheets live, including the per-page wizard.css and
47 // media-player.css that arrive later through `{% block head %}` and
48 // that nothing here can order. `base` and `responsive` are still
49 // empty; declaring an empty layer costs nothing and fixes its
50 // position.
51 .layered(["base", "components", "responsive"])
52 // Whole value is being early: a preload discovered after the sheets
53 // it races bought nothing.
54 .with_head_first(
55 "<link rel=\"preload\" href=\"/static/fonts/Lato-Regular.woff2\" as=\"font\" type=\"font/woff2\" crossorigin>\
56 <link rel=\"preload\" href=\"/static/fonts/ysrf.woff2\" as=\"font\" type=\"font/woff2\" crossorigin>",
57 )
58 .styled(format!("/static/geometry.css?v={V}"))
59 .styled(format!("/static/layout.css?v={V}"))
60 .styled(format!("/static/style.css?v={V}"))
61 // Last in the head, after htmx: the favicon has no order to keep,
62 // and neither script reads htmx at load. `upload.js` only defines
63 // `S3Uploader`, and the core module is deferred by being a module,
64 // so it still runs after the deferred htmx above it.
65 .with_head(format!(
66 "<link rel=\"icon\" href=\"/static/images/favicon.ico\" type=\"image/x-icon\">\
67 <script src=\"/static/upload.js?v={V}\"></script>\
68 <script type=\"module\" src=\"/static/dist-{V}/core/index.js\"></script>"
69 ))
70 .parts()
71 })
72 }
73
74 /// `<!doctype>` through the head's contents, without `</head>`.
75 ///
76 /// Called from `base.html`, which appends the title and the per-page head and
77 /// closes the element.
78 pub fn head() -> &'static str {
79 &parts().head
80 }
81
82 /// The attributes the shell owns on `<body>`, each one space-prefixed.
83 ///
84 /// `base.html` writes `<body{{ body_attrs() }}{% block body_attrs %}>`, so a
85 /// page's own class attribute composes with these instead of replacing them.
86 pub fn body_attrs() -> &'static str {
87 &parts().body_attrs
88 }
89
90 pub use makeover_layout::Measure;
91
92 /// The body class for a screen's measure.
93 ///
94 /// `0eccff0d`. 69 of 72 templates carried one of these three strings as a
95 /// literal, which made how wide a page runs a fact about the template rather
96 /// than about the screen. The strings are unchanged and the rules in
97 /// `style.css` are untouched: what moved is where the choice is written down,
98 /// from a class name in markup to a described property with a name in every
99 /// renderer's vocabulary.
100 ///
101 /// The old names stay on the left-hand side of the rules because they are what
102 /// `style.css` matches, and renaming them is a separate change with no
103 /// description in it. `padded-page` is [`Measure::Wide`] because a padded page
104 /// is the full width with gutters, which is what the class always meant.
105 ///
106 /// The four standalone tokens -- `health-page`, `purchase-page`, `buy-page`,
107 /// `stripe-disclaimer-page` -- are screen identity rather than measure, and are
108 /// deliberately not here.
109 #[must_use]
110 pub const fn measure(measure: Measure) -> &'static str {
111 match measure {
112 Measure::Contained => "centered-page",
113 Measure::Reading => "article-page",
114 // The default, and the arm a member added upstream lands in. A measure
115 // this server has not learned yet should render at the width every
116 // other page does rather than unstyled.
117 _ => "padded-page",
118 }
119 }
120
121 #[cfg(test)]
122 mod tests {
123 use super::*;
124
125 #[test]
126 fn the_layer_statement_precedes_every_stylesheet() {
127 // The property the hand-written `<style>@layer ...</style>` existed to
128 // hold, now held by the renderer. It is the one that fails silently:
129 // the CSS stays valid and buttons and badges look subtly wrong.
130 let head = head();
131 let stmt = head
132 .find("@layer makeover, base, components, responsive;")
133 .expect("the order is stated");
134 for sheet in ["geometry.css", "layout.css", "style.css"] {
135 assert!(stmt < head.find(sheet).expect("the sheet is linked"));
136 }
137 }
138
139 #[test]
140 fn the_head_is_not_closed_and_carries_no_title() {
141 // Both are `base.html`'s, and emitting either here would produce a
142 // second one rather than an error.
143 assert!(!head().contains("</head>"));
144 assert!(!head().contains("<title>"));
145 assert!(head().starts_with("<!doctype html><html lang=\"en\">"));
146 }
147
148 #[test]
149 fn the_fonts_are_preloaded_before_the_sheets_that_race_them() {
150 let head = head();
151 assert!(head.find("Lato-Regular.woff2") < head.find("style.css"));
152 }
153
154 #[test]
155 fn the_body_attributes_start_with_a_space_so_a_page_can_add_its_own() {
156 // `base.html` writes them straight against `<body`, and a page's
157 // `{% block body_attrs %}` straight after. Neither side puts a
158 // separator in, so this one has to carry it.
159 let attrs = body_attrs();
160 assert!(attrs.starts_with(' '));
161 assert!(attrs.contains("hx-ext=\"morph\""));
162 // No class of its own, or a page's class attribute would be the second
163 // on the tag and the browser would drop it.
164 assert!(!attrs.contains("class="));
165 }
166
167 #[test]
168 fn every_measure_keeps_the_class_the_templates_used_to_write() {
169 // `0eccff0d` moved where the choice is written down and changed no
170 // rule in `style.css`, so the three strings have to come out exactly as
171 // the 69 templates spelled them. A typo here renders 53 pages unstyled.
172 assert_eq!(measure(Measure::Wide), "padded-page");
173 assert_eq!(measure(Measure::Contained), "centered-page");
174 assert_eq!(measure(Measure::Reading), "article-page");
175 }
176
177 #[test]
178 fn no_template_still_writes_a_layout_class_by_hand() {
179 // The done-condition, checked rather than remembered: the layout axis
180 // is derived from the described property. A new template pasted from an
181 // old one fails here instead of quietly reintroducing the literal.
182 //
183 // The four standalone tokens are screen identity rather than measure
184 // and are deliberately left alone, so they are not looked for.
185 let mut offenders = Vec::new();
186 for entry in walk("templates") {
187 let source = std::fs::read_to_string(&entry).expect("a template reads");
188 for (at, line) in source.lines().enumerate() {
189 if !line.contains("block body_attrs") {
190 continue;
191 }
192 // The literal, as distinct from the call that produces it: the
193 // rendered class still says `padded-page`, and should.
194 let derived = line.contains("crate::shell::measure(");
195 let literal = ["padded-page", "centered-page", "article-page"]
196 .iter()
197 .any(|name| line.contains(name));
198 if literal && !derived {
199 offenders.push(format!("{}:{}", entry.display(), at + 1));
200 }
201 }
202 }
203 assert!(offenders.is_empty(), "{offenders:?}");
204 }
205
206 /// Every `.html` under a directory.
207 fn walk(root: &str) -> Vec<std::path::PathBuf> {
208 let mut found = Vec::new();
209 let mut stack = vec![std::path::PathBuf::from(root)];
210 while let Some(at) = stack.pop() {
211 let Ok(entries) = std::fs::read_dir(&at) else {
212 continue;
213 };
214 for entry in entries.flatten() {
215 let path = entry.path();
216 if path.is_dir() {
217 stack.push(path);
218 } else if path.extension().is_some_and(|ext| ext == "html") {
219 found.push(path);
220 }
221 }
222 }
223 found
224 }
225
226 #[test]
227 fn morph_is_loaded_wherever_a_swap_can_ask_for_it() {
228 // The shell writes `hx-ext="morph"` onto every page's body, so the
229 // extension has to be served. It is: `static/idiomorph-ext.min.js`.
230 assert!(head().contains("idiomorph-ext.min.js"));
231 assert!(
232 std::path::Path::new(concat!(
233 env!("CARGO_MANIFEST_DIR"),
234 "/static/idiomorph-ext.min.js"
235 ))
236 .exists()
237 );
238 }
239 }
240