feat(api): streamline generated app authoring

Move the canonical examples toward generated component-root helpers, typed form decoding, async/state handler registration, and derive-driven app/component registry wiring. Tighten requirements and diagnostics for the server-first, selectorless authoring path.

Verified with cargo run -p slhx-xtask -- test, cargo check --workspace, redgate list, redgate refs, redgate health --strict, and git diff --check.

req: canonical/001

req: canonical/003

req: canonical/004

req: dx/002

req: derive_app/001

req: component/003

req: form/004

req: axum_integration/003
This commit is contained in:
slhx agent
2026-06-05 06:33:41 +02:00
parent eb6086616c
commit d4e865ef92
34 changed files with 4573 additions and 1156 deletions
+412 -102
View File
@@ -4,18 +4,12 @@ use axum::routing::get;
use axum::Router;
use futures_util::{stream, StreamExt};
use hemplate::Hemplate;
use slhx::{push, IntoEffect, SafeHtml};
use slhx::{Html, IntoEffect};
use slhx_axum::{
interactions, runtime_js, sse, DispatchRegistry, EffectResponse, InteractionRequest, PageRequest,
interactions, runtime_js, sse, EffectResponse, Form, InteractionRequest, PageRequest, Registry,
};
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, targets as auth_targets};
use slhx_v0_examples::ui::counter::{handles as counter_handles, targets as counter_targets};
use slhx_v0_examples::ui::notifications::targets as notification_targets;
use slhx_v0_examples::ui::page_swap::{handles as page_handles, targets as page_targets};
use slhx_v0_examples::ui::todos::{forms as todo_forms, handles as todo_handles, targets as todo_targets};
use slhx_v0_examples::ui::wizard::{handles as wizard_handles, targets as wizard_targets};
use slhx_v0_examples::ui::{auth, counter, notifications, page_swap, todo_row, todos, wizard};
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::net::SocketAddr;
@@ -25,36 +19,101 @@ use std::time::Duration;
#[derive(Default)]
struct ExampleState {
counter: Mutex<u64>,
todos: Mutex<Vec<Todo>>,
todos: Mutex<Vec<TodoRecord>>,
wizard_step: Mutex<u8>,
}
#[derive(Clone)]
struct Todo {
struct TodoRecord {
// Domain/SQL-shaped rows stay as boring Rust data; hemplate view structs are derived at render/update boundaries.
// req: examples/001 req: view/001
id: u64,
title: String,
}
#[slhx::form("new_todo")]
struct NewTodo {
title: String,
}
#[slhx::form("rename_todo")]
struct RenameTodo {
id: u64,
title: String,
}
#[slhx::form("delete_todo")]
struct DeleteTodo {
id: u64,
}
#[slhx::form("wizard_input")]
struct WizardInput {
step: String,
}
#[slhx::form("credentials")]
struct Credentials {
email: String,
password: String,
}
#[derive(Debug)]
struct TodoMutationError(String);
impl std::fmt::Display for TodoMutationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for TodoMutationError {}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct TodoItems {
items: Vec<TodoItem>,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct TodoRow {
id: u64,
title: String,
}
impl slhx::KeyedPartial for TodoRow {
fn slhx_key(&self) -> String {
self.id.to_string()
}
}
struct TodoItem {
id: u64,
title: String,
}
#[derive(Hemplate)]
struct Counter;
#[derive(Hemplate)]
struct Wizard;
#[derive(Hemplate)]
struct Auth;
#[derive(Hemplate)]
struct Notifications;
#[derive(Hemplate)]
struct PageSwap {
content: SafeHtml,
content: Html,
title: &'static str,
}
#[derive(Hemplate)]
struct AppShell {
body: SafeHtml,
body: Html,
}
#[derive(Hemplate)]
@@ -100,7 +159,7 @@ async fn interact(
State(state): State<Arc<ExampleState>>,
request: InteractionRequest,
) -> Result<EffectResponse, impl IntoResponse> {
request.dispatch(registry(state))
request.dispatch_async(registry(state)).await
}
async fn runtime() -> impl IntoResponse {
@@ -110,96 +169,201 @@ async fn runtime() -> impl IntoResponse {
// 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_targets::notifications.text("Server event #1");
return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed());
let effect = notifications::notifications.set("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_targets::notifications.text(format!("Server event #{count}"));
Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1))
let effect = notifications::notifications.set(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 {
#[slhx::app(
counter_handlers,
todo_handlers,
todo_row_handlers,
wizard_handlers,
auth_handlers
)]
fn registry(state: Arc<ExampleState>) -> Registry {
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_targets::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() });
}
(
todo_targets::todo_list.put(&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_targets::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_targets::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_targets::content.put(&DocsContent {
message: "This content came from a generated update response.",
}),
page_targets::title.text("Docs"),
push("/docs"),
)
})
}
fn all_examples() -> SafeHtml {
// Static `.heml` fragments are lowered by generated code before they join rendered views.
fn all_examples() -> Html {
// 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")),
Html::join([
counter::render(&Counter),
ui::render(&todos_view(&[])),
wizard::render(&Wizard),
auth::render(&Auth),
render_page_swap("Welcome", "Welcome"),
notifications::static_fragment(include_str!("../templates/notifications.heml")),
notifications::render(&Notifications),
])
}
fn shell(body: SafeHtml) -> SafeHtml {
fn shell(body: Html) -> Html {
// 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 {
#[slhx::component("counter")]
mod counter_handlers {
use super::*;
#[slhx::handler]
async fn increment(State(state): State<Arc<ExampleState>>) -> impl IntoEffect {
// req: examples/001
let mut counter = state.counter.lock().unwrap();
*counter += 1;
counter::counter_value.set(*counter)
}
}
#[slhx::component("todos")]
mod todo_handlers {
use super::*;
#[slhx::handler]
async fn add_todo(
State(state): State<Arc<ExampleState>>,
Form(form): Form<NewTodo>,
) -> Result<impl IntoEffect, TodoMutationError> {
// req: examples/001 req: canonical_authoring/003
let mut todos = state.todos.lock().unwrap();
let id = todos.last().map_or(1, |todo| todo.id + 1);
let created = if form.title.is_empty() {
None
} else {
todos.push(TodoRecord {
id,
title: form.title.clone(),
});
Some(todos::todo_row.append(TodoRow {
id,
title: form.title,
}))
};
let summary = todo_summary(&todos);
Ok((
created,
todos::summary.set(summary),
todos::new_todo.clear(),
))
}
#[slhx::handler]
async fn rename_todo(
State(state): State<Arc<ExampleState>>,
Form(form): Form<RenameTodo>,
) -> impl IntoEffect {
rename_todo_effect(state, form)
}
#[slhx::handler]
async fn delete_todo(
State(state): State<Arc<ExampleState>>,
Form(form): Form<DeleteTodo>,
) -> impl IntoEffect {
delete_todo_effect(state, form)
}
}
#[slhx::component("todo_row")]
mod todo_row_handlers {
use super::*;
#[slhx::handler]
async fn rename_todo_row(
State(state): State<Arc<ExampleState>>,
Form(form): Form<RenameTodo>,
) -> impl IntoEffect {
rename_todo_effect(state, form)
}
#[slhx::handler]
async fn delete_todo_row(
State(state): State<Arc<ExampleState>>,
Form(form): Form<DeleteTodo>,
) -> impl IntoEffect {
delete_todo_effect(state, form)
}
}
#[slhx::component("wizard")]
mod wizard_handlers {
use super::*;
#[slhx::handler]
async fn next_step(
State(state): State<Arc<ExampleState>>,
Form(form): Form<WizardInput>,
) -> impl IntoEffect {
// req: examples/001
let _submitted_step = form.step;
let mut step = state.wizard_step.lock().unwrap();
*step += 1;
wizard::wizard_step.set(format!("Step {}", *step + 1))
}
}
#[slhx::component("auth")]
mod auth_handlers {
use super::*;
#[slhx::handler]
async fn login(
State(_state): State<Arc<ExampleState>>,
Form(credentials): Form<Credentials>,
) -> impl IntoEffect {
// req: examples/001
let ok = credentials.email == "demo@example.com" && !credentials.password.is_empty();
auth::login_status.set(if ok {
"Signed in as demo@example.com"
} else {
"Try demo@example.com with any password"
})
}
}
fn rename_todo_effect(state: Arc<ExampleState>, form: RenameTodo) -> impl IntoEffect {
// req: examples/001 req: canonical_authoring/003
let mut todos = state.todos.lock().unwrap();
todos
.iter_mut()
.find(|todo| todo.id == form.id)
.map(|todo| {
todo.title = form.title.clone();
todos::todo_row.replace(TodoRow {
id: form.id,
title: form.title,
})
})
}
fn delete_todo_effect(state: Arc<ExampleState>, form: DeleteTodo) -> impl IntoEffect {
// req: examples/001 req: canonical_authoring/003
let mut todos = state.todos.lock().unwrap();
let before = todos.len();
todos.retain(|todo| todo.id != form.id);
let deleted = todos.len() != before;
let summary = todo_summary(&todos);
(
deleted.then(|| todos::todo_row.remove(form.id)),
deleted.then(|| todos::summary.set(summary)),
)
}
fn todos_view(todos: &[TodoRecord]) -> TodoItems {
// req: html_safety/002 req: view/001
TodoItems {
items: todos
@@ -212,7 +376,15 @@ fn todos_view(todos: &[Todo]) -> TodoItems {
}
}
fn render_page_swap(title: &'static str, message: &'static str) -> SafeHtml {
fn todo_summary(todos: &[TodoRecord]) -> String {
match todos.len() {
0 => "No todos".to_owned(),
1 => "1 todo".to_owned(),
count => format!("{count} todos"),
}
}
fn render_page_swap(title: &'static str, message: &'static str) -> Html {
// req: html_safety/002 req: view/001 req: component/003
page_swap::render(&PageSwap {
content: render_docs_content(message),
@@ -220,7 +392,7 @@ fn render_page_swap(title: &'static str, message: &'static str) -> SafeHtml {
})
}
fn render_docs_content(message: &'static str) -> SafeHtml {
fn render_docs_content(message: &'static str) -> Html {
// req: html_safety/002 req: view/001 req: component/003
ui::render(&DocsContent { message })
}
@@ -229,11 +401,25 @@ fn render_docs_content(message: &'static str) -> SafeHtml {
mod tests {
use super::*;
use scraper::{Html, Selector};
use slhx_test::{
article_selector, document_title_selector, escaped_markup_selector, heading_selector,
inspect_batch, keyed_items_selector, keyed_selector, list_item_selector,
page_nav_link_selector, prose_selector, root_element_selector, runtime_script_selector,
};
fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses")
}
fn form<I>(handle: slhx::Handle<I>, fields: &[(&str, &str)]) -> slhx_axum::InteractionForm {
slhx_axum::InteractionForm::for_handle(
handle,
fields
.iter()
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned())),
)
}
// req: html_safety/002 req: view/001 req: test/005
#[test]
fn shell_is_rendered_by_a_hemplate_view() {
@@ -241,15 +427,28 @@ mod tests {
let document = Html::parse_document(html.as_str());
assert_eq!(
document
.select(&selector("title"))
.select(&selector(document_title_selector()))
.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_eq!(
document
.select(&selector(runtime_script_selector()))
.count(),
1
);
assert_eq!(
document
.select(&selector(&root_element_selector("main", "docs")))
.count(),
1
);
assert!(
document.root_element().text().all(|text| !text.contains("{+=")),
document
.root_element()
.text()
.all(|text| !text.contains("{+=")),
"hemplate insertion markers must not leak into rendered text"
);
}
@@ -261,14 +460,14 @@ mod tests {
let document = Html::parse_fragment(html.as_str());
assert_eq!(
document
.select(&selector("h1"))
.select(&selector(&heading_selector("", 1)))
.next()
.map(|heading| heading.text().collect::<String>()),
Some("Docs".to_owned())
);
assert_eq!(
document
.select(&selector("p"))
.select(&selector(&prose_selector("")))
.next()
.map(|paragraph| paragraph.text().collect::<String>()),
Some("This content came from a generated update response.".to_owned())
@@ -280,11 +479,22 @@ mod tests {
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"))
.select(&selector(&root_element_selector("main", "docs")))
.count(),
1
);
assert_eq!(document.select(&selector(article_selector())).count(), 1);
assert_eq!(
document
.select(&selector(&page_nav_link_selector("/docs")))
.count(),
1
);
assert_eq!(
document
.select(&selector(&heading_selector("article", 1)))
.next()
.map(|heading| heading.text().collect::<String>()),
Some("Docs".to_owned())
@@ -294,15 +504,26 @@ mod tests {
// 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 todos = vec![TodoRecord {
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<_>>();
let rows = document
.select(&selector(&keyed_items_selector("li")))
.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());
let row = document
.select(&selector(&keyed_selector("li", 7)))
.next()
.expect("generated keyed row");
assert_eq!(row.text().collect::<String>(), "<b>Ship v0</b>");
assert!(document
.select(&selector(&escaped_markup_selector("b")))
.next()
.is_none());
}
// req: html_safety/002 req: view/001 req: test/005
@@ -310,8 +531,97 @@ mod tests {
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");
let rows = document
.select(&selector(&list_item_selector("")))
.collect::<Vec<_>>();
assert_eq!(rows.len(), 0);
}
// req: examples/001 req: page_swap/002 req: page_swap/003 req: component/005 req: public_api/003 req: test/005
#[tokio::test]
async fn registry_dispatches_generated_keyed_crud_effects() {
let state = Arc::new(ExampleState::default());
let counter = inspect_batch(
InteractionRequest::from(form(counter::increment, &[]))
.dispatch_async(registry(state.clone()))
.await
.unwrap()
.batch,
);
assert_eq!(*state.counter.lock().unwrap(), 1);
assert!(counter.updates_text(counter::counter_value));
assert!(counter.payload_contains("1"));
let add = inspect_batch(
InteractionRequest::from(form(todos::add_todo, &[("title", "Ship v0")]))
.dispatch_async(registry(state.clone()))
.await
.unwrap()
.batch,
);
assert_eq!(state.todos.lock().unwrap()[0].title, "Ship v0");
assert_eq!(add.op_count(), 3);
assert!(add.inserts_html_containing(todos::todo_row, "1", "Ship v0"));
assert!(add.updates_text(todos::summary));
assert!(add.resets_form(todos::new_todo));
let rename = inspect_batch(
InteractionRequest::from(form(
todo_row::rename_todo_row,
&[("id", "1"), ("title", "Ship 1.0")],
))
.dispatch_async(registry(state.clone()))
.await
.unwrap()
.batch,
);
assert_eq!(state.todos.lock().unwrap()[0].title, "Ship 1.0");
assert_eq!(rename.op_count(), 1);
assert!(rename.replaces_keyed_html_containing(todos::todo_row, "1", "Ship 1.0"));
let delete = inspect_batch(
InteractionRequest::from(form(todo_row::delete_todo_row, &[("id", "1")]))
.dispatch_async(registry(state.clone()))
.await
.unwrap()
.batch,
);
assert!(state.todos.lock().unwrap().is_empty());
assert_eq!(delete.op_count(), 2);
assert!(delete.removes_key(todos::todo_row, "1"));
assert!(delete.updates_text(todos::summary));
let missing_delete = inspect_batch(
InteractionRequest::from(form(todo_row::delete_todo_row, &[("id", "99")]))
.dispatch_async(registry(state.clone()))
.await
.unwrap()
.batch,
);
assert!(missing_delete.is_empty());
let wizard = inspect_batch(
InteractionRequest::from(form(wizard::next_step, &[("step", "1")]))
.dispatch_async(registry(state.clone()))
.await
.unwrap()
.batch,
);
assert!(wizard.updates_text(wizard::wizard_step));
assert!(wizard.payload_contains("Step 2"));
let auth = inspect_batch(
InteractionRequest::from(form(
auth::login,
&[("email", "demo@example.com"), ("password", "secret")],
))
.dispatch_async(registry(state))
.await
.unwrap()
.batch,
);
assert!(auth.updates_text(auth::login_status));
assert!(auth.payload_contains("Signed in as demo@example.com"));
}
}