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
+175 -41
View File
@@ -68,6 +68,13 @@ impl ResourceId {
}
}
/// A generated UI target that can be inspected without exposing raw slots.
/// req: dx/006 req: test/001
pub trait GeneratedTarget {
#[doc(hidden)]
fn __slhx_resource_id(self) -> ResourceId;
}
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub enum ScopeKey {
KeyValue(String),
@@ -281,6 +288,10 @@ impl EventName {
pub const fn as_str(self) -> &'static str {
self.name
}
pub fn emit(self, payload: impl Into<String>) -> Effect {
event(self, payload)
}
}
impl AsRef<str> for EventName {
@@ -402,19 +413,42 @@ pub enum FormControlKind {
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum Effect {
Put { target: ResourceRef, payload: Payload },
Insert { target: ResourceRef, key: String, payload: Payload },
Prepend { target: ResourceRef, key: String, payload: Payload },
Remove { target: ResourceRef, key: Option<String> },
Move { target: ResourceRef, key: String, before: Option<String> },
Focus { target: ResourceRef },
Put {
target: ResourceRef,
payload: Payload,
},
Insert {
target: ResourceRef,
key: String,
payload: Payload,
},
Prepend {
target: ResourceRef,
key: String,
payload: Payload,
},
Remove {
target: ResourceRef,
key: Option<String>,
},
Move {
target: ResourceRef,
key: String,
before: Option<String>,
},
Focus {
target: ResourceRef,
},
Navigate {
url: String,
mode: NavigateMode,
scroll: ScrollBehavior,
title: Option<String>,
},
Emit { name: String, payload: String },
Emit {
name: String,
payload: String,
},
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
@@ -476,13 +510,21 @@ fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
write_ref(target, out);
write_payload(payload, out);
}
Effect::Insert { target, key, payload } => {
Effect::Insert {
target,
key,
payload,
} => {
write_u8(1, out);
write_ref(target, out);
write_str(key, out);
write_payload(payload, out);
}
Effect::Prepend { target, key, payload } => {
Effect::Prepend {
target,
key,
payload,
} => {
write_u8(2, out);
write_ref(target, out);
write_str(key, out);
@@ -493,7 +535,11 @@ fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
write_ref(target, out);
write_option_str(key.as_deref(), out);
}
Effect::Move { target, key, before } => {
Effect::Move {
target,
key,
before,
} => {
write_u8(4, out);
write_ref(target, out);
write_str(key, out);
@@ -503,14 +549,22 @@ fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
write_u8(5, out);
write_ref(target, out);
}
Effect::Navigate { url, mode, scroll, title } => {
Effect::Navigate {
url,
mode,
scroll,
title,
} => {
write_u8(6, out);
write_str(url, out);
write_u8(match mode {
NavigateMode::Push => 0,
NavigateMode::Replace => 1,
NavigateMode::Redirect => 2,
}, out);
write_u8(
match mode {
NavigateMode::Push => 0,
NavigateMode::Replace => 1,
NavigateMode::Redirect => 2,
},
out,
);
write_scroll(scroll, out);
write_option_str(title.as_deref(), out);
}
@@ -523,12 +577,15 @@ fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
}
fn write_ref(reference: &ResourceRef, out: &mut Vec<u8>) {
write_u8(match reference.resource.kind {
ResourceKind::Slot => 0,
ResourceKind::Atom => 1,
ResourceKind::Handle => 2,
ResourceKind::Form => 3,
}, out);
write_u8(
match reference.resource.kind {
ResourceKind::Slot => 0,
ResourceKind::Atom => 1,
ResourceKind::Handle => 2,
ResourceKind::Form => 3,
},
out,
);
write_u32(reference.resource.id, out);
match &reference.scope {
None => write_u8(0, out),
@@ -661,17 +718,41 @@ fn read_batch(bytes: &[u8]) -> Result<EffectBatch, WireError> {
ops.push(read_effect(&mut reader)?);
}
reader.finish()?;
Ok(EffectBatch { abi_version, fingerprint, ops })
Ok(EffectBatch {
abi_version,
fingerprint,
ops,
})
}
fn read_effect(reader: &mut WireReader<'_>) -> Result<Effect, WireError> {
match reader.read_u8()? {
0 => Ok(Effect::Put { target: read_ref(reader)?, payload: read_payload(reader)? }),
1 => Ok(Effect::Insert { target: read_ref(reader)?, key: reader.read_str()?, payload: read_payload(reader)? }),
2 => Ok(Effect::Prepend { target: read_ref(reader)?, key: reader.read_str()?, payload: read_payload(reader)? }),
3 => Ok(Effect::Remove { target: read_ref(reader)?, key: read_option_str(reader)? }),
4 => Ok(Effect::Move { target: read_ref(reader)?, key: reader.read_str()?, before: read_option_str(reader)? }),
5 => Ok(Effect::Focus { target: read_ref(reader)? }),
0 => Ok(Effect::Put {
target: read_ref(reader)?,
payload: read_payload(reader)?,
}),
1 => Ok(Effect::Insert {
target: read_ref(reader)?,
key: reader.read_str()?,
payload: read_payload(reader)?,
}),
2 => Ok(Effect::Prepend {
target: read_ref(reader)?,
key: reader.read_str()?,
payload: read_payload(reader)?,
}),
3 => Ok(Effect::Remove {
target: read_ref(reader)?,
key: read_option_str(reader)?,
}),
4 => Ok(Effect::Move {
target: read_ref(reader)?,
key: reader.read_str()?,
before: read_option_str(reader)?,
}),
5 => Ok(Effect::Focus {
target: read_ref(reader)?,
}),
6 => Ok(Effect::Navigate {
url: reader.read_str()?,
mode: match reader.read_u8()? {
@@ -683,7 +764,10 @@ fn read_effect(reader: &mut WireReader<'_>) -> Result<Effect, WireError> {
scroll: read_scroll(reader)?,
title: read_option_str(reader)?,
}),
7 => Ok(Effect::Emit { name: reader.read_str()?, payload: reader.read_str()? }),
7 => Ok(Effect::Emit {
name: reader.read_str()?,
payload: reader.read_str()?,
}),
_ => Err(WireError::UnknownTag),
}
}
@@ -758,6 +842,14 @@ impl IntoEffect for () {
fn append_to(self, _ops: &mut Vec<Effect>) {}
}
impl<T: IntoEffect> IntoEffect for Option<T> {
fn append_to(self, ops: &mut Vec<Effect>) {
if let Some(effect) = self {
effect.append_to(ops);
}
}
}
macro_rules! impl_tuple_into_effect {
($($name:ident $idx:tt),+) => {
impl<$($name),+> IntoEffect for ($($name,)+)
@@ -822,10 +914,10 @@ impl<T> Slot<T> {
}
}
pub fn html(self, value: SafeHtml) -> Effect {
pub fn html(self, value: impl Into<SafeHtml>) -> Effect {
Effect::Put {
target: ResourceRef::unscoped(self.id),
payload: Payload::html(value),
payload: Payload::html(value.into()),
}
}
}
@@ -901,30 +993,30 @@ where
}
}
pub fn append_html(self, key: K, value: SafeHtml) -> Effect {
pub fn append_html(self, key: K, value: impl Into<SafeHtml>) -> Effect {
Effect::Insert {
target: ResourceRef::unscoped(self.id),
key: key.to_string(),
payload: Payload::html(value),
payload: Payload::html(value.into()),
}
}
pub fn prepend_html(self, key: K, value: SafeHtml) -> Effect {
pub fn prepend_html(self, key: K, value: impl Into<SafeHtml>) -> Effect {
Effect::Prepend {
target: ResourceRef::unscoped(self.id),
key: key.to_string(),
payload: Payload::html(value),
payload: Payload::html(value.into()),
}
}
pub fn replace_html(self, key: K, value: SafeHtml) -> Effect {
pub fn replace_html(self, key: K, value: impl Into<SafeHtml>) -> Effect {
let key = key.to_string();
Effect::Put {
target: ResourceRef {
resource: self.id,
scope: Some(ScopeKey::KeyValue(key)),
},
payload: Payload::html(value),
payload: Payload::html(value.into()),
}
}
@@ -1009,9 +1101,47 @@ impl<I> core::fmt::Display for Handle<I> {
}
}
pub trait FormValue {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FormError {
message: String,
}
impl<T> FormValue for T where T: std::str::FromStr {}
impl FormError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn message(&self) -> &str {
&self.message
}
}
impl core::fmt::Display for FormError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for FormError {}
pub trait FormValue: Sized {
fn parse_form_value(value: &str) -> Result<Self, String>;
}
impl<T> FormValue for T
where
T: std::str::FromStr,
{
fn parse_form_value(value: &str) -> Result<Self, String> {
value.parse().map_err(|_| "invalid form value".to_owned())
}
}
pub trait FromForm: Sized {
fn from_form_fields(fields: &[(String, String)]) -> Result<Self, FormError>;
}
pub trait FormModel {}
@@ -1065,7 +1195,11 @@ impl<T> Form<T> {
}
}
pub fn clear(self, field: impl Into<String>) -> Effect {
pub fn clear(self) -> Effect {
self.reset()
}
pub fn clear_field(self, field: impl Into<String>) -> Effect {
Effect::Put {
target: self.field(field),
payload: Payload::text(""),
+14 -1
View File
@@ -28,6 +28,16 @@ fn effect_batch_wire_round_trips() {
assert!(decoded.is_compatible());
}
#[test]
fn optional_effects_compose_into_batches() {
// req: component/005 req: public_api/003
let count = Slot::<u32>::new(1);
let batch = (Some(count.text(2)), Option::<Effect>::None).into_batch(BuildFingerprint(42));
assert_eq!(batch.ops.len(), 1);
assert_eq!(batch.ops[0], count.text(2));
}
#[test]
fn keyed_slot_replace_uses_scoped_resource_ref() {
let todos = KeyedSlot::<u64, String>::new(9);
@@ -142,7 +152,10 @@ fn generated_css_classes_join_for_hemplate_dynamic_class_attrs() {
assert_eq!(classes.as_str(), "work-card is-selected");
assert_eq!(classes.to_string(), "work-card is-selected");
assert_eq!(CARD.with(SELECTED).as_str(), "work-card is-selected");
assert_eq!(CARD.with_if(true, SELECTED).as_str(), "work-card is-selected");
assert_eq!(
CARD.with_if(true, SELECTED).as_str(),
"work-card is-selected"
);
assert_eq!(CARD.with_if(false, SELECTED).as_str(), "work-card");
}