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 | let hash = new |
| 10 | .args |
| 11 | .output |
| 12 | .ok |
| 13 | .filter |
| 14 | .and_then |
| 15 | .map |
| 16 | .unwrap_or_default; |
| 17 | |
| 18 | println!; |
| 19 | // Only re-run when HEAD changes |
| 20 | println!; |
| 21 | |
| 22 | // Compile the TypeScript frontend to static/dist/ (best-effort, see fn). |
| 23 | build_frontend; |
| 24 | |
| 25 | // The two generated stylesheets. Spacing comes from makeover-geometry and |
| 26 | // composition from makeover-webview, the same way colour already comes |
| 27 | // from makeover through theming.rs. Written into static/ rather than a |
| 28 | // bundle directory because the server serves its stylesheets; both are |
| 29 | // gitignored, since the crates are the source and a checked-in copy would |
| 30 | // drift from the pin. |
| 31 | // |
| 32 | // No explicit touch selector: density hangs off (hover: none), |
| 33 | // (pointer: coarse) alone, because the server has no mode class to key it |
| 34 | // on. GO passes `.ui-mode-mobile` because it has one. |
| 35 | geometry_css; |
| 36 | layout_css; |
| 37 | |
| 38 | // The embeds get their own copy of the spacing layer, because they are |
| 39 | // served into third-party iframes and cannot link a stylesheet. Pointer |
| 40 | // density only, and no `@media` block: an embed body is `height: 100vh` |
| 41 | // inside an iframe the host page sized, so growing the gaps on a coarse |
| 42 | // pointer clips rather than reflows, and the host author never sees it |
| 43 | // happen. Revisit if embeds ever gain a resize protocol. |
| 44 | write |
| 45 | "static/embed-geometry.css", |
| 46 | geometry_css_vars, |
| 47 | |
| 48 | .expect; |
| 49 | |
| 50 | println!; |
| 51 | |
| 52 | // The landing shots' own dimensions, so the description can reserve their |
| 53 | // space. Generated rather than written down; see `shot_dimensions`. |
| 54 | let shots = shot_dimensions; |
| 55 | let mut table = Stringfrom |
| 56 | "/// Intrinsic size of each landing screenshot, by URL path.\n\ |
| 57 | /// Generated by build.rs from the files themselves.\n\ |
| 58 | pub static SHOT_DIMENSIONS: &[(&str, u32, u32)] = &[\n", |
| 59 | ; |
| 60 | for in &shots |
| 61 | let _ = writeln!; |
| 62 | |
| 63 | table.push_str; |
| 64 | write |
| 65 | new.join, |
| 66 | table, |
| 67 | |
| 68 | .expect; |
| 69 | |
| 70 | // The same check goingson and balanced_breakfast run, out of |
| 71 | // makeover-build rather than a third copy of it. The named-list form: |
| 72 | // static/ holds the generated stylesheets and a bundler's output beside |
| 73 | // the hand-written ones, so there is no directory to scan. No tuning |
| 74 | // widths -- every threshold here is a shell boundary. |
| 75 | check_breakpoints_files; |
| 76 | check_vocabulary_files |
| 77 | &HAND_WRITTEN_CSS, |
| 78 | &default, |
| 79 | REVIEWED_OVERLAPS, |
| 80 | ; |
| 81 | check_vocabulary_use |
| 82 | &markup_files, |
| 83 | &default, |
| 84 | DEAD_VOCABULARY_HIGH_WATER, |
| 85 | ; |
| 86 | |
| 87 | // --- Static asset fingerprinting --- |
| 88 | // Hash the content of key static files to produce a version suffix. |
| 89 | // When any watched file changes, URLs in templates get a new ?v= param, |
| 90 | // busting browser caches automatically. |
| 91 | let static_files = |
| 92 | "static/style.css", |
| 93 | // The two per-page sheets. Linked from page templates rather than |
| 94 | // from the head, which is why they were outside the fingerprint and |
| 95 | // served stale to any browser holding a cached copy. |
| 96 | "static/wizard.css", |
| 97 | "static/media-player.css", |
| 98 | // Same case again, found 2026-08-15: linked from a <noscript> in the |
| 99 | // carousel partial rather than from the head, so it was outside the |
| 100 | // fingerprint and a change to it served stale to any browser holding a |
| 101 | // cached copy. The visitors it exists for are the ones least likely to |
| 102 | // hard-refresh. |
| 103 | "static/no-js.css", |
| 104 | "static/htmx.min.js", |
| 105 | // The morph extension, vendored from quasi. quasi-webview's Shell emits |
| 106 | // hx-ext="morph" and points at this path by default; htmx falls back to |
| 107 | // innerHTML silently when the extension is absent, so a 404 here is a |
| 108 | // destructive swap rather than a missing feature. |
| 109 | "static/idiomorph-ext.min.js", |
| 110 | "static/upload.js", |
| 111 | "static/passkey.js", |
| 112 | "static/insertions.js", |
| 113 | ]; |
| 114 | |
| 115 | let mut hasher = new; |
| 116 | for path in &static_files |
| 117 | println!; |
| 118 | if let Ok = read |
| 119 | content.hash; |
| 120 | |
| 121 | |
| 122 | // Fold the emitted frontend bundles into the version so `?v=` busts when |
| 123 | // the TypeScript changes. Read-only: the inputs under frontend/src are the |
| 124 | // watched trigger (in build_frontend); watching the outputs would loop. |
| 125 | hash_dir_js; |
| 126 | // Same treatment for the two generated stylesheets: read-only, so a bump |
| 127 | // of makeover-geometry or makeover-webview busts `?v=` without the build |
| 128 | // script watching a file it writes itself. |
| 129 | for path in |
| 130 | "static/geometry.css", |
| 131 | "static/layout.css", |
| 132 | "static/embed-geometry.css", |
| 133 | ] |
| 134 | if let Ok = read |
| 135 | content.hash; |
| 136 | |
| 137 | |
| 138 | let static_hash = format!; |
| 139 | let version = &static_hash; |
| 140 | |
| 141 | // The head's own assets are no longer a generated partial: crate::shell |
| 142 | // builds them into quasi-webview's Shell, which is what a described screen |
| 143 | // renders through, so both halves of the converted site emit one head. All |
| 144 | // that crosses the build boundary now is the version. |
| 145 | println!; |
| 146 | |
| 147 | // Per-page island loader macro. Heavy/page-specific islands (media player, |
| 148 | // uploader, ...) load on the pages that use them via |
| 149 | // `{% import "_island.html" as island %}{% call island::island("name") %}`, |
| 150 | // cache-busted by the same content hash as the head assets. |
| 151 | let island_partial = r#"{% macro island(name) -%} |
| 152 | <script type="module" src="/static/dist-__VER__/islands/{{ name }}.js"></script> |
| 153 | {%- endmacro %} |
| 154 | "# |
| 155 | .replace; |
| 156 | write_if_changed; |
| 157 | |
| 158 | // Per-page stylesheet loader, same idea as the island macro. wizard.css and |
| 159 | // media-player.css are linked by the pages that need them rather than by |
| 160 | // the shell, so this is how they get the content hash. |
| 161 | let sheet_partial = r#"{% macro sheet(name) -%} |
| 162 | <link rel="stylesheet" href="/static/{{ name }}?v=__VER__"> |
| 163 | {%- endmacro %} |
| 164 | "# |
| 165 | .replace; |
| 166 | write_if_changed; |
| 167 | |
| 168 | |
| 169 | /// Intrinsic dimensions of the landing screenshots, read from the files. |
| 170 | /// |
| 171 | /// A picture that cannot say how big it is cannot have its space reserved, so |
| 172 | /// the browser gives it none until the bytes land and then takes its full |
| 173 | /// height at once. That was measured at a 478px jump per frame on the landing |
| 174 | /// page and 0.087 CLS for the document (2026-08-14). |
| 175 | /// |
| 176 | /// Read here rather than written down, because these files are regenerated by |
| 177 | /// `scripts/capture-landing-carousel.mjs` and a hand-maintained number would be |
| 178 | /// wrong the first time anyone re-shot them -- silently, since a wrong reserve |
| 179 | /// looks like a right one until the image lands. |
| 180 | /// |
| 181 | /// WebP only, which is what the capture script emits. A file this cannot parse |
| 182 | /// is skipped rather than guessed at: `None` reserves nothing, which is the |
| 183 | /// behaviour before this existed, while a wrong number reserves the wrong room. |
| 184 | |
| 185 | let dir = new; |
| 186 | println!; |
| 187 | let mut out = Vecnew; |
| 188 | let Ok = read_dir else |
| 189 | return out; |
| 190 | ; |
| 191 | for entry in entries.flatten |
| 192 | let path = entry.path; |
| 193 | if path.extension.and_then != Some |
| 194 | continue; |
| 195 | |
| 196 | let Ok = read else ; |
| 197 | let Some = webp_dimensions else |
| 198 | continue; |
| 199 | ; |
| 200 | if let Some = path.file_name.and_then |
| 201 | out.push; |
| 202 | |
| 203 | |
| 204 | out.sort; |
| 205 | out |
| 206 | |
| 207 | |
| 208 | /// Canvas size from a WebP header, for the three chunk layouts that exist. |
| 209 | /// |
| 210 | /// Header offsets only -- nothing is decoded. `None` for anything unrecognised, |
| 211 | /// which the caller treats as "this picture does not know its size". |
| 212 | |
| 213 | if b.len < 30 || &b != b"RIFF" || &b != b"WEBP" |
| 214 | return None; |
| 215 | |
| 216 | match &b |
| 217 | // Lossy. Three-byte frame tag, then the 3-byte sync code, then two |
| 218 | // 14-bit dimensions. |
| 219 | b"VP8 " => |
| 220 | let w = u16from_le_bytes & 0x3fff; |
| 221 | let h = u16from_le_bytes & 0x3fff; |
| 222 | Some |
| 223 | |
| 224 | // Lossless. One signature byte, then 14 bits of width-1 and 14 of |
| 225 | // height-1 packed into the next four. |
| 226 | b"VP8L" => |
| 227 | let bits = u32from_le_bytes; |
| 228 | Some |
| 229 | |
| 230 | // Extended. Canvas size as two 24-bit little-endian minus-ones. |
| 231 | b"VP8X" => |
| 232 | let w = u32from_le_bytes + 1; |
| 233 | let h = u32from_le_bytes + 1; |
| 234 | Some |
| 235 | |
| 236 | _ => None, |
| 237 | |
| 238 | |
| 239 | |
| 240 | /// How many generated classes may go unused before the build fails. |
| 241 | /// |
| 242 | /// One-sided: over this fails, under it warns and asks for the seal to be |
| 243 | /// lowered. A build that broke on deleting dead CSS would teach the wrong |
| 244 | /// lesson, so the number only ever ratchets down. |
| 245 | /// |
| 246 | /// Measured against [`markup_files`]. The stylesheets are deliberately out -- a |
| 247 | /// class in `style.css` is that class being styled, not that class being |
| 248 | /// emitted, and counting them would mark the whole vocabulary used by |
| 249 | /// definition. `static/dist` is out for a subtler reason: it is `tsc` output of |
| 250 | /// `frontend/src`, so including both counts one class-writing line twice, and |
| 251 | /// the source is the half a human edits. |
| 252 | /// |
| 253 | /// The server's markup is spread wider than either desktop app's -- 200-odd |
| 254 | /// Askama templates, the hand-written scripts in `static/`, the TypeScript they |
| 255 | /// are being replaced by, and the Rust that writes markup directly -- which is |
| 256 | /// the whole reason this seal took a pass of its own rather than landing beside |
| 257 | /// the check that reads the stylesheets. |
| 258 | /// |
| 259 | /// # 18 to 20, 2026-08-14: `picture-img` and `picture-caption` |
| 260 | /// |
| 261 | /// The one direction this number is allowed to move is down, so a rise wants an |
| 262 | /// argument rather than a nudge. These two are not dead vocabulary: they are |
| 263 | /// emitted by `quasi-webview` at request time, from a description, and this |
| 264 | /// scanner reads *this repo's* files. Markup written by a dependency is |
| 265 | /// invisible to it by construction. |
| 266 | /// |
| 267 | /// That category already existed and was already counted here. `figure-value`, |
| 268 | /// `figure-caption` and `figure-change` sit in the same set for the same |
| 269 | /// reason, and the nine `cell-*` classes beside them are the described table. |
| 270 | /// The carousel port (`c0b63ea9`) is the first widget to add to it. |
| 271 | /// |
| 272 | /// So the seal still measures what it was built to measure -- CSS generated for |
| 273 | /// markup nobody writes -- and the honest ratchet is downward as the |
| 274 | /// description layer takes over more of the site, at which point these classes |
| 275 | /// stop being reachable from templates *and* stay used. If this number ever |
| 276 | /// needs raising for a class MNW's own markup should have been writing, that is |
| 277 | /// the defect this exists to catch and the answer is the markup, not the seal. |
| 278 | /// |
| 279 | /// # 20 to 23, 2026-08-15: `track-entry`, `track-slot`, `track-tick` |
| 280 | /// |
| 281 | /// The same category again, and the third entry in it. The track vocabulary |
| 282 | /// arrived in quasi 0.6.0 (`describe a time axis`) and 0.8.0 (`label a track by |
| 283 | /// its unit`); this server had been pinned to quasi 0.5 and could not resolve at |
| 284 | /// all, so the classes had never been weighed here. Forward-fixing the pin is |
| 285 | /// what surfaced them. |
| 286 | /// |
| 287 | /// Emitted by `quasi-webview` from a description, like the eighteen above them, |
| 288 | /// and invisible to a scanner reading this repo's files for the same reason. No |
| 289 | /// template should be writing them. |
| 290 | const DEAD_VOCABULARY_HIGH_WATER: usize = 23; |
| 291 | |
| 292 | /// Every file that can carry a class name. |
| 293 | /// |
| 294 | /// Sorted within each group, so two machines read the same set in the same |
| 295 | /// order. It makes no difference to the count and every difference to reading a |
| 296 | /// diff of the warning. |
| 297 | |
| 298 | let mut files = Vecnew; |
| 299 | for in |
| 300 | , |
| 301 | , |
| 302 | , |
| 303 | , |
| 304 | ] |
| 305 | let mut found = Vecnew; |
| 306 | collect; |
| 307 | found.sort; |
| 308 | files.extend; |
| 309 | |
| 310 | files |
| 311 | |
| 312 | |
| 313 | /// Every file under `dir` with this extension, recursively. |
| 314 | |
| 315 | let Ok = read_dir else |
| 316 | return; |
| 317 | ; |
| 318 | for entry in entries.flatten |
| 319 | let path = entry.path; |
| 320 | if path.is_dir |
| 321 | // The bundler's output, which is `frontend/src` compiled. Reading |
| 322 | // both would count the same line twice. |
| 323 | if path.file_name.is_some_and |
| 324 | continue; |
| 325 | |
| 326 | collect; |
| 327 | else if path.extension.is_some_and |
| 328 | out.push; |
| 329 | |
| 330 | |
| 331 | |
| 332 | |
| 333 | /// The hand-written stylesheets. Ordered, so the guard reports the same way |
| 334 | /// twice. `geometry.css` and `layout.css` are excluded: they are generated. |
| 335 | /// |
| 336 | /// `no-js.css` joined the list on 2026-08-15. It had been missed since it was |
| 337 | /// written: it is hand-authored CSS served to real visitors, so the breakpoint |
| 338 | /// and vocabulary guards apply to it exactly as they do to the other three, and |
| 339 | /// a sheet outside the list is a sheet that can diverge without the build |
| 340 | /// noticing. It is also what `tests/frontend_payload.rs` weighs, and a seal |
| 341 | /// that skipped a sheet would let bytes move between sheets and read as a |
| 342 | /// deletion. |
| 343 | const HAND_WRITTEN_CSS: = |
| 344 | "static/style.css", |
| 345 | "static/wizard.css", |
| 346 | "static/media-player.css", |
| 347 | "static/no-js.css", |
| 348 | ]; |
| 349 | |
| 350 | /// Write `contents` to `path` only if it differs, to avoid needless rebuilds. |
| 351 | |
| 352 | let needs_write = read_to_string.map_or; |
| 353 | if needs_write |
| 354 | write |
| 355 | .unwrap_or_else; |
| 356 | |
| 357 | |
| 358 | |
| 359 | /// Compile the TypeScript frontend (`frontend/`) to browser ESM in |
| 360 | /// `static/dist/` via `npm run build` (which runs `tsc`). |
| 361 | /// |
| 362 | /// Self-contained: on a fresh checkout or a new build host (no `node_modules`) |
| 363 | /// it runs `npm ci` first, so there is no manual install gate before a deploy. |
| 364 | /// Best-effort and non-fatal otherwise, an absent Node or a compile error only |
| 365 | /// emits a `cargo::warning` and leaves the Rust build to succeed against |
| 366 | /// whatever `static/dist/` already holds. Set `MNW_SKIP_FRONTEND_BUILD=1` to |
| 367 | /// opt out entirely (e.g. a Node-less CI that doesn't need the JS). |
| 368 | |
| 369 | // Re-run the whole build script when the TS sources or its config change. |
| 370 | println!; |
| 371 | println!; |
| 372 | println!; |
| 373 | println!; |
| 374 | |
| 375 | if var_os.is_some |
| 376 | println!; |
| 377 | return; |
| 378 | |
| 379 | // Fresh checkout / new build host: install deps once (clean, from the |
| 380 | // lockfile) so the frontend build needs no manual `npm install` gate before |
| 381 | // a deploy. Skipped once node_modules exists; needs network on this run. |
| 382 | if !new.is_dir |
| 383 | match new |
| 384 | .args |
| 385 | .current_dir |
| 386 | .status |
| 387 | |
| 388 | Ok if s.success => |
| 389 | Ok => |
| 390 | println! |
| 391 | "cargo::warning=npm ci failed (exit {:?}); skipping frontend build (serving existing static/dist)" |
| 392 | s.code |
| 393 | ); |
| 394 | return; |
| 395 | |
| 396 | Err => |
| 397 | println! |
| 398 | "cargo::warning=could not run npm ({e}); is Node installed? skipping frontend build (serving existing static/dist)" |
| 399 | ; |
| 400 | return; |
| 401 | |
| 402 | |
| 403 | |
| 404 | match new |
| 405 | .args |
| 406 | .current_dir |
| 407 | .status |
| 408 | |
| 409 | Ok if s.success => |
| 410 | Ok => println! |
| 411 | "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist" |
| 412 | s.code |
| 413 | ), |
| 414 | Err => println! |
| 415 | "cargo::warning=could not run npm for the frontend build ({e}); is Node installed? \ |
| 416 | skipping (serving existing static/dist)" |
| 417 | , |
| 418 | |
| 419 | |
| 420 | |
| 421 | /// Recursively hash every `.js` file under `dir` into `hasher`, in a |
| 422 | /// deterministic order. Missing directory is a no-op (first build before the |
| 423 | /// frontend has been compiled). |
| 424 | |
| 425 | let Ok = read_dir else |
| 426 | return; |
| 427 | ; |
| 428 | let mut paths: = entries.flatten.map.collect; |
| 429 | paths.sort; |
| 430 | for path in paths |
| 431 | if path.is_dir |
| 432 | hash_dir_js; |
| 433 | else if path.extension.and_then == Some |
| 434 | && let Ok = read |
| 435 | |
| 436 | content.hash; |
| 437 | |
| 438 | |
| 439 | |
| 440 | |
| 441 | /// Class-and-property overlaps with the generated stylesheet that have been |
| 442 | /// read and kept. |
| 443 | /// |
| 444 | /// Three kinds, and only the first is what the check is really for. |
| 445 | /// |
| 446 | /// **Different selector arms.** `.badge { color }` and `.card { color }`: |
| 447 | /// makeover colours the tone and disabled arms, the server colours the base and |
| 448 | /// its own variants (`.badge.ai-tier-*`, `.badge--founder-*`). Neither touches |
| 449 | /// the other's arm. The check collapses arms, because separating them would |
| 450 | /// need a selector matcher and a checker that guesses wrong about specificity |
| 451 | /// fails correct builds, so the judgement is recorded here. |
| 452 | /// |
| 453 | /// **A deliberate pairing.** `.progress-fill { background }` is a different |
| 454 | /// element rather than a different arm: the media scrubber's fill, which lives |
| 455 | /// inside `.progress-bar` and never matches the generated |
| 456 | /// `.progress > .progress-fill`. It already carried a `respec-ok` comment |
| 457 | /// saying so. |
| 458 | /// |
| 459 | /// `.tab { box-shadow }` and `.chosen { box-shadow }` are the third kind and the |
| 460 | /// awkward one -- one rule, two entries, because the check reads every class in |
| 461 | /// a selector and `.tab.chosen` is both. This file zeroes the base `button` |
| 462 | /// shadow on a tab, and unlayered CSS outranks `@layer makeover` whatever the |
| 463 | /// specificity, so the generated `.tab.chosen` bevel cannot apply on its own. |
| 464 | /// The chosen arm takes `box-shadow: revert-layer`, which hands the property |
| 465 | /// back to the layer rather than naming a value. Two arms, one of which is a |
| 466 | /// deferral rather than an override, so both entries stay. |
| 467 | /// |
| 468 | /// **A divergence taken on purpose.** `.table-row { display }` is `grid` here |
| 469 | /// against makeover's `table-row`, which is the same build-time grid story |
| 470 | /// goingson is on. |
| 471 | /// |
| 472 | /// `.tab { background }` was here and is gone. `.tab.is-selected` set |
| 473 | /// `--surface-raised` and `--bevel-raised`, byte for byte what the generated |
| 474 | /// `.tab.chosen` sets: a second name for makeover's own state, which is the |
| 475 | /// defect makeover-webview 0.27.0 exists to prevent. The tabs carry `chosen` |
| 476 | /// now, in four templates and in `frontend/src/core/tabs.ts`, and the rule is |
| 477 | /// deleted. The caret's two entries went the same way, one release later: |
| 478 | /// makeover-webview 0.31.0 emits the leading space and the reserved box itself. |
| 479 | /// |
| 480 | /// An entry that stops colliding fails the build, so this list cannot outlive |
| 481 | /// what it describes. |
| 482 | const REVIEWED_OVERLAPS: & = & |
| 483 | , |
| 484 | // A carousel showing one frame at a time, 2026-08-14. This was |
| 485 | // `("picture-img", "display")` until makeover-layout 0.23.0 described the |
| 486 | // showing itself; the frames are wrapped in `.showing-frame` now and the old |
| 487 | // entry stopped colliding, which this list is built to notice. |
| 488 | // |
| 489 | // The two arms are the same rule at two moments, and that is the whole of |
| 490 | // why the overlap is kept. Makeover collapses the stack on `[data-ready]`, |
| 491 | // which is the only honest default for a renderer that cannot know whether |
| 492 | // a page has script: ship every child, take them away once something binds |
| 493 | // them. This site collapses it from first paint instead, because rendering |
| 494 | // three frames and collapsing them was a measured 141px -> 58px jump that |
| 495 | // every visitor with JS paid, and `no-js.css` from a <noscript> opens the |
| 496 | // stack for the few without. That trade is this landing page's to make and |
| 497 | // a generated stylesheet has no way to reach <noscript>. |
| 498 | , |
| 499 | , |
| 500 | , |
| 501 | , |
| 502 | , |
| 503 | , |
| 504 | , |
| 505 | , |
| 506 | , |
| 507 | ]; |
| 508 |