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::{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, } #[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(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(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_handle(ui::counter::handles::increment, { 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_handle(ui::todos::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() }); } ( ui::todos::slots::todo_list.html(render_todos(&todos)), ui::todos::forms::new_todo.clear("title"), ) } }) .register_handle(ui::wizard::handles::next_step, { 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_handle(ui::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()); ui::auth::slots::login_status.text(if ok { "Signed in as demo@example.com" } else { "Try demo@example.com with any password" }) }) .register_handle(ui::page_swap::handles::load_docs, |_| { // req: page_swap/002, req: examples/001 ( ui::page_swap::slots::content.html(render_docs_content( "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")), render_page_swap("Welcome", "Welcome"), ui::notifications::lower_html(include_str!("../templates/notifications.heml")), ] .join("\n") } fn shell(body: String) -> String { // req: html_safety/002 req: view/001 render_template(&AppShell { body: SafeHtml::trusted(body), }) } fn render_todos(todos: &[Todo]) -> SafeHtml { // req: html_safety/002 req: view/001 render_html(&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) -> String { // req: html_safety/002 req: view/001 ui::page_swap::lower_html(render_template(&PageSwap { content: render_docs_content(message), title, })) } fn render_docs_content(message: &'static str) -> SafeHtml { // req: html_safety/002 req: view/001 render_html(&DocsContent { message }) } fn render_html(template: &impl Hemplate) -> SafeHtml { SafeHtml::trusted(render_template(template)) } fn render_template(template: &impl Hemplate) -> String { template.render().expect("v0 hemplate view renders") } #[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); 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); 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 = render_todos(&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 = render_todos(&[]); 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"); } }