use std::collections::hash_map::DefaultHasher; use std::fs; use std::hash::{Hash, Hasher}; use std::path::Path; use std::process::Command; fn main() { build_frontend(); fingerprint_assets(); } /// Content-hash the served static files and emit the template partials that /// carry a `?v=` cache-buster on every asset URL. /// /// Runs after `build_frontend` so the hash covers the JS that was just emitted /// into `static/dist/`, not the previous build's. /// /// One global version rather than a per-file hash: an upgrade to any watched /// file re-fetches all of them, which costs a handful of requests once and /// keeps the templates free of per-asset bookkeeping. What it buys is that a /// redeployed file can never be served stale from browser cache, for a chat /// island that would mean old protocol logic talking to a new server, which /// presents as "chat is broken for some people" rather than as a cache bug. /// /// The generated partials are gitignored build output. They are written only /// when their content changes, so a no-op build does not retrigger Askama. fn fingerprint_assets() { let static_files = ["static/style.css", "static/htmx.min.js", "static/mt.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 bundles in so `?v=` also busts when the TypeScript // changes. Read-only: `frontend/src` is the watched trigger (in // build_frontend); watching the outputs we generate would loop. hash_dir_js(Path::new("static/dist"), &mut hasher); let version = &format!("{:016x}", hasher.finish())[..8]; // Included by base.html's . let head = format!( r#" "# ); write_if_changed(Path::new("templates/_head_assets.html"), &head); // Included at the end of base.html's . Kept separate from the head // partial because mt.js is a classic (non-deferred) script and has to keep // running after the document is parsed. let scripts = format!(r#" "#); write_if_changed(Path::new("templates/_body_scripts.html"), &scripts); // Per-page island loader, for the compiled ESM under static/dist/. Used as // `{% import "_island.html" as island %}{% call island::island("chat") %}` // and cache-busted by the same content hash as the head assets. let island = r#"{% macro island(name) -%} {%- endmacro %} "# .replace("__VER__", version); write_if_changed(Path::new("templates/_island.html"), &island); } /// 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())); } } /// Recursively hash every `.js` file under `dir` into `hasher`, in a /// deterministic order. A missing directory is a no-op, the first build on a /// fresh checkout runs 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); } } } /// Compile the TypeScript frontend (`frontend/`) to browser ESM in /// `static/dist/` via `npm run build` (which runs `tsc`). /// /// This runs from `cargo build` on purpose. `static/dist/` is gitignored /// regenerable output, so a fresh checkout has no JS at all, and the deploy path /// builds on astra rather than on the machine that edited the TypeScript. Making /// it a build script means there is no "remember to run npm build first" step /// standing between an edit and a working deploy. /// /// Self-contained: on a host without `node_modules` it runs `npm ci` first. /// /// Best-effort and non-fatal. A missing Node or a compile error emits a /// `cargo::warning` and lets the Rust build succeed against whatever /// `static/dist/` already holds, because a type error in a chat widget should /// not be able to stop the forum from building. The Sando `code_smoke` tier is /// where a frontend failure is meant to be fatal. /// /// Set `MT_SKIP_FRONTEND_BUILD=1` to opt out entirely. fn build_frontend() { 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("MT_SKIP_FRONTEND_BUILD").is_some() { println!("cargo::warning=frontend build skipped (MT_SKIP_FRONTEND_BUILD set)"); return; } 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)" ), } }