Skip to content

Commit d0deaa5

Browse files
Release v0.4.6: Flow DX, public/, PWA defaults, and SPA query navigation.
Adds loader_refresh_input, query_nav_link, public/ serving, theme→PWA, flow-booking template, __resuma.navigate/buildUrl in core.js, and docs cookbook. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 620acb6 commit d0deaa5

40 files changed

Lines changed: 1906 additions & 195 deletions

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
44

55
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

7+
## [0.4.6] - 2026-06-02
8+
9+
Flow DX release: query navigation, `public/`, and booking scaffold.
10+
11+
### Added
12+
13+
- **`__resuma.navigate` / `__resuma.buildUrl`** on the default `core.js` runtime for SPA reloads when query params change (server `#[load]` re-runs).
14+
- **`loader_refresh_input`**, **`loader_refresh_form`**, **`query_nav_link`**, **`build_query_href`** — Rust helpers for query-driven pages.
15+
- **`public/` auto-serve** on `FlowApp` (defaults to `{CARGO_MANIFEST_DIR}/public`), with paths merged into PWA precache.
16+
- **`with_theme_pwa(Theme)`** — maps theme primary/background into auto PWA colors.
17+
- **PWA icons from `public/`**`icons/icon-192.png` etc. override generated SVG manifest entries when present.
18+
- **`resuma new --template flow-booking`** — minimal appointments sample.
19+
- **`resuma dev --kill-stale`** (Linux) and `cargo watch` on `public/`.
20+
- Docs: [docs/FLOW_COOKBOOK.md](./docs/FLOW_COOKBOOK.md).
21+
22+
### Fixed
23+
24+
- **NavLink `active` after SPA navigation with query**`/reservar` stays active on `/reservar?fecha=…` (Rust + `core.js`).
25+
- **PWA manifest icons from `public/`** — use the file’s real `Content-Type` (PNG/SVG).
26+
- **Clippy** — simplified CSP `from_env` toggle (CI `-D warnings`).
27+
728
## [0.4.2] - 2026-05-31
829

930
CLI onboarding patch release.

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ members = [
1111
]
1212

1313
[workspace.package]
14-
version = "0.4.5"
14+
version = "0.4.6"
1515
edition = "2021"
1616
rust-version = "1.91"
1717
authors = ["Resuma Contributors"]
@@ -25,7 +25,7 @@ categories = ["web-programming", "asynchronous"]
2525
readme = "README.md"
2626

2727
[workspace.dependencies]
28-
resuma-macros = { path = "crates/resuma-macros", version = "0.4.5" }
28+
resuma-macros = { path = "crates/resuma-macros", version = "0.4.6" }
2929

3030
# 3rd-party
3131
serde = { version = "1", features = ["derive"] }

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ cd my-app
7575
resuma dev --open
7676
```
7777

78-
**Templates:** `basic` · `todo` · `flow` · `flow-fullstack`
78+
**Templates:** `basic` · `todo` · `flow` · `flow-booking` · `flow-fullstack`
7979

8080
```rust
8181
use resuma::prelude::*;

