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