|
1 |
+ |
use std::collections::hash_map::DefaultHasher;
|
|
2 |
+ |
use std::fs;
|
|
3 |
+ |
use std::hash::{Hash, Hasher};
|
| 1 |
4 |
|
use std::path::Path;
|
| 2 |
5 |
|
use std::process::Command;
|
| 3 |
6 |
|
|
| 4 |
7 |
|
fn main() {
|
| 5 |
8 |
|
build_frontend();
|
|
9 |
+ |
fingerprint_assets();
|
|
10 |
+ |
}
|
|
11 |
+ |
|
|
12 |
+ |
/// Content-hash the served static files and emit the template partials that
|
|
13 |
+ |
/// carry a `?v=<hash>` cache-buster on every asset URL.
|
|
14 |
+ |
///
|
|
15 |
+ |
/// Runs after `build_frontend` so the hash covers the JS that was just emitted
|
|
16 |
+ |
/// into `static/dist/`, not the previous build's.
|
|
17 |
+ |
///
|
|
18 |
+ |
/// One global version rather than a per-file hash: an upgrade to any watched
|
|
19 |
+ |
/// file re-fetches all of them, which costs a handful of requests once and
|
|
20 |
+ |
/// keeps the templates free of per-asset bookkeeping. What it buys is that a
|
|
21 |
+ |
/// redeployed file can never be served stale from browser cache — for a chat
|
|
22 |
+ |
/// island that would mean old protocol logic talking to a new server, which
|
|
23 |
+ |
/// presents as "chat is broken for some people" rather than as a cache bug.
|
|
24 |
+ |
///
|
|
25 |
+ |
/// The generated partials are gitignored build output. They are written only
|
|
26 |
+ |
/// when their content changes, so a no-op build does not retrigger Askama.
|
|
27 |
+ |
fn fingerprint_assets() {
|
|
28 |
+ |
let static_files = ["static/style.css", "static/htmx.min.js", "static/mt.js"];
|
|
29 |
+ |
|
|
30 |
+ |
let mut hasher = DefaultHasher::new();
|
|
31 |
+ |
for path in &static_files {
|
|
32 |
+ |
println!("cargo::rerun-if-changed={path}");
|
|
33 |
+ |
if let Ok(content) = fs::read(path) {
|
|
34 |
+ |
content.hash(&mut hasher);
|
|
35 |
+ |
}
|
|
36 |
+ |
}
|
|
37 |
+ |
// Fold the emitted bundles in so `?v=` also busts when the TypeScript
|
|
38 |
+ |
// changes. Read-only: `frontend/src` is the watched trigger (in
|
|
39 |
+ |
// build_frontend); watching the outputs we generate would loop.
|
|
40 |
+ |
hash_dir_js(Path::new("static/dist"), &mut hasher);
|
|
41 |
+ |
let version = &format!("{:016x}", hasher.finish())[..8];
|
|
42 |
+ |
|
|
43 |
+ |
// Included by base.html's <head>.
|
|
44 |
+ |
let head = format!(
|
|
45 |
+ |
r#" <link rel="stylesheet" href="/static/style.css?v={version}">
|
|
46 |
+ |
<script src="/static/htmx.min.js?v={version}"></script>"#
|
|
47 |
+ |
);
|
|
48 |
+ |
write_if_changed(Path::new("templates/_head_assets.html"), &head);
|
|
49 |
+ |
|
|
50 |
+ |
// Included at the end of base.html's <body>. Kept separate from the head
|
|
51 |
+ |
// partial because mt.js is a classic (non-deferred) script and has to keep
|
|
52 |
+ |
// running after the document is parsed.
|
|
53 |
+ |
let scripts = format!(r#" <script src="/static/mt.js?v={version}"></script>"#);
|
|
54 |
+ |
write_if_changed(Path::new("templates/_body_scripts.html"), &scripts);
|
|
55 |
+ |
|
|
56 |
+ |
// Per-page island loader, for the compiled ESM under static/dist/. Used as
|
|
57 |
+ |
// `{% import "_island.html" as island %}{% call island::island("chat") %}`
|
|
58 |
+ |
// and cache-busted by the same content hash as the head assets.
|
|
59 |
+ |
let island = r#"{% macro island(name) -%}
|
|
60 |
+ |
<script type="module" src="/static/dist/{{ name }}.js?v=__VER__"></script>
|
|
61 |
+ |
{%- endmacro %}
|
|
62 |
+ |
"#
|
|
63 |
+ |
.replace("__VER__", version);
|
|
64 |
+ |
write_if_changed(Path::new("templates/_island.html"), &island);
|
|
65 |
+ |
}
|
|
66 |
+ |
|
|
67 |
+ |
/// Write `contents` to `path` only if it differs, to avoid needless rebuilds.
|
|
68 |
+ |
fn write_if_changed(path: &Path, contents: &str) {
|
|
69 |
+ |
let needs_write = fs::read_to_string(path)
|
|
70 |
+ |
.map(|existing| existing != contents)
|
|
71 |
+ |
.unwrap_or(true);
|
|
72 |
+ |
if needs_write {
|
|
73 |
+ |
fs::write(path, contents)
|
|
74 |
+ |
.unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
|
|
75 |
+ |
}
|
|
76 |
+ |
}
|
|
77 |
+ |
|
|
78 |
+ |
/// Recursively hash every `.js` file under `dir` into `hasher`, in a
|
|
79 |
+ |
/// deterministic order. A missing directory is a no-op — the first build on a
|
|
80 |
+ |
/// fresh checkout runs before the frontend has been compiled.
|
|
81 |
+ |
fn hash_dir_js(dir: &Path, hasher: &mut DefaultHasher) {
|
|
82 |
+ |
let Ok(entries) = fs::read_dir(dir) else {
|
|
83 |
+ |
return;
|
|
84 |
+ |
};
|
|
85 |
+ |
let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
|
|
86 |
+ |
paths.sort();
|
|
87 |
+ |
for path in paths {
|
|
88 |
+ |
if path.is_dir() {
|
|
89 |
+ |
hash_dir_js(&path, hasher);
|
|
90 |
+ |
} else if path.extension().and_then(|e| e.to_str()) == Some("js")
|
|
91 |
+ |
&& let Ok(content) = fs::read(&path)
|
|
92 |
+ |
{
|
|
93 |
+ |
content.hash(hasher);
|
|
94 |
+ |
}
|
|
95 |
+ |
}
|
| 6 |
96 |
|
}
|
| 7 |
97 |
|
|
| 8 |
98 |
|
/// Compile the TypeScript frontend (`frontend/`) to browser ESM in
|