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. That was measured at a 478px jump per frame on the landing |
| 287 | /// page and 0.087 CLS for the document (2026-08-14). |
| 288 | /// |
| 289 | /// Read here rather than written down, because these files are regenerated by |
| 290 | /// `scripts/capture-landing-carousel.mjs` and a hand-maintained number would be |
| 291 | /// wrong the first time anyone re-shot them -- silently, since a wrong reserve |
| 292 | /// looks like a right one until the image lands. |
| 293 | /// |
| 294 | /// WebP only, which is what the capture script emits. A file this cannot parse |
| 295 | /// is skipped rather than guessed at: `None` reserves nothing, which is the |
| 296 | /// behaviour before this existed, while a wrong number reserves the wrong room. |
| 297 | |
| 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 | /// # 18 to 20, 2026-08-14: `picture-img` and `picture-caption` |
| 373 | /// |
| 374 | /// The one direction this number is allowed to move is down, so a rise wants an |
| 375 | /// argument rather than a nudge. These two are not dead vocabulary: they are |
| 376 | /// emitted by `quasi-webview` at request time, from a description, and this |
| 377 | /// scanner reads *this repo's* files. Markup written by a dependency is |
| 378 | /// invisible to it by construction. |
| 379 | /// |
| 380 | /// That category already existed and was already counted here. `figure-value`, |
| 381 | /// `figure-caption` and `figure-change` sit in the same set for the same |
| 382 | /// reason, and the nine `cell-*` classes beside them are the described table. |
| 383 | /// The carousel port (`c0b63ea9`) is the first widget to add to it. |
| 384 | /// |
| 385 | /// So the seal still measures what it was built to measure -- CSS generated for |
| 386 | /// markup nobody writes -- and the honest ratchet is downward as the |
| 387 | /// description layer takes over more of the site, at which point these classes |
| 388 | /// stop being reachable from templates *and* stay used. If this number ever |
| 389 | /// needs raising for a class MNW's own markup should have been writing, that is |
| 390 | /// the defect this exists to catch and the answer is the markup, not the seal. |
| 391 | /// |
| 392 | /// # 20 to 23, 2026-08-15: `track-entry`, `track-slot`, `track-tick` |
| 393 | /// |
| 394 | /// The same category again, and the third entry in it. The track vocabulary |
| 395 | /// arrived in quasi 0.6.0 (`describe a time axis`) and 0.8.0 (`label a track by |
| 396 | /// its unit`); this server had been pinned to quasi 0.5 and could not resolve at |
| 397 | /// all, so the classes had never been weighed here. Forward-fixing the pin is |
| 398 | /// what surfaced them. |
| 399 | /// |
| 400 | /// Emitted by `quasi-webview` from a description, like the eighteen above them, |
| 401 | /// and invisible to a scanner reading this repo's files for the same reason. No |
| 402 | /// template should be writing them. |
| 403 | /// |
| 404 | /// # 23 to 24, 2026-08-17: `row-relaxed` |
| 405 | /// |
| 406 | /// The same category a fourth time. `makeover-layout` 0.28.1 named `Flow` and |
| 407 | /// `makeover-webview` 0.48.0 emits the clamp, so a described row can say a part |
| 408 | /// may take two lines. `quasi-webview` writes the class when a description asks |
| 409 | /// for it, so no template here should be writing it and a scanner over this |
| 410 | /// repo cannot see it either way. |
| 411 | /// |
| 412 | /// # 24 to 28, 2026-08-18: the four `run-*` classes |
| 413 | /// |
| 414 | /// The same category a fifth time. `makeover-layout` 0.29.0 named `Fallback`, |
| 415 | /// `makeover-webview` 0.49.0 emits a rule per member, and quasi 0.28.0 lets a |
| 416 | /// region say its leading row is shared -- goingson's tab strip and the toolbar |
| 417 | /// beside it, which is the case the ruling was made on. |
| 418 | /// |
| 419 | /// `quasi-webview` writes the wrapper and the fallback class from a description, |
| 420 | /// so no template here should be writing either and a scanner over this repo |
| 421 | /// cannot see them. |
| 422 | /// |
| 423 | /// The denominator went 55 to 60 and only four of the five land here. `.run` |
| 424 | /// reads as used, and it is not: the scan is a word match over markup files and |
| 425 | /// "Re-run pipeline" in `admin_upload_entries.html` is enough to satisfy it. |
| 426 | /// Worth knowing rather than fixing -- this count is documented loose in the |
| 427 | /// safe direction already, and a scanner that could tell a class from a word |
| 428 | /// would need to parse every template. |
| 429 | /// |
| 430 | /// # 28 to 33, 2026-08-18: five of the seven `facet-*` classes |
| 431 | /// |
| 432 | /// The same category a sixth time, and the first rise whose subject this repo |
| 433 | /// is the reason for. `makeover-layout` 0.30.0 named `Facet` and |
| 434 | /// `makeover-webview` 0.50.0 draws one, both measured against `discover`: it |
| 435 | /// filters six ways through six mechanisms, and every filter row in |
| 436 | /// `discover_sidebar.html` carries a tick box *and* a chevron only because a |
| 437 | /// tag's selection and its browse position are separate state. |
| 438 | /// |
| 439 | /// So these are not classes a feed reader has no call for, the way `.cell-*` |
| 440 | /// is. They are the classes the discover sidebar *will* write, and it does not |
| 441 | /// write them yet because it has not been described yet. That port is its own |
| 442 | /// work and the seal tightens with it. |
| 443 | /// |
| 444 | /// The denominator went 60 to 67 and only five of the seven land here. `.facet` |
| 445 | /// and `.facet-count` read as used and are not: the scan is a word match, and |
| 446 | /// `data-facet="tag"` in the sidebar plus the word "facet" in its own comment |
| 447 | /// are enough to satisfy them. The `.run` case above, again, and documented |
| 448 | /// rather than fixed for the same reason. |
| 449 | /// |
| 450 | /// It tightens when the discover sidebar ports, not before. |
| 451 | /// |
| 452 | /// 33 to 32 on 2026-08-18: the library tab strip is described (`6b24f2df`), and |
| 453 | /// the run it declares its overflow on is the first `.run-menu` this server |
| 454 | /// emits. The `.run` case documented above stopped being hypothetical, which is |
| 455 | /// the ratchet working rather than an exception to it. |
| 456 | /// 32 to 34 on the makeover-build 0.46 to 0.47 bump, which carries |
| 457 | /// makeover-webview 0.52.0 and the chrome a markdown field gets: |
| 458 | /// `.form-editor-modes` and `.form-editor-preview` around a |
| 459 | /// `FieldKind::Rich` control. The denominator went 67 to 69 and both land here. |
| 460 | /// |
| 461 | /// The facet case above, exactly: these are classes this repo is the reason |
| 462 | /// for and does not write yet. `partial-item-text-editor.html` has the |
| 463 | /// Write/Preview pair hand-written and `partial-item-text-editor.js` renders |
| 464 | /// the preview, and the renderer emitting the same shape is what those four |
| 465 | /// section editors convert onto. It tightens when they port, not before. |
| 466 | /// 34 to 37 on the quasi 0.45.0 / makeover-webview 0.53.0 bump: a field's |
| 467 | /// suggestion list. `Field::suggests` (`71852b16`) gave a field the list of |
| 468 | /// candidates it owns, and the three classes it is drawn with are |
| 469 | /// `.form-suggestions`, `.form-suggestion` and `.form-suggestion-why`. The |
| 470 | /// denominator went 69 to 72 and all three land here. |
| 471 | /// |
| 472 | /// The facet case a third time, and this repo is the reason for these too: |
| 473 | /// discover's search box and its tag typeahead are the two measured sites the |
| 474 | /// member was designed against, and both are still bare inputs driven from |
| 475 | /// `page-discover.js`. It tightens when they port, which is B7, not before. |
| 476 | /// 37 to 36, 2026-08-21: the five embeds are described (`54d7f8cf`), and their |
| 477 | /// markup is the renderer's now rather than five hand-written `<style>` blocks. |
| 478 | /// One class the templates had no word for is written by the row they became. |
| 479 | /// Measured rather than predicted; the check asked for it on the build that |
| 480 | /// converted them. |
| 481 | /// 36 to 34, 2026-08-21: the tag typeahead ported (N8, `1503db12`), which is |
| 482 | /// the tightening the suggestion-list paragraph above said to wait for. Two of |
| 483 | /// its three classes are written now — `.form-suggestions` by the list the |
| 484 | /// field owns and `.form-suggestion` by each candidate. `.form-suggestion-why` |
| 485 | /// is not among them: it came out of makeover-webview 0.57.0 when a candidate |
| 486 | /// grew a second line (`1fcf2e9b`), so it is gone rather than dead. The search |
| 487 | /// box is still hand-written and is the site that would tighten this again. |
| 488 | /// 34 to 44, 2026-08-22: makeover-webview 0.59.0 wrote down the unruled half |
| 489 | /// of its own vocabulary, so the denominator went 73 to 88 and every one of |
| 490 | /// the fifteen new names arrives dead here by construction. They are classes |
| 491 | /// that crate's emitters write -- a cell's width and drop, a field's group, |
| 492 | /// label, hint and error -- and this server spells none of them itself, |
| 493 | /// because the markup that carries them is Rust in a dependency rather than a |
| 494 | /// template in this repo. Nothing about this server changed; what changed is |
| 495 | /// that the check can now see the half of the vocabulary it was blind to. |
| 496 | /// It tightens as screens convert, exactly as the lines above did. |
| 497 | /// 44 to 45, 2026-08-26, and this one LOOSENS -- the first line here that does. |
| 498 | /// `64b33b26` deleted the seven Askama renderings the description replaced, and |
| 499 | /// `.form-label` lost its last two spellings with them (both in |
| 500 | /// `partials/tabs/user_ssh_keys_tab.html`). The class is not gone and is not |
| 501 | /// unused: makeover-webview's field emitter still writes it, on the described |
| 502 | /// screen that replaced that template. It is dead only in the sense this check |
| 503 | /// can measure, which is "no markup in this repo spells it", and the markup |
| 504 | /// that spells it is now Rust in a dependency. |
| 505 | /// |
| 506 | /// 45 to 46, 2026-08-27: makeover-layout 0.36.0 added `Field::note` and |
| 507 | /// makeover-webview 0.62.0 draws it, so `.form-note` arrives dead here by |
| 508 | /// construction -- the same way `.form-hint` and `.form-error` did on the |
| 509 | /// 0.59.0 bump above. It is markup in a dependency, and this server spells no |
| 510 | /// field messages of its own. It stops being dead the moment a described |
| 511 | /// screen here carries a note. |
| 512 | /// |
| 513 | /// So a rising number here is not automatically a regression. Read it against |
| 514 | /// what moved: markup deleted in favour of a description will raise it, and a |
| 515 | /// class that genuinely lost its writer will too. The list the failure prints |
| 516 | /// is what tells them apart -- if a name on it is one this server still means |
| 517 | /// to render, that is the bug. |
| 518 | const DEAD_VOCABULARY_HIGH_WATER: usize = 46; |
| 519 | |
| 520 | /// The scripts this build script generates into `static/`. |
| 521 | /// |
| 522 | /// One list, two consumers, and that is the whole point of it being a const. |
| 523 | /// The write loop iterates it, and [`markup_files`] excludes it. Those two have |
| 524 | /// to agree or the build recompiles on every cargo invocation forever. |
| 525 | /// |
| 526 | /// # Why a generated file may never reach `markup_files` |
| 527 | /// |
| 528 | /// `markup_files` globs `static/*.js`, and `makeover_build::check_vocabulary_use` |
| 529 | /// emits a `cargo::rerun-if-changed` for every file it reads. So a generated file |
| 530 | /// under `static/` is a file this script both **writes and watches**: the write |
| 531 | /// moves its mtime, cargo then sees a watched file newer than the build script's |
| 532 | /// own output, and the fingerprint can never settle. Nothing fails, nothing warns; |
| 533 | /// the crate just recompiles every time, which cost a second full 5m25s compile |
| 534 | /// inside every Sando `cargo_test` gate for five days. |
| 535 | /// |
| 536 | /// That has now happened twice through two different doors — `bd448cbe` left a |
| 537 | /// deleted file in the watched list, and `a52ec579` added generated ones to a |
| 538 | /// globbed directory the day after the first was fixed. `write_if_changed` fixes |
| 539 | /// an instance. This list plus the assertion in `markup_files` closes the door. |
| 540 | /// |
| 541 | /// **Adding a generated file under `static/`? Add it here too.** |
| 542 | /// |
| 543 | /// **Three were missing until 2026-08-26**, and the failure was exactly the one |
| 544 | /// goingson's copy of this list warns about: the shell links every script the |
| 545 | /// renderer ships unless the host says otherwise, and this host says otherwise |
| 546 | /// about none of them. `quasi-reveal.js` and `quasi-repeat.js` had been linked |
| 547 | /// and unserved -- a 404 per document, with nothing in the log -- and |
| 548 | /// `quasi-awaiting.js` would have joined them the moment it shipped. Found |
| 549 | /// adding the third, which is the only reason the other two were. |
| 550 | const GENERATED_STATIC_JS: = |
| 551 | , |
| 552 | , |
| 553 | , |
| 554 | , |
| 555 | , |
| 556 | , |
| 557 | , |
| 558 | , |
| 559 | ]; |
| 560 | |
| 561 | /// Every file that can carry a class name. |
| 562 | /// |
| 563 | /// Sorted within each group, so two machines read the same set in the same |
| 564 | /// order. It makes no difference to the count and every difference to reading a |
| 565 | /// diff of the warning. |
| 566 | /// |
| 567 | /// Generated files are excluded: see [`GENERATED_STATIC_JS`] for why watching one |
| 568 | /// is a build that never settles. These four are also quasi-webview's emitted |
| 569 | /// output rather than markup this repo writes, so measuring them against this |
| 570 | /// server's own vocabulary seal was answering a question about another crate. |
| 571 | |
| 572 | let mut files = Vecnew; |
| 573 | for in |
| 574 | , |
| 575 | , |
| 576 | , |
| 577 | , |
| 578 | ] |
| 579 | let mut found = Vecnew; |
| 580 | collect; |
| 581 | found.sort; |
| 582 | files.extend; |
| 583 | |
| 584 | |
| 585 | // The door, closed. Excluding by file name rather than by full path because |
| 586 | // `collect` walks recursively and the generated ones sit at the root of |
| 587 | // `static/`; a name collision deeper in the tree would be a second file |
| 588 | // called `quasi-clock.js`, which is its own problem. |
| 589 | let before = files.len; |
| 590 | files.retain |
| 591 | !path |
| 592 | .file_name |
| 593 | .and_then |
| 594 | .is_some_and |
| 595 | ; |
| 596 | |
| 597 | // Catches a listed script that `markup_files` cannot see: an entry given a |
| 598 | // name `collect` does not glob, such as a `.mjs` or one written somewhere |
| 599 | // other than `static/`. It would be excluded from nothing while still being |
| 600 | // written and watched. |
| 601 | // |
| 602 | // It deliberately does NOT catch a deleted or renamed file, and the comment |
| 603 | // here used to claim it did. It cannot: the write loop iterates this same |
| 604 | // const and regenerates every entry before this function runs, so a missing |
| 605 | // one is recreated rather than absent. Verified by deleting |
| 606 | // `static/quasi-fill.js` and watching the build stay green. The `.gitignore` |
| 607 | // cross-check below is the half that does the real work. |
| 608 | let excluded = before - files.len; |
| 609 | assert_eq! |
| 610 | excluded, |
| 611 | GENERATED_STATIC_JS.len, |
| 612 | "markup_files excluded {excluded} generated scripts but GENERATED_STATIC_JS \ |
| 613 | names {}. A generated file under static/ that reaches markup_files is one \ |
| 614 | this build script both writes and watches, which recompiles the crate on \ |
| 615 | every cargo invocation. Update GENERATED_STATIC_JS to match what is written.", |
| 616 | GENERATED_STATIC_JS.len, |
| 617 | ; |
| 618 | |
| 619 | // The other half of the guard, and the one that catches the likelier |
| 620 | // mistake. The assertion above catches a listed script that stopped |
| 621 | // existing; this catches a NEW generated script nobody added to the list. |
| 622 | // |
| 623 | // `.gitignore` is the cross-check because it is the other place a generated |
| 624 | // file has to be named: it is not committed, so it either gets an entry or |
| 625 | // it shows up as untracked in every `git status` until someone adds one. |
| 626 | // Read rather than asked of `git`, so this costs no subprocess and still |
| 627 | // works where there is no git at all -- and where the file is unreadable it |
| 628 | // fails open, which is the right direction for a guard rather than a gate. |
| 629 | let ignored = read_to_string.unwrap_or_default; |
| 630 | for name in ignored |
| 631 | .lines |
| 632 | .map |
| 633 | .filter_map |
| 634 | .filter |
| 635 | new |
| 636 | .extension |
| 637 | .is_some_and |
| 638 | |
| 639 | |
| 640 | assert! |
| 641 | GENERATED_STATIC_JS.iter.any, |
| 642 | "`.gitignore` names generated script static/{name}, which \ |
| 643 | GENERATED_STATIC_JS does not. markup_files therefore watches a file \ |
| 644 | this build script writes, and the crate will recompile on every cargo \ |
| 645 | invocation with no other symptom. Add it to GENERATED_STATIC_JS.", |
| 646 | ; |
| 647 | |
| 648 | |
| 649 | files |
| 650 | |
| 651 | |
| 652 | /// Every file under `dir` with this extension, recursively. |
| 653 | |
| 654 | let Ok = read_dir else |
| 655 | return; |
| 656 | ; |
| 657 | for entry in entries.flatten |
| 658 | let path = entry.path; |
| 659 | if path.is_dir |
| 660 | // The bundler's output, which is `frontend/src` compiled. Reading |
| 661 | // both would count the same line twice. |
| 662 | if path.file_name.is_some_and |
| 663 | continue; |
| 664 | |
| 665 | collect; |
| 666 | else if path.extension.is_some_and |
| 667 | out.push; |
| 668 | |
| 669 | |
| 670 | |
| 671 | |
| 672 | /// The hand-written stylesheets. Ordered, so the guard reports the same way |
| 673 | /// twice. `geometry.css` and `layout.css` are excluded: they are generated. |
| 674 | /// |
| 675 | /// `no-js.css` joined the list on 2026-08-15. It had been missed since it was |
| 676 | /// written: it is hand-authored CSS served to real visitors, so the breakpoint |
| 677 | /// and vocabulary guards apply to it exactly as they do to the other three, and |
| 678 | /// a sheet outside the list is a sheet that can diverge without the build |
| 679 | /// noticing. It is also what `tests/frontend_payload.rs` weighs, and a seal |
| 680 | /// that skipped a sheet would let bytes move between sheets and read as a |
| 681 | /// deletion. |
| 682 | const HAND_WRITTEN_CSS: = |
| 683 | "static/style.css", |
| 684 | "static/wizard.css", |
| 685 | "static/media-player.css", |
| 686 | "static/no-js.css", |
| 687 | ]; |
| 688 | |
| 689 | /// Write `contents` to `path` only if it differs, to avoid needless rebuilds. |
| 690 | |
| 691 | let needs_write = read_to_string.map_or; |
| 692 | if needs_write |
| 693 | write |
| 694 | .unwrap_or_else; |
| 695 | |
| 696 | |
| 697 | |
| 698 | /// Compile the TypeScript frontend (`frontend/`) to browser ESM in |
| 699 | /// `static/dist/` via `npm run build` (which runs `tsc`). |
| 700 | /// |
| 701 | /// Self-contained: on a fresh checkout or a new build host (no `node_modules`) |
| 702 | /// it runs `npm ci` first, so there is no manual install gate before a deploy. |
| 703 | /// Best-effort and non-fatal otherwise, an absent Node or a compile error only |
| 704 | /// emits a `cargo::warning` and leaves the Rust build to succeed against |
| 705 | /// whatever `static/dist/` already holds. Set `MNW_SKIP_FRONTEND_BUILD=1` to |
| 706 | /// opt out entirely (e.g. a Node-less CI that doesn't need the JS). |
| 707 | |
| 708 | // Re-run the whole build script when the TS sources or its config change. |
| 709 | println!; |
| 710 | println!; |
| 711 | println!; |
| 712 | println!; |
| 713 | |
| 714 | if var_os.is_some |
| 715 | println!; |
| 716 | return; |
| 717 | |
| 718 | // Fresh checkout / new build host: install deps once (clean, from the |
| 719 | // lockfile) so the frontend build needs no manual `npm install` gate before |
| 720 | // a deploy. Skipped once node_modules exists; needs network on this run. |
| 721 | if !new.is_dir |
| 722 | match new |
| 723 | .args |
| 724 | .current_dir |
| 725 | .status |
| 726 | |
| 727 | Ok if s.success => |
| 728 | Ok => |
| 729 | println! |
| 730 | "cargo::warning=npm ci failed (exit {:?}); skipping frontend build (serving existing static/dist)" |
| 731 | s.code |
| 732 | ); |
| 733 | return; |
| 734 | |
| 735 | Err => |
| 736 | println! |
| 737 | "cargo::warning=could not run npm ({e}); is Node installed? skipping frontend build (serving existing static/dist)" |
| 738 | ; |
| 739 | return; |
| 740 | |
| 741 | |
| 742 | |
| 743 | match new |
| 744 | .args |
| 745 | .current_dir |
| 746 | .status |
| 747 | |
| 748 | Ok if s.success => |
| 749 | Ok => println! |
| 750 | "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist" |
| 751 | s.code |
| 752 | ), |
| 753 | Err => println! |
| 754 | "cargo::warning=could not run npm for the frontend build ({e}); is Node installed? \ |
| 755 | skipping (serving existing static/dist)" |
| 756 | , |
| 757 | |
| 758 | |
| 759 | |
| 760 | /// Recursively hash every `.js` file under `dir` into `hasher`, in a |
| 761 | /// deterministic order. Missing directory is a no-op (first build before the |
| 762 | /// frontend has been compiled). |
| 763 | |
| 764 | let Ok = read_dir else |
| 765 | return; |
| 766 | ; |
| 767 | let mut paths: = entries.flatten.map.collect; |
| 768 | paths.sort; |
| 769 | for path in paths |
| 770 | if path.is_dir |
| 771 | hash_dir_js; |
| 772 | else if path.extension.and_then == Some |
| 773 | && let Ok = read |
| 774 | |
| 775 | content.hash; |
| 776 | |
| 777 | |
| 778 | |
| 779 | |
| 780 | /// Class-and-property overlaps with the generated stylesheet that have been |
| 781 | /// read and kept. |
| 782 | /// |
| 783 | /// Two kinds, and only the first is what the check is really for. |
| 784 | /// |
| 785 | /// **Different selector arms.** `.badge { color }` and `.card { color }`: |
| 786 | /// makeover colours the tone and disabled arms, the server colours the base and |
| 787 | /// its own variants (`.badge.ai-tier-*`, `.badge--founder-*`). Neither touches |
| 788 | /// the other's arm. The check collapses arms, because separating them would |
| 789 | /// need a selector matcher and a checker that guesses wrong about specificity |
| 790 | /// fails correct builds, so the judgement is recorded here. |
| 791 | /// |
| 792 | /// **A deliberate pairing.** `.progress-fill { background }` is a different |
| 793 | /// element rather than a different arm: the media scrubber's fill, which lives |
| 794 | /// inside `.progress-bar` and never matches the generated |
| 795 | /// `.progress > .progress-fill`. It already carried a `respec-ok` comment |
| 796 | /// saying so. |
| 797 | /// |
| 798 | /// `.tab { box-shadow }`, `{ color }` and `{ cursor }` are this file taking the |
| 799 | /// property outright: it zeroes the base `button` shadow on a tab and dresses |
| 800 | /// the tab's own text, and a described tab gets that rather than makeover's. |
| 801 | /// The chosen arm is the opposite and is not here any more -- it is a |
| 802 | /// `revert-layer`, and since makeover-build 0.50.0 a handoff is not read as a |
| 803 | /// taking, so it needs no entry. |
| 804 | /// |
| 805 | /// **What is no longer in this list, and why that is the point.** Nineteen |
| 806 | /// entries came out on 2026-08-22, every one of them a `revert-layer`. The |
| 807 | /// check used to read property names without their values, so a later layer |
| 808 | /// handing a property back looked identical to a later layer taking it, and |
| 809 | /// each of those entries had to be written to buy silence for a remedy. The |
| 810 | /// cost was not the noise: an entry permits a real override on the same pair |
| 811 | /// for good, so the list of remedies and the list of licences were the same |
| 812 | /// list. makeover-build 0.50.0 reads the value, and the handoffs at the end of |
| 813 | /// `style.css` now speak for themselves. |
| 814 | /// |
| 815 | /// The element rules those handoffs defer past are the other half of the same |
| 816 | /// release: `REVIEWED_ELEMENT_OVERLAPS` below, and the section at the end of |
| 817 | /// `style.css` that keeps it empty. |
| 818 | /// |
| 819 | /// Still overlapping and still this file's: `.badge` and `.card` on their own |
| 820 | /// arms, and the disabled treatment's `opacity`, which is a charter decision |
| 821 | /// and a property makeover does not set. |
| 822 | /// |
| 823 | /// **A divergence taken on purpose.** `.table-row { display }` is `grid` here |
| 824 | /// against makeover's `table-row`, which is the same build-time grid story |
| 825 | /// goingson is on. |
| 826 | /// |
| 827 | /// `.tab { background }` was here and is gone. `.tab.is-selected` set |
| 828 | /// `--surface-raised` and `--bevel-raised`, byte for byte what the generated |
| 829 | /// `.tab.chosen` sets: a second name for makeover's own state, which is the |
| 830 | /// defect makeover-webview 0.27.0 exists to prevent. The tabs carry `chosen` |
| 831 | /// now, in four templates and in `frontend/src/core/tabs.ts`, and the rule is |
| 832 | /// deleted. The caret's two entries went the same way, one release later: |
| 833 | /// makeover-webview 0.31.0 emits the leading space and the reserved box itself. |
| 834 | /// |
| 835 | /// An entry that stops colliding fails the build, so this list cannot outlive |
| 836 | /// what it describes. |
| 837 | const REVIEWED_OVERLAPS: & = & |
| 838 | , |
| 839 | // A carousel showing one frame at a time, 2026-08-14. This was |
| 840 | // `("picture-img", "display")` until makeover-layout 0.23.0 described the |
| 841 | // showing itself; the frames are wrapped in `.showing-frame` now and the old |
| 842 | // entry stopped colliding, which this list is built to notice. |
| 843 | // |
| 844 | // The two arms are the same rule at two moments, and that is the whole of |
| 845 | // why the overlap is kept. Makeover collapses the stack on `[data-ready]`, |
| 846 | // which is the only honest default for a renderer that cannot know whether |
| 847 | // a page has script: ship every child, take them away once something binds |
| 848 | // them. This site collapses it from first paint instead, because rendering |
| 849 | // three frames and collapsing them was a measured 141px -> 58px jump that |
| 850 | // every visitor with JS paid, and `no-js.css` from a <noscript> opens the |
| 851 | // stack for the few without. That trade is this landing page's to make and |
| 852 | // a generated stylesheet has no way to reach <noscript>. |
| 853 | , |
| 854 | , |
| 855 | , |
| 856 | , |
| 857 | , |
| 858 | , |
| 859 | , |
| 860 | , |
| 861 | ]; |
| 862 | |
| 863 | /// Bare element rules that reach a generated class and have been read and kept. |
| 864 | /// |
| 865 | /// The second pass of the same check, and the one this file had no answer to |
| 866 | /// until makeover-build 0.50.0: a rule with no class in it is invisible to the |
| 867 | /// list above, and `button { color: var(--content) }` in @layer components beat |
| 868 | /// the generated `.button[data-tone]` on every described act on the site. A |
| 869 | /// destructive act rendered identically to an ordinary one for months. |
| 870 | /// |
| 871 | /// Empty, and meant to stay that way. The remedy for every one of them is a |
| 872 | /// `revert-layer` handoff in style.css, which says which arms makeover keeps in |
| 873 | /// the file the browser reads rather than in a build script. |
| 874 | const REVIEWED_ELEMENT_OVERLAPS: & = &; |
| 875 | |
| 876 | /// The short commit sha to stamp into `GIT_HASH`, and the watches that decide |
| 877 | /// when this build script has to run again. |
| 878 | /// |
| 879 | /// The watches are the whole point. Cargo treats a `rerun-if-changed` path that |
| 880 | /// does not exist as *changed*, so a watch on a path that can never exist makes |
| 881 | /// this script re-run on every single cargo invocation, and re-running it |
| 882 | /// recompiles the crate. This file used to watch `.git/HEAD`, which resolves |
| 883 | /// against the package root: there is no `.git` in `server/` or in |
| 884 | /// `multithreaded/` because the repository root is `MNW/`. So the watch never |
| 885 | /// resolved, and the crate recompiled every time. |
| 886 | /// |
| 887 | /// It cost 347s per Sando pipeline (294s in the server, 53s in multithreaded, |
| 888 | /// measured off `/srv/sando/logs/0.11.20/cargo_test.log`), which is 35% of the |
| 889 | /// `cargo_test` gate, and it cost the same on every local `cargo build`, |
| 890 | /// `cargo test` and `cargo clippy`. The two crates carrying a `build.rs` were |
| 891 | /// the only two in the pipeline that recompiled on a second cargo invocation; |
| 892 | /// the ones without one were already free. |
| 893 | /// |
| 894 | /// Two rules follow, and both matter: |
| 895 | /// - resolve the paths through git rather than guessing them, and |
| 896 | /// - emit a watch ONLY for a path that exists, or the bug comes straight back. |
| 897 | |
| 898 | // An explicit hash wins and skips git entirely, for a build system that |
| 899 | // already knows the sha. Nothing sets this today: Sando would have to set it |
| 900 | // on EVERY cargo invocation it makes, because `GIT_HASH` is a `rustc-env` |
| 901 | // and therefore part of the crate fingerprint, so a value present for the |
| 902 | // release build and absent for `cargo_test` would force the recompile this |
| 903 | // function exists to remove. |
| 904 | println!; |
| 905 | if let Ok = var |
| 906 | let h = h.trim.to_string; |
| 907 | if !h.is_empty |
| 908 | return h; |
| 909 | |
| 910 | |
| 911 | |
| 912 | // Watch what git actually rewrites when the checkout moves. `.git/HEAD` |
| 913 | // alone is not enough even when resolved: committing on a branch rewrites |
| 914 | // that branch's ref, not HEAD, so a watch on HEAD by itself would go stale |
| 915 | // in the ordinary case of a commit. |
| 916 | watch_if_exists; |
| 917 | if let Some = git_output |
| 918 | watch_if_exists; |
| 919 | |
| 920 | // A ref that has been packed has no loose file, so this is the fallback |
| 921 | // that keeps the watch honest after a `git gc`. |
| 922 | watch_if_exists; |
| 923 | |
| 924 | git_output.unwrap_or_default |
| 925 | |
| 926 | |
| 927 | /// Run a git command in the package directory and return its trimmed stdout. |
| 928 | |
| 929 | new |
| 930 | .args |
| 931 | .output |
| 932 | .ok |
| 933 | .filter |
| 934 | .and_then |
| 935 | .map |
| 936 | .filter |
| 937 | |
| 938 | |
| 939 | /// Resolve a name inside the git directory to a path, honouring worktrees and a |
| 940 | /// `.git` file that points elsewhere. `None` when this is not a checkout at all, |
| 941 | /// which is the case in a vendored or packaged build. |
| 942 | |
| 943 | git_output |
| 944 | |
| 945 | |
| 946 | /// Emit a watch for a path, but only when it exists. |
| 947 | /// |
| 948 | /// The guard is the fix. A missing path reads as changed to cargo, so emitting |
| 949 | /// one unconditionally is what caused the recompile-every-time bug. |
| 950 | |
| 951 | if let Some = path |
| 952 | && new.exists |
| 953 | |
| 954 | println!; |
| 955 | |
| 956 | |
| 957 |