diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 37f13af..8009df0 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -237,6 +237,9 @@ a `data-*` handle param is statically known or runtime-extracted. ### req: codegen/005 005 Generated module `atoms` exports `Atom` for values that must be addressable, bootstrapped, or synced. Ordinary Rust fields on app/components are not automatically atoms. +### req: codegen/006 +006 `slhx-build` discovers `data-slhx-on` event names from hemplate Surface inputs and emits generated event constants. Event constants are metadata for checked Rust authoring and diagnostics; they do not create a trigger mini-language or new browser runtime semantics. [north_star] + --- ## public_api diff --git a/slhx-build/src/lib.rs b/slhx-build/src/lib.rs index 210d383..ff886c0 100644 --- a/slhx-build/src/lib.rs +++ b/slhx-build/src/lib.rs @@ -76,6 +76,7 @@ struct Resources { forms: BTreeMap, atoms: BTreeMap, classes: BTreeMap, + events: BTreeMap, } #[derive(Clone, Debug)] @@ -108,6 +109,14 @@ struct ClassToken { token: String, } +#[derive(Clone, Debug)] +struct EventToken { + symbol: String, + ident: String, + component: String, + name: String, +} + impl Resources { fn add_surface(&mut self, root: &Path, path: &Path, surface: &SurfaceDocument) -> io::Result<()> { let component = component_ident(root, path)?; @@ -123,6 +132,13 @@ impl Resources { } } + if let Some(on_attr) = static_attr(&node.attrs, "data-slhx-on") { + for event in event_tokens(&on_attr) { + let canonical = canonical_symbol(root, path, event); + self.insert_event(canonical, event.to_owned(), component.clone())?; + } + } + if let Some(name) = static_attr(&node.attrs, "data-slhx-slot") { reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?; let keyed = is_inside_keyed_for(surface, node.scope); @@ -232,6 +248,27 @@ impl Resources { Ok(()) } + fn insert_event(&mut self, symbol: String, name: String, component: String) -> io::Result<()> { + let ident = event_ident(&name).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid slhx event `{name}`; expected an ASCII event name usable from Rust"), + ) + })?; + let event = EventToken { symbol, ident, component, name }; + match self.events.get(&event.symbol) { + Some(existing) if existing.name != event.name => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("conflicting event name for `{}`", existing.symbol), + )), + Some(_) => Ok(()), + None => { + self.events.insert(event.symbol.clone(), event); + Ok(()) + } + } + } + fn insert_form( &mut self, symbol: String, @@ -360,6 +397,20 @@ impl Resources { } out.push_str(&format!("{pad}}}\n\n")); + out.push_str(&format!("{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod events {{\n")); + let mut emitted_events = BTreeSet::new(); + for event in self.events.values().filter(|event| event_matches(event, component)) { + if !emitted_events.insert(event.ident.as_str()) { + continue; + } + out.push_str(&format!( + "{inner}pub const {}: &'static str = {};\n", + event.ident, + rust_str(&event.name) + )); + } + out.push_str(&format!("{pad}}}\n\n")); + out.push_str(&format!("{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod forms {{\n")); for form in self.forms.values().filter(|form| component_matches(&form.resource, component)) { let res = &form.resource; @@ -515,6 +566,7 @@ fn __slhx_attr(tag: &str, attr: &str) -> Option<::std::string::String> { .chain(self.atoms.values().map(|res| &res.component)) .chain(self.forms.values().map(|form| &form.resource.component)) .chain(self.classes.values().map(|class| &class.component)) + .chain(self.events.values().map(|event| &event.component)) { if !components.contains(component) { components.push(component.clone()); @@ -550,6 +602,9 @@ fn __slhx_attr(tag: &str, attr: &str) -> Option<::std::string::String> { for class in self.classes.values() { out.push_str(&format!("class\t{}\t{}\t{}\n", class.symbol, class.ident, class.token)); } + for event in self.events.values() { + out.push_str(&format!("event\t{}\t{}\t{}\n", event.symbol, event.ident, event.name)); + } out } @@ -701,10 +756,21 @@ fn class_matches(class: &ClassToken, component: Option<&str>) -> bool { } } +fn event_matches(event: &EventToken, component: Option<&str>) -> bool { + match component { + Some(component) => event.component == component, + None => true, + } +} + fn class_tokens(value: &str) -> impl Iterator { value.split_ascii_whitespace().filter(|token| !token.is_empty()) } +fn event_tokens(value: &str) -> impl Iterator { + value.split_ascii_whitespace().filter(|token| !token.is_empty()) +} + fn stylesheet_class_tokens(source: &str) -> Vec<&str> { let bytes = source.as_bytes(); let mut tokens = Vec::new(); @@ -787,6 +853,10 @@ fn data_param_ident(name: &str) -> Option { rust_ident(&data_name.replace('-', "_")) } +fn event_ident(name: &str) -> Option { + rust_ident(&name.replace(['-', ':'], "_")) +} + fn is_inside_keyed_for(surface: &SurfaceDocument, mut scope: ScopeId) -> bool { loop { let Some(current) = surface.scopes.get(scope.0 as usize) else { @@ -927,7 +997,7 @@ mod tests { std::fs::create_dir_all(&templates).unwrap(); std::fs::write( templates.join("todo.heml"), - r#"
    "#, + r#"
      "#, ) .unwrap(); @@ -941,6 +1011,9 @@ mod tests { assert!(generated.contains("pub mod forms")); assert!(generated.contains("pub mod atoms")); assert!(generated.contains("pub const filter")); + assert!(generated.contains("pub mod events")); + assert!(generated.contains("pub const click: &'static str = \"click\"")); + assert!(generated.contains("pub const keydown: &'static str = \"keydown\"")); assert!(generated.contains("pub const new_todo")); assert!(generated.contains("pub mod todo")); assert!(generated.contains("pub const ALL_IDS")); @@ -956,6 +1029,9 @@ mod tests { assert!(syms.contains("\tfilter\t")); assert!(syms.contains("handle_form\tcreate\tnew_todo\n")); assert!(syms.contains("handle_param\tdelete\ttodo_id\n")); + assert!(syms.contains("event\t")); + assert!(syms.contains("\tclick\tclick\n")); + assert!(syms.contains("\tkeydown\tkeydown\n")); let _ = std::fs::remove_dir_all(&base); }