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
+13 -10
View File
@@ -6,7 +6,7 @@ mod tests {
use super::ui::{board, board_card};
use hemplate::Hemplate;
use scraper::{Html, Selector};
use slhx::{Effect, IntoEffect, Payload};
use slhx::IntoEffect;
use slhx_test::inspect;
#[derive(Hemplate)]
@@ -27,12 +27,11 @@ mod tests {
#[test]
fn kanban_board_updates_generated_slot() {
fn render_board() -> impl IntoEffect {
board::targets::board.put(&empty_board())
board::board.put(&empty_board())
}
let effect = inspect(render_board());
assert!(effect.has_slot(board::slots::board));
assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Html(_), .. }]));
assert!(effect.updates_html(board::board));
}
// req: html_safety/002 req: view/001 req: test/005
@@ -45,7 +44,9 @@ mod tests {
fn empty_board() -> BoardColumns {
// req: html_safety/002 req: view/001
BoardColumns { columns: Vec::new() }
BoardColumns {
columns: Vec::new(),
}
}
fn selector(value: &str) -> Selector {
@@ -57,19 +58,21 @@ mod tests {
fn kanban_form_handler_is_checked_against_hemplate_form() {
#[slhx::handler]
fn create_card(_form: slhx::Form<CreateCard>) -> impl IntoEffect {
board::slots::notice.text("queued")
board::notice.text("queued")
}
let effect = inspect(create_card(CreateCard::FORM));
assert!(effect.has_slot(board::slots::notice));
assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }]));
assert!(effect.updates_text(board::notice));
}
// req: examples/001 req: form/002 req: codegen/003
#[test]
fn kanban_template_exports_form_and_card_handles() {
assert_ne!(board::handles::create_card.id(), board_card::handles::move_right.id());
assert_eq!(board::forms::create_card.field("title").resource, board::forms::create_card.id());
assert_ne!(board::create_card.id(), board_card::move_right.id());
assert_eq!(
board::create_card_form.field("title").resource,
board::create_card_form.id()
);
}
}
+119 -38
View File
@@ -4,14 +4,14 @@ use axum::routing::get;
use axum::Router;
use futures_util::{stream, StreamExt};
use hemplate::Hemplate;
use slhx::{IntoEffect, SafeHtml};
use slhx::{Html, IntoEffect};
use slhx_axum::{
interactions, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse,
InteractionRequest, PageRequest,
};
use slhx_kanban_example::ui::board::{self as board};
use slhx_kanban_example::ui::board_card as card_board;
use slhx_kanban_example::ui::{self, board as board_ui};
use slhx_kanban_example::ui::board::{forms, handles, slots, targets};
use slhx_kanban_example::ui::board_card::handles as card_handles;
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::net::SocketAddr;
@@ -40,7 +40,7 @@ struct Card {
#[derive(Hemplate)]
struct AppShell {
body: SafeHtml,
body: Html,
}
#[derive(Hemplate)]
@@ -67,8 +67,8 @@ struct BoardCard {
#[derive(Hemplate)]
struct Board {
options: SafeHtml,
board: SafeHtml,
options: Html,
board: Html,
}
#[derive(Hemplate)]
@@ -96,9 +96,21 @@ async fn main() {
board: Mutex::new(BoardState {
next_id: 4,
cards: vec![
Card { id: 1, title: "Write requirements".into(), column: 0 },
Card { id: 2, title: "Build browser example".into(), column: 1 },
Card { id: 3, title: "Verify with HTTP".into(), column: 2 },
Card {
id: 1,
title: "Write requirements".into(),
column: 0,
},
Card {
id: 2,
title: "Build browser example".into(),
column: 1,
},
Card {
id: 3,
title: "Verify with HTTP".into(),
column: 2,
},
],
}),
});
@@ -138,14 +150,20 @@ async fn interact(
// 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 = targets::presence.put(&Presence { count: 1 });
return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed());
let effect = board::presence.put(&Presence { count: 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(4)).await;
let effect = targets::presence.put(&Presence { count });
Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1))
let effect = board::presence.put(&Presence { count });
Some((
Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)),
count + 1,
))
})
.boxed();
sse(batches)
@@ -153,7 +171,7 @@ async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResp
fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
interactions(ui::BUILD_FINGERPRINT)
.on(handles::create_card, {
.on(board::create_card, {
let state = state.clone();
move |form| {
// req: examples/001 req: form/002
@@ -163,12 +181,16 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
if !title.is_empty() {
let id = board.next_id;
board.next_id += 1;
board.cards.push(Card { id, title: title.into(), column });
board.cards.push(Card {
id,
title: title.into(),
column,
});
}
board_effects(&board, "Card added")
}
})
.on(card_handles::move_left, {
.on(card_board::move_left, {
let state = state.clone();
move |form| {
// req: examples/001 req: list/003
@@ -176,10 +198,17 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
let moved = update_card(&mut board, form.parse("card_id"), |card| {
card.column = card.column.saturating_sub(1);
});
board_effects(&board, if moved { "Card moved left" } else { "Card not found" })
board_effects(
&board,
if moved {
"Card moved left"
} else {
"Card not found"
},
)
}
})
.on(card_handles::move_right, {
.on(card_board::move_right, {
let state = state.clone();
move |form| {
// req: examples/001 req: list/003
@@ -187,10 +216,17 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
let moved = update_card(&mut board, form.parse("card_id"), |card| {
card.column = (card.column + 1).min(COLUMNS.len() - 1);
});
board_effects(&board, if moved { "Card moved right" } else { "Card not found" })
board_effects(
&board,
if moved {
"Card moved right"
} else {
"Card not found"
},
)
}
})
.on(card_handles::delete_card, {
.on(card_board::delete_card, {
let state = state.clone();
move |form| {
// req: examples/001 req: list/003
@@ -199,16 +235,23 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
if let Some(id) = form.parse::<u64>("card_id") {
board.cards.retain(|card| card.id != id);
}
board_effects(&board, if board.cards.len() < before { "Card deleted" } else { "Card not found" })
board_effects(
&board,
if board.cards.len() < before {
"Card deleted"
} else {
"Card not found"
},
)
}
})
}
fn board_effects(board: &BoardState, notice: &'static str) -> impl IntoEffect {
(
targets::board.put(&board_view(board)),
slots::notice.text(notice),
forms::create_card.clear("title"),
board::board.put(&board_view(board)),
board::notice.text(notice),
board::create_card_form.clear(),
)
}
@@ -235,7 +278,7 @@ fn parse_column(value: Option<&str>) -> usize {
.unwrap_or(0)
}
fn page_html(board: &BoardState) -> SafeHtml {
fn page_html(board: &BoardState) -> Html {
// req: html_safety/002 req: view/001
board_ui::render(&Board {
options: render_options(),
@@ -243,12 +286,12 @@ fn page_html(board: &BoardState) -> SafeHtml {
})
}
fn shell(body: SafeHtml) -> SafeHtml {
fn shell(body: Html) -> Html {
// req: html_safety/001 req: html_safety/002 req: axum_integration/001
slhx::render(&AppShell { body })
}
fn render_options() -> SafeHtml {
fn render_options() -> Html {
// req: html_safety/002 req: view/001
ui::render(&ColumnOptions {
options: COLUMNS
@@ -290,6 +333,11 @@ fn render_card(card: &Card) -> BoardCard {
mod tests {
use super::*;
use scraper::{Html, Selector};
use slhx_test::{
class_child_selector, disabled_button_selector, element_class_selector,
escaped_markup_selector, form_selector, keyed_selector, root_element_selector,
select_options_selector, small_text_selector, strong_text_selector,
};
fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses")
@@ -303,10 +351,22 @@ mod tests {
assert!(!html.as_str().contains("__BOARD__"));
let document = Html::parse_fragment(html.as_str());
assert_eq!(document.select(&selector("section[data-slhx-root=\"kanban\"]")).count(), 1);
assert_eq!(document.select(&selector("select[name=\"column\"] > option")).count(), 3);
assert_eq!(document.select(&selector("[data-sid]")).count(), 3);
assert_eq!(document.select(&selector("[data-hid]")).count(), 1);
assert_eq!(
document
.select(&selector(&root_element_selector("section", "kanban")))
.count(),
1
);
assert_eq!(
document
.select(&selector(&select_options_selector("column")))
.count(),
3
);
assert_eq!(
document.select(&selector(&form_selector("header"))).count(),
1
);
}
// req: html_safety/002 req: view/001 req: test/005
@@ -323,15 +383,31 @@ mod tests {
let html = ui::render(&board_view(&board));
let document = Html::parse_fragment(html.as_str());
assert_eq!(document.select(&selector(".columns > section.column")).count(), 3);
assert_eq!(
document
.select(&selector(&class_child_selector(
"columns", "section", "column"
)))
.count(),
3
);
let card = document
.select(&selector("article.card[data-key=\"1\"]"))
.select(&selector(&keyed_selector("article.card", 1)))
.next()
.expect("card renders");
let title = card.select(&selector("strong")).next().expect("card title renders");
let title = card
.select(&selector(strong_text_selector()))
.next()
.expect("card title renders");
assert_eq!(title.text().collect::<String>(), "<b>Compile checked</b>");
assert!(card.select(&selector("b")).next().is_none());
assert_eq!(card.select(&selector("button[disabled]")).count(), 1);
assert!(card
.select(&selector(&escaped_markup_selector("b")))
.next()
.is_none());
assert_eq!(
card.select(&selector(disabled_button_selector())).count(),
1
);
}
// req: html_safety/002 req: view/001 req: test/005
@@ -339,10 +415,15 @@ mod tests {
fn presence_payload_is_rendered_by_a_hemplate_view() {
let html = ui::render(&Presence { count: 7 });
let document = Html::parse_fragment(html.as_str());
assert_eq!(document.select(&selector("span.presence")).count(), 2);
assert_eq!(
document
.select(&selector("small"))
.select(&selector(&element_class_selector("span", "presence")))
.count(),
2
);
assert_eq!(
document
.select(&selector(small_text_selector()))
.next()
.map(|small| small.text().collect::<String>()),
Some("tick #7".to_owned())