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(); // --- 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", "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); 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); } /// 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); } } }