use axum::extract::{Query, State}; use axum::response::IntoResponse; use axum::routing::get; use axum::Router; use futures_util::{stream, StreamExt}; use hemplate::Hemplate; use slhx::{push, IntoEffect, SafeHtml}; use slhx_axum::{ interactions, runtime_js, sse, DispatchRegistry, EffectResponse, InteractionRequest, PageRequest, }; use slhx_v0_examples::ui; use slhx_v0_examples::ui::{auth, counter, notifications, page_swap, todos, wizard}; use slhx_v0_examples::ui::auth::{handles as auth_handles, slots as auth_slots}; use slhx_v0_examples::ui::counter::{handles as counter_handles, slots as counter_slots}; use slhx_v0_examples::ui::notifications::slots as notification_slots; use slhx_v0_examples::ui::page_swap::{handles as page_handles, slots as page_slots}; use slhx_v0_examples::ui::todos::{forms as todo_forms, handles as todo_handles, slots as todo_slots}; use slhx_v0_examples::ui::wizard::{handles as wizard_handles, slots as wizard_slots}; 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, } #[derive(Hemplate)] #[hemplate = "partials"] struct TodoItems { items: Vec, } struct TodoItem { id: u64, title: String, } #[derive(Hemplate)] struct PageSwap { content: SafeHtml, title: &'static str, } #[derive(Hemplate)] struct AppShell { body: SafeHtml, } #[derive(Hemplate)] #[hemplate = "partials"] struct DocsContent { message: &'static str, } #[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_html(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 = render_page_swap("Docs", "This page was swapped without a full reload."); request .page_html(partial, shell) .title("Docs") .fingerprint(ui::BUILD_FINGERPRINT) } async fn interact( State(state): State>, request: InteractionRequest, ) -> Result { request.dispatch(registry(state)) } 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 = notification_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 = notification_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) -> impl DispatchRegistry { interactions(ui::BUILD_FINGERPRINT) .on(counter_handles::increment, { let state = state.clone(); move |_| { // req: examples/001 let mut counter = state.counter.lock().unwrap(); *counter += 1; counter_slots::counter_value.text(*counter) } }) .on(todo_handles::add_todo, { 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() }); } ( todos::put(todo_slots::todo_list, &todos_view(&todos)), todo_forms::new_todo.clear("title"), ) } }) .on(wizard_handles::next_step, { let state = state.clone(); move |_| { // req: examples/001 let mut step = state.wizard_step.lock().unwrap(); *step += 1; wizard_slots::wizard_step.text(format!("Step {}", *step + 1)) } }) .on(auth_handles::login, |form| { // req: examples/001 let ok = form.value("email") == Some("demo@example.com") && form.value("password").is_some_and(|password| !password.is_empty()); auth_slots::login_status.text(if ok { "Signed in as demo@example.com" } else { "Try demo@example.com with any password" }) }) .on(page_handles::load_docs, |_| { // req: page_swap/002, req: examples/001 ( page_swap::put(page_slots::content, &DocsContent { message: "This content came from an EffectBatch.", }), page_slots::title.text("Docs"), push("/docs"), ) }) } fn all_examples() -> SafeHtml { // Static `.heml` fragments are lowered by generated code before they join rendered views. // req: html_safety/001 req: html_safety/002 req: component/003 SafeHtml::join([ counter::static_fragment(include_str!("../templates/counter.heml")), todos::static_fragment(include_str!("../templates/todos.heml")), wizard::static_fragment(include_str!("../templates/wizard.heml")), auth::static_fragment(include_str!("../templates/auth.heml")), render_page_swap("Welcome", "Welcome"), notifications::static_fragment(include_str!("../templates/notifications.heml")), ]) } fn shell(body: SafeHtml) -> SafeHtml { // req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003 ui::render(&AppShell { body }) } fn todos_view(todos: &[Todo]) -> TodoItems { // req: html_safety/002 req: view/001 TodoItems { items: todos .iter() .map(|todo| TodoItem { id: todo.id, title: todo.title.clone(), }) .collect(), } } fn render_page_swap(title: &'static str, message: &'static str) -> SafeHtml { // req: html_safety/002 req: view/001 req: component/003 page_swap::render(&PageSwap { content: render_docs_content(message), title, }) } fn render_docs_content(message: &'static str) -> SafeHtml { // req: html_safety/002 req: view/001 req: component/003 ui::render(&DocsContent { message }) } #[cfg(test)] mod tests { use super::*; use scraper::{Html, Selector}; fn selector(value: &str) -> Selector { Selector::parse(value).expect("test selector parses") } // req: html_safety/002 req: view/001 req: test/005 #[test] fn shell_is_rendered_by_a_hemplate_view() { let html = shell(render_page_swap("Welcome", "Welcome")); let document = Html::parse_document(html.as_str()); assert_eq!( document .select(&selector("title")) .next() .map(|title| title.text().collect::()), Some("slhx v0 examples".to_owned()) ); assert_eq!(document.select(&selector("script[src=\"/slhx.js\"]")).count(), 1); assert_eq!(document.select(&selector("main[data-slhx-root=\"docs\"]")).count(), 1); assert!( document.root_element().text().all(|text| !text.contains("{+=")), "hemplate insertion markers must not leak into rendered text" ); } // req: html_safety/002 req: view/001 req: test/005 #[test] fn docs_content_payload_is_rendered_by_a_hemplate_view() { let html = render_docs_content("This content came from an EffectBatch."); let document = Html::parse_fragment(html.as_str()); assert_eq!( document .select(&selector("h1")) .next() .map(|heading| heading.text().collect::()), Some("Docs".to_owned()) ); assert_eq!( document .select(&selector("p")) .next() .map(|paragraph| paragraph.text().collect::()), Some("This content came from an EffectBatch.".to_owned()) ); } // req: html_safety/002 req: view/001 req: test/005 #[test] fn docs_page_partial_is_rendered_by_a_hemplate_view() { let html = render_page_swap("Docs", "This page was swapped without a full reload."); let document = Html::parse_fragment(html.as_str()); assert_eq!(document.select(&selector("main[data-slhx-root=\"docs\"]")).count(), 1); assert_eq!(document.select(&selector("article[data-sid]")).count(), 1); assert_eq!( document .select(&selector("article h1")) .next() .map(|heading| heading.text().collect::()), Some("Docs".to_owned()) ); } // req: html_safety/002 req: view/001 req: test/005 #[test] fn todos_payload_is_rendered_by_a_hemplate_view() { let todos = vec![Todo { id: 7, title: "Ship v0".to_owned() }]; let html = ui::render(&todos_view(&todos)); let document = Html::parse_fragment(html.as_str()); let rows = document.select(&selector("li[data-key]")).collect::>(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].value().attr("data-key"), Some("7")); assert_eq!(rows[0].text().collect::(), "Ship v0"); assert!(document.select(&selector("b")).next().is_none()); } // req: html_safety/002 req: view/001 req: test/005 #[test] fn empty_todos_payload_is_rendered_by_a_hemplate_view() { let html = ui::render(&todos_view(&[])); let document = Html::parse_fragment(html.as_str()); let rows = document.select(&selector("li")).collect::>(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].text().collect::(), "No todos yet"); } }