Files
hemx/examples/v0/src/main.rs
T
slhx agent 15809c86a6 feat(build): add lower-aware put helper
Generate ui::put(slot, view) for lower-aware slot HTML updates and move examples/docs/contracts to that ergonomic path instead of hand-composing slot.html(render(...)).

req: dx/006

req: component/003

req: runtime/001

req: examples/003
2026-06-02 02:59:29 +02:00

318 lines
11 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::{
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<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_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<Arc<ExampleState>>,
request: InteractionRequest,
) -> Result<EffectResponse, impl IntoResponse> {
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<BTreeMap<String, String>>) -> 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<ExampleState>) -> 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::<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.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::<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 = ui::render(&todos_view(&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 = ui::render(&todos_view(&[]));
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");
}
}