crates/resuma/assets/core.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/resuma/assets/runtime.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/resuma/src/cli/mod.rs

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ enum Commands {
5252
Doctor,
5353
/// Run the app with hot reload.
5454
Dev {
55-
/// Bind address (default: 127.0.0.1:3000).
55+
/// Bind address (default: 127.0.0.1:3000). If the port is taken, the next free port is used.
5656
#[arg(long, default_value = "127.0.0.1:3000")]
5757
addr: String,
5858
/// Open the default browser once the server starts.
@@ -61,6 +61,9 @@ enum Commands {
6161
/// Skip building the JS runtime (useful when prebuilt).
6262
#[arg(long)]
6363
skip_runtime: bool,
64+
/// Kill any process listening on the dev port before starting (Linux).
65+
#[arg(long)]
66+
kill_stale: bool,
6467
},
6568
/// Build a production binary + JS bundles.
6669
Build {
@@ -108,7 +111,8 @@ pub fn run() -> Result<()> {
108111
addr,
109112
open,
110113
skip_runtime,
111-
} => dev_command(&addr, open, skip_runtime),
114+
kill_stale,
115+
} => dev_command(&addr, open, skip_runtime, kill_stale),
112116
Commands::Build {
113117
static_export,
114118
out,
@@ -194,6 +198,20 @@ pub(crate) fn ensure_rust_toolchain() -> Result<()> {
194198
}
195199
}
196200

201+
#[cfg(unix)]
202+
fn kill_stale_port(port: u16) {
203+
let target = format!("{port}/tcp");
204+
let status = Command::new("fuser").args(["-k", &target]).status();
205+
if let Ok(s) = status {
206+
if s.success() {
207+
println!("[resuma] freed port {port}");
208+
}
209+
}
210+
}
211+
212+
#[cfg(not(unix))]
213+
fn kill_stale_port(_port: u16) {}
214+
197215
fn ensure_cargo_watch() -> Result<()> {
198216
let ok = Command::new("cargo")
199217
.args(["watch", "--version"])
@@ -217,13 +235,29 @@ fn ensure_cargo_watch() -> Result<()> {
217235
Ok(())
218236
}
219237

220-
fn dev_command(addr: &str, open: bool, skip_runtime: bool) -> Result<()> {
238+
fn dev_command(addr: &str, open: bool, skip_runtime: bool, kill_stale: bool) -> Result<()> {
221239
ensure_rust_toolchain()?;
222240
ensure_cargo_watch()?;
223241
if !skip_runtime {
224242
ensure_runtime_built()?;
225243
}
226244

245+
let preferred: std::net::SocketAddr = addr
246+
.parse()
247+
.map_err(|_| anyhow!("invalid --addr {addr:?} (expected e.g. 127.0.0.1:3000)"))?;
248+
if kill_stale {
249+
kill_stale_port(preferred.port());
250+
}
251+
let bound = crate::server::resolve_listen_addr(preferred)
252+
.map_err(|e| anyhow!("could not bind {preferred}: {e}"))?;
253+
if bound != preferred {
254+
println!(
255+
"[resuma] port {} in use, using http://{}",
256+
preferred.port(),
257+
bound
258+
);
259+
}
260+
let addr = bound.to_string();
227261
let url = format!("http://{}", addr);
228262
println!("[resuma] starting dev server at {}", url);
229263
if open {
@@ -233,11 +267,12 @@ fn dev_command(addr: &str, open: bool, skip_runtime: bool) -> Result<()> {
233267

234268
let mut watch_args = vec![
235269
"watch".to_string(),
236-
"-c".to_string(),
237270
"-q".to_string(),
238271
"-w".to_string(),
239272
"src".to_string(),
240273
"-w".to_string(),
274+
"public".to_string(),
275+
"-w".to_string(),
241276
"Cargo.toml".to_string(),
242277
];
243278
#[cfg(windows)]

crates/resuma/src/cli/prompt.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const TEMPLATE_CHOICES: &[(&str, &str)] = &[
3535
("basic", "static SSR page, zero client JS"),
3636
("todo", "full showcase (signals, server, islands)"),
3737
("flow", "multi-page app with src/pages/"),
38+
("flow-booking", "appointments + query-driven #[load]"),
3839
("flow-fullstack", "Flow + SQLx SQLite sample"),
3940
];
4041

@@ -49,10 +50,11 @@ pub fn prompt_template() -> Result<String> {
4950
"" | "1" => "basic",
5051
"2" => "todo",
5152
"3" => "flow",
52-
"4" => "flow-fullstack",
53+
"4" => "flow-booking",
54+
"5" => "flow-fullstack",
5355
other if TEMPLATE_CHOICES.iter().any(|(id, _)| *id == other) => other,
5456
_ => {
55-
eprintln!(" (pick 1–4 or type basic/todo/flow/flow-fullstack)");
57+
eprintln!(" (pick 1–5 or type basic/todo/flow/flow-booking/flow-fullstack)");
5658
continue;
5759
}
5860
};

crates/resuma/src/cli/scaffold.rs

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,27 @@ const FLOW_ABOUT: &str = include_str!("../../templates/flow/pages/about.rs");
8080
const FLOW_MOD: &str = include_str!("../../templates/flow/pages/mod.rs");
8181
const FLOW_REGISTRY: &str = include_str!("../../templates/flow/pages/_registry.rs");
8282

83+
const CARGO_FLOW_BOOKING: &str = r#"[package]
84+
name = "%NAME%"
85+
version = "0.1.0"
86+
edition = "2021"
87+
88+
[dependencies]
89+
resuma = "%RESUMA_VERSION%"
90+
tokio = { version = "1", features = ["full"] }
91+
serde = { version = "1", features = ["derive"] }
92+
once_cell = "1"
93+
parking_lot = "0.12"
94+
"#;
95+
96+
const FLOW_BOOKING_MAIN: &str = include_str!("../../templates/flow-booking/main.rs");
97+
const FLOW_BOOKING_STORE: &str = include_str!("../../templates/flow-booking/booking_store.rs");
98+
const FLOW_BOOKING_MOD: &str = include_str!("../../templates/flow-booking/pages/mod.rs");
99+
const FLOW_BOOKING_REGISTRY: &str = include_str!("../../templates/flow-booking/pages/_registry.rs");
100+
const FLOW_BOOKING_INDEX: &str = include_str!("../../templates/flow-booking/pages/index.rs");
101+
const FLOW_BOOKING_BOOK: &str = include_str!("../../templates/flow-booking/pages/book.rs");
102+
const FLOW_BOOKING_GRACIAS: &str = include_str!("../../templates/flow-booking/pages/gracias.rs");
103+
83104
const CARGO_FULLSTACK: &str = r#"[package]
84105
name = "%NAME%"
85106
version = "0.1.0"
@@ -115,6 +136,7 @@ Created with [Resuma](https://github.com/GolfredoPerezFernandez/resuma).
115136
- **todo** - full Resuma showcase (signals, server, island, security, js!)
116137
- **flow** - multi-page app with `src/pages/` and FlowApp
117138
- **flow-fullstack** - Flow + SQLx (SQLite) with users CRUD sample
139+
- **flow-booking** - appointments with query-driven `#[load]` + `loader_refresh_input`
118140
119141
## Develop
120142
@@ -182,6 +204,30 @@ pub fn create_project(name: &str, template: &str) -> Result<()> {
182204
fs::write(pages.join("index.rs"), FLOW_INDEX).context("write pages/index.rs")?;
183205
fs::write(pages.join("about.rs"), FLOW_ABOUT).context("write pages/about.rs")?;
184206
}
207+
"flow-booking" => {
208+
fs::write(dir.join("Cargo.toml"), render_cargo(CARGO_FLOW_BOOKING, name))
209+
.context("write Cargo.toml")?;
210+
fs::write(
211+
dir.join("src/main.rs"),
212+
FLOW_BOOKING_MAIN.replace("%NAME%", name),
213+
)
214+
.context("write src/main.rs")?;
215+
fs::write(dir.join("src/booking_store.rs"), FLOW_BOOKING_STORE)
216+
.context("write src/booking_store.rs")?;
217+
let pages = dir.join("src/pages");
218+
fs::create_dir_all(&pages)?;
219+
fs::write(pages.join("mod.rs"), FLOW_BOOKING_MOD).context("write pages/mod.rs")?;
220+
fs::write(pages.join("_registry.rs"), FLOW_BOOKING_REGISTRY)
221+
.context("write pages/_registry.rs")?;
222+
fs::write(pages.join("index.rs"), FLOW_BOOKING_INDEX.replace("%NAME%", name))
223+
.context("write pages/index.rs")?;
224+
fs::write(pages.join("book.rs"), FLOW_BOOKING_BOOK).context("write pages/book.rs")?;
225+
fs::write(pages.join("gracias.rs"), FLOW_BOOKING_GRACIAS)
226+
.context("write pages/gracias.rs")?;
227+
let public = dir.join("public");
228+
fs::create_dir_all(&public)?;
229+
fs::write(public.join(".gitkeep"), "").ok();
230+
}
185231
"flow-fullstack" => {
186232
fs::write(dir.join("Cargo.toml"), render_cargo(CARGO_FULLSTACK, name))
187233
.context("write Cargo.toml")?;
@@ -205,7 +251,7 @@ pub fn create_project(name: &str, template: &str) -> Result<()> {
205251
}
206252
other => {
207253
return Err(anyhow!(
208-
"unknown template `{}` (try: basic, todo, flow, flow-fullstack)",
254+
"unknown template `{}` (try: basic, todo, flow, flow-booking, flow-fullstack)",
209255
other
210256
));
211257
}
@@ -230,7 +276,13 @@ mod tests {
230276

231277
#[test]
232278
fn cargo_templates_pin_the_cli_crate_version() {
233-
for cargo_template in [CARGO_BASIC, CARGO_TODO, CARGO_FLOW, CARGO_FULLSTACK] {
279+
for cargo_template in [
280+
CARGO_BASIC,
281+
CARGO_TODO,
282+
CARGO_FLOW,
283+
CARGO_FLOW_BOOKING,
284+
CARGO_FULLSTACK,
285+
] {
234286
let rendered = render_cargo(cargo_template, "smoke-app");
235287
assert!(rendered.contains("name = \"smoke-app\""));
236288
assert!(rendered.contains(&format!("\"{}\"", env!("CARGO_PKG_VERSION"))));

crates/resuma/src/core/nav.rs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,46 @@ fn paths_match(href: &str, current: &str) -> bool {
4949
if href == current {
5050
return true;
5151
}
52-
if href != "/" && current.starts_with(href) {
53-
return current
52+
let (href_path, href_query) = split_path_query(href);
53+
let (cur_path, cur_query) = split_path_query(current);
54+
if href_query.is_some() {
55+
return href_path == cur_path && href_query == cur_query;
56+
}
57+
if href_path == cur_path {
58+
return true;
59+
}
60+
if href_path != "/" && cur_path.starts_with(href_path) {
61+
return cur_path
5462
.as_bytes()
55-
.get(href.len())
63+
.get(href_path.len())
5664
.is_none_or(|b| *b == b'/');
5765
}
5866
false
5967
}
68+
69+
fn split_path_query(s: &str) -> (&str, Option<&str>) {
70+
match s.split_once('?') {
71+
Some((path, query)) => (path, Some(query)),
72+
None => (s, None),
73+
}
74+
}
75+
76+
#[cfg(test)]
77+
mod tests {
78+
use super::paths_match;
79+
80+
#[test]
81+
fn path_only_active_with_query_on_current() {
82+
assert!(paths_match("/reservar", "/reservar?fecha=2026-06-02"));
83+
}
84+
85+
#[test]
86+
fn query_href_requires_exact_match() {
87+
assert!(paths_match(
88+
"/book?fecha=1",
89+
"/book?fecha=1"
90+
));
91+
assert!(!paths_match("/book?fecha=1", "/book?fecha=2"));
92+
assert!(!paths_match("/book?fecha=1", "/book"));
93+
}
94+
}

0 commit comments

Comments
 (0)