Skip to content

Commit ca89530

Browse files
committed
Emit versioned asset bundles for immutable caching
1 parent 84130bb commit ca89530

6 files changed

Lines changed: 3513 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ jobs:
3535
run: |
3636
make clean
3737
make assets
38-
git diff --exit-code static/sf/sf.css static/sf/sf.js
38+
git diff --exit-code static/sf/sf.css static/sf/sf.js static/sf/sf.*.css static/sf/sf.*.js
3939
4040
- name: Check formatting
4141
run: cargo fmt --all -- --check

Makefile

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ RUST_VERSION := 1.75+
2323
# ============== Asset Sources ==============
2424
CSS_SRC := $(sort $(wildcard css-src/*.css))
2525
JS_SRC := $(sort $(wildcard js-src/*.js))
26+
VERSIONED_CSS := static/sf/sf.$(VERSION).css
27+
VERSIONED_JS := static/sf/sf.$(VERSION).js
2628

2729
# ============== Phony Targets ==============
2830
.PHONY: banner help assets build build-release test test-quick test-doc test-unit test-one \
@@ -40,16 +42,18 @@ banner:
4042

4143
# ============== Asset Targets ==============
4244

43-
assets: static/sf/sf.css static/sf/sf.js
45+
assets: static/sf/sf.css static/sf/sf.js $(VERSIONED_CSS) $(VERSIONED_JS)
4446

45-
static/sf/sf.css: $(CSS_SRC)
47+
static/sf/sf.css $(VERSIONED_CSS): $(CSS_SRC)
4648
@printf "$(PROGRESS) CSS sf.css ($(words $(CSS_SRC)) files)\n"
47-
@cat $(CSS_SRC) > $@
49+
@cat $(CSS_SRC) > static/sf/sf.css
50+
@cp static/sf/sf.css $(VERSIONED_CSS)
4851
@printf "$(GREEN)$(CHECK) CSS bundled$(RESET)\n"
4952

50-
static/sf/sf.js: $(JS_SRC)
53+
static/sf/sf.js $(VERSIONED_JS): $(JS_SRC)
5154
@printf "$(PROGRESS) JS sf.js ($(words $(JS_SRC)) files)\n"
52-
@cat $(JS_SRC) > $@
55+
@cat $(JS_SRC) > static/sf/sf.js
56+
@cp static/sf/sf.js $(VERSIONED_JS)
5357
@printf "$(GREEN)$(CHECK) JS bundled$(RESET)\n"
5458

5559
# ============== Build Targets ==============
@@ -228,7 +232,7 @@ publish: banner
228232
clean:
229233
@printf "$(ARROW) Cleaning build artifacts...\n"
230234
@cargo clean
231-
@rm -f static/sf/sf.css static/sf/sf.js
235+
@rm -f static/sf/sf.css static/sf/sf.js static/sf/sf.*.css static/sf/sf.*.js
232236
@printf "$(GREEN)$(CHECK) Clean complete$(RESET)\n"
233237

234238
# ============== Development ==============

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ let app = api::router(state)
2323

2424
That's it. Every asset is compiled into the binary via `include_dir!`.
2525

26+
For production caching, versioned bundle filenames are also emitted as
27+
`/sf/sf.<crate-version>.css` and `/sf/sf.<crate-version>.js`. Those versioned
28+
files are served with immutable caching, while the stable `sf.css` and `sf.js`
29+
paths remain available for compatibility.
30+
2631
## Screenshots
2732

2833
**Planner123** — Gantt chart with dependency arrows, project-colored bars, and constraint scoring:
@@ -437,6 +442,10 @@ make
437442
cargo build
438443
```
439444

445+
Bundling writes both stable compatibility assets (`static/sf/sf.css`,
446+
`static/sf/sf.js`) and versioned assets (`static/sf/sf.<version>.css`,
447+
`static/sf/sf.<version>.js`).
448+
440449
## Acknowledgments
441450

442451
solverforge-ui builds on these excellent open-source projects:

src/lib.rs

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,56 @@ fn mime_from_path(path: &str) -> &'static str {
5353
}
5454

5555
fn is_immutable(path: &str) -> bool {
56-
path.starts_with("fonts/") || path.starts_with("vendor/") || path.starts_with("img/")
56+
path.starts_with("fonts/")
57+
|| path.starts_with("vendor/")
58+
|| path.starts_with("img/")
59+
|| is_versioned_bundle(path)
60+
}
61+
62+
fn is_versioned_bundle(path: &str) -> bool {
63+
path.strip_prefix("sf.")
64+
.and_then(|rest| rest.rsplit_once('.'))
65+
.map(|(version, ext)| {
66+
!version.is_empty()
67+
&& version.chars().all(|ch| {
68+
ch.is_ascii_digit() || ch == '.' || ch == '-' || ch.is_ascii_alphabetic()
69+
})
70+
&& matches!(ext, "css" | "js")
71+
})
72+
.unwrap_or(false)
73+
}
74+
75+
#[cfg(test)]
76+
mod tests {
77+
use super::{is_immutable, is_versioned_bundle, mime_from_path};
78+
79+
#[test]
80+
fn versioned_bundles_are_detected() {
81+
assert!(is_versioned_bundle("sf.0.1.0.css"));
82+
assert!(is_versioned_bundle("sf.0.1.0.js"));
83+
assert!(is_versioned_bundle("sf.0.2.0-beta.1.js"));
84+
assert!(!is_versioned_bundle("sf.css"));
85+
assert!(!is_versioned_bundle("sf.js"));
86+
assert!(!is_versioned_bundle("vendor/sf.0.1.0.js"));
87+
}
88+
89+
#[test]
90+
fn immutable_cache_policy_includes_versioned_bundles() {
91+
assert!(is_immutable("fonts/space-grotesk.woff2"));
92+
assert!(is_immutable("vendor/split/split.min.js"));
93+
assert!(is_immutable("img/solverforge-logo.svg"));
94+
assert!(is_immutable("sf.0.1.0.css"));
95+
assert!(is_immutable("sf.0.1.0.js"));
96+
assert!(!is_immutable("sf.css"));
97+
assert!(!is_immutable("sf.js"));
98+
}
99+
100+
#[test]
101+
fn mime_detection_still_works_for_versioned_assets() {
102+
assert_eq!(mime_from_path("sf.0.1.0.css"), "text/css; charset=utf-8");
103+
assert_eq!(
104+
mime_from_path("sf.0.1.0.js"),
105+
"application/javascript; charset=utf-8"
106+
);
107+
}
57108
}

0 commit comments

Comments
 (0)