Skip to main content

max / makenotwork

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