From ee6045aec560b5a7565da14fa5f681ce117fc463 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Tue, 2 Jun 2026 03:05:37 +0200 Subject: [PATCH] feat(build): generate keyed view commands Add lower-aware generated append/prepend/replace helpers for keyed slots, move the v0 keyed todo example off the facade extension trait, and keep beginner examples from importing lower-level render shortcuts. req: dx/006 req: codegen/002 req: public_api/003 req: examples/003 --- REQUIREMENTS.md | 4 +-- examples/v0/src/lib.rs | 5 ++-- slhx-build/src/lib.rs | 39 +++++++++++++++++++++++----- slhx-test/tests/examples_contract.rs | 1 + slhx/src/lib.rs | 2 +- 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index d3aa5fb..8881e6e 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: `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] +006 Generated resource commands are the preferred authoring API: `ui::put(slots::todo_list, &view)`, `ui::append(slots::card, 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, `append/prepend/replace(keyed_slot, key, view)` for lower-aware keyed slot 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. @@ -226,7 +226,7 @@ a `data-*` handle param is statically known or runtime-extracted. 001 `slhx_build` generates three artifacts from the generic Surface IR: (a) `slhx.generated.rs` containing ergonomic resource modules (`slots`, `handles`, `forms`, `atoms`), (b) `slhx.syms` for proc-macro validation, (c) runtime id-lowering tables. `slhx_build` interprets tool-specific conventions (`data-slhx-*`, `h-for`, `h-key`, form controls) from the Surface. [north_star] ### req: codegen/002 -002 Generated module `slots` exposes ergonomic methods: `Slot::render(value)`, `Slot::text(value)`, `KeyedSlot::append(key, value)`, `KeyedSlot::prepend(key, value)`, `KeyedSlot::replace(key, value)`, `KeyedSlot::remove(key)`. Methods return `impl IntoEffect`. +002 Generated view modules expose ergonomic resource commands: `put(slot, value)`, `append(keyed_slot, key, value)`, `prepend(keyed_slot, key, value)`, `replace(keyed_slot, key, value)`, plus typed resource constants for direct text/remove operations. Commands return `impl IntoEffect` and preserve generated lowering. ### req: codegen/003 003 Generated module `handles` exports typed constants: `Handle` where `I` is `Form`, a param type, or `()`. Users rarely reference handles directly; they are consumed by `#[slhx::handler]` for validation. diff --git a/examples/v0/src/lib.rs b/examples/v0/src/lib.rs index d1ef998..7a4f3a0 100644 --- a/examples/v0/src/lib.rs +++ b/examples/v0/src/lib.rs @@ -5,7 +5,7 @@ pub mod ui {} mod tests { use super::ui::{auth, counter, notifications, page_swap, todos, wizard}; use hemplate::Hemplate; - use slhx::{push, Effect, IntoEffect, NavigateMode, Payload, RenderKeyedSlotExt}; + use slhx::{push, Effect, IntoEffect, NavigateMode, Payload}; use slhx_test::inspect; #[derive(Clone, Debug)] @@ -77,7 +77,8 @@ mod tests { fn todos_append_keyed_rows_from_form_input() { fn add_todo(input: TodoInput) -> impl IntoEffect { let todo = Todo { id: 7, title: input.title }; - todos::slots::todo_row.append( + todos::append( + todos::slots::todo_row, todo.id.to_string(), &TodoRow { title: todo.title }, ) diff --git a/slhx-build/src/lib.rs b/slhx-build/src/lib.rs index e288d38..3ae80b4 100644 --- a/slhx-build/src/lib.rs +++ b/slhx-build/src/lib.rs @@ -534,9 +534,27 @@ 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}pub fn put(slot: ::slhx::Slot, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect {{\n")); out.push_str(&format!("{pad} slot.html(render(view))\n")); out.push_str(&format!("{pad}}}\n\n")); + out.push_str(&format!("{pad}pub fn append(slot: ::slhx::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect\n")); + out.push_str(&format!("{pad}where\n")); + out.push_str(&format!("{pad} K: ::std::string::ToString,\n")); + out.push_str(&format!("{pad}{{\n")); + out.push_str(&format!("{pad} slot.append_html(key, render(view))\n")); + out.push_str(&format!("{pad}}}\n\n")); + out.push_str(&format!("{pad}pub fn prepend(slot: ::slhx::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect\n")); + out.push_str(&format!("{pad}where\n")); + out.push_str(&format!("{pad} K: ::std::string::ToString,\n")); + out.push_str(&format!("{pad}{{\n")); + out.push_str(&format!("{pad} slot.prepend_html(key, render(view))\n")); + out.push_str(&format!("{pad}}}\n\n")); + out.push_str(&format!("{pad}pub fn replace(slot: ::slhx::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect\n")); + out.push_str(&format!("{pad}where\n")); + out.push_str(&format!("{pad} K: ::std::string::ToString,\n")); + out.push_str(&format!("{pad}{{\n")); + out.push_str(&format!("{pad} slot.replace_html(key, render(view))\n")); + out.push_str(&format!("{pad}}}\n\n")); self.push_lowering_table(out, component, indent, &table_name); } @@ -1310,7 +1328,10 @@ 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("pub fn put(slot: ::slhx::Slot, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect")); + assert!(generated.contains("pub fn append(slot: ::slhx::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect")); + assert!(generated.contains("pub fn prepend(slot: ::slhx::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect")); + assert!(generated.contains("pub fn replace(slot: ::slhx::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect")); assert!(generated.contains("data-slhx-slot")); assert!(generated.contains("data-slhx-form")); assert!(generated.contains("data-sid")); @@ -1380,13 +1401,15 @@ 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("\npub fn put(slot: ::slhx::Slot, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect")); + assert!(generated.contains("\npub fn append(slot: ::slhx::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect")); 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")); + assert!(generated.contains(" pub fn put(slot: ::slhx::Slot, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect")); + assert!(generated.contains(" pub fn append(slot: ::slhx::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect")); let _ = std::fs::remove_dir_all(&base); } @@ -1488,10 +1511,12 @@ mod slhx {{ #[derive(Clone, Copy)] pub struct BuildFingerprint; impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }} pub struct Effect; + pub trait IntoEffect {{}} + impl IntoEffect for Effect {{}} #[derive(Clone, Copy)] pub struct Slot(::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) }} }} + impl KeyedSlot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub fn append_html(self, _: K, _: SafeHtml) -> Effect {{ Effect }} pub fn prepend_html(self, _: K, _: SafeHtml) -> Effect {{ Effect }} pub fn replace_html(self, _: K, _: SafeHtml) -> Effect {{ Effect }} }} #[derive(Clone, Copy)] pub struct Handle(::std::marker::PhantomData); impl Handle {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[derive(Clone, Copy)] pub struct Atom(::std::marker::PhantomData); @@ -1763,10 +1788,12 @@ mod slhx {{ #[derive(Clone, Copy)] pub struct BuildFingerprint; impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }} pub struct Effect; + pub trait IntoEffect {{}} + impl IntoEffect for Effect {{}} #[derive(Clone, Copy)] pub struct Slot(::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) }} }} + impl KeyedSlot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub fn append_html(self, _: K, _: SafeHtml) -> Effect {{ Effect }} pub fn prepend_html(self, _: K, _: SafeHtml) -> Effect {{ Effect }} pub fn replace_html(self, _: K, _: SafeHtml) -> Effect {{ Effect }} }} #[derive(Clone, Copy)] pub struct Handle(::std::marker::PhantomData); impl Handle {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[derive(Clone, Copy)] pub struct Atom(::std::marker::PhantomData); diff --git a/slhx-test/tests/examples_contract.rs b/slhx-test/tests/examples_contract.rs index ddb21df..4a18383 100644 --- a/slhx-test/tests/examples_contract.rs +++ b/slhx-test/tests/examples_contract.rs @@ -67,6 +67,7 @@ fn canonical_examples_do_not_author_low_level_resource_plumbing() { "InteractionForm", "register_handle(", "RenderSlotExt", + "RenderKeyedSlotExt", ".render(&", ".html(ui::render", ".html(slhx::render", diff --git a/slhx/src/lib.rs b/slhx/src/lib.rs index 4aab2a0..c5ba711 100644 --- a/slhx/src/lib.rs +++ b/slhx/src/lib.rs @@ -84,7 +84,7 @@ pub mod prelude { CssClasses, Effect, EventName, Form, FormModel, FormValue, Handle, IntoEffect, KeyedSlot, ParamName, SafeHtml, Slot, }; - pub use crate::{render, render_html, RenderKeyedSlotExt}; + pub use crate::{render, render_html}; pub use slhx_derive::{app, component, form, handler, surface}; }