Files
hemx/examples/v0/src/main.rs
T
slhx agent bc8534a768 feat(axum): register checked handles
Add HandlerRegistry::register_handle for generated Handle<T> values and migrate examples away from passing raw numeric handle ids at registration sites.

req: ceremony/004

req: public_api/001
2026-05-26 00:26:42 +02:00

318 lines
10 KiB
Rust

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<u64>,
todos: Mutex<Vec<Todo>>,
wizard_step: Mutex<u8>,
}
#[derive(Clone)]
struct Todo {
id: u64,
title: String,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct TodoItems {
items: Vec<TodoItem>,
}
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<Arc<ExampleState>>,
form: InteractionForm,
) -> Result<EffectResponse, impl IntoResponse> {
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<BTreeMap<String, String>>) -> 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<ExampleState>) -> 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::<String>()),
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::<String>()),
Some("Docs".to_owned())
);
assert_eq!(
document
.select(&selector("p"))
.next()
.map(|paragraph| paragraph.text().collect::<String>()),
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::<String>()),
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: "<b>Ship v0</b>".to_owned() }];
let html = render_todos(&todos);
let document = Html::parse_fragment(html.as_str());
let rows = document.select(&selector("li[data-key]")).collect::<Vec<_>>();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].value().attr("data-key"), Some("7"));
assert_eq!(rows[0].text().collect::<String>(), "<b>Ship v0</b>");
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::<Vec<_>>();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].text().collect::<String>(), "No todos yet");
}
}