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);
}
}
}