Skip to main content

max / makenotwork

22.4 KB · 508 lines History Blame Raw
1 use std::collections::hash_map::DefaultHasher;
2 use std::fmt::Write as _;
3 use std::hash::{Hash, Hasher};
4 use std::process::Command;
5 use std::{fs, path::Path};
6
7 fn main() {
8 // Set GIT_HASH env var for compile-time inclusion via option_env!()
9 let hash = Command::new("git")
10 .args(["rev-parse", "--short", "HEAD"])
11 .output()
12 .ok()
13 .filter(|o| o.status.success())
14 .and_then(|o| String::from_utf8(o.stdout).ok())
15 .map(|s| s.trim().to_string())
16 .unwrap_or_default();
17
18 println!("cargo::rustc-env=GIT_HASH={hash}");
19 // Only re-run when HEAD changes
20 println!("cargo::rerun-if-changed=.git/HEAD");
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 makeover_build::geometry_css("static/geometry.css", None);
36 makeover_build::layout_css("static/layout.css", &makeover_build::Emit::default());
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 fs::write(
45 "static/embed-geometry.css",
46 makeover_geometry::geometry_css_vars(makeover_geometry::Density::Pointer),
47 )
48 .expect("write static/embed-geometry.css");
49
50 println!("cargo::rerun-if-changed=build.rs");
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 = String::from(
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 (path, w, h) in &shots {
61 let _ = writeln!(table, " ({path:?}, {w}, {h}),");
62 }
63 table.push_str("];\n");
64 fs::write(
65 Path::new(&std::env::var("OUT_DIR").expect("OUT_DIR")).join("shot_dimensions.rs"),
66 table,
67 )
68 .expect("write shot_dimensions.rs");
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 makeover_build::check_breakpoints_files(&HAND_WRITTEN_CSS, &[]);
76 makeover_build::check_vocabulary_files(
77 &HAND_WRITTEN_CSS,
78 &makeover_build::Emit::default(),
79 REVIEWED_OVERLAPS,
80 );
81 makeover_build::check_vocabulary_use(
82 &markup_files(),
83 &makeover_build::Emit::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 = DefaultHasher::new();
116 for path in &static_files {
117 println!("cargo::rerun-if-changed={path}");
118 if let Ok(content) = fs::read(path) {
119 content.hash(&mut hasher);
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(Path::new("static/dist"), &mut hasher);
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(content) = fs::read(path) {
135 content.hash(&mut hasher);
136 }
137 }
138 let static_hash = format!("{:016x}", hasher.finish());
139 let version = &static_hash[..8];
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!("cargo::rustc-env=STATIC_VERSION={version}");
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("__VER__", version);
156 write_if_changed(Path::new("templates/_island.html"), &island_partial);
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("__VER__", version);
166 write_if_changed(Path::new("templates/_sheet.html"), &sheet_partial);
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 fn shot_dimensions() -> Vec<(String, u32, u32)> {
185 let dir = Path::new("static/images/shots");
186 println!("cargo::rerun-if-changed=static/images/shots");
187 let mut out = Vec::new();
188 let Ok(entries) = fs::read_dir(dir) else {
189 return out;
190 };
191 for entry in entries.flatten() {
192 let path = entry.path();
193 if path.extension().and_then(|e| e.to_str()) != Some("webp") {
194 continue;
195 }
196 let Ok(bytes) = fs::read(&path) else { continue };
197 let Some((w, h)) = webp_dimensions(&bytes) else {
198 continue;
199 };
200 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
201 out.push((format!("/static/images/shots/{name}"), w, h));
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 fn webp_dimensions(b: &[u8]) -> Option<(u32, u32)> {
213 if b.len() < 30 || &b[0..4] != b"RIFF" || &b[8..12] != b"WEBP" {
214 return None;
215 }
216 match &b[12..16] {
217 // Lossy. Three-byte frame tag, then the 3-byte sync code, then two
218 // 14-bit dimensions.
219 b"VP8 " => {
220 let w = u16::from_le_bytes([b[26], b[27]]) & 0x3fff;
221 let h = u16::from_le_bytes([b[28], b[29]]) & 0x3fff;
222 Some((u32::from(w), u32::from(h)))
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 = u32::from_le_bytes([b[21], b[22], b[23], b[24]]);
228 Some(((bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1))
229 }
230 // Extended. Canvas size as two 24-bit little-endian minus-ones.
231 b"VP8X" => {
232 let w = u32::from_le_bytes([b[24], b[25], b[26], 0]) + 1;
233 let h = u32::from_le_bytes([b[27], b[28], b[29], 0]) + 1;
234 Some((w, h))
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 fn markup_files() -> Vec<std::path::PathBuf> {
298 let mut files = Vec::new();
299 for (dir, extension) in [
300 ("templates", "html"),
301 ("static", "js"),
302 ("frontend/src", "ts"),
303 ("src", "rs"),
304 ] {
305 let mut found = Vec::new();
306 collect(Path::new(dir), extension, &mut found);
307 found.sort();
308 files.extend(found);
309 }
310 files
311 }
312
313 /// Every file under `dir` with this extension, recursively.
314 fn collect(dir: &Path, extension: &str, out: &mut Vec<std::path::PathBuf>) {
315 let Ok(entries) = fs::read_dir(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(|name| name == "dist") {
324 continue;
325 }
326 collect(&path, extension, out);
327 } else if path.extension().is_some_and(|ext| ext == extension) {
328 out.push(path);
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: [&str; 4] = [
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 fn write_if_changed(path: &Path, contents: &str) {
352 let needs_write = fs::read_to_string(path).map_or(true, |existing| existing != contents);
353 if needs_write {
354 fs::write(path, contents)
355 .unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
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 fn build_frontend() {
369 // Re-run the whole build script when the TS sources or its config change.
370 println!("cargo::rerun-if-changed=frontend/src");
371 println!("cargo::rerun-if-changed=frontend/package.json");
372 println!("cargo::rerun-if-changed=frontend/package-lock.json");
373 println!("cargo::rerun-if-changed=frontend/tsconfig.json");
374
375 if std::env::var_os("MNW_SKIP_FRONTEND_BUILD").is_some() {
376 println!("cargo::warning=frontend build skipped (MNW_SKIP_FRONTEND_BUILD set)");
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 !Path::new("frontend/node_modules").is_dir() {
383 match Command::new("npm")
384 .args(["ci"])
385 .current_dir("frontend")
386 .status()
387 {
388 Ok(s) if s.success() => {}
389 Ok(s) => {
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(e) => {
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 Command::new("npm")
405 .args(["run", "build"])
406 .current_dir("frontend")
407 .status()
408 {
409 Ok(s) if s.success() => {}
410 Ok(s) => println!(
411 "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist",
412 s.code()
413 ),
414 Err(e) => 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 fn hash_dir_js(dir: &Path, hasher: &mut DefaultHasher) {
425 let Ok(entries) = fs::read_dir(dir) else {
426 return;
427 };
428 let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
429 paths.sort();
430 for path in paths {
431 if path.is_dir() {
432 hash_dir_js(&path, hasher);
433 } else if path.extension().and_then(|e| e.to_str()) == Some("js")
434 && let Ok(content) = fs::read(&path)
435 {
436 content.hash(hasher);
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: &[(&str, &str)] = &[
483 ("badge", "color"),
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 ("showing-frame", "display"),
499 ("current", "display"),
500 ("card", "color"),
501 ("chosen", "box-shadow"),
502 ("progress-fill", "background"),
503 ("tab", "box-shadow"),
504 ("tab", "color"),
505 ("tab", "cursor"),
506 ("table-row", "display"),
507 ];
508