Skip to main content

max / makenotwork

38.3 KB · 821 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, which is a 478px jump per frame on the landing page and
287 /// 0.087 CLS for the document.
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 /// A rise usually means a dependency grew vocabulary this repo does not spell.
373 /// `quasi-webview` and `makeover-webview` emit markup from a description at
374 /// request time, so their classes are invisible to a scanner reading this
375 /// repo's files and arrive dead here by construction. Those tighten as screens
376 /// convert. A few can never tighten: `menu.js` builds the overflow control when
377 /// it measures a run as tight, so no markup anywhere can spell
378 /// `.run-overflow` (see `makeover_webview::RUN_CLASSES`).
379 ///
380 /// The scan is a word match over markup files, so a class whose name appears as
381 /// an ordinary word (`.run`, `.facet`) reads as used when it is not. Documented
382 /// loose in the safe direction rather than fixed; telling a class from a word
383 /// would mean parsing every template.
384 ///
385 /// So a rising number here is not automatically a regression. Read it against
386 /// what moved: markup deleted in favour of a description will raise it, and a
387 /// class that genuinely lost its writer will too. The list the failure prints
388 /// is what tells them apart -- if a name on it is one this server still means
389 /// to render, that is the bug.
390 const DEAD_VOCABULARY_HIGH_WATER: usize = 48;
391
392 /// The scripts this build script generates into `static/`.
393 ///
394 /// One list, two consumers, and that is the whole point of it being a const.
395 /// The write loop iterates it, and [`markup_files`] excludes it. Those two have
396 /// to agree or the build recompiles on every cargo invocation forever.
397 ///
398 /// # Why a generated file may never reach `markup_files`
399 ///
400 /// `markup_files` globs `static/*.js`, and `makeover_build::check_vocabulary_use`
401 /// emits a `cargo::rerun-if-changed` for every file it reads. So a generated file
402 /// under `static/` is a file this script both **writes and watches**: the write
403 /// moves its mtime, cargo then sees a watched file newer than the build script's
404 /// own output, and the fingerprint can never settle. Nothing fails, nothing warns;
405 /// the crate just recompiles every time, which cost a second full 5m25s compile
406 /// inside every Sando `cargo_test` gate for five days.
407 ///
408 /// Two doors lead here: a deleted file left in the watched list, and generated
409 /// files added to a globbed directory. `write_if_changed` fixes an instance.
410 /// This list plus the assertion in `markup_files` closes the door.
411 ///
412 /// **Adding a generated file under `static/`? Add it here too.** The shell links
413 /// every script the renderer ships unless the host says otherwise, and this host
414 /// says otherwise about none of them, so a script missing from this list is
415 /// linked and unserved: a 404 per document, with nothing in the log.
416 const GENERATED_STATIC_JS: [(&str, &str); 12] = [
417 ("quasi-selection.js", quasi_webview::SELECTION_JS),
418 ("quasi-clock.js", quasi_webview::CLOCK_JS),
419 ("quasi-download.js", quasi_webview::DOWNLOAD_JS),
420 ("quasi-fill.js", quasi_webview::FILL_JS),
421 ("quasi-reveal.js", quasi_webview::REVEAL_JS),
422 ("quasi-copy.js", quasi_webview::COPY_JS),
423 ("quasi-repeat.js", quasi_webview::REPEAT_JS),
424 ("quasi-awaiting.js", quasi_webview::AWAITING_JS),
425 ("quasi-instant.js", quasi_webview::INSTANT_JS),
426 ("quasi-focus.js", quasi_webview::FOCUS_JS),
427 ("quasi-menu.js", quasi_webview::MENU_JS),
428 ("quasi-outline.js", quasi_webview::OUTLINE_JS),
429 ];
430
431 /// Every file that can carry a class name.
432 ///
433 /// Sorted within each group, so two machines read the same set in the same
434 /// order. It makes no difference to the count and every difference to reading a
435 /// diff of the warning.
436 ///
437 /// Generated files are excluded: see [`GENERATED_STATIC_JS`] for why watching one
438 /// is a build that never settles. These four are also quasi-webview's emitted
439 /// output rather than markup this repo writes, so measuring them against this
440 /// server's own vocabulary seal was answering a question about another crate.
441 fn markup_files() -> Vec<std::path::PathBuf> {
442 let mut files = Vec::new();
443 for (dir, extension) in [
444 ("templates", "html"),
445 ("static", "js"),
446 ("frontend/src", "ts"),
447 ("src", "rs"),
448 ] {
449 let mut found = Vec::new();
450 collect(Path::new(dir), extension, &mut found);
451 found.sort();
452 files.extend(found);
453 }
454
455 // The door, closed. Excluding by file name rather than by full path because
456 // `collect` walks recursively and the generated ones sit at the root of
457 // `static/`; a name collision deeper in the tree would be a second file
458 // called `quasi-clock.js`, which is its own problem.
459 let before = files.len();
460 files.retain(|path| {
461 !path
462 .file_name()
463 .and_then(|name| name.to_str())
464 .is_some_and(|name| GENERATED_STATIC_JS.iter().any(|(g, _)| *g == name))
465 });
466
467 // Catches a listed script that `markup_files` cannot see: an entry given a
468 // name `collect` does not glob, such as a `.mjs` or one written somewhere
469 // other than `static/`. It would be excluded from nothing while still being
470 // written and watched.
471 //
472 // It deliberately does NOT catch a deleted or renamed file, and the comment
473 // here used to claim it did. It cannot: the write loop iterates this same
474 // const and regenerates every entry before this function runs, so a missing
475 // one is recreated rather than absent. Verified by deleting
476 // `static/quasi-fill.js` and watching the build stay green. The `.gitignore`
477 // cross-check below is the half that does the real work.
478 let excluded = before - files.len();
479 assert_eq!(
480 excluded,
481 GENERATED_STATIC_JS.len(),
482 "markup_files excluded {excluded} generated scripts but GENERATED_STATIC_JS \
483 names {}. A generated file under static/ that reaches markup_files is one \
484 this build script both writes and watches, which recompiles the crate on \
485 every cargo invocation. Update GENERATED_STATIC_JS to match what is written.",
486 GENERATED_STATIC_JS.len(),
487 );
488
489 // The other half of the guard, and the one that catches the likelier
490 // mistake. The assertion above catches a listed script that stopped
491 // existing; this catches a NEW generated script nobody added to the list.
492 //
493 // `.gitignore` is the cross-check because it is the other place a generated
494 // file has to be named: it is not committed, so it either gets an entry or
495 // it shows up as untracked in every `git status` until someone adds one.
496 // Read rather than asked of `git`, so this costs no subprocess and still
497 // works where there is no git at all -- and where the file is unreadable it
498 // fails open, which is the right direction for a guard rather than a gate.
499 let ignored = fs::read_to_string("../.gitignore").unwrap_or_default();
500 for name in ignored
501 .lines()
502 .map(str::trim)
503 .filter_map(|line| line.strip_prefix("server/static/"))
504 .filter(|name| {
505 Path::new(name)
506 .extension()
507 .is_some_and(|ext| ext.eq_ignore_ascii_case("js"))
508 })
509 {
510 assert!(
511 GENERATED_STATIC_JS.iter().any(|(g, _)| *g == name),
512 "`.gitignore` names generated script static/{name}, which \
513 GENERATED_STATIC_JS does not. markup_files therefore watches a file \
514 this build script writes, and the crate will recompile on every cargo \
515 invocation with no other symptom. Add it to GENERATED_STATIC_JS.",
516 );
517 }
518
519 files
520 }
521
522 /// Every file under `dir` with this extension, recursively.
523 fn collect(dir: &Path, extension: &str, out: &mut Vec<std::path::PathBuf>) {
524 let Ok(entries) = fs::read_dir(dir) else {
525 return;
526 };
527 for entry in entries.flatten() {
528 let path = entry.path();
529 if path.is_dir() {
530 // The bundler's output, which is `frontend/src` compiled. Reading
531 // both would count the same line twice.
532 if path.file_name().is_some_and(|name| name == "dist") {
533 continue;
534 }
535 collect(&path, extension, out);
536 } else if path.extension().is_some_and(|ext| ext == extension) {
537 out.push(path);
538 }
539 }
540 }
541
542 /// The hand-written stylesheets. Ordered, so the guard reports the same way
543 /// twice. `geometry.css` and `layout.css` are excluded: they are generated.
544 ///
545 /// Every hand-authored sheet served to real visitors belongs here: the
546 /// breakpoint and vocabulary guards apply to all of them, and a sheet outside
547 /// the list can diverge without the build noticing. `tests/frontend_payload.rs`
548 /// weighs the same list, and a seal that skipped a sheet would let bytes move
549 /// between sheets and read as a deletion.
550 const HAND_WRITTEN_CSS: [&str; 4] = [
551 "static/style.css",
552 "static/wizard.css",
553 "static/media-player.css",
554 "static/no-js.css",
555 ];
556
557 /// Write `contents` to `path` only if it differs, to avoid needless rebuilds.
558 fn write_if_changed(path: &Path, contents: &str) {
559 let needs_write = fs::read_to_string(path).map_or(true, |existing| existing != contents);
560 if needs_write {
561 fs::write(path, contents)
562 .unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
563 }
564 }
565
566 /// Compile the TypeScript frontend (`frontend/`) to browser ESM in
567 /// `static/dist/` via `npm run build` (which runs `tsc`).
568 ///
569 /// Self-contained: on a fresh checkout or a new build host (no `node_modules`)
570 /// it runs `npm ci` first, so there is no manual install gate before a deploy.
571 /// Best-effort and non-fatal otherwise, an absent Node or a compile error only
572 /// emits a `cargo::warning` and leaves the Rust build to succeed against
573 /// whatever `static/dist/` already holds. Set `MNW_SKIP_FRONTEND_BUILD=1` to
574 /// opt out entirely (e.g. a Node-less CI that doesn't need the JS).
575 fn build_frontend() {
576 // Re-run the whole build script when the TS sources or its config change.
577 println!("cargo::rerun-if-changed=frontend/src");
578 println!("cargo::rerun-if-changed=frontend/package.json");
579 println!("cargo::rerun-if-changed=frontend/package-lock.json");
580 println!("cargo::rerun-if-changed=frontend/tsconfig.json");
581
582 if std::env::var_os("MNW_SKIP_FRONTEND_BUILD").is_some() {
583 println!("cargo::warning=frontend build skipped (MNW_SKIP_FRONTEND_BUILD set)");
584 return;
585 }
586 // Fresh checkout / new build host: install deps once (clean, from the
587 // lockfile) so the frontend build needs no manual `npm install` gate before
588 // a deploy. Skipped once node_modules exists; needs network on this run.
589 if !Path::new("frontend/node_modules").is_dir() {
590 match Command::new("npm")
591 .args(["ci"])
592 .current_dir("frontend")
593 .status()
594 {
595 Ok(s) if s.success() => {}
596 Ok(s) => {
597 println!(
598 "cargo::warning=npm ci failed (exit {:?}); skipping frontend build (serving existing static/dist)",
599 s.code()
600 );
601 return;
602 }
603 Err(e) => {
604 println!(
605 "cargo::warning=could not run npm ({e}); is Node installed? skipping frontend build (serving existing static/dist)"
606 );
607 return;
608 }
609 }
610 }
611 match Command::new("npm")
612 .args(["run", "build"])
613 .current_dir("frontend")
614 .status()
615 {
616 Ok(s) if s.success() => {}
617 Ok(s) => println!(
618 "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist",
619 s.code()
620 ),
621 Err(e) => println!(
622 "cargo::warning=could not run npm for the frontend build ({e}); is Node installed? \
623 skipping (serving existing static/dist)"
624 ),
625 }
626 }
627
628 /// Recursively hash every `.js` file under `dir` into `hasher`, in a
629 /// deterministic order. Missing directory is a no-op (first build before the
630 /// frontend has been compiled).
631 fn hash_dir_js(dir: &Path, hasher: &mut DefaultHasher) {
632 let Ok(entries) = fs::read_dir(dir) else {
633 return;
634 };
635 let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
636 paths.sort();
637 for path in paths {
638 if path.is_dir() {
639 hash_dir_js(&path, hasher);
640 } else if path.extension().and_then(|e| e.to_str()) == Some("js")
641 && let Ok(content) = fs::read(&path)
642 {
643 content.hash(hasher);
644 }
645 }
646 }
647
648 /// Class-and-property overlaps with the generated stylesheet that have been
649 /// read and kept.
650 ///
651 /// Two kinds, and only the first is what the check is really for.
652 ///
653 /// **Different selector arms.** `.badge { color }` and `.card { color }`:
654 /// makeover colours the tone and disabled arms, the server colours the base and
655 /// its own variants (`.badge.ai-tier-*`, `.badge--founder-*`). Neither touches
656 /// the other's arm. The check collapses arms, because separating them would
657 /// need a selector matcher and a checker that guesses wrong about specificity
658 /// fails correct builds, so the judgement is recorded here.
659 ///
660 /// **A deliberate pairing.** `.progress-fill { background }` is a different
661 /// element rather than a different arm: the media scrubber's fill, which lives
662 /// inside `.progress-bar` and never matches the generated
663 /// `.progress > .progress-fill`. It already carried a `respec-ok` comment
664 /// saying so.
665 ///
666 /// `.tab { box-shadow }`, `{ color }` and `{ cursor }` are this file taking the
667 /// property outright: it zeroes the base `button` shadow on a tab and dresses
668 /// the tab's own text, and a described tab gets that rather than makeover's.
669 /// The chosen arm is the opposite: it is a `revert-layer`, which is not read as
670 /// a taking and needs no entry.
671 ///
672 /// **A `revert-layer` needs no entry.** The check reads the value, not only the
673 /// property name, so a later layer handing a property back is not read as a
674 /// later layer taking it, and the handoffs at the end of `style.css` speak for
675 /// themselves. Keep them out of this list: an entry permits a real override on
676 /// the same pair for good, which turns a remedy into a licence.
677 ///
678 /// The element rules those handoffs defer past are the other half of the same
679 /// release: `REVIEWED_ELEMENT_OVERLAPS` below, and the section at the end of
680 /// `style.css` that keeps it empty.
681 ///
682 /// Still overlapping and still this file's: `.badge` and `.card` on their own
683 /// arms, and the disabled treatment's `opacity`, which is a charter decision
684 /// and a property makeover does not set.
685 ///
686 /// **A divergence taken on purpose.** `.table-row { display }` is `grid` here
687 /// against makeover's `table-row`, which is the same build-time grid story
688 /// goingson is on. `.table-head { display }` is the same divergence on the
689 /// heading row: the feed's five column tracks have to be stated on both arms
690 /// or the headings stop lining up with the cells under them.
691 ///
692 /// Do not restate a state makeover already names: tabs carry `chosen`, in the
693 /// templates and in `frontend/src/core/tabs.ts`, rather than a local
694 /// `.tab.is-selected` setting the same `--surface-raised` and `--bevel-raised`.
695 /// The caret is the renderer's too: it emits the leading space and the reserved
696 /// box itself.
697 ///
698 /// An entry that stops colliding fails the build, so this list cannot outlive
699 /// what it describes.
700 const REVIEWED_OVERLAPS: &[(&str, &str)] = &[
701 ("badge", "color"),
702 // A carousel showing one frame at a time, 2026-08-14. This was
703 // `("picture-img", "display")` until makeover-layout 0.23.0 described the
704 // showing itself; the frames are wrapped in `.showing-frame` now and the old
705 // entry stopped colliding, which this list is built to notice.
706 //
707 // The two arms are the same rule at two moments, and that is the whole of
708 // why the overlap is kept. Makeover collapses the stack on `[data-ready]`,
709 // which is the only honest default for a renderer that cannot know whether
710 // a page has script: ship every child, take them away once something binds
711 // them. This site collapses it from first paint instead, because rendering
712 // three frames and collapsing them was a measured 141px -> 58px jump that
713 // every visitor with JS paid, and `no-js.css` from a <noscript> opens the
714 // stack for the few without. That trade is this landing page's to make and
715 // a generated stylesheet has no way to reach <noscript>.
716 ("showing-frame", "display"),
717 ("current", "display"),
718 ("card", "color"),
719 ("progress-fill", "background"),
720 ("tab", "box-shadow"),
721 ("tab", "color"),
722 ("tab", "cursor"),
723 ("table-row", "display"),
724 ("table-head", "display"),
725 ];
726
727 /// Bare element rules that reach a generated class and have been read and kept.
728 ///
729 /// The second pass of the same check, and the one this file had no answer to
730 /// until makeover-build 0.50.0: a rule with no class in it is invisible to the
731 /// list above, and `button { color: var(--content) }` in @layer components beat
732 /// the generated `.button[data-tone]` on every described act on the site. A
733 /// destructive act rendered identically to an ordinary one for months.
734 ///
735 /// Empty, and meant to stay that way. The remedy for every one of them is a
736 /// `revert-layer` handoff in style.css, which says which arms makeover keeps in
737 /// the file the browser reads rather than in a build script.
738 const REVIEWED_ELEMENT_OVERLAPS: &[(&str, &str, &str)] = &[];
739
740 /// The short commit sha to stamp into `GIT_HASH`, and the watches that decide
741 /// when this build script has to run again.
742 ///
743 /// The watches are the whole point. Cargo treats a `rerun-if-changed` path that
744 /// does not exist as *changed*, so a watch on a path that can never exist makes
745 /// this script re-run on every single cargo invocation, and re-running it
746 /// recompiles the crate. This file used to watch `.git/HEAD`, which resolves
747 /// against the package root: there is no `.git` in `server/` or in
748 /// `multithreaded/` because the repository root is `MNW/`. So the watch never
749 /// resolved, and the crate recompiled every time.
750 ///
751 /// It cost 347s per Sando pipeline (294s in the server, 53s in multithreaded,
752 /// measured off `/srv/sando/logs/0.11.20/cargo_test.log`), which is 35% of the
753 /// `cargo_test` gate, and it cost the same on every local `cargo build`,
754 /// `cargo test` and `cargo clippy`. The two crates carrying a `build.rs` were
755 /// the only two in the pipeline that recompiled on a second cargo invocation;
756 /// the ones without one were already free.
757 ///
758 /// Two rules follow, and both matter:
759 /// - resolve the paths through git rather than guessing them, and
760 /// - emit a watch ONLY for a path that exists, or the bug comes straight back.
761 fn git_hash() -> String {
762 // An explicit hash wins and skips git entirely, for a build system that
763 // already knows the sha. Nothing sets this today: Sando would have to set it
764 // on EVERY cargo invocation it makes, because `GIT_HASH` is a `rustc-env`
765 // and therefore part of the crate fingerprint, so a value present for the
766 // release build and absent for `cargo_test` would force the recompile this
767 // function exists to remove.
768 println!("cargo::rerun-if-env-changed=MNW_GIT_HASH");
769 if let Ok(h) = std::env::var("MNW_GIT_HASH") {
770 let h = h.trim().to_string();
771 if !h.is_empty() {
772 return h;
773 }
774 }
775
776 // Watch what git actually rewrites when the checkout moves. `.git/HEAD`
777 // alone is not enough even when resolved: committing on a branch rewrites
778 // that branch's ref, not HEAD, so a watch on HEAD by itself would go stale
779 // in the ordinary case of a commit.
780 watch_if_exists(git_path("HEAD").as_deref());
781 if let Some(r) = git_output(&["symbolic-ref", "-q", "HEAD"]) {
782 watch_if_exists(git_path(&r).as_deref());
783 }
784 // A ref that has been packed has no loose file, so this is the fallback
785 // that keeps the watch honest after a `git gc`.
786 watch_if_exists(git_path("packed-refs").as_deref());
787
788 git_output(&["rev-parse", "--short", "HEAD"]).unwrap_or_default()
789 }
790
791 /// Run a git command in the package directory and return its trimmed stdout.
792 fn git_output(args: &[&str]) -> Option<String> {
793 Command::new("git")
794 .args(args)
795 .output()
796 .ok()
797 .filter(|o| o.status.success())
798 .and_then(|o| String::from_utf8(o.stdout).ok())
799 .map(|s| s.trim().to_string())
800 .filter(|s| !s.is_empty())
801 }
802
803 /// Resolve a name inside the git directory to a path, honouring worktrees and a
804 /// `.git` file that points elsewhere. `None` when this is not a checkout at all,
805 /// which is the case in a vendored or packaged build.
806 fn git_path(name: &str) -> Option<String> {
807 git_output(&["rev-parse", "--git-path", name])
808 }
809
810 /// Emit a watch for a path, but only when it exists.
811 ///
812 /// The guard is the fix. A missing path reads as changed to cargo, so emitting
813 /// one unconditionally is what caused the recompile-every-time bug.
814 fn watch_if_exists(path: Option<&str>) {
815 if let Some(p) = path
816 && Path::new(p).exists()
817 {
818 println!("cargo::rerun-if-changed={p}");
819 }
820 }
821