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