Skip to main content

max / makenotwork

6.6 KB · 166 lines History Blame Raw
1 use std::collections::hash_map::DefaultHasher;
2 use std::hash::{Hash, Hasher};
3 use std::process::Command;
4 use std::{fs, path::Path};
5
6 fn main() {
7 // Set GIT_HASH env var for compile-time inclusion via option_env!()
8 let hash = Command::new("git")
9 .args(["rev-parse", "--short", "HEAD"])
10 .output()
11 .ok()
12 .filter(|o| o.status.success())
13 .and_then(|o| String::from_utf8(o.stdout).ok())
14 .map(|s| s.trim().to_string())
15 .unwrap_or_default();
16
17 println!("cargo::rustc-env=GIT_HASH={hash}");
18 // Only re-run when HEAD changes
19 println!("cargo::rerun-if-changed=.git/HEAD");
20
21 // Compile the TypeScript frontend to static/dist/ (best-effort, see fn).
22 build_frontend();
23
24 // --- Static asset fingerprinting ---
25 // Hash the content of key static files to produce a version suffix.
26 // When any watched file changes, URLs in templates get a new ?v= param,
27 // busting browser caches automatically.
28 let static_files = [
29 "static/style.css",
30 "static/htmx.min.js",
31 "static/upload.js",
32 "static/passkey.js",
33 "static/insertions.js",
34 ];
35
36 let mut hasher = DefaultHasher::new();
37 for path in &static_files {
38 println!("cargo::rerun-if-changed={path}");
39 if let Ok(content) = fs::read(path) {
40 content.hash(&mut hasher);
41 }
42 }
43 // Fold the emitted frontend bundles into the version so `?v=` busts when
44 // the TypeScript changes. Read-only: the inputs under frontend/src are the
45 // watched trigger (in build_frontend); watching the outputs would loop.
46 hash_dir_js(Path::new("static/dist"), &mut hasher);
47 let static_hash = format!("{:016x}", hasher.finish());
48 let version = &static_hash[..8];
49
50 // Generate a template partial with versioned asset URLs.
51 // base.html includes this via {% include "_head_assets.html" %}
52 let partial = format!(
53 r#" <link rel="preload" href="/static/fonts/Lato-Regular.woff2" as="font" type="font/woff2" crossorigin>
54 <link rel="preload" href="/static/fonts/ysrf.woff2" as="font" type="font/woff2" crossorigin>
55 <link rel="stylesheet" href="/static/style.css?v={version}">
56 <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
57 <script src="/static/htmx.min.js"></script>
58 <script src="/static/upload.js?v={version}"></script>
59 <script type="module" src="/static/dist/core/index.js?v={version}"></script>"#,
60 );
61
62 write_if_changed(Path::new("templates/_head_assets.html"), &partial);
63
64 // Per-page island loader macro. Heavy/page-specific islands (media player,
65 // uploader, ...) load on the pages that use them via
66 // `{% import "_island.html" as island %}{% call island::island("name") %}`,
67 // cache-busted by the same content hash as the head assets.
68 let island_partial = r#"{% macro island(name) -%}
69 <script type="module" src="/static/dist/islands/{{ name }}.js?v=__VER__"></script>
70 {%- endmacro %}
71 "#
72 .replace("__VER__", version);
73 write_if_changed(Path::new("templates/_island.html"), &island_partial);
74 }
75
76 /// Write `contents` to `path` only if it differs, to avoid needless rebuilds.
77 fn write_if_changed(path: &Path, contents: &str) {
78 let needs_write = fs::read_to_string(path).map_or(true, |existing| existing != contents);
79 if needs_write {
80 fs::write(path, contents)
81 .unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
82 }
83 }
84
85 /// Compile the TypeScript frontend (`frontend/`) to browser ESM in
86 /// `static/dist/` via `npm run build` (which runs `tsc`).
87 ///
88 /// Self-contained: on a fresh checkout or a new build host (no `node_modules`)
89 /// it runs `npm ci` first, so there is no manual install gate before a deploy.
90 /// Best-effort and non-fatal otherwise, an absent Node or a compile error only
91 /// emits a `cargo::warning` and leaves the Rust build to succeed against
92 /// whatever `static/dist/` already holds. Set `MNW_SKIP_FRONTEND_BUILD=1` to
93 /// opt out entirely (e.g. a Node-less CI that doesn't need the JS).
94 fn build_frontend() {
95 // Re-run the whole build script when the TS sources or its config change.
96 println!("cargo::rerun-if-changed=frontend/src");
97 println!("cargo::rerun-if-changed=frontend/package.json");
98 println!("cargo::rerun-if-changed=frontend/package-lock.json");
99 println!("cargo::rerun-if-changed=frontend/tsconfig.json");
100
101 if std::env::var_os("MNW_SKIP_FRONTEND_BUILD").is_some() {
102 println!("cargo::warning=frontend build skipped (MNW_SKIP_FRONTEND_BUILD set)");
103 return;
104 }
105 // Fresh checkout / new build host: install deps once (clean, from the
106 // lockfile) so the frontend build needs no manual `npm install` gate before
107 // a deploy. Skipped once node_modules exists; needs network on this run.
108 if !Path::new("frontend/node_modules").is_dir() {
109 match Command::new("npm")
110 .args(["ci"])
111 .current_dir("frontend")
112 .status()
113 {
114 Ok(s) if s.success() => {}
115 Ok(s) => {
116 println!(
117 "cargo::warning=npm ci failed (exit {:?}); skipping frontend build (serving existing static/dist)",
118 s.code()
119 );
120 return;
121 }
122 Err(e) => {
123 println!(
124 "cargo::warning=could not run npm ({e}); is Node installed? skipping frontend build (serving existing static/dist)"
125 );
126 return;
127 }
128 }
129 }
130 match Command::new("npm")
131 .args(["run", "build"])
132 .current_dir("frontend")
133 .status()
134 {
135 Ok(s) if s.success() => {}
136 Ok(s) => println!(
137 "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist",
138 s.code()
139 ),
140 Err(e) => println!(
141 "cargo::warning=could not run npm for the frontend build ({e}); is Node installed? \
142 skipping (serving existing static/dist)"
143 ),
144 }
145 }
146
147 /// Recursively hash every `.js` file under `dir` into `hasher`, in a
148 /// deterministic order. Missing directory is a no-op (first build before the
149 /// frontend has been compiled).
150 fn hash_dir_js(dir: &Path, hasher: &mut DefaultHasher) {
151 let Ok(entries) = fs::read_dir(dir) else {
152 return;
153 };
154 let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
155 paths.sort();
156 for path in paths {
157 if path.is_dir() {
158 hash_dir_js(&path, hasher);
159 } else if path.extension().and_then(|e| e.to_str()) == Some("js")
160 && let Ok(content) = fs::read(&path)
161 {
162 content.hash(hasher);
163 }
164 }
165 }
166