diff --git a/Cargo.lock b/Cargo.lock index a70d7cd..e12df22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,6 +22,12 @@ dependencies = [ "syn", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "axum" version = "0.7.9" @@ -35,6 +41,7 @@ dependencies = [ "http", "http-body", "http-body-util", + "hyper", "hyper-util", "itoa", "matchit", @@ -45,11 +52,15 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", "tokio", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -70,6 +81,7 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -136,12 +148,41 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-task" version = "0.3.32" @@ -155,6 +196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "slab", @@ -235,10 +277,15 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ + "atomic-waker", "bytes", + "futures-channel", + "futures-core", "http", "http-body", + "httparse", "httpdate", + "itoa", "pin-project-lite", "smallvec", "tokio", @@ -281,6 +328,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + [[package]] name = "matchit" version = "0.7.3" @@ -327,6 +380,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -404,6 +463,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "serde" version = "1.0.228" @@ -448,6 +513,29 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "shlex" version = "1.3.0" @@ -518,9 +606,13 @@ dependencies = [ name = "slhx-v0-examples" version = "0.1.0" dependencies = [ + "axum", + "futures-util", "slhx", + "slhx-axum", "slhx-build", "slhx-test", + "tokio", ] [[package]] @@ -626,6 +718,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -640,6 +733,26 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "tree-sitter" version = "0.25.10" diff --git a/examples/v0/Cargo.toml b/examples/v0/Cargo.toml index e4ed439..53716c6 100644 --- a/examples/v0/Cargo.toml +++ b/examples/v0/Cargo.toml @@ -8,7 +8,11 @@ publish = false path = "src/lib.rs" [dependencies] +axum = "0.7" +futures-util = "0.3" slhx = { path = "../../slhx" } +slhx-axum = { path = "../../slhx-axum" } +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] } [dev-dependencies] slhx-test = { path = "../../slhx-test" } diff --git a/examples/v0/README.md b/examples/v0/README.md new file mode 100644 index 0000000..eccdac6 --- /dev/null +++ b/examples/v0/README.md @@ -0,0 +1,18 @@ +# slhx v0 browser examples + +Run the examples server: + +```sh +cargo run -p slhx-v0-examples +``` + +Open . + +The page includes working examples for: + +- counter updates +- todo form submission +- wizard step updates +- login form feedback +- page swap/navigation +- server-sent notifications diff --git a/examples/v0/src/main.rs b/examples/v0/src/main.rs new file mode 100644 index 0000000..5f3f410 --- /dev/null +++ b/examples/v0/src/main.rs @@ -0,0 +1,210 @@ +use axum::extract::{Query, State}; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::Router; +use futures_util::{stream, StreamExt}; +use slhx::{push, IntoEffect, SafeHtml}; +use slhx_axum::{runtime_js, sse, EffectResponse, HandlerRegistry, InteractionForm, PageRequest}; +use slhx_v0_examples::ui; +use std::collections::BTreeMap; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +#[derive(Default)] +struct ExampleState { + counter: Mutex, + todos: Mutex>, + wizard_step: Mutex, +} + +#[derive(Clone)] +struct Todo { + id: u64, + title: String, +} + +#[tokio::main] +async fn main() { + let state = Arc::new(ExampleState::default()); + let app = Router::new() + .route("/", get(home).post(interact)) + .route("/docs", get(docs).post(interact)) + .route("/events", get(events)) + .route("/slhx.js", get(runtime)) + .with_state(state); + + let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + println!("slhx v0 examples: http://{addr}"); + axum::serve(listener, app).await.unwrap(); +} + +// req: examples/001 +async fn home(request: PageRequest) -> impl IntoResponse { + request + .page(all_examples(), shell) + .title("slhx v0 examples") + .fingerprint(ui::BUILD_FINGERPRINT) +} + +// req: page_swap/002, req: examples/001 +async fn docs(request: PageRequest) -> impl IntoResponse { + let partial = ui::page_swap::lower_html( + r#"
+ +

Docs

This page was swapped without a full reload.

+ Docs +
"#, + ); + request + .page(partial, shell) + .title("Docs") + .fingerprint(ui::BUILD_FINGERPRINT) +} + +async fn interact( + State(state): State>, + form: InteractionForm, +) -> Result { + registry(state).dispatch(form) +} + +async fn runtime() -> impl IntoResponse { + runtime_js() +} + +// req: push/001, req: push/003, req: examples/001 +async fn events(Query(params): Query>) -> impl IntoResponse { + if params.contains_key("once") { + let effect = ui::notifications::slots::notifications.text("Server event #1"); + return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed()); + } + + let batches = stream::unfold(1_u64, |count| async move { + tokio::time::sleep(Duration::from_secs(3)).await; + let effect = ui::notifications::slots::notifications.text(format!("Server event #{count}")); + Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1)) + }) + .boxed(); + sse(batches) +} + +fn registry(state: Arc) -> HandlerRegistry { + HandlerRegistry::new(ui::BUILD_FINGERPRINT) + .register(ui::counter::handles::increment.id().id, { + let state = state.clone(); + move |_| { + // req: examples/001 + let mut counter = state.counter.lock().unwrap(); + *counter += 1; + ui::counter::slots::counter_value.text(*counter) + } + }) + .register(ui::todos::handles::add_todo.id().id, { + let state = state.clone(); + move |form| { + // req: examples/001 + let title = form.value("title").unwrap_or("").trim(); + let mut todos = state.todos.lock().unwrap(); + if !title.is_empty() { + let id = todos.last().map_or(1, |todo| todo.id + 1); + todos.push(Todo { id, title: title.into() }); + } + ( + ui::todos::slots::todo_list.html(SafeHtml::trusted(render_todos(&todos))), + ui::todos::forms::new_todo.clear("title"), + ) + } + }) + .register(ui::wizard::handles::next_step.id().id, { + let state = state.clone(); + move |_| { + // req: examples/001 + let mut step = state.wizard_step.lock().unwrap(); + *step += 1; + ui::wizard::slots::wizard_step.text(format!("Step {}", *step + 1)) + } + }) + .register(ui::auth::handles::login.id().id, |form| { + // req: examples/001 + let ok = form.value("email") == Some("demo@example.com") + && form.value("password").is_some_and(|password| !password.is_empty()); + ui::auth::slots::login_status.text(if ok { + "Signed in as demo@example.com" + } else { + "Try demo@example.com with any password" + }) + }) + .register(ui::page_swap::handles::load_docs.id().id, |_| { + // req: page_swap/002, req: examples/001 + ( + ui::page_swap::slots::content.html(SafeHtml::trusted( + "

Docs

This content came from an EffectBatch.

", + )), + ui::page_swap::slots::title.text("Docs"), + push("/docs"), + ) + }) +} + +fn all_examples() -> String { + [ + ui::counter::lower_html(include_str!("../templates/counter.heml")), + ui::todos::lower_html(include_str!("../templates/todos.heml")), + ui::wizard::lower_html(include_str!("../templates/wizard.heml")), + ui::auth::lower_html(include_str!("../templates/auth.heml")), + ui::page_swap::lower_html(include_str!("../templates/page_swap.heml")), + ui::notifications::lower_html(include_str!("../templates/notifications.heml")), + ] + .join("\n") +} + +fn shell(body: String) -> String { + format!( + r#" + + + + + slhx v0 examples + + + + +

slhx v0 browser examples

+

Try the counter, todo form, wizard, login, page swap, and live SSE notifications.

+ {body} + +"# + ) +} + +fn render_todos(todos: &[Todo]) -> String { + if todos.is_empty() { + return "
  • No todos yet
  • ".into(); + } + todos + .iter() + .map(|todo| format!(r#"
  • {}
  • "#, todo.id, escape_html(&todo.title))) + .collect::>() + .join("") +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} diff --git a/examples/v0/templates/notifications.heml b/examples/v0/templates/notifications.heml index c9e0c7e..f2bc5e6 100644 --- a/examples/v0/templates/notifications.heml +++ b/examples/v0/templates/notifications.heml @@ -1,4 +1,4 @@ -
    +

    Notifications

    No notifications