Skip to main content

max / makenotwork

46.2 KB · 949 lines History Blame Raw
1 use std::collections::hash_map::DefaultHasher;
2 use std::fmt::Write as _;
3 use std::hash::{Hash, Hasher};
4 use std::process::Command;
5 use std::{fs, path::Path};
6
7 fn main() {
8 // Set GIT_HASH env var for compile-time inclusion via option_env!()
9 println!("cargo::rustc-env=GIT_HASH={}", git_hash());
10
11 // Compile the TypeScript frontend to static/dist/ (best-effort, see fn).
12 build_frontend();
13
14 // The three generated stylesheets. Spacing comes from makeover-geometry,
15 // time from makeover-timing and composition from makeover-webview, the
16 // same way colour already comes from makeover through theming.rs. Written
17 // into static/ rather than a bundle directory because the server serves
18 // its stylesheets; all three are gitignored, since the crates are the
19 // source and a checked-in copy would drift from the pin.
20 //
21 // No explicit touch selector: density hangs off (hover: none),
22 // (pointer: coarse) alone, because the server has no mode class to key it
23 // on. GO passes `.ui-mode-mobile` because it has one.
24 // The four scripts quasi-webview ships, written where the shell already says
25 // they are served from. Each is behaviour a description cannot state and a
26 // renderer therefore owns: how many rows are ticked and that a control over
27 // none of them should not be pressable (`quasi-selection.js`), a readout
28 // reckoned against the current time that keeps moving (`quasi-clock.js`),
29 // an htmx answer that has to become a saved file rather than page text
30 // (`quasi-download.js`), and a chosen value that lands in the box the
31 // reader is typing in (`quasi-fill.js`).
32 // `Shell::under("/static")` has named these addresses since each script
33 // existed and only two of the files were being written, so every page has
34 // been linking a script that 404s -- which is what the loop is for: a name
35 // added to the renderer and forgotten here is a feature that silently does
36 // nothing.
37 //
38 // Written from the crate's own constants for the reason they are constants:
39 // the script reads hooks the emitter writes, so a copy checked in here goes
40 // stale against the next bump in silence. Gitignored, like the stylesheets.
41 for (name, source) in GENERATED_STATIC_JS {
42 // `write_if_changed`, not `fs::write`, and this is load-bearing rather
43 // than a micro-optimisation. `markup_files` globs `static/*.js`, and
44 // `check_vocabulary_use` emits a `rerun-if-changed` for every file it
45 // reads, so these four are files this script both writes AND watches.
46 // An unconditional write moves the mtime on every run, cargo then sees
47 // a watched file newer than the build script's own output, and the
48 // crate recompiles on every cargo invocation forever. That is what put
49 // a second full 5m25s compile inside every Sando `cargo_test` gate
50 // (task 4200580f); cargo named this exact file as the stale one.
51 write_if_changed(Path::new(&format!("static/{name}")), source);
52 }
53
54 makeover_build::geometry_css("static/geometry.css", None);
55 // The time axis, beside the spacing one and for the same reason: the
56 // durations are makeover-timing's answer, and a number typed into a
57 // hand-written sheet here is the drift that crate exists to end.
58 makeover_build::timing_css("static/timing.css");
59 makeover_build::layout_css("static/layout.css", &makeover_build::Emit::default());
60
61 // The embeds get their own copy of the spacing layer, because they are
62 // served into third-party iframes and cannot link a stylesheet. Pointer
63 // density only, and no `@media` block: an embed body is `height: 100vh`
64 // inside an iframe the host page sized, so growing the gaps on a coarse
65 // pointer clips rather than reflows, and the host author never sees it
66 // happen. Revisit if embeds ever gain a resize protocol.
67 fs::write(
68 "static/embed-geometry.css",
69 makeover_geometry::geometry_css_vars(makeover_geometry::Density::Pointer),
70 )
71 .expect("write static/embed-geometry.css");
72
73 // The typography layer, and the faces it fetches.
74 //
75 // Two halves that have to agree: makeover_build writes the `@font-face`
76 // rules and the tokens, and `cut_house_faces` puts the woff2 files where
77 // those rules point. Both name the file through
78 // `makeover::WEBFONT_*_FILE`, so the agreement is a constant rather than a
79 // string typed twice.
80 //
81 // Young Serif is the third token, and it is here rather than in style.css
82 // because that is what layer 0 is for. It used to be a hand-written
83 // `@font-face` plus a `--font-heading` nothing outside this repository had
84 // heard of; declared as an override it is the same shape as the two house
85 // slots, and `--font-display` is the name every product uses for its brand
86 // tier. The face itself is committed rather than cut: it is upstream's,
87 // unmodified, and quasi-type has no part in it.
88 makeover_build::typography_css_from(
89 "static/typography.css",
90 &makeover_build::Typography::house("/static/fonts").with_override(
91 makeover_build::FontOverride::new(
92 makeover_build::FontSlot::Display,
93 "\"Young Serif\", serif",
94 )
95 .with_face(makeover_build::FontFace::new(
96 "Young Serif",
97 ["ysrf.woff2", "ysrf.ttf"],
98 )),
99 ),
100 );
101
102 // The embeds get the house layer WITHOUT the override, for the same reason
103 // they get their own spacing layer: different context, different answer.
104 // An embed carries no brand face by decision — `--font-display` is left
105 // undefined so the `var(--font-display, Georgia, serif)` fallback in each
106 // template renders — and the alternative is 21 KB of a serif face on
107 // somebody else's page to style one card title. The two files are
108 // generated from one `Typography` vocabulary, so they cannot disagree
109 // about the house slots the way two hand-written sheets did.
110 makeover_build::typography_css_from(
111 "static/embed-typography.css",
112 &makeover_build::Typography::house("/static/fonts"),
113 );
114
115 cut_house_faces();
116
117 println!("cargo::rerun-if-changed=build.rs");
118
119 // The landing shots' own dimensions, so the description can reserve their
120 // space. Generated rather than written down; see `shot_dimensions`.
121 let shots = shot_dimensions();
122 let mut table = String::from(
123 "/// Intrinsic size of each landing screenshot, by URL path.\n\
124 /// Generated by build.rs from the files themselves.\n\
125 pub static SHOT_DIMENSIONS: &[(&str, u32, u32)] = &[\n",
126 );
127 for (path, w, h) in &shots {
128 let _ = writeln!(table, " ({path:?}, {w}, {h}),");
129 }
130 table.push_str("];\n");
131 fs::write(
132 Path::new(&std::env::var("OUT_DIR").expect("OUT_DIR")).join("shot_dimensions.rs"),
133 table,
134 )
135 .expect("write shot_dimensions.rs");
136
137 // The same check goingson and balanced_breakfast run, out of
138 // makeover-build rather than a third copy of it. The named-list form:
139 // static/ holds the generated stylesheets and a bundler's output beside
140 // the hand-written ones, so there is no directory to scan. No tuning
141 // widths -- every threshold here is a shell boundary.
142 makeover_build::check_breakpoints_files(&HAND_WRITTEN_CSS, &[]);
143 makeover_build::check_vocabulary_files(
144 &HAND_WRITTEN_CSS,
145 &makeover_build::Emit::default(),
146 REVIEWED_OVERLAPS,
147 REVIEWED_ELEMENT_OVERLAPS,
148 );
149 makeover_build::check_vocabulary_use(
150 &markup_files(),
151 &makeover_build::Emit::default(),
152 DEAD_VOCABULARY_HIGH_WATER,
153 );
154
155 // --- Static asset fingerprinting ---
156 // Hash the content of key static files to produce a version suffix.
157 // When any watched file changes, URLs in templates get a new ?v= param,
158 // busting browser caches automatically.
159 let static_files = [
160 "static/style.css",
161 // The two per-page sheets. Linked from page templates rather than
162 // from the head, which is why they were outside the fingerprint and
163 // served stale to any browser holding a cached copy.
164 "static/wizard.css",
165 "static/media-player.css",
166 // Same case again, found 2026-08-15: linked from a <noscript> in the
167 // carousel partial rather than from the head, so it was outside the
168 // fingerprint and a change to it served stale to any browser holding a
169 // cached copy. The visitors it exists for are the ones least likely to
170 // hard-refresh.
171 "static/no-js.css",
172 "static/htmx.min.js",
173 "static/upload.js",
174 "static/passkey.js",
175 // `static/insertions.js` was here until 2026-08-20. The file went away
176 // in bd448cbe and the entry did not, so it was a watch on a path that
177 // could not exist -- which cargo reads as changed, re-running this
178 // script and recompiling the crate on every invocation. Same bug as the
179 // old `.git/HEAD` watch; see `git_hash`. Anything added here must exist.
180 ];
181
182 let mut hasher = DefaultHasher::new();
183 for path in &static_files {
184 // Every path in the list above is expected to exist. Assert it rather
185 // than watching a phantom: a deleted file that keeps its entry costs a
186 // full recompile per cargo invocation and is invisible otherwise.
187 assert!(
188 Path::new(path).exists(),
189 "build.rs watches {path}, which does not exist. Remove the entry, or \
190 restore the file: a missing watch path recompiles this crate on \
191 every cargo invocation.",
192 );
193 println!("cargo::rerun-if-changed={path}");
194 if let Ok(content) = fs::read(path) {
195 content.hash(&mut hasher);
196 }
197 }
198 // Fold the emitted frontend bundles into the version so `?v=` busts when
199 // the TypeScript changes. Read-only: the inputs under frontend/src are the
200 // watched trigger (in build_frontend); watching the outputs would loop.
201 hash_dir_js(Path::new("static/dist"), &mut hasher);
202 // Same treatment for the generated stylesheets: read-only, so a bump of
203 // makeover-geometry, makeover-timing or makeover-webview busts `?v=`
204 // without the build script watching a file it writes itself.
205 for path in [
206 "static/geometry.css",
207 "static/timing.css",
208 "static/layout.css",
209 "static/embed-geometry.css",
210 // The tokens and the @font-face rules. Note what this does NOT cover:
211 // the woff2 files themselves are served under fixed names with no `?v=`,
212 // so a re-cut of the same slot ships new bytes at an old URL and a
213 // browser holding a cached copy keeps it. That was equally true of the
214 // Plex and Lato files this replaced, and a face changes about as often
215 // as the glyph set version does, so it is a known edge rather than a
216 // regression. The fix, if it ever bites, is a version in the URL
217 // makeover is handed.
218 "static/typography.css",
219 ] {
220 if let Ok(content) = fs::read(path) {
221 content.hash(&mut hasher);
222 }
223 }
224 let static_hash = format!("{:016x}", hasher.finish());
225 let version = &static_hash[..8];
226
227 // The head's own assets are no longer a generated partial: crate::shell
228 // builds them into quasi-webview's Shell, which is what a described screen
229 // renders through, so both halves of the converted site emit one head. All
230 // that crosses the build boundary now is the version.
231 println!("cargo::rustc-env=STATIC_VERSION={version}");
232
233 // Per-page island loader macro. Heavy/page-specific islands (media player,
234 // uploader, ...) load on the pages that use them via
235 // `{% import "_island.html" as island %}{% call island::island("name") %}`,
236 // cache-busted by the same content hash as the head assets.
237 let island_partial = r#"{% macro island(name) -%}
238 <script type="module" src="/static/dist-__VER__/islands/{{ name }}.js"></script>
239 {%- endmacro %}
240 "#
241 .replace("__VER__", version);
242 write_if_changed(Path::new("templates/_island.html"), &island_partial);
243
244 // Per-page stylesheet loader, same idea as the island macro. wizard.css and
245 // media-player.css are linked by the pages that need them rather than by
246 // the shell, so this is how they get the content hash.
247 let sheet_partial = r#"{% macro sheet(name) -%}
248 <link rel="stylesheet" href="/static/{{ name }}?v=__VER__">
249 {%- endmacro %}
250 "#
251 .replace("__VER__", version);
252 write_if_changed(Path::new("templates/_sheet.html"), &sheet_partial);
253 }
254
255 /// Cut Quasi Mono and Quasi Body into `static/fonts/`, as woff2.
256 ///
257 /// Cut rather than committed, which is the same rule `shop-font` and the Alloy
258 /// image follow: a face in the repository is a second source of truth that
259 /// nothing rebuilds, so the glyph set and the shipped face drift and nobody
260 /// finds out until a mark looks wrong. The pipeline is the source; these are
261 /// its output, and they are gitignored.
262 ///
263 /// The base is downloaded into `OUT_DIR` once and checksummed against
264 /// quasi-type's pins, so a warm target directory needs no network. `offline` is
265 /// false because the first build on a fresh machine has to be able to fetch,
266 /// and a failure here is fatal rather than skipped: a missing face is a site
267 /// rendering in the fallback with nothing to say so.
268 fn cut_house_faces() {
269 let cache = Path::new(&std::env::var("OUT_DIR").expect("cargo sets OUT_DIR")).join("bases");
270 quasi_type::cut_web(
271 Path::new("static/fonts"),
272 &cache,
273 false,
274 &[
275 ("quasi-mono", makeover_build::WEBFONT_MONO_FILE),
276 ("quasi-body", makeover_build::WEBFONT_SANS_FILE),
277 ],
278 )
279 .expect("cut the house faces");
280 }
281
282 /// Intrinsic dimensions of the landing screenshots, read from the files.
283 ///
284 /// A picture that cannot say how big it is cannot have its space reserved, so
285 /// the browser gives it none until the bytes land and then takes its full
286 /// height at once. That was measured at a 478px jump per frame on the landing
287 /// page and 0.087 CLS for the document (2026-08-14).
288 ///
289 /// Read here rather than written down, because these files are regenerated by
290 /// `scripts/capture-landing-carousel.mjs` and a hand-maintained number would be
291 /// wrong the first time anyone re-shot them -- silently, since a wrong reserve
292 /// looks like a right one until the image lands.
293 ///
294 /// WebP only, which is what the capture script emits. A file this cannot parse
295 /// is skipped rather than guessed at: `None` reserves nothing, which is the
296 /// behaviour before this existed, while a wrong number reserves the wrong room.
297 fn shot_dimensions() -> Vec<(String, u32, u32)> {
298 let dir = Path::new("static/images/shots");
299 println!("cargo::rerun-if-changed=static/images/shots");
300 let mut out = Vec::new();
301 let Ok(entries) = fs::read_dir(dir) else {
302 return out;
303 };
304 for entry in entries.flatten() {
305 let path = entry.path();
306 if path.extension().and_then(|e| e.to_str()) != Some("webp") {
307 continue;
308 }
309 let Ok(bytes) = fs::read(&path) else { continue };
310 let Some((w, h)) = webp_dimensions(&bytes) else {
311 continue;
312 };
313 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
314 out.push((format!("/static/images/shots/{name}"), w, h));
315 }
316 }
317 out.sort();
318 out
319 }
320
321 /// Canvas size from a WebP header, for the three chunk layouts that exist.
322 ///
323 /// Header offsets only -- nothing is decoded. `None` for anything unrecognised,
324 /// which the caller treats as "this picture does not know its size".
325 fn webp_dimensions(b: &[u8]) -> Option<(u32, u32)> {
326 if b.len() < 30 || &b[0..4] != b"RIFF" || &b[8..12] != b"WEBP" {
327 return None;
328 }
329 match &b[12..16] {
330 // Lossy. Three-byte frame tag, then the 3-byte sync code, then two
331 // 14-bit dimensions.
332 b"VP8 " => {
333 let w = u16::from_le_bytes([b[26], b[27]]) & 0x3fff;
334 let h = u16::from_le_bytes([b[28], b[29]]) & 0x3fff;
335 Some((u32::from(w), u32::from(h)))
336 }
337 // Lossless. One signature byte, then 14 bits of width-1 and 14 of
338 // height-1 packed into the next four.
339 b"VP8L" => {
340 let bits = u32::from_le_bytes([b[21], b[22], b[23], b[24]]);
341 Some(((bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1))
342 }
343 // Extended. Canvas size as two 24-bit little-endian minus-ones.
344 b"VP8X" => {
345 let w = u32::from_le_bytes([b[24], b[25], b[26], 0]) + 1;
346 let h = u32::from_le_bytes([b[27], b[28], b[29], 0]) + 1;
347 Some((w, h))
348 }
349 _ => None,
350 }
351 }
352
353 /// How many generated classes may go unused before the build fails.
354 ///
355 /// One-sided: over this fails, under it warns and asks for the seal to be
356 /// lowered. A build that broke on deleting dead CSS would teach the wrong
357 /// lesson, so the number only ever ratchets down.
358 ///
359 /// Measured against [`markup_files`]. The stylesheets are deliberately out -- a
360 /// class in `style.css` is that class being styled, not that class being
361 /// emitted, and counting them would mark the whole vocabulary used by
362 /// definition. `static/dist` is out for a subtler reason: it is `tsc` output of
363 /// `frontend/src`, so including both counts one class-writing line twice, and
364 /// the source is the half a human edits.
365 ///
366 /// The server's markup is spread wider than either desktop app's -- 200-odd
367 /// Askama templates, the hand-written scripts in `static/`, the TypeScript they
368 /// are being replaced by, and the Rust that writes markup directly -- which is
369 /// the whole reason this seal took a pass of its own rather than landing beside
370 /// the check that reads the stylesheets.
371 ///
372 /// # 18 to 20, 2026-08-14: `picture-img` and `picture-caption`
373 ///
374 /// The one direction this number is allowed to move is down, so a rise wants an
375 /// argument rather than a nudge. These two are not dead vocabulary: they are
376 /// emitted by `quasi-webview` at request time, from a description, and this
377 /// scanner reads *this repo's* files. Markup written by a dependency is
378 /// invisible to it by construction.
379 ///
380 /// That category already existed and was already counted here. `figure-value`,
381 /// `figure-caption` and `figure-change` sit in the same set for the same
382 /// reason, and the nine `cell-*` classes beside them are the described table.
383 /// The carousel port (`c0b63ea9`) is the first widget to add to it.
384 ///
385 /// So the seal still measures what it was built to measure -- CSS generated for
386 /// markup nobody writes -- and the honest ratchet is downward as the
387 /// description layer takes over more of the site, at which point these classes
388 /// stop being reachable from templates *and* stay used. If this number ever
389 /// needs raising for a class MNW's own markup should have been writing, that is
390 /// the defect this exists to catch and the answer is the markup, not the seal.
391 ///
392 /// # 20 to 23, 2026-08-15: `track-entry`, `track-slot`, `track-tick`
393 ///
394 /// The same category again, and the third entry in it. The track vocabulary
395 /// arrived in quasi 0.6.0 (`describe a time axis`) and 0.8.0 (`label a track by
396 /// its unit`); this server had been pinned to quasi 0.5 and could not resolve at
397 /// all, so the classes had never been weighed here. Forward-fixing the pin is
398 /// what surfaced them.
399 ///
400 /// Emitted by `quasi-webview` from a description, like the eighteen above them,
401 /// and invisible to a scanner reading this repo's files for the same reason. No
402 /// template should be writing them.
403 ///
404 /// # 23 to 24, 2026-08-17: `row-relaxed`
405 ///
406 /// The same category a fourth time. `makeover-layout` 0.28.1 named `Flow` and
407 /// `makeover-webview` 0.48.0 emits the clamp, so a described row can say a part
408 /// may take two lines. `quasi-webview` writes the class when a description asks
409 /// for it, so no template here should be writing it and a scanner over this
410 /// repo cannot see it either way.
411 ///
412 /// # 24 to 28, 2026-08-18: the four `run-*` classes
413 ///
414 /// The same category a fifth time. `makeover-layout` 0.29.0 named `Fallback`,
415 /// `makeover-webview` 0.49.0 emits a rule per member, and quasi 0.28.0 lets a
416 /// region say its leading row is shared -- goingson's tab strip and the toolbar
417 /// beside it, which is the case the ruling was made on.
418 ///
419 /// `quasi-webview` writes the wrapper and the fallback class from a description,
420 /// so no template here should be writing either and a scanner over this repo
421 /// cannot see them.
422 ///
423 /// The denominator went 55 to 60 and only four of the five land here. `.run`
424 /// reads as used, and it is not: the scan is a word match over markup files and
425 /// "Re-run pipeline" in `admin_upload_entries.html` is enough to satisfy it.
426 /// Worth knowing rather than fixing -- this count is documented loose in the
427 /// safe direction already, and a scanner that could tell a class from a word
428 /// would need to parse every template.
429 ///
430 /// # 28 to 33, 2026-08-18: five of the seven `facet-*` classes
431 ///
432 /// The same category a sixth time, and the first rise whose subject this repo
433 /// is the reason for. `makeover-layout` 0.30.0 named `Facet` and
434 /// `makeover-webview` 0.50.0 draws one, both measured against `discover`: it
435 /// filters six ways through six mechanisms, and every filter row in
436 /// `discover_sidebar.html` carries a tick box *and* a chevron only because a
437 /// tag's selection and its browse position are separate state.
438 ///
439 /// So these are not classes a feed reader has no call for, the way `.cell-*`
440 /// is. They are the classes the discover sidebar *will* write, and it does not
441 /// write them yet because it has not been described yet. That port is its own
442 /// work and the seal tightens with it.
443 ///
444 /// The denominator went 60 to 67 and only five of the seven land here. `.facet`
445 /// and `.facet-count` read as used and are not: the scan is a word match, and
446 /// `data-facet="tag"` in the sidebar plus the word "facet" in its own comment
447 /// are enough to satisfy them. The `.run` case above, again, and documented
448 /// rather than fixed for the same reason.
449 ///
450 /// It tightens when the discover sidebar ports, not before.
451 ///
452 /// 33 to 32 on 2026-08-18: the library tab strip is described (`6b24f2df`), and
453 /// the run it declares its overflow on is the first `.run-menu` this server
454 /// emits. The `.run` case documented above stopped being hypothetical, which is
455 /// the ratchet working rather than an exception to it.
456 /// 32 to 34 on the makeover-build 0.46 to 0.47 bump, which carries
457 /// makeover-webview 0.52.0 and the chrome a markdown field gets:
458 /// `.form-editor-modes` and `.form-editor-preview` around a
459 /// `FieldKind::Rich` control. The denominator went 67 to 69 and both land here.
460 ///
461 /// The facet case above, exactly: these are classes this repo is the reason
462 /// for and does not write yet. `partial-item-text-editor.html` has the
463 /// Write/Preview pair hand-written and `partial-item-text-editor.js` renders
464 /// the preview, and the renderer emitting the same shape is what those four
465 /// section editors convert onto. It tightens when they port, not before.
466 /// 34 to 37 on the quasi 0.45.0 / makeover-webview 0.53.0 bump: a field's
467 /// suggestion list. `Field::suggests` (`71852b16`) gave a field the list of
468 /// candidates it owns, and the three classes it is drawn with are
469 /// `.form-suggestions`, `.form-suggestion` and `.form-suggestion-why`. The
470 /// denominator went 69 to 72 and all three land here.
471 ///
472 /// The facet case a third time, and this repo is the reason for these too:
473 /// discover's search box and its tag typeahead are the two measured sites the
474 /// member was designed against, and both are still bare inputs driven from
475 /// `page-discover.js`. It tightens when they port, which is B7, not before.
476 /// 37 to 36, 2026-08-21: the five embeds are described (`54d7f8cf`), and their
477 /// markup is the renderer's now rather than five hand-written `<style>` blocks.
478 /// One class the templates had no word for is written by the row they became.
479 /// Measured rather than predicted; the check asked for it on the build that
480 /// converted them.
481 /// 36 to 34, 2026-08-21: the tag typeahead ported (N8, `1503db12`), which is
482 /// the tightening the suggestion-list paragraph above said to wait for. Two of
483 /// its three classes are written now — `.form-suggestions` by the list the
484 /// field owns and `.form-suggestion` by each candidate. `.form-suggestion-why`
485 /// is not among them: it came out of makeover-webview 0.57.0 when a candidate
486 /// grew a second line (`1fcf2e9b`), so it is gone rather than dead. The search
487 /// box is still hand-written and is the site that would tighten this again.
488 /// 34 to 44, 2026-08-22: makeover-webview 0.59.0 wrote down the unruled half
489 /// of its own vocabulary, so the denominator went 73 to 88 and every one of
490 /// the fifteen new names arrives dead here by construction. They are classes
491 /// that crate's emitters write -- a cell's width and drop, a field's group,
492 /// label, hint and error -- and this server spells none of them itself,
493 /// because the markup that carries them is Rust in a dependency rather than a
494 /// template in this repo. Nothing about this server changed; what changed is
495 /// that the check can now see the half of the vocabulary it was blind to.
496 /// It tightens as screens convert, exactly as the lines above did.
497 /// 44 to 45, 2026-08-26, and this one LOOSENS -- the first line here that does.
498 /// `64b33b26` deleted the seven Askama renderings the description replaced, and
499 /// `.form-label` lost its last two spellings with them (both in
500 /// `partials/tabs/user_ssh_keys_tab.html`). The class is not gone and is not
501 /// unused: makeover-webview's field emitter still writes it, on the described
502 /// screen that replaced that template. It is dead only in the sense this check
503 /// can measure, which is "no markup in this repo spells it", and the markup
504 /// that spells it is now Rust in a dependency.
505 ///
506 /// So a rising number here is not automatically a regression. Read it against
507 /// what moved: markup deleted in favour of a description will raise it, and a
508 /// class that genuinely lost its writer will too. The list the failure prints
509 /// is what tells them apart -- if a name on it is one this server still means
510 /// to render, that is the bug.
511 const DEAD_VOCABULARY_HIGH_WATER: usize = 45;
512
513 /// The scripts this build script generates into `static/`.
514 ///
515 /// One list, two consumers, and that is the whole point of it being a const.
516 /// The write loop iterates it, and [`markup_files`] excludes it. Those two have
517 /// to agree or the build recompiles on every cargo invocation forever.
518 ///
519 /// # Why a generated file may never reach `markup_files`
520 ///
521 /// `markup_files` globs `static/*.js`, and `makeover_build::check_vocabulary_use`
522 /// emits a `cargo::rerun-if-changed` for every file it reads. So a generated file
523 /// under `static/` is a file this script both **writes and watches**: the write
524 /// moves its mtime, cargo then sees a watched file newer than the build script's
525 /// own output, and the fingerprint can never settle. Nothing fails, nothing warns;
526 /// the crate just recompiles every time, which cost a second full 5m25s compile
527 /// inside every Sando `cargo_test` gate for five days.
528 ///
529 /// That has now happened twice through two different doors — `bd448cbe` left a
530 /// deleted file in the watched list, and `a52ec579` added generated ones to a
531 /// globbed directory the day after the first was fixed. `write_if_changed` fixes
532 /// an instance. This list plus the assertion in `markup_files` closes the door.
533 ///
534 /// **Adding a generated file under `static/`? Add it here too.**
535 ///
536 /// **Three were missing until 2026-08-26**, and the failure was exactly the one
537 /// goingson's copy of this list warns about: the shell links every script the
538 /// renderer ships unless the host says otherwise, and this host says otherwise
539 /// about none of them. `quasi-reveal.js` and `quasi-repeat.js` had been linked
540 /// and unserved -- a 404 per document, with nothing in the log -- and
541 /// `quasi-awaiting.js` would have joined them the moment it shipped. Found
542 /// adding the third, which is the only reason the other two were.
543 const GENERATED_STATIC_JS: [(&str, &str); 7] = [
544 ("quasi-selection.js", quasi_webview::SELECTION_JS),
545 ("quasi-clock.js", quasi_webview::CLOCK_JS),
546 ("quasi-download.js", quasi_webview::DOWNLOAD_JS),
547 ("quasi-fill.js", quasi_webview::FILL_JS),
548 ("quasi-reveal.js", quasi_webview::REVEAL_JS),
549 ("quasi-repeat.js", quasi_webview::REPEAT_JS),
550 ("quasi-awaiting.js", quasi_webview::AWAITING_JS),
551 ];
552
553 /// Every file that can carry a class name.
554 ///
555 /// Sorted within each group, so two machines read the same set in the same
556 /// order. It makes no difference to the count and every difference to reading a
557 /// diff of the warning.
558 ///
559 /// Generated files are excluded: see [`GENERATED_STATIC_JS`] for why watching one
560 /// is a build that never settles. These four are also quasi-webview's emitted
561 /// output rather than markup this repo writes, so measuring them against this
562 /// server's own vocabulary seal was answering a question about another crate.
563 fn markup_files() -> Vec<std::path::PathBuf> {
564 let mut files = Vec::new();
565 for (dir, extension) in [
566 ("templates", "html"),
567 ("static", "js"),
568 ("frontend/src", "ts"),
569 ("src", "rs"),
570 ] {
571 let mut found = Vec::new();
572 collect(Path::new(dir), extension, &mut found);
573 found.sort();
574 files.extend(found);
575 }
576
577 // The door, closed. Excluding by file name rather than by full path because
578 // `collect` walks recursively and the generated ones sit at the root of
579 // `static/`; a name collision deeper in the tree would be a second file
580 // called `quasi-clock.js`, which is its own problem.
581 let before = files.len();
582 files.retain(|path| {
583 !path
584 .file_name()
585 .and_then(|name| name.to_str())
586 .is_some_and(|name| GENERATED_STATIC_JS.iter().any(|(g, _)| *g == name))
587 });
588
589 // Catches a listed script that `markup_files` cannot see: an entry given a
590 // name `collect` does not glob, such as a `.mjs` or one written somewhere
591 // other than `static/`. It would be excluded from nothing while still being
592 // written and watched.
593 //
594 // It deliberately does NOT catch a deleted or renamed file, and the comment
595 // here used to claim it did. It cannot: the write loop iterates this same
596 // const and regenerates every entry before this function runs, so a missing
597 // one is recreated rather than absent. Verified by deleting
598 // `static/quasi-fill.js` and watching the build stay green. The `.gitignore`
599 // cross-check below is the half that does the real work.
600 let excluded = before - files.len();
601 assert_eq!(
602 excluded,
603 GENERATED_STATIC_JS.len(),
604 "markup_files excluded {excluded} generated scripts but GENERATED_STATIC_JS \
605 names {}. A generated file under static/ that reaches markup_files is one \
606 this build script both writes and watches, which recompiles the crate on \
607 every cargo invocation. Update GENERATED_STATIC_JS to match what is written.",
608 GENERATED_STATIC_JS.len(),
609 );
610
611 // The other half of the guard, and the one that catches the likelier
612 // mistake. The assertion above catches a listed script that stopped
613 // existing; this catches a NEW generated script nobody added to the list.
614 //
615 // `.gitignore` is the cross-check because it is the other place a generated
616 // file has to be named: it is not committed, so it either gets an entry or
617 // it shows up as untracked in every `git status` until someone adds one.
618 // Read rather than asked of `git`, so this costs no subprocess and still
619 // works where there is no git at all -- and where the file is unreadable it
620 // fails open, which is the right direction for a guard rather than a gate.
621 let ignored = fs::read_to_string("../.gitignore").unwrap_or_default();
622 for name in ignored
623 .lines()
624 .map(str::trim)
625 .filter_map(|line| line.strip_prefix("server/static/"))
626 .filter(|name| {
627 Path::new(name)
628 .extension()
629 .is_some_and(|ext| ext.eq_ignore_ascii_case("js"))
630 })
631 {
632 assert!(
633 GENERATED_STATIC_JS.iter().any(|(g, _)| *g == name),
634 "`.gitignore` names generated script static/{name}, which \
635 GENERATED_STATIC_JS does not. markup_files therefore watches a file \
636 this build script writes, and the crate will recompile on every cargo \
637 invocation with no other symptom. Add it to GENERATED_STATIC_JS.",
638 );
639 }
640
641 files
642 }
643
644 /// Every file under `dir` with this extension, recursively.
645 fn collect(dir: &Path, extension: &str, out: &mut Vec<std::path::PathBuf>) {
646 let Ok(entries) = fs::read_dir(dir) else {
647 return;
648 };
649 for entry in entries.flatten() {
650 let path = entry.path();
651 if path.is_dir() {
652 // The bundler's output, which is `frontend/src` compiled. Reading
653 // both would count the same line twice.
654 if path.file_name().is_some_and(|name| name == "dist") {
655 continue;
656 }
657 collect(&path, extension, out);
658 } else if path.extension().is_some_and(|ext| ext == extension) {
659 out.push(path);
660 }
661 }
662 }
663
664 /// The hand-written stylesheets. Ordered, so the guard reports the same way
665 /// twice. `geometry.css` and `layout.css` are excluded: they are generated.
666 ///
667 /// `no-js.css` joined the list on 2026-08-15. It had been missed since it was
668 /// written: it is hand-authored CSS served to real visitors, so the breakpoint
669 /// and vocabulary guards apply to it exactly as they do to the other three, and
670 /// a sheet outside the list is a sheet that can diverge without the build
671 /// noticing. It is also what `tests/frontend_payload.rs` weighs, and a seal
672 /// that skipped a sheet would let bytes move between sheets and read as a
673 /// deletion.
674 const HAND_WRITTEN_CSS: [&str; 4] = [
675 "static/style.css",
676 "static/wizard.css",
677 "static/media-player.css",
678 "static/no-js.css",
679 ];
680
681 /// Write `contents` to `path` only if it differs, to avoid needless rebuilds.
682 fn write_if_changed(path: &Path, contents: &str) {
683 let needs_write = fs::read_to_string(path).map_or(true, |existing| existing != contents);
684 if needs_write {
685 fs::write(path, contents)
686 .unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
687 }
688 }
689
690 /// Compile the TypeScript frontend (`frontend/`) to browser ESM in
691 /// `static/dist/` via `npm run build` (which runs `tsc`).
692 ///
693 /// Self-contained: on a fresh checkout or a new build host (no `node_modules`)
694 /// it runs `npm ci` first, so there is no manual install gate before a deploy.
695 /// Best-effort and non-fatal otherwise, an absent Node or a compile error only
696 /// emits a `cargo::warning` and leaves the Rust build to succeed against
697 /// whatever `static/dist/` already holds. Set `MNW_SKIP_FRONTEND_BUILD=1` to
698 /// opt out entirely (e.g. a Node-less CI that doesn't need the JS).
699 fn build_frontend() {
700 // Re-run the whole build script when the TS sources or its config change.
701 println!("cargo::rerun-if-changed=frontend/src");
702 println!("cargo::rerun-if-changed=frontend/package.json");
703 println!("cargo::rerun-if-changed=frontend/package-lock.json");
704 println!("cargo::rerun-if-changed=frontend/tsconfig.json");
705
706 if std::env::var_os("MNW_SKIP_FRONTEND_BUILD").is_some() {
707 println!("cargo::warning=frontend build skipped (MNW_SKIP_FRONTEND_BUILD set)");
708 return;
709 }
710 // Fresh checkout / new build host: install deps once (clean, from the
711 // lockfile) so the frontend build needs no manual `npm install` gate before
712 // a deploy. Skipped once node_modules exists; needs network on this run.
713 if !Path::new("frontend/node_modules").is_dir() {
714 match Command::new("npm")
715 .args(["ci"])
716 .current_dir("frontend")
717 .status()
718 {
719 Ok(s) if s.success() => {}
720 Ok(s) => {
721 println!(
722 "cargo::warning=npm ci failed (exit {:?}); skipping frontend build (serving existing static/dist)",
723 s.code()
724 );
725 return;
726 }
727 Err(e) => {
728 println!(
729 "cargo::warning=could not run npm ({e}); is Node installed? skipping frontend build (serving existing static/dist)"
730 );
731 return;
732 }
733 }
734 }
735 match Command::new("npm")
736 .args(["run", "build"])
737 .current_dir("frontend")
738 .status()
739 {
740 Ok(s) if s.success() => {}
741 Ok(s) => println!(
742 "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist",
743 s.code()
744 ),
745 Err(e) => println!(
746 "cargo::warning=could not run npm for the frontend build ({e}); is Node installed? \
747 skipping (serving existing static/dist)"
748 ),
749 }
750 }
751
752 /// Recursively hash every `.js` file under `dir` into `hasher`, in a
753 /// deterministic order. Missing directory is a no-op (first build before the
754 /// frontend has been compiled).
755 fn hash_dir_js(dir: &Path, hasher: &mut DefaultHasher) {
756 let Ok(entries) = fs::read_dir(dir) else {
757 return;
758 };
759 let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
760 paths.sort();
761 for path in paths {
762 if path.is_dir() {
763 hash_dir_js(&path, hasher);
764 } else if path.extension().and_then(|e| e.to_str()) == Some("js")
765 && let Ok(content) = fs::read(&path)
766 {
767 content.hash(hasher);
768 }
769 }
770 }
771
772 /// Class-and-property overlaps with the generated stylesheet that have been
773 /// read and kept.
774 ///
775 /// Two kinds, and only the first is what the check is really for.
776 ///
777 /// **Different selector arms.** `.badge { color }` and `.card { color }`:
778 /// makeover colours the tone and disabled arms, the server colours the base and
779 /// its own variants (`.badge.ai-tier-*`, `.badge--founder-*`). Neither touches
780 /// the other's arm. The check collapses arms, because separating them would
781 /// need a selector matcher and a checker that guesses wrong about specificity
782 /// fails correct builds, so the judgement is recorded here.
783 ///
784 /// **A deliberate pairing.** `.progress-fill { background }` is a different
785 /// element rather than a different arm: the media scrubber's fill, which lives
786 /// inside `.progress-bar` and never matches the generated
787 /// `.progress > .progress-fill`. It already carried a `respec-ok` comment
788 /// saying so.
789 ///
790 /// `.tab { box-shadow }`, `{ color }` and `{ cursor }` are this file taking the
791 /// property outright: it zeroes the base `button` shadow on a tab and dresses
792 /// the tab's own text, and a described tab gets that rather than makeover's.
793 /// The chosen arm is the opposite and is not here any more -- it is a
794 /// `revert-layer`, and since makeover-build 0.50.0 a handoff is not read as a
795 /// taking, so it needs no entry.
796 ///
797 /// **What is no longer in this list, and why that is the point.** Nineteen
798 /// entries came out on 2026-08-22, every one of them a `revert-layer`. The
799 /// check used to read property names without their values, so a later layer
800 /// handing a property back looked identical to a later layer taking it, and
801 /// each of those entries had to be written to buy silence for a remedy. The
802 /// cost was not the noise: an entry permits a real override on the same pair
803 /// for good, so the list of remedies and the list of licences were the same
804 /// list. makeover-build 0.50.0 reads the value, and the handoffs at the end of
805 /// `style.css` now speak for themselves.
806 ///
807 /// The element rules those handoffs defer past are the other half of the same
808 /// release: `REVIEWED_ELEMENT_OVERLAPS` below, and the section at the end of
809 /// `style.css` that keeps it empty.
810 ///
811 /// Still overlapping and still this file's: `.badge` and `.card` on their own
812 /// arms, and the disabled treatment's `opacity`, which is a charter decision
813 /// and a property makeover does not set.
814 ///
815 /// **A divergence taken on purpose.** `.table-row { display }` is `grid` here
816 /// against makeover's `table-row`, which is the same build-time grid story
817 /// goingson is on.
818 ///
819 /// `.tab { background }` was here and is gone. `.tab.is-selected` set
820 /// `--surface-raised` and `--bevel-raised`, byte for byte what the generated
821 /// `.tab.chosen` sets: a second name for makeover's own state, which is the
822 /// defect makeover-webview 0.27.0 exists to prevent. The tabs carry `chosen`
823 /// now, in four templates and in `frontend/src/core/tabs.ts`, and the rule is
824 /// deleted. The caret's two entries went the same way, one release later:
825 /// makeover-webview 0.31.0 emits the leading space and the reserved box itself.
826 ///
827 /// An entry that stops colliding fails the build, so this list cannot outlive
828 /// what it describes.
829 const REVIEWED_OVERLAPS: &[(&str, &str)] = &[
830 ("badge", "color"),
831 // A carousel showing one frame at a time, 2026-08-14. This was
832 // `("picture-img", "display")` until makeover-layout 0.23.0 described the
833 // showing itself; the frames are wrapped in `.showing-frame` now and the old
834 // entry stopped colliding, which this list is built to notice.
835 //
836 // The two arms are the same rule at two moments, and that is the whole of
837 // why the overlap is kept. Makeover collapses the stack on `[data-ready]`,
838 // which is the only honest default for a renderer that cannot know whether
839 // a page has script: ship every child, take them away once something binds
840 // them. This site collapses it from first paint instead, because rendering
841 // three frames and collapsing them was a measured 141px -> 58px jump that
842 // every visitor with JS paid, and `no-js.css` from a <noscript> opens the
843 // stack for the few without. That trade is this landing page's to make and
844 // a generated stylesheet has no way to reach <noscript>.
845 ("showing-frame", "display"),
846 ("current", "display"),
847 ("card", "color"),
848 ("progress-fill", "background"),
849 ("tab", "box-shadow"),
850 ("tab", "color"),
851 ("tab", "cursor"),
852 ("table-row", "display"),
853 ];
854
855 /// Bare element rules that reach a generated class and have been read and kept.
856 ///
857 /// The second pass of the same check, and the one this file had no answer to
858 /// until makeover-build 0.50.0: a rule with no class in it is invisible to the
859 /// list above, and `button { color: var(--content) }` in @layer components beat
860 /// the generated `.button[data-tone]` on every described act on the site. A
861 /// destructive act rendered identically to an ordinary one for months.
862 ///
863 /// Empty, and meant to stay that way. The remedy for every one of them is a
864 /// `revert-layer` handoff in style.css, which says which arms makeover keeps in
865 /// the file the browser reads rather than in a build script.
866 const REVIEWED_ELEMENT_OVERLAPS: &[(&str, &str, &str)] = &[];
867
868 /// The short commit sha to stamp into `GIT_HASH`, and the watches that decide
869 /// when this build script has to run again.
870 ///
871 /// The watches are the whole point. Cargo treats a `rerun-if-changed` path that
872 /// does not exist as *changed*, so a watch on a path that can never exist makes
873 /// this script re-run on every single cargo invocation, and re-running it
874 /// recompiles the crate. This file used to watch `.git/HEAD`, which resolves
875 /// against the package root: there is no `.git` in `server/` or in
876 /// `multithreaded/` because the repository root is `MNW/`. So the watch never
877 /// resolved, and the crate recompiled every time.
878 ///
879 /// It cost 347s per Sando pipeline (294s in the server, 53s in multithreaded,
880 /// measured off `/srv/sando/logs/0.11.20/cargo_test.log`), which is 35% of the
881 /// `cargo_test` gate, and it cost the same on every local `cargo build`,
882 /// `cargo test` and `cargo clippy`. The two crates carrying a `build.rs` were
883 /// the only two in the pipeline that recompiled on a second cargo invocation;
884 /// the ones without one were already free.
885 ///
886 /// Two rules follow, and both matter:
887 /// - resolve the paths through git rather than guessing them, and
888 /// - emit a watch ONLY for a path that exists, or the bug comes straight back.
889 fn git_hash() -> String {
890 // An explicit hash wins and skips git entirely, for a build system that
891 // already knows the sha. Nothing sets this today: Sando would have to set it
892 // on EVERY cargo invocation it makes, because `GIT_HASH` is a `rustc-env`
893 // and therefore part of the crate fingerprint, so a value present for the
894 // release build and absent for `cargo_test` would force the recompile this
895 // function exists to remove.
896 println!("cargo::rerun-if-env-changed=MNW_GIT_HASH");
897 if let Ok(h) = std::env::var("MNW_GIT_HASH") {
898 let h = h.trim().to_string();
899 if !h.is_empty() {
900 return h;
901 }
902 }
903
904 // Watch what git actually rewrites when the checkout moves. `.git/HEAD`
905 // alone is not enough even when resolved: committing on a branch rewrites
906 // that branch's ref, not HEAD, so a watch on HEAD by itself would go stale
907 // in the ordinary case of a commit.
908 watch_if_exists(git_path("HEAD").as_deref());
909 if let Some(r) = git_output(&["symbolic-ref", "-q", "HEAD"]) {
910 watch_if_exists(git_path(&r).as_deref());
911 }
912 // A ref that has been packed has no loose file, so this is the fallback
913 // that keeps the watch honest after a `git gc`.
914 watch_if_exists(git_path("packed-refs").as_deref());
915
916 git_output(&["rev-parse", "--short", "HEAD"]).unwrap_or_default()
917 }
918
919 /// Run a git command in the package directory and return its trimmed stdout.
920 fn git_output(args: &[&str]) -> Option<String> {
921 Command::new("git")
922 .args(args)
923 .output()
924 .ok()
925 .filter(|o| o.status.success())
926 .and_then(|o| String::from_utf8(o.stdout).ok())
927 .map(|s| s.trim().to_string())
928 .filter(|s| !s.is_empty())
929 }
930
931 /// Resolve a name inside the git directory to a path, honouring worktrees and a
932 /// `.git` file that points elsewhere. `None` when this is not a checkout at all,
933 /// which is the case in a vendored or packaged build.
934 fn git_path(name: &str) -> Option<String> {
935 git_output(&["rev-parse", "--git-path", name])
936 }
937
938 /// Emit a watch for a path, but only when it exists.
939 ///
940 /// The guard is the fix. A missing path reads as changed to cargo, so emitting
941 /// one unconditionally is what caused the recompile-every-time bug.
942 fn watch_if_exists(path: Option<&str>) {
943 if let Some(p) = path
944 && Path::new(p).exists()
945 {
946 println!("cargo::rerun-if-changed={p}");
947 }
948 }
949