max / makenotwork
| 1 | use DefaultHasher; |
| 2 | use Write as _; |
| 3 | use ; |
| 4 | use Command; |
| 5 | use ; |
| 6 | |
| 7 | |
| 8 | // Set GIT_HASH env var for compile-time inclusion via option_env!() |
| 9 | println!; |
| 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 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; |
| 52 | |
| 53 | |
| 54 | geometry_css; |
| 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 | timing_css; |
| 59 | layout_css; |
| 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 | write |
| 68 | "static/embed-geometry.css", |
| 69 | geometry_css_vars, |
| 70 | |
| 71 | .expect; |
| 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 | typography_css_from |
| 89 | "static/typography.css", |
| 90 | &house.with_override |
| 91 | new |
| 92 | Display, |
| 93 | "\"Young Serif\", serif", |
| 94 | |
| 95 | .with_face |
| 96 | "Young Serif", |
| 97 | , |
| 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 | typography_css_from |
| 111 | "static/embed-typography.css", |
| 112 | &house, |
| 113 | ; |
| 114 | |
| 115 | cut_house_faces; |
| 116 | |
| 117 | println!; |
| 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 = Stringfrom |
| 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 in &shots |
| 128 | let _ = writeln!; |
| 129 | |
| 130 | table.push_str; |
| 131 | write |
| 132 | new.join, |
| 133 | table, |
| 134 | |
| 135 | .expect; |
| 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 | check_breakpoints_files; |
| 143 | check_vocabulary_files |
| 144 | &HAND_WRITTEN_CSS, |
| 145 | &default, |
| 146 | REVIEWED_OVERLAPS, |
| 147 | REVIEWED_ELEMENT_OVERLAPS, |
| 148 | ; |
| 149 | check_vocabulary_use |
| 150 | &markup_files, |
| 151 | &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 = 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 | new.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!; |
| 194 | if let Ok = read |
| 195 | content.hash; |
| 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; |
| 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 = read |
| 221 | content.hash; |
| 222 | |
| 223 | |
| 224 | let static_hash = format!; |
| 225 | let version = &static_hash; |
| 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!; |
| 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; |
| 242 | write_if_changed; |
| 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; |
| 252 | write_if_changed; |
| 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 | |
| 269 | let cache = new.join; |
| 270 | cut_web |
| 271 | new, |
| 272 | &cache, |
| 273 | false, |
| 274 | & |
| 275 | , |
| 276 | , |
| 277 | ], |
| 278 | |
| 279 | .expect; |
| 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 | |
| 298 | let dir = new; |
| 299 | println!; |
| 300 | let mut out = Vecnew; |
| 301 | let Ok = read_dir else |
| 302 | return out; |
| 303 | ; |
| 304 | for entry in entries.flatten |
| 305 | let path = entry.path; |
| 306 | if path.extension.and_then != Some |
| 307 | continue; |
| 308 | |
| 309 | let Ok = read else ; |
| 310 | let Some = webp_dimensions else |
| 311 | continue; |
| 312 | ; |
| 313 | if let Some = path.file_name.and_then |
| 314 | out.push; |
| 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 | |
| 326 | if b.len < 30 || &b != b"RIFF" || &b != b"WEBP" |
| 327 | return None; |
| 328 | |
| 329 | match &b |
| 330 | // Lossy. Three-byte frame tag, then the 3-byte sync code, then two |
| 331 | // 14-bit dimensions. |
| 332 | b"VP8 " => |
| 333 | let w = u16from_le_bytes & 0x3fff; |
| 334 | let h = u16from_le_bytes & 0x3fff; |
| 335 | Some |
| 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 = u32from_le_bytes; |
| 341 | Some |
| 342 | |
| 343 | // Extended. Canvas size as two 24-bit little-endian minus-ones. |
| 344 | b"VP8X" => |
| 345 | let w = u32from_le_bytes + 1; |
| 346 | let h = u32from_le_bytes + 1; |
| 347 | Some |
| 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: = |
| 417 | , |
| 418 | , |
| 419 | , |
| 420 | , |
| 421 | , |
| 422 | , |
| 423 | , |
| 424 | , |
| 425 | , |
| 426 | , |
| 427 | , |
| 428 | , |
| 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 | |
| 442 | let mut files = Vecnew; |
| 443 | for in |
| 444 | , |
| 445 | , |
| 446 | , |
| 447 | , |
| 448 | ] |
| 449 | let mut found = Vecnew; |
| 450 | collect; |
| 451 | found.sort; |
| 452 | files.extend; |
| 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 |
| 461 | !path |
| 462 | .file_name |
| 463 | .and_then |
| 464 | .is_some_and |
| 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 = read_to_string.unwrap_or_default; |
| 500 | for name in ignored |
| 501 | .lines |
| 502 | .map |
| 503 | .filter_map |
| 504 | .filter |
| 505 | new |
| 506 | .extension |
| 507 | .is_some_and |
| 508 | |
| 509 | |
| 510 | assert! |
| 511 | GENERATED_STATIC_JS.iter.any, |
| 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 | |
| 524 | let Ok = read_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 |
| 533 | continue; |
| 534 | |
| 535 | collect; |
| 536 | else if path.extension.is_some_and |
| 537 | out.push; |
| 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: = |
| 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 | |
| 559 | let needs_write = read_to_string.map_or; |
| 560 | if needs_write |
| 561 | write |
| 562 | .unwrap_or_else; |
| 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 | |
| 576 | // Re-run the whole build script when the TS sources or its config change. |
| 577 | println!; |
| 578 | println!; |
| 579 | println!; |
| 580 | println!; |
| 581 | |
| 582 | if var_os.is_some |
| 583 | println!; |
| 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 !new.is_dir |
| 590 | match new |
| 591 | .args |
| 592 | .current_dir |
| 593 | .status |
| 594 | |
| 595 | Ok if s.success => |
| 596 | Ok => |
| 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 => |
| 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 new |
| 612 | .args |
| 613 | .current_dir |
| 614 | .status |
| 615 | |
| 616 | Ok if s.success => |
| 617 | Ok => println! |
| 618 | "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist" |
| 619 | s.code |
| 620 | ), |
| 621 | Err => 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 | |
| 632 | let Ok = read_dir else |
| 633 | return; |
| 634 | ; |
| 635 | let mut paths: = entries.flatten.map.collect; |
| 636 | paths.sort; |
| 637 | for path in paths |
| 638 | if path.is_dir |
| 639 | hash_dir_js; |
| 640 | else if path.extension.and_then == Some |
| 641 | && let Ok = read |
| 642 | |
| 643 | content.hash; |
| 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: & = & |
| 701 | , |
| 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 | , |
| 717 | , |
| 718 | , |
| 719 | , |
| 720 | , |
| 721 | , |
| 722 | , |
| 723 | , |
| 724 | , |
| 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: & = &; |
| 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 | |
| 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!; |
| 769 | if let Ok = var |
| 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; |
| 781 | if let Some = git_output |
| 782 | watch_if_exists; |
| 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; |
| 787 | |
| 788 | git_output.unwrap_or_default |
| 789 | |
| 790 | |
| 791 | /// Run a git command in the package directory and return its trimmed stdout. |
| 792 | |
| 793 | new |
| 794 | .args |
| 795 | .output |
| 796 | .ok |
| 797 | .filter |
| 798 | .and_then |
| 799 | .map |
| 800 | .filter |
| 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 | |
| 807 | git_output |
| 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 | |
| 815 | if let Some = path |
| 816 | && new.exists |
| 817 | |
| 818 | println!; |
| 819 | |
| 820 | |
| 821 |