diff --git a/docs/runbooks/web-dashboard-rollout.md b/docs/runbooks/web-dashboard-rollout.md index b84d4e1..9059d19 100644 --- a/docs/runbooks/web-dashboard-rollout.md +++ b/docs/runbooks/web-dashboard-rollout.md @@ -9,7 +9,9 @@ already be complete. The deployed binary does not require Node. For UI development, edit `src/web/tailwind.css`, the templates, or `src/web/static/app.js`, then run `npm run css` and commit the regenerated `src/web/static/app.css` alongside the -source. CI and release preparation should run `npm run css:check` to detect +source. The `/static/*.css|js` URLs carry a hash of the embedded assets +(`web::ASSET_VERSION`), so a new build busts browser caches on its own — no +hard refresh and no version bump needed. CI and release preparation should run `npm run css:check` to detect stylesheet drift. ```sh diff --git a/src/web/mod.rs b/src/web/mod.rs index a328f8e..1601081 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -6,7 +6,7 @@ pub mod session; pub mod users; use std::fmt; -use std::sync::Mutex; +use std::sync::{LazyLock, Mutex}; use std::time::SystemTime; use askama::Template; @@ -204,8 +204,21 @@ pub struct Page { pub flash: Option, pub active_nav: String, pub version: &'static str, + /// Cache-busting token for `/static/*.css|js` URLs: a content hash, so + /// any stylesheet or script change reaches browsers that cached the + /// previous build (they are served with a one-day `max-age`). + pub asset_version: &'static str, } +/// First 12 hex digits of the SHA-256 over the embedded CSS and JS assets. +pub static ASSET_VERSION: LazyLock = LazyLock::new(|| { + let mut hasher = Sha256::new(); + hasher.update(include_str!("static/app.css")); + hasher.update(include_str!("static/app.js")); + hasher.update(include_str!("static/theme.js")); + hex::encode(hasher.finalize())[..12].to_string() +}); + impl Page { pub fn new(title: impl Into, viewer: Option, active_nav: &str) -> Self { Self { @@ -214,6 +227,7 @@ impl Page { flash: None, active_nav: active_nav.to_string(), version: crate::VERSION, + asset_version: ASSET_VERSION.as_str(), } } @@ -995,6 +1009,24 @@ mod tests { assert_eq!(session.status(), StatusCode::OK); } + #[test] + fn asset_urls_carry_a_content_hash_not_the_crate_version() { + let page = Page::new("t", None, "latest"); + assert_eq!(page.asset_version.len(), 12); + assert!(page.asset_version.chars().all(|c| c.is_ascii_hexdigit())); + assert_ne!(page.asset_version, crate::VERSION); + let html = ErrorTemplate { + page, + heading: "h".into(), + message: "m".into(), + } + .render() + .unwrap(); + let expected = format!("/static/app.css?v={}", ASSET_VERSION.as_str()); + assert!(html.contains(&expected), "{html}"); + assert!(!html.contains(&format!("/static/app.css?v={}", crate::VERSION))); + } + #[tokio::test] async fn static_assets_use_content_hash_etags() { let (_dir, state) = test_state(Config::default()).await; diff --git a/src/web/templates/layout.html b/src/web/templates/layout.html index 15ed82e..9f3d057 100644 --- a/src/web/templates/layout.html +++ b/src/web/templates/layout.html @@ -4,9 +4,9 @@ {{ page.title }} · The Daily EPUB - + - + @@ -47,6 +47,6 @@ {% match page.flash %}{% when Some with (flash) %}
{{ flash.text }}
{% when None %}{% endmatch %}
{% block content %}{% endblock %}
The Daily EPUBdaily-epub {{ page.version }}
- +