diff --git a/Cargo.lock b/Cargo.lock index 83c3dbd..65b79db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -618,6 +618,7 @@ dependencies = [ name = "hemx-test" version = "0.1.0" dependencies = [ + "hemx-build", "hemx-core", ] diff --git a/examples/html_examples/src/main.rs b/examples/html_examples/src/main.rs index 8875689..92d7cdb 100644 --- a/examples/html_examples/src/main.rs +++ b/examples/html_examples/src/main.rs @@ -739,7 +739,12 @@ mod tests { .unwrap() .batch, ); - assert!(edit.updates_html_containing(gallery::contact_card, "save_contact")); + // Component-level rendering now lowers contact_card-local handles, so the + // effect contains the lowered handle id, not the source name. + assert!(edit.updates_html_containing( + gallery::contact_card, + &format!("data-hid=\"{}\"", contact_card::save_contact), + )); let save = inspect_batch( InteractionRequest::from(form( diff --git a/hemx-build/src/lib.rs b/hemx-build/src/lib.rs index 08d7298..75f0ac3 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -539,16 +539,20 @@ impl Resources { )); // req: build/001 req: component/003 + let component_names: BTreeSet = self.component_names().into_iter().collect(); + if global_exports { self.push_component_refs(&mut out, 0); - self.push_resource_modules(&mut out, None, 0); + self.push_resource_modules(&mut out, &component_names, None, 0); } self.push_lowering_api(&mut out, None, 0); for component in self.component_names() { out.push_str("\n#[allow(non_upper_case_globals)]\n"); out.push_str(&format!("pub mod {component} {{\n")); - self.push_resource_modules(&mut out, Some(&component), 1); + out.push_str(" #[derive(Clone, Copy)]\n"); + out.push_str(" pub struct Component;\n\n"); + self.push_resource_modules(&mut out, &component_names, Some(&component), 1); self.push_lowering_api(&mut out, Some(&component), 1); out.push_str("}\n"); } @@ -572,7 +576,13 @@ impl Resources { out.push_str(&format!("{pad}}}\n")); } - fn push_resource_modules(&self, out: &mut String, component: Option<&str>, indent: usize) { + fn push_resource_modules( + &self, + out: &mut String, + components: &BTreeSet, + component: Option<&str>, + indent: usize, + ) { let pad = " ".repeat(indent); let inner = " ".repeat(indent + 1); let mut handle_ids = Vec::new(); @@ -613,34 +623,53 @@ impl Resources { out.push_str(&format!( "{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod targets {{\n" )); + let child_prefix = if component.is_some() { + "super::super::" + } else { + "super::" + }; out.push_str(&format!("{inner}#[derive(Clone, Copy)]\n")); out.push_str(&format!( - "{inner}pub struct SlotTarget {{ slot: ::hemx::advanced::Slot }}\n" + "{inner}pub struct SlotTarget {{ slot: ::hemx::advanced::Slot, _marker: ::std::marker::PhantomData }}\n" )); out.push_str(&format!("{inner}#[allow(dead_code)]\n")); - out.push_str(&format!("{inner}impl SlotTarget {{\n")); - out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::Slot) -> Self {{ Self {{ slot }} }}\n")); - out.push_str(&format!("{inner} pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n")); - out.push_str(&format!("{inner} pub fn replace(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n")); + out.push_str(&format!("{inner}impl SlotTarget {{\n")); + out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::Slot) -> Self {{ Self {{ slot, _marker: ::std::marker::PhantomData }} }}\n")); out.push_str(&format!("{inner} pub fn text(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n")); out.push_str(&format!("{inner} pub fn set(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n")); out.push_str(&format!("{inner}}}\n")); + out.push_str(&format!("{inner}impl SlotTarget {{\n")); + out.push_str(&format!("{inner} pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n")); + out.push_str(&format!("{inner} pub fn replace(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n")); + out.push_str(&format!("{inner}}}\n")); + for res in self + .slots + .values() + .filter(|res| component_matches(res, component)) + .filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component) + { + let child = &res.ident; + out.push_str(&format!("{inner}impl SlotTarget {{\n")); + out.push_str(&format!("{inner} pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ {child_prefix}{child}::put(self.slot, view) }}\n")); + out.push_str(&format!("{inner} pub fn replace(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ {child_prefix}{child}::put(self.slot, view) }}\n")); + out.push_str(&format!("{inner}}}\n")); + } out.push_str(&format!( - "{inner}impl ::hemx::GeneratedTarget for SlotTarget {{\n" + "{inner}impl ::hemx::GeneratedTarget for SlotTarget {{\n" )); out.push_str(&format!("{inner} fn __hemx_resource_id(self) -> ::hemx::advanced::ResourceId {{ self.slot.id() }}\n")); out.push_str(&format!("{inner}}}\n")); out.push_str(&format!("{inner}#[derive(Clone, Copy)]\n")); - out.push_str(&format!("{inner}pub struct KeyedSlotTarget {{ slot: ::hemx::advanced::KeyedSlot }}\n")); + out.push_str(&format!("{inner}pub struct KeyedSlotTarget {{ slot: ::hemx::advanced::KeyedSlot, _marker: ::std::marker::PhantomData }}\n")); out.push_str(&format!("{inner}#[allow(dead_code)]\n")); - out.push_str(&format!("{inner}impl KeyedSlotTarget\n")); + out.push_str(&format!("{inner}impl KeyedSlotTarget\n")); out.push_str(&format!("{inner}where\n")); out.push_str(&format!("{inner} K: ::std::string::ToString,\n")); out.push_str(&format!("{inner}{{\n")); - out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::KeyedSlot) -> Self {{ Self {{ slot }} }}\n")); + out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::KeyedSlot) -> Self {{ Self {{ slot, _marker: ::std::marker::PhantomData }} }}\n")); out.push_str(&format!("{inner}}}\n")); out.push_str(&format!( - "{inner}impl KeyedSlotTarget<::std::string::String, T> {{\n" + "{inner}impl KeyedSlotTarget<::std::string::String, T, ()> {{\n" )); out.push_str(&format!("{inner} pub fn append(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.append_html(view.hemx_key(), super::render(&view)) }}\n")); out.push_str(&format!("{inner} pub fn prepend(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(view.hemx_key(), super::render(&view)) }}\n")); @@ -650,8 +679,28 @@ impl Resources { out.push_str(&format!("{inner} pub fn replace_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.replace_html(key.to_string(), super::render(view)) }}\n")); out.push_str(&format!("{inner} pub fn remove(self, key: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.remove(key.to_string()) }}\n")); out.push_str(&format!("{inner}}}\n")); + for res in self + .slots + .values() + .filter(|res| component_matches(res, component)) + .filter(|res| res.keyed) + .filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component) + { + let child = &res.ident; + out.push_str(&format!( + "{inner}impl KeyedSlotTarget<::std::string::String, T, {child_prefix}{child}::Component> {{\n" + )); + out.push_str(&format!("{inner} pub fn append(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.append_html(view.hemx_key(), {child_prefix}{child}::render(&view)) }}\n")); + out.push_str(&format!("{inner} pub fn prepend(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(view.hemx_key(), {child_prefix}{child}::render(&view)) }}\n")); + out.push_str(&format!("{inner} pub fn replace(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect {{ self.slot.replace_html(view.hemx_key(), {child_prefix}{child}::render(&view)) }}\n")); + out.push_str(&format!("{inner} pub fn append_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.append_html(key.to_string(), {child_prefix}{child}::render(view)) }}\n")); + out.push_str(&format!("{inner} pub fn prepend_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.prepend_html(key.to_string(), {child_prefix}{child}::render(view)) }}\n")); + out.push_str(&format!("{inner} pub fn replace_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ self.slot.replace_html(key.to_string(), {child_prefix}{child}::render(view)) }}\n")); + out.push_str(&format!("{inner} pub fn remove(self, key: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.remove(key.to_string()) }}\n")); + out.push_str(&format!("{inner}}}\n")); + } out.push_str(&format!( - "{inner}impl ::hemx::GeneratedTarget for KeyedSlotTarget {{\n" + "{inner}impl ::hemx::GeneratedTarget for KeyedSlotTarget {{\n" )); out.push_str(&format!("{inner} fn __hemx_resource_id(self) -> ::hemx::advanced::ResourceId {{ self.slot.id() }}\n")); out.push_str(&format!("{inner}}}\n")); @@ -660,14 +709,19 @@ impl Resources { .values() .filter(|res| component_matches(res, component)) { + let marker = if components.contains(&res.ident) && Some(res.ident.as_str()) != component { + format!("{child_prefix}{}::Component", res.ident) + } else { + "()".to_string() + }; if res.keyed { out.push_str(&format!( - "{inner}pub const {}: KeyedSlotTarget<::std::string::String, ::std::string::String> = KeyedSlotTarget::new(super::advanced::slots::{});\n", + "{inner}pub const {}: KeyedSlotTarget<::std::string::String, ::std::string::String, {marker}> = KeyedSlotTarget::new(super::advanced::slots::{});\n", res.ident, res.ident )); } else { out.push_str(&format!( - "{inner}pub const {}: SlotTarget<::std::string::String> = SlotTarget::new(super::advanced::slots::{});\n", + "{inner}pub const {}: SlotTarget<::std::string::String, {marker}> = SlotTarget::new(super::advanced::slots::{});\n", res.ident, res.ident )); } @@ -2127,10 +2181,10 @@ mod tests { assert!(generated.contains("pub mod slots")); assert!(generated.contains("pub const todos")); assert!(generated.contains("pub mod targets")); - assert!(generated.contains("pub struct SlotTarget")); - assert!(generated.contains("impl ::hemx::GeneratedTarget for SlotTarget")); + assert!(generated.contains("pub struct SlotTarget")); + assert!(generated.contains("impl ::hemx::GeneratedTarget for SlotTarget")); assert!(generated.contains( - "impl ::hemx::GeneratedTarget for KeyedSlotTarget" + "impl ::hemx::GeneratedTarget for KeyedSlotTarget" )); assert!(generated.contains( "pub fn append(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect" @@ -2138,7 +2192,7 @@ mod tests { assert!(generated.contains( "pub fn append_keyed(self, key: impl ::std::string::ToString, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect" )); - assert!(generated.contains("pub const todos: SlotTarget<::std::string::String> = SlotTarget::new(super::advanced::slots::todos);")); + assert!(generated.contains("pub const todos: SlotTarget<::std::string::String, ()> = SlotTarget::new(super::advanced::slots::todos);")); assert!(generated.contains("pub use self::targets::todos;")); assert!(generated.contains( "pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect" @@ -2253,7 +2307,7 @@ mod tests { assert!(generated.contains("pub mod todo")); assert!(generated.contains("pub const create: ::hemx::Handle<()> = ::hemx::Handle::new(")); assert!(generated.contains("pub const todos: ::hemx::advanced::Slot<::std::string::String> = ::hemx::advanced::Slot::new(")); - assert!(generated.contains("pub const todos: SlotTarget<::std::string::String> = SlotTarget::new(super::advanced::slots::todos);")); + assert!(generated.contains("pub const todos: SlotTarget<::std::string::String, ()> = SlotTarget::new(super::advanced::slots::todos);")); assert!(generated.contains("pub use self::targets::todos;")); assert!(generated .contains("pub const click: ::hemx::EventName = ::hemx::EventName::new(\"click\")")); @@ -2301,7 +2355,7 @@ mod tests { assert!(generated.contains(" pub mod advanced")); assert!(generated.contains(" pub mod slots")); assert!(generated.contains(" pub mod targets")); - assert!(generated.contains(" pub const todos: SlotTarget<::std::string::String> = SlotTarget::new(super::advanced::slots::todos);")); + assert!(generated.contains(" pub const todos: SlotTarget<::std::string::String, ()> = SlotTarget::new(super::advanced::slots::todos);")); assert!(generated.contains(" pub use self::targets::todos;")); assert!(generated.contains(" #[doc(hidden)]\n pub fn lower_html")); assert!(generated.contains( diff --git a/hemx-test/Cargo.toml b/hemx-test/Cargo.toml index 0bc7459..fc08d14 100644 --- a/hemx-test/Cargo.toml +++ b/hemx-test/Cargo.toml @@ -5,3 +5,6 @@ edition.workspace = true [dependencies] hemx-core = { path = "../hemx-core" } + +[dev-dependencies] +hemx-build = { path = "../hemx-build" } diff --git a/hemx-test/tests/examples_contract.rs b/hemx-test/tests/examples_contract.rs index ecfe35e..d8d9ee5 100644 --- a/hemx-test/tests/examples_contract.rs +++ b/hemx-test/tests/examples_contract.rs @@ -278,6 +278,51 @@ fn browser_e2e_does_not_shortcut_product_interactions() { ); } +#[test] +fn html_examples_slot_targets_dispatch_to_component_put() { + // req: runtime/005 req: examples/001 req: htmx_equivalents/001 + // Regression: slots named after a component (e.g. contact_card, editable_row) + // must lower injected partials using that component's handle table, not the + // containing page's handle table. + let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let template_dir = root.join("examples/html_examples/templates"); + let out_dir = std::env::temp_dir().join(format!( + "hemx-test-html-examples-slot-targets-{}", + std::process::id() + )); + + let _ = std::fs::remove_dir_all(&out_dir); + std::fs::create_dir_all(&out_dir).unwrap(); + + let result = std::panic::catch_unwind(|| { + hemx_build::app() + .template_dir(&template_dir) + .out_dir(&out_dir) + .run() + .expect("hemx-build should generate html_examples artifacts"); + + let generated = + std::fs::read_to_string(out_dir.join("hemx.generated.rs")).unwrap_or_default(); + + for component in ["contact_card", "editable_row"] { + let component_path = format!("super::super::{component}"); + let impl_header = format!("impl SlotTarget"); + let put_call = format!("{component_path}::put(self.slot, view)"); + assert!( + generated.contains(&impl_header), + "gallery::targets should have a component-specific SlotTarget impl for `{component}`; missing `{impl_header}`" + ); + assert!( + generated.contains(&put_call), + "gallery::targets::{component} put/replace should dispatch to `{component_path}::put`; missing `{put_call}`" + ); + } + }); + + let _ = std::fs::remove_dir_all(&out_dir); + result.unwrap(); +} + fn scan_examples(dir: &Path, visit: &mut impl FnMut(&Path, &str)) { for entry in std::fs::read_dir(dir).unwrap() { let entry = entry.unwrap();