Skip to main content

max / makenotwork

13.7 KB · 332 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 // The two generated stylesheets. Spacing comes from makeover-geometry and
25 // composition from makeover-webview, the same way colour already comes
26 // from makeover through theming.rs. Written into static/ rather than a
27 // bundle directory because the server serves its stylesheets; both are
28 // gitignored, since the crates are the source and a checked-in copy would
29 // drift from the pin.
30 //
31 // No explicit touch selector: density hangs off (hover: none),
32 // (pointer: coarse) alone, because the server has no mode class to key it
33 // on. GO passes `.ui-mode-mobile` because it has one.
34 makeover_build::geometry_css("static/geometry.css", None);
35 makeover_build::layout_css("static/layout.css", &makeover_build::Emit::default());
36
37 // The embeds get their own copy of the spacing layer, because they are
38 // served into third-party iframes and cannot link a stylesheet. Pointer
39 // density only, and no `@media` block: an embed body is `height: 100vh`
40 // inside an iframe the host page sized, so growing the gaps on a coarse
41 // pointer clips rather than reflows, and the host author never sees it
42 // happen. Revisit if embeds ever gain a resize protocol.
43 fs::write(
44 "static/embed-geometry.css",
45 makeover_geometry::geometry_css_vars(makeover_geometry::Density::Pointer),
46 )
47 .expect("write static/embed-geometry.css");
48
49 println!("cargo::rerun-if-changed=build.rs");
50
51 check_breakpoints();
52
53 // --- Static asset fingerprinting ---
54 // Hash the content of key static files to produce a version suffix.
55 // When any watched file changes, URLs in templates get a new ?v= param,
56 // busting browser caches automatically.
57 let static_files = [
58 "static/style.css",
59 // The two per-page sheets. Linked from page templates rather than from
60 // _head_assets.html, which is why they were outside the fingerprint
61 // and served stale to any browser holding a cached copy.
62 "static/wizard.css",
63 "static/media-player.css",
64 "static/htmx.min.js",
65 "static/upload.js",
66 "static/passkey.js",
67 "static/insertions.js",
68 ];
69
70 let mut hasher = DefaultHasher::new();
71 for path in &static_files {
72 println!("cargo::rerun-if-changed={path}");
73 if let Ok(content) = fs::read(path) {
74 content.hash(&mut hasher);
75 }
76 }
77 // Fold the emitted frontend bundles into the version so `?v=` busts when
78 // the TypeScript changes. Read-only: the inputs under frontend/src are the
79 // watched trigger (in build_frontend); watching the outputs would loop.
80 hash_dir_js(Path::new("static/dist"), &mut hasher);
81 // Same treatment for the two generated stylesheets: read-only, so a bump
82 // of makeover-geometry or makeover-webview busts `?v=` without the build
83 // script watching a file it writes itself.
84 for path in [
85 "static/geometry.css",
86 "static/layout.css",
87 "static/embed-geometry.css",
88 ] {
89 if let Ok(content) = fs::read(path) {
90 content.hash(&mut hasher);
91 }
92 }
93 let static_hash = format!("{:016x}", hasher.finish());
94 let version = &static_hash[..8];
95
96 // Generate a template partial with versioned asset URLs.
97 // base.html includes this via {% include "_head_assets.html" %}
98 let partial = format!(
99 r#" <link rel="preload" href="/static/fonts/Lato-Regular.woff2" as="font" type="font/woff2" crossorigin>
100 <link rel="preload" href="/static/fonts/ysrf.woff2" as="font" type="font/woff2" crossorigin>
101 <!-- Cascade layer order, declared before any stylesheet so it is a statement
102 rather than an accident of link order. A layer's position is fixed where
103 its name is FIRST seen, so without this the two generated sheets below
104 would establish `makeover` simply by loading first, and reordering these
105 links would silently reorder the cascade. It also has to precede the
106 per-page sheets (wizard.css, media-player.css), which this partial does
107 not link and which base.html therefore cannot order on its own.
108
109 Earlier in the list = lower priority. `makeover` is first because the
110 design system is what the site overrides, never the reverse. Unlayered
111 rules still beat every named layer, which is why style.css works today
112 with no layers of its own, and why the theme block that theming.rs
113 injects into <head> keeps outranking everything. The other three names
114 are declared ahead of any layer adoption in style.css and are empty
115 until then; declaring an empty layer costs nothing. -->
116 <style>@layer makeover, base, components, responsive;</style>
117 <link rel="stylesheet" href="/static/geometry.css?v={version}">
118 <link rel="stylesheet" href="/static/layout.css?v={version}">
119 <link rel="stylesheet" href="/static/style.css?v={version}">
120 <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
121 <script src="/static/htmx.min.js"></script>
122 <script src="/static/upload.js?v={version}"></script>
123 <script type="module" src="/static/dist/core/index.js?v={version}"></script>"#,
124 );
125
126 write_if_changed(Path::new("templates/_head_assets.html"), &partial);
127
128 // Per-page island loader macro. Heavy/page-specific islands (media player,
129 // uploader, ...) load on the pages that use them via
130 // `{% import "_island.html" as island %}{% call island::island("name") %}`,
131 // cache-busted by the same content hash as the head assets.
132 let island_partial = r#"{% macro island(name) -%}
133 <script type="module" src="/static/dist/islands/{{ name }}.js?v=__VER__"></script>
134 {%- endmacro %}
135 "#
136 .replace("__VER__", version);
137 write_if_changed(Path::new("templates/_island.html"), &island_partial);
138
139 // Per-page stylesheet loader, same idea as the island macro. wizard.css and
140 // media-player.css are linked by the pages that need them rather than by
141 // _head_assets.html, so this is how they get the content hash.
142 let sheet_partial = r#"{% macro sheet(name) -%}
143 <link rel="stylesheet" href="/static/{{ name }}?v=__VER__">
144 {%- endmacro %}
145 "#
146 .replace("__VER__", version);
147 write_if_changed(Path::new("templates/_sheet.html"), &sheet_partial);
148 }
149
150 /// The hand-written stylesheets. Ordered, so the guard reports the same way
151 /// twice. `geometry.css` and `layout.css` are excluded: they are generated.
152 const HAND_WRITTEN_CSS: [&str; 3] = [
153 "static/style.css",
154 "static/wizard.css",
155 "static/media-player.css",
156 ];
157
158 /// Fail the build on any width breakpoint that is not a `SizeClass` boundary.
159 ///
160 /// A media condition cannot read a custom property and `@custom-media` has
161 /// shipped nowhere, so every threshold in the stylesheets is a hand-typed
162 /// literal and there is no generator path. The guard is the substitute: bump
163 /// makeover-geometry to a release that moves a boundary and the build breaks
164 /// here, rather than the layout breaking quietly in a browser.
165 ///
166 /// Only the numbers are checked, not the surrounding syntax. That accepts
167 /// `(min-width: 600px) and (max-width: 839px)` without having to parse it, and
168 /// still catches the thing worth catching: a number nobody can trace to the
169 /// scale.
170 fn check_breakpoints() {
171 use makeover_geometry::SizeClass;
172
173 let compact_max = SizeClass::Medium.min_px() - 1;
174 let expanded_min = SizeClass::Expanded.min_px();
175 let allowed = [
176 compact_max,
177 SizeClass::Medium.min_px(),
178 expanded_min - 1,
179 expanded_min,
180 ];
181
182 let mut strays: Vec<String> = Vec::new();
183 for path in HAND_WRITTEN_CSS {
184 println!("cargo::rerun-if-changed={path}");
185 let Ok(css) = fs::read_to_string(path) else {
186 continue;
187 };
188 for (n, line) in css.lines().enumerate() {
189 for px in width_conditions(line) {
190 if !allowed.contains(&px) {
191 strays.push(format!("{path}:{}: (…-width: {px}px)", n + 1));
192 }
193 }
194 }
195 }
196
197 assert!(
198 strays.is_empty(),
199 "stylesheet width breakpoints that are not SizeClass boundaries \
200 ({}, {}, {}, {}px):\n {}\n\
201 Either move the rule to a boundary, or make it dimensional so it \
202 needs no threshold at all: a grid wants \
203 repeat(auto-fit, minmax(<content floor>, 1fr)) and a size wants \
204 clamp(). A threshold is for what appears and disappears.",
205 allowed[0],
206 allowed[1],
207 allowed[2],
208 allowed[3],
209 strays.join("\n ")
210 );
211 }
212
213 /// Every pixel value used as a `min-width` or `max-width` media feature on one
214 /// line. Deliberately narrow: it reads `width` features and ignores the
215 /// `width` property, `min-width`/`max-width` declarations, and every other
216 /// number in the sheet.
217 fn width_conditions(line: &str) -> Vec<u16> {
218 let mut found = Vec::new();
219 for (i, _) in line.match_indices("-width:") {
220 let prefix = &line[..i];
221 if !(prefix.ends_with("min") || prefix.ends_with("max")) {
222 continue;
223 }
224 // A media feature is parenthesised; the property form never is.
225 let opened = prefix.trim_end_matches(['m', 'i', 'n', 'a', 'x']);
226 if !opened.ends_with('(') {
227 continue;
228 }
229 let rest = &line[i + "-width:".len()..];
230 let digits: String = rest
231 .trim_start()
232 .chars()
233 .take_while(char::is_ascii_digit)
234 .collect();
235 if let Ok(px) = digits.parse::<u16>() {
236 found.push(px);
237 }
238 }
239 found
240 }
241
242 /// Write `contents` to `path` only if it differs, to avoid needless rebuilds.
243 fn write_if_changed(path: &Path, contents: &str) {
244 let needs_write = fs::read_to_string(path).map_or(true, |existing| existing != contents);
245 if needs_write {
246 fs::write(path, contents)
247 .unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
248 }
249 }
250
251 /// Compile the TypeScript frontend (`frontend/`) to browser ESM in
252 /// `static/dist/` via `npm run build` (which runs `tsc`).
253 ///
254 /// Self-contained: on a fresh checkout or a new build host (no `node_modules`)
255 /// it runs `npm ci` first, so there is no manual install gate before a deploy.
256 /// Best-effort and non-fatal otherwise, an absent Node or a compile error only
257 /// emits a `cargo::warning` and leaves the Rust build to succeed against
258 /// whatever `static/dist/` already holds. Set `MNW_SKIP_FRONTEND_BUILD=1` to
259 /// opt out entirely (e.g. a Node-less CI that doesn't need the JS).
260 fn build_frontend() {
261 // Re-run the whole build script when the TS sources or its config change.
262 println!("cargo::rerun-if-changed=frontend/src");
263 println!("cargo::rerun-if-changed=frontend/package.json");
264 println!("cargo::rerun-if-changed=frontend/package-lock.json");
265 println!("cargo::rerun-if-changed=frontend/tsconfig.json");
266
267 if std::env::var_os("MNW_SKIP_FRONTEND_BUILD").is_some() {
268 println!("cargo::warning=frontend build skipped (MNW_SKIP_FRONTEND_BUILD set)");
269 return;
270 }
271 // Fresh checkout / new build host: install deps once (clean, from the
272 // lockfile) so the frontend build needs no manual `npm install` gate before
273 // a deploy. Skipped once node_modules exists; needs network on this run.
274 if !Path::new("frontend/node_modules").is_dir() {
275 match Command::new("npm")
276 .args(["ci"])
277 .current_dir("frontend")
278 .status()
279 {
280 Ok(s) if s.success() => {}
281 Ok(s) => {
282 println!(
283 "cargo::warning=npm ci failed (exit {:?}); skipping frontend build (serving existing static/dist)",
284 s.code()
285 );
286 return;
287 }
288 Err(e) => {
289 println!(
290 "cargo::warning=could not run npm ({e}); is Node installed? skipping frontend build (serving existing static/dist)"
291 );
292 return;
293 }
294 }
295 }
296 match Command::new("npm")
297 .args(["run", "build"])
298 .current_dir("frontend")
299 .status()
300 {
301 Ok(s) if s.success() => {}
302 Ok(s) => println!(
303 "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist",
304 s.code()
305 ),
306 Err(e) => println!(
307 "cargo::warning=could not run npm for the frontend build ({e}); is Node installed? \
308 skipping (serving existing static/dist)"
309 ),
310 }
311 }
312
313 /// Recursively hash every `.js` file under `dir` into `hasher`, in a
314 /// deterministic order. Missing directory is a no-op (first build before the
315 /// frontend has been compiled).
316 fn hash_dir_js(dir: &Path, hasher: &mut DefaultHasher) {
317 let Ok(entries) = fs::read_dir(dir) else {
318 return;
319 };
320 let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
321 paths.sort();
322 for path in paths {
323 if path.is_dir() {
324 hash_dir_js(&path, hasher);
325 } else if path.extension().and_then(|e| e.to_str()) == Some("js")
326 && let Ok(content) = fs::read(&path)
327 {
328 content.hash(hasher);
329 }
330 }
331 }
332