use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::process::Command; use std::{fs, path::Path}; fn main() { // Set GIT_HASH env var for compile-time inclusion via option_env!() let hash = Command::new("git") .args(["rev-parse", "--short", "HEAD"]) .output() .ok() .filter(|o| o.status.success()) .and_then(|o| String::from_utf8(o.stdout).ok()) .map(|s| s.trim().to_string()) .unwrap_or_default(); println!("cargo::rustc-env=GIT_HASH={hash}"); // Only re-run when HEAD changes println!("cargo::rerun-if-changed=.git/HEAD"); // Compile the TypeScript frontend to static/dist/ (best-effort, see fn). build_frontend(); // The two generated stylesheets. Spacing comes from makeover-geometry and // composition from makeover-webview, the same way colour already comes // from makeover through theming.rs. Written into static/ rather than a // bundle directory because the server serves its stylesheets; both are // gitignored, since the crates are the source and a checked-in copy would // drift from the pin. // // No explicit touch selector: density hangs off (hover: none), // (pointer: coarse) alone, because the server has no mode class to key it // on. GO passes `.ui-mode-mobile` because it has one. makeover_build::geometry_css("static/geometry.css", None); makeover_build::layout_css("static/layout.css", &makeover_build::Emit::default()); // The embeds get their own copy of the spacing layer, because they are // served into third-party iframes and cannot link a stylesheet. Pointer // density only, and no `@media` block: an embed body is `height: 100vh` // inside an iframe the host page sized, so growing the gaps on a coarse // pointer clips rather than reflows, and the host author never sees it // happen. Revisit if embeds ever gain a resize protocol. fs::write( "static/embed-geometry.css", makeover_geometry::geometry_css_vars(makeover_geometry::Density::Pointer), ) .expect("write static/embed-geometry.css"); println!("cargo::rerun-if-changed=build.rs"); check_breakpoints(); // --- Static asset fingerprinting --- // Hash the content of key static files to produce a version suffix. // When any watched file changes, URLs in templates get a new ?v= param, // busting browser caches automatically. let static_files = [ "static/style.css", // The two per-page sheets. Linked from page templates rather than from // _head_assets.html, which is why they were outside the fingerprint // and served stale to any browser holding a cached copy. "static/wizard.css", "static/media-player.css", "static/htmx.min.js", "static/upload.js", "static/passkey.js", "static/insertions.js", ]; let mut hasher = DefaultHasher::new(); for path in &static_files { println!("cargo::rerun-if-changed={path}"); if let Ok(content) = fs::read(path) { content.hash(&mut hasher); } } // Fold the emitted frontend bundles into the version so `?v=` busts when // the TypeScript changes. Read-only: the inputs under frontend/src are the // watched trigger (in build_frontend); watching the outputs would loop. hash_dir_js(Path::new("static/dist"), &mut hasher); // Same treatment for the two generated stylesheets: read-only, so a bump // of makeover-geometry or makeover-webview busts `?v=` without the build // script watching a file it writes itself. for path in [ "static/geometry.css", "static/layout.css", "static/embed-geometry.css", ] { if let Ok(content) = fs::read(path) { content.hash(&mut hasher); } } let static_hash = format!("{:016x}", hasher.finish()); let version = &static_hash[..8]; // Generate a template partial with versioned asset URLs. // base.html includes this via {% include "_head_assets.html" %} let partial = format!( r#" "#, ); write_if_changed(Path::new("templates/_head_assets.html"), &partial); // Per-page island loader macro. Heavy/page-specific islands (media player, // uploader, ...) load on the pages that use them via // `{% import "_island.html" as island %}{% call island::island("name") %}`, // cache-busted by the same content hash as the head assets. let island_partial = r#"{% macro island(name) -%} {%- endmacro %} "# .replace("__VER__", version); write_if_changed(Path::new("templates/_island.html"), &island_partial); // Per-page stylesheet loader, same idea as the island macro. wizard.css and // media-player.css are linked by the pages that need them rather than by // _head_assets.html, so this is how they get the content hash. let sheet_partial = r#"{% macro sheet(name) -%} {%- endmacro %} "# .replace("__VER__", version); write_if_changed(Path::new("templates/_sheet.html"), &sheet_partial); } /// The hand-written stylesheets. Ordered, so the guard reports the same way /// twice. `geometry.css` and `layout.css` are excluded: they are generated. const HAND_WRITTEN_CSS: [&str; 3] = [ "static/style.css", "static/wizard.css", "static/media-player.css", ]; /// Fail the build on any width breakpoint that is not a `SizeClass` boundary. /// /// A media condition cannot read a custom property and `@custom-media` has /// shipped nowhere, so every threshold in the stylesheets is a hand-typed /// literal and there is no generator path. The guard is the substitute: bump /// makeover-geometry to a release that moves a boundary and the build breaks /// here, rather than the layout breaking quietly in a browser. /// /// Only the numbers are checked, not the surrounding syntax. That accepts /// `(min-width: 600px) and (max-width: 839px)` without having to parse it, and /// still catches the thing worth catching: a number nobody can trace to the /// scale. fn check_breakpoints() { use makeover_geometry::SizeClass; let compact_max = SizeClass::Medium.min_px() - 1; let expanded_min = SizeClass::Expanded.min_px(); let allowed = [ compact_max, SizeClass::Medium.min_px(), expanded_min - 1, expanded_min, ]; let mut strays: Vec = Vec::new(); for path in HAND_WRITTEN_CSS { println!("cargo::rerun-if-changed={path}"); let Ok(css) = fs::read_to_string(path) else { continue; }; for (n, line) in css.lines().enumerate() { for px in width_conditions(line) { if !allowed.contains(&px) { strays.push(format!("{path}:{}: (…-width: {px}px)", n + 1)); } } } } assert!( strays.is_empty(), "stylesheet width breakpoints that are not SizeClass boundaries \ ({}, {}, {}, {}px):\n {}\n\ Either move the rule to a boundary, or make it dimensional so it \ needs no threshold at all: a grid wants \ repeat(auto-fit, minmax(, 1fr)) and a size wants \ clamp(). A threshold is for what appears and disappears.", allowed[0], allowed[1], allowed[2], allowed[3], strays.join("\n ") ); } /// Every pixel value used as a `min-width` or `max-width` media feature on one /// line. Deliberately narrow: it reads `width` features and ignores the /// `width` property, `min-width`/`max-width` declarations, and every other /// number in the sheet. fn width_conditions(line: &str) -> Vec { let mut found = Vec::new(); for (i, _) in line.match_indices("-width:") { let prefix = &line[..i]; if !(prefix.ends_with("min") || prefix.ends_with("max")) { continue; } // A media feature is parenthesised; the property form never is. let opened = prefix.trim_end_matches(['m', 'i', 'n', 'a', 'x']); if !opened.ends_with('(') { continue; } let rest = &line[i + "-width:".len()..]; let digits: String = rest .trim_start() .chars() .take_while(char::is_ascii_digit) .collect(); if let Ok(px) = digits.parse::() { found.push(px); } } found } /// Write `contents` to `path` only if it differs, to avoid needless rebuilds. fn write_if_changed(path: &Path, contents: &str) { let needs_write = fs::read_to_string(path).map_or(true, |existing| existing != contents); if needs_write { fs::write(path, contents) .unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display())); } } /// Compile the TypeScript frontend (`frontend/`) to browser ESM in /// `static/dist/` via `npm run build` (which runs `tsc`). /// /// Self-contained: on a fresh checkout or a new build host (no `node_modules`) /// it runs `npm ci` first, so there is no manual install gate before a deploy. /// Best-effort and non-fatal otherwise, an absent Node or a compile error only /// emits a `cargo::warning` and leaves the Rust build to succeed against /// whatever `static/dist/` already holds. Set `MNW_SKIP_FRONTEND_BUILD=1` to /// opt out entirely (e.g. a Node-less CI that doesn't need the JS). fn build_frontend() { // Re-run the whole build script when the TS sources or its config change. println!("cargo::rerun-if-changed=frontend/src"); println!("cargo::rerun-if-changed=frontend/package.json"); println!("cargo::rerun-if-changed=frontend/package-lock.json"); println!("cargo::rerun-if-changed=frontend/tsconfig.json"); if std::env::var_os("MNW_SKIP_FRONTEND_BUILD").is_some() { println!("cargo::warning=frontend build skipped (MNW_SKIP_FRONTEND_BUILD set)"); return; } // Fresh checkout / new build host: install deps once (clean, from the // lockfile) so the frontend build needs no manual `npm install` gate before // a deploy. Skipped once node_modules exists; needs network on this run. if !Path::new("frontend/node_modules").is_dir() { match Command::new("npm") .args(["ci"]) .current_dir("frontend") .status() { Ok(s) if s.success() => {} Ok(s) => { println!( "cargo::warning=npm ci failed (exit {:?}); skipping frontend build (serving existing static/dist)", s.code() ); return; } Err(e) => { println!( "cargo::warning=could not run npm ({e}); is Node installed? skipping frontend build (serving existing static/dist)" ); return; } } } match Command::new("npm") .args(["run", "build"]) .current_dir("frontend") .status() { Ok(s) if s.success() => {} Ok(s) => println!( "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist", s.code() ), Err(e) => println!( "cargo::warning=could not run npm for the frontend build ({e}); is Node installed? \ skipping (serving existing static/dist)" ), } } /// Recursively hash every `.js` file under `dir` into `hasher`, in a /// deterministic order. Missing directory is a no-op (first build before the /// frontend has been compiled). fn hash_dir_js(dir: &Path, hasher: &mut DefaultHasher) { let Ok(entries) = fs::read_dir(dir) else { return; }; let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect(); paths.sort(); for path in paths { if path.is_dir() { hash_dir_js(&path, hasher); } else if path.extension().and_then(|e| e.to_str()) == Some("js") && let Ok(content) = fs::read(&path) { content.hash(hasher); } } }