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
This commit is contained in:
slhx agent
2026-06-02 03:05:37 +02:00
parent 15809c86a6
commit ee6045aec5
5 changed files with 40 additions and 11 deletions
+2 -2
View File
@@ -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”. 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 ### 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 ### 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. 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] 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 ### req: codegen/002
002 Generated module `slots` exposes ergonomic methods: `Slot<T>::render(value)`, `Slot<T>::text(value)`, `KeyedSlot<K,T>::append(key, value)`, `KeyedSlot<K,T>::prepend(key, value)`, `KeyedSlot<K,T>::replace(key, value)`, `KeyedSlot<K,T>::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 ### req: codegen/003
003 Generated module `handles` exports typed constants: `Handle<I>` where `I` is `Form<T>`, a param type, or `()`. Users rarely reference handles directly; they are consumed by `#[slhx::handler]` for validation. 003 Generated module `handles` exports typed constants: `Handle<I>` where `I` is `Form<T>`, a param type, or `()`. Users rarely reference handles directly; they are consumed by `#[slhx::handler]` for validation.
+3 -2
View File
@@ -5,7 +5,7 @@ pub mod ui {}
mod tests { mod tests {
use super::ui::{auth, counter, notifications, page_swap, todos, wizard}; use super::ui::{auth, counter, notifications, page_swap, todos, wizard};
use hemplate::Hemplate; use hemplate::Hemplate;
use slhx::{push, Effect, IntoEffect, NavigateMode, Payload, RenderKeyedSlotExt}; use slhx::{push, Effect, IntoEffect, NavigateMode, Payload};
use slhx_test::inspect; use slhx_test::inspect;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -77,7 +77,8 @@ mod tests {
fn todos_append_keyed_rows_from_form_input() { fn todos_append_keyed_rows_from_form_input() {
fn add_todo(input: TodoInput) -> impl IntoEffect { fn add_todo(input: TodoInput) -> impl IntoEffect {
let todo = Todo { id: 7, title: input.title }; let todo = Todo { id: 7, title: input.title };
todos::slots::todo_row.append( todos::append(
todos::slots::todo_row,
todo.id.to_string(), todo.id.to_string(),
&TodoRow { title: todo.title }, &TodoRow { title: todo.title },
) )
+33 -6
View File
@@ -534,9 +534,27 @@ impl Resources {
out.push_str(&format!("{pad} ::slhx::SafeHtml::trusted(lower(html))\n")); out.push_str(&format!("{pad} ::slhx::SafeHtml::trusted(lower(html))\n"));
out.push_str(&format!("{pad}}}\n\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 render_html(view: &impl ::hemplate::Hemplate) -> ::slhx::SafeHtml {{ render(view) }}\n\n"));
out.push_str(&format!("{pad}pub fn put<T>(slot: ::slhx::Slot<T>, view: &impl ::hemplate::Hemplate) -> ::slhx::Effect {{\n")); out.push_str(&format!("{pad}pub fn put<T>(slot: ::slhx::Slot<T>, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect {{\n"));
out.push_str(&format!("{pad} slot.html(render(view))\n")); out.push_str(&format!("{pad} slot.html(render(view))\n"));
out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!("{pad}}}\n\n"));
out.push_str(&format!("{pad}pub fn append<K, T>(slot: ::slhx::KeyedSlot<K, T>, 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<K, T>(slot: ::slhx::KeyedSlot<K, T>, 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<K, T>(slot: ::slhx::KeyedSlot<K, T>, 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); 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 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(view: &impl ::hemplate::Hemplate) -> ::slhx::SafeHtml"));
assert!(generated.contains("pub fn render_html(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<T>(slot: ::slhx::Slot<T>, view: &impl ::hemplate::Hemplate) -> ::slhx::Effect")); assert!(generated.contains("pub fn put<T>(slot: ::slhx::Slot<T>, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect"));
assert!(generated.contains("pub fn append<K, T>(slot: ::slhx::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect"));
assert!(generated.contains("pub fn prepend<K, T>(slot: ::slhx::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect"));
assert!(generated.contains("pub fn replace<K, T>(slot: ::slhx::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect"));
assert!(generated.contains("data-slhx-slot")); assert!(generated.contains("data-slhx-slot"));
assert!(generated.contains("data-slhx-form")); assert!(generated.contains("data-slhx-form"));
assert!(generated.contains("data-sid")); 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 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(view: &impl ::hemplate::Hemplate) -> ::slhx::SafeHtml"));
assert!(generated.contains("\npub fn render_html(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<T>(slot: ::slhx::Slot<T>, view: &impl ::hemplate::Hemplate) -> ::slhx::Effect")); assert!(generated.contains("\npub fn put<T>(slot: ::slhx::Slot<T>, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect"));
assert!(generated.contains("\npub fn append<K, T>(slot: ::slhx::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect"));
assert!(generated.contains("pub mod todo")); assert!(generated.contains("pub mod todo"));
assert!(generated.contains(" pub const COMPONENT: ::slhx::ComponentRef = ::slhx::ComponentRef::new(\"todo\")")); assert!(generated.contains(" pub const COMPONENT: ::slhx::ComponentRef = ::slhx::ComponentRef::new(\"todo\")"));
assert!(generated.contains(" pub mod slots")); assert!(generated.contains(" pub mod slots"));
assert!(generated.contains(" pub fn lower_html")); assert!(generated.contains(" pub fn lower_html"));
assert!(generated.contains(" pub fn static_fragment(html: &'static str) -> ::slhx::SafeHtml")); assert!(generated.contains(" pub fn static_fragment(html: &'static str) -> ::slhx::SafeHtml"));
assert!(generated.contains(" pub fn put<T>(slot: ::slhx::Slot<T>, view: &impl ::hemplate::Hemplate) -> ::slhx::Effect")); assert!(generated.contains(" pub fn put<T>(slot: ::slhx::Slot<T>, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect"));
assert!(generated.contains(" pub fn append<K, T>(slot: ::slhx::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> impl ::slhx::IntoEffect"));
let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&base);
} }
@@ -1488,10 +1511,12 @@ mod slhx {{
#[derive(Clone, Copy)] pub struct BuildFingerprint; #[derive(Clone, Copy)] pub struct BuildFingerprint;
impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }} impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }}
pub struct Effect; pub struct Effect;
pub trait IntoEffect {{}}
impl IntoEffect for Effect {{}}
#[derive(Clone, Copy)] pub struct Slot<T>(::std::marker::PhantomData<T>); #[derive(Clone, Copy)] pub struct Slot<T>(::std::marker::PhantomData<T>);
impl<T> Slot<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub fn html(self, _: SafeHtml) -> Effect {{ Effect }} }} impl<T> Slot<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub fn html(self, _: SafeHtml) -> Effect {{ Effect }} }}
#[derive(Clone, Copy)] pub struct KeyedSlot<K, T>(::std::marker::PhantomData<(K, T)>); #[derive(Clone, Copy)] pub struct KeyedSlot<K, T>(::std::marker::PhantomData<(K, T)>);
impl<K, T> KeyedSlot<K, T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} impl<K, T> KeyedSlot<K, T> {{ 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<T>(::std::marker::PhantomData<T>); #[derive(Clone, Copy)] pub struct Handle<T>(::std::marker::PhantomData<T>);
impl<T> Handle<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} impl<T> Handle<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct Atom<T>(::std::marker::PhantomData<T>); #[derive(Clone, Copy)] pub struct Atom<T>(::std::marker::PhantomData<T>);
@@ -1763,10 +1788,12 @@ mod slhx {{
#[derive(Clone, Copy)] pub struct BuildFingerprint; #[derive(Clone, Copy)] pub struct BuildFingerprint;
impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }} impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }}
pub struct Effect; pub struct Effect;
pub trait IntoEffect {{}}
impl IntoEffect for Effect {{}}
#[derive(Clone, Copy)] pub struct Slot<T>(::std::marker::PhantomData<T>); #[derive(Clone, Copy)] pub struct Slot<T>(::std::marker::PhantomData<T>);
impl<T> Slot<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub fn html(self, _: SafeHtml) -> Effect {{ Effect }} }} impl<T> Slot<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} pub fn html(self, _: SafeHtml) -> Effect {{ Effect }} }}
#[derive(Clone, Copy)] pub struct KeyedSlot<K, T>(::std::marker::PhantomData<(K, T)>); #[derive(Clone, Copy)] pub struct KeyedSlot<K, T>(::std::marker::PhantomData<(K, T)>);
impl<K, T> KeyedSlot<K, T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} impl<K, T> KeyedSlot<K, T> {{ 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<T>(::std::marker::PhantomData<T>); #[derive(Clone, Copy)] pub struct Handle<T>(::std::marker::PhantomData<T>);
impl<T> Handle<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} impl<T> Handle<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
#[derive(Clone, Copy)] pub struct Atom<T>(::std::marker::PhantomData<T>); #[derive(Clone, Copy)] pub struct Atom<T>(::std::marker::PhantomData<T>);
+1
View File
@@ -67,6 +67,7 @@ fn canonical_examples_do_not_author_low_level_resource_plumbing() {
"InteractionForm", "InteractionForm",
"register_handle(", "register_handle(",
"RenderSlotExt", "RenderSlotExt",
"RenderKeyedSlotExt",
".render(&", ".render(&",
".html(ui::render", ".html(ui::render",
".html(slhx::render", ".html(slhx::render",
+1 -1
View File
@@ -84,7 +84,7 @@ pub mod prelude {
CssClasses, Effect, EventName, Form, FormModel, FormValue, Handle, IntoEffect, KeyedSlot, CssClasses, Effect, EventName, Form, FormModel, FormValue, Handle, IntoEffect, KeyedSlot,
ParamName, SafeHtml, Slot, 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}; pub use slhx_derive::{app, component, form, handler, surface};
} }