From 15809c86a6614d9d94390a82e966efcc79d48a37 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Tue, 2 Jun 2026 02:59:29 +0200 Subject: [PATCH] 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 --- REQUIREMENTS.md | 2 +- examples/kanban.md | 2 +- examples/kanban/src/lib.rs | 2 +- examples/kanban/src/main.rs | 6 +++--- examples/techdemo/src/lib.rs | 2 +- examples/techdemo/src/main.rs | 18 +++++++++--------- examples/v0/src/lib.rs | 4 ++-- examples/v0/src/main.rs | 6 +++--- slhx-build/src/lib.rs | 12 ++++++++++-- slhx-test/tests/examples_contract.rs | 3 +++ 10 files changed, 34 insertions(+), 23 deletions(-) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index a0cfe04..d3aa5fb 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -76,7 +76,7 @@ slhx competes with React by making frontend frameworks unnecessary for most apps 005 Error messages must explain fixes in author language, not internal language. Say “add `h-key="todo.id"` to this `h-for`”, not “missing ScopeKey for ResourceRef”. ### req: dx/006 -006 Generated resource methods are the preferred authoring API: `slots::todo_list.html(ui::render(&view))`, `slots::card.replace(key, view)`, `slots::count.text(42)`, `atoms::user.set(user)`. The public facade and generated view modules expose `render(view)` for trusted hemplate-to-`SafeHtml` page and fragment composition, `static_fragment(include_str!(...))` for prototype/static `.heml` fragments that need generated resource lowering as `SafeHtml`, plus `lower(html)` for callers that need the lowered string; `render_html(view)` and `lower_html(html)` remain explicit compatibility aliases. The beginner prelude should not expose lower-level slot render shortcuts that bypass generated lowering. These return `impl IntoEffect`, `SafeHtml`, or lowered HTML at the boundary. Raw `Effect` constructors, opcodes, and `EffectWriter` remain low-level. [north_star] +006 Generated resource methods are the preferred authoring API: `ui::put(slots::todo_list, &view)`, `slots::card.replace(key, view)`, `slots::count.text(42)`, `atoms::user.set(user)`. The public facade and generated view modules expose `render(view)` for trusted hemplate-to-`SafeHtml` page and fragment composition, `put(slot, view)` for lower-aware slot HTML updates, `static_fragment(include_str!(...))` for prototype/static `.heml` fragments that need generated resource lowering as `SafeHtml`, plus `lower(html)` for callers that need the lowered string; `render_html(view)` and `lower_html(html)` remain explicit compatibility aliases. The beginner prelude should not expose lower-level slot render shortcuts that bypass generated lowering. These return `impl IntoEffect`, `SafeHtml`, or lowered HTML at the boundary. Raw `Effect` constructors, opcodes, and `EffectWriter` remain low-level. [north_star] ### req: dx/007 007 Tuple composition of `IntoEffect` is the canonical batch syntax: `(a, b, c)` implements `IntoEffect` up to arity 12. `Effect::batch((...))` is available but not required for the happy path. diff --git a/examples/kanban.md b/examples/kanban.md index 0a211ae..897bb5c 100644 --- a/examples/kanban.md +++ b/examples/kanban.md @@ -240,7 +240,7 @@ pub fn apply_board_patch( PatchResult::Conflict { canonical_board } => Effect::batch(( Effect::set(atoms::BOARD, canonical_board.clone()), - slots::board.html(ui::render(&BoardView::from(canonical_board))), + ui::put(slots::board, &BoardView::from(canonical_board)), )), } } diff --git a/examples/kanban/src/lib.rs b/examples/kanban/src/lib.rs index e666df1..e250d60 100644 --- a/examples/kanban/src/lib.rs +++ b/examples/kanban/src/lib.rs @@ -27,7 +27,7 @@ mod tests { #[test] fn kanban_board_updates_generated_slot() { fn render_board() -> impl IntoEffect { - board::slots::board.html(super::ui::render(&empty_board())) + super::ui::put(board::slots::board, &empty_board()) } let effect = inspect(render_board()); diff --git a/examples/kanban/src/main.rs b/examples/kanban/src/main.rs index eb13a02..c3add90 100644 --- a/examples/kanban/src/main.rs +++ b/examples/kanban/src/main.rs @@ -138,13 +138,13 @@ async fn interact( // req: push/001 req: push/003 req: examples/001 async fn events(Query(params): Query>) -> impl IntoResponse { if params.contains_key("once") { - let effect = slots::presence.html(ui::render(&Presence { count: 1 })); + let effect = ui::put(slots::presence, &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 = slots::presence.html(ui::render(&Presence { count })); + let effect = ui::put(slots::presence, &Presence { count }); Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1)) }) .boxed(); @@ -206,7 +206,7 @@ fn registry(state: Arc) -> impl DispatchRegistry { fn board_effects(board: &BoardState, notice: &'static str) -> impl IntoEffect { ( - slots::board.html(ui::render(&board_view(board))), + ui::put(slots::board, &board_view(board)), slots::notice.text(notice), forms::create_card.clear("title"), ) diff --git a/examples/techdemo/src/lib.rs b/examples/techdemo/src/lib.rs index 0adc4a0..9f573db 100644 --- a/examples/techdemo/src/lib.rs +++ b/examples/techdemo/src/lib.rs @@ -30,7 +30,7 @@ mod tests { fn techdemo_uses_generated_slots_for_multi_target_updates() { fn update() -> impl IntoEffect { ( - slots::hero_metrics.html(super::ui::render(&FastMetric { label: "fast" })), + super::ui::put(slots::hero_metrics, &FastMetric { label: "fast" }), slots::notice.text("typed"), ) } diff --git a/examples/techdemo/src/main.rs b/examples/techdemo/src/main.rs index 980a710..d0b0bfa 100644 --- a/examples/techdemo/src/main.rs +++ b/examples/techdemo/src/main.rs @@ -269,13 +269,13 @@ async fn interact( // req: push/001 req: push/003 req: examples/001 async fn events(Query(params): Query>) -> impl IntoResponse { if params.contains_key("once") { - let effect = slots::live_feed.html(ui::render(&LiveFeed { tick: 1 })); + let effect = ui::put(slots::live_feed, &LiveFeed { tick: 1 }); return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed()); } let batches = stream::unfold(1_u64, |tick| async move { tokio::time::sleep(Duration::from_secs(4)).await; - let effect = slots::live_feed.html(ui::render(&LiveFeed { tick })); + let effect = ui::put(slots::live_feed, &LiveFeed { tick }); Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), tick + 1)) }) .boxed(); @@ -384,10 +384,10 @@ fn registry(shared: Arc) -> impl DispatchRegistry { let mut demo = shared.demo.lock().unwrap(); demo.log("Simulated push event produced the same EffectBatch shape"); ( - slots::live_feed.html(ui::render(&LiveFeed { + ui::put(slots::live_feed, &LiveFeed { tick: demo.activity.len() as u64, - })), - slots::activity.html(ui::render(&activity_view(&demo))), + }), + ui::put(slots::activity, &activity_view(&demo)), slots::notice.text("Push simulated · no client app code"), slhx::event("slhx:island-orbit", island_snapshot(&demo)), ) @@ -414,10 +414,10 @@ fn update_work(demo: &mut DemoState, id: Option, update: impl FnOnce(&mut W fn demo_effects(demo: &DemoState, notice: &'static str) -> impl IntoEffect { ( - slots::hero_metrics.html(ui::render(&hero_view(demo))), - slots::board.html(ui::render(&board_view(demo))), - slots::activity.html(ui::render(&activity_view(demo))), - slots::inspector.html(ui::render(&inspector_view(demo))), + ui::put(slots::hero_metrics, &hero_view(demo)), + ui::put(slots::board, &board_view(demo)), + ui::put(slots::activity, &activity_view(demo)), + ui::put(slots::inspector, &inspector_view(demo)), slots::notice.text(notice), forms::launch_work.clear("title"), slhx::event("slhx:island-orbit", island_snapshot(demo)), diff --git a/examples/v0/src/lib.rs b/examples/v0/src/lib.rs index bbd7760..d1ef998 100644 --- a/examples/v0/src/lib.rs +++ b/examples/v0/src/lib.rs @@ -121,9 +121,9 @@ mod tests { fn page_swap_updates_content_and_history() { fn load_docs() -> impl IntoEffect { ( - page_swap::slots::content.html(page_swap::render(&DocsContent { + page_swap::put(page_swap::slots::content, &DocsContent { message: "This page was swapped.", - })), + }), page_swap::slots::title.text("Docs"), push("/docs"), ) diff --git a/examples/v0/src/main.rs b/examples/v0/src/main.rs index 437c0fe..5189812 100644 --- a/examples/v0/src/main.rs +++ b/examples/v0/src/main.rs @@ -145,7 +145,7 @@ fn registry(state: Arc) -> impl DispatchRegistry { todos.push(Todo { id, title: title.into() }); } ( - todo_slots::todo_list.html(ui::render(&todos_view(&todos))), + todos::put(todo_slots::todo_list, &todos_view(&todos)), todo_forms::new_todo.clear("title"), ) } @@ -172,9 +172,9 @@ fn registry(state: Arc) -> impl DispatchRegistry { .on(page_handles::load_docs, |_| { // req: page_swap/002, req: examples/001 ( - page_slots::content.html(ui::render(&DocsContent { + page_swap::put(page_slots::content, &DocsContent { message: "This content came from an EffectBatch.", - })), + }), page_slots::title.text("Docs"), push("/docs"), ) diff --git a/slhx-build/src/lib.rs b/slhx-build/src/lib.rs index 8c65bf0..e288d38 100644 --- a/slhx-build/src/lib.rs +++ b/slhx-build/src/lib.rs @@ -534,6 +534,9 @@ impl Resources { out.push_str(&format!("{pad} ::slhx::SafeHtml::trusted(lower(html))\n")); out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!("{pad}pub fn render_html(view: &impl ::hemplate::Hemplate) -> ::slhx::SafeHtml {{ render(view) }}\n\n")); + out.push_str(&format!("{pad}pub fn put(slot: ::slhx::Slot, view: &impl ::hemplate::Hemplate) -> ::slhx::Effect {{\n")); + out.push_str(&format!("{pad} slot.html(render(view))\n")); + out.push_str(&format!("{pad}}}\n\n")); self.push_lowering_table(out, component, indent, &table_name); } @@ -1307,6 +1310,7 @@ mod tests { assert!(generated.contains("pub fn static_fragment(html: &'static str) -> ::slhx::SafeHtml")); assert!(generated.contains("pub fn render(view: &impl ::hemplate::Hemplate) -> ::slhx::SafeHtml")); assert!(generated.contains("pub fn render_html(view: &impl ::hemplate::Hemplate) -> ::slhx::SafeHtml")); + assert!(generated.contains("pub fn put(slot: ::slhx::Slot, view: &impl ::hemplate::Hemplate) -> ::slhx::Effect")); assert!(generated.contains("data-slhx-slot")); assert!(generated.contains("data-slhx-form")); assert!(generated.contains("data-sid")); @@ -1376,11 +1380,13 @@ mod tests { assert!(generated.contains("\npub fn static_fragment(html: &'static str) -> ::slhx::SafeHtml")); assert!(generated.contains("\npub fn render(view: &impl ::hemplate::Hemplate) -> ::slhx::SafeHtml")); assert!(generated.contains("\npub fn render_html(view: &impl ::hemplate::Hemplate) -> ::slhx::SafeHtml")); + assert!(generated.contains("\npub fn put(slot: ::slhx::Slot, view: &impl ::hemplate::Hemplate) -> ::slhx::Effect")); assert!(generated.contains("pub mod todo")); assert!(generated.contains(" pub const COMPONENT: ::slhx::ComponentRef = ::slhx::ComponentRef::new(\"todo\")")); assert!(generated.contains(" pub mod slots")); assert!(generated.contains(" pub fn lower_html")); assert!(generated.contains(" pub fn static_fragment(html: &'static str) -> ::slhx::SafeHtml")); + assert!(generated.contains(" pub fn put(slot: ::slhx::Slot, view: &impl ::hemplate::Hemplate) -> ::slhx::Effect")); let _ = std::fs::remove_dir_all(&base); } @@ -1481,8 +1487,9 @@ mod tests { mod slhx {{ #[derive(Clone, Copy)] pub struct BuildFingerprint; impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }} + pub struct Effect; #[derive(Clone, Copy)] pub struct Slot(::std::marker::PhantomData); - impl Slot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} + impl Slot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub fn html(self, _: SafeHtml) -> Effect {{ Effect }} }} #[derive(Clone, Copy)] pub struct KeyedSlot(::std::marker::PhantomData<(K, T)>); impl KeyedSlot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[derive(Clone, Copy)] pub struct Handle(::std::marker::PhantomData); @@ -1755,8 +1762,9 @@ fn main() {{ mod slhx {{ #[derive(Clone, Copy)] pub struct BuildFingerprint; impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }} + pub struct Effect; #[derive(Clone, Copy)] pub struct Slot(::std::marker::PhantomData); - impl Slot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} + impl Slot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub fn html(self, _: SafeHtml) -> Effect {{ Effect }} }} #[derive(Clone, Copy)] pub struct KeyedSlot(::std::marker::PhantomData<(K, T)>); impl KeyedSlot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[derive(Clone, Copy)] pub struct Handle(::std::marker::PhantomData); diff --git a/slhx-test/tests/examples_contract.rs b/slhx-test/tests/examples_contract.rs index 0fb7c34..ddb21df 100644 --- a/slhx-test/tests/examples_contract.rs +++ b/slhx-test/tests/examples_contract.rs @@ -68,6 +68,9 @@ fn canonical_examples_do_not_author_low_level_resource_plumbing() { "register_handle(", "RenderSlotExt", ".render(&", + ".html(ui::render", + ".html(slhx::render", + ".html(super::ui::render", "__h=", "name=\"__h\"", "name='__h'",