use hemplate_core::ast::build_ast; use hemplate_core::surface::{ extract_surface, AttributeOrigin, ControlKind, ScopeId, ScopeKind, SurfaceAttribute, SurfaceDocument, SurfaceNodeKind, }; use slhx_core::{EFFECT_BATCH_ABI_VERSION, RUNTIME_ABI_VERSION, SURFACE_SCHEMA_VERSION}; use std::collections::{BTreeMap, BTreeSet}; use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; #[derive(Clone, Debug)] pub struct AppBuilder { out_dir: Option, template_dir: PathBuf, } impl AppBuilder { pub fn out_dir(mut self, out_dir: impl Into) -> Self { self.out_dir = Some(out_dir.into()); self } pub fn template_dir(mut self, template_dir: impl Into) -> Self { self.template_dir = template_dir.into(); self } pub fn run(self) -> io::Result<()> { println!("cargo:rerun-if-changed={}", self.template_dir.display()); let out_dir = self .out_dir .or_else(|| std::env::var_os("OUT_DIR").map(PathBuf::from)); let Some(out_dir) = out_dir else { return Ok(()); }; std::fs::create_dir_all(&out_dir)?; let mut resources = Resources::default(); for path in collect_heml(&self.template_dir)? { let source = std::fs::read_to_string(&path)?; let doc = build_ast(Arc::new(source)).map_err(|err| parse_error(&path, err))?; let Some(doc) = doc else { continue; }; let surface = extract_surface(&doc); resources.add_surface(&self.template_dir, &path, &surface)?; } for path in collect_stylesheets(&self.template_dir)? { let source = std::fs::read_to_string(&path)?; resources.add_stylesheet(&self.template_dir, &path, &source)?; } std::fs::write(out_dir.join("slhx.generated.rs"), resources.generated_rs())?; std::fs::write(out_dir.join("slhx.syms"), resources.syms())?; Ok(()) } } pub fn app() -> AppBuilder { AppBuilder { out_dir: None, template_dir: PathBuf::from("templates"), } } #[derive(Default)] struct Resources { slots: BTreeMap, handles: BTreeMap, handle_forms: BTreeMap, handle_params: BTreeMap>, forms: BTreeMap, atoms: BTreeMap, classes: BTreeMap, } #[derive(Clone, Debug)] struct Resource { symbol: String, ident: String, component: String, keyed: bool, id: u32, } #[derive(Clone, Debug)] struct FormResource { resource: Resource, controls: Vec, } #[derive(Clone, Debug)] struct GeneratedControl { name: String, kind: ControlKind, required: bool, } #[derive(Clone, Debug)] struct ClassToken { symbol: String, ident: String, component: String, token: String, } impl Resources { fn add_surface(&mut self, root: &Path, path: &Path, surface: &SurfaceDocument) -> io::Result<()> { let component = component_ident(root, path)?; for node in &surface.nodes { let SurfaceNodeKind::Element { tag } = &node.kind else { continue; }; if let Some(class_attr) = static_attr(&node.attrs, "class") { for token in class_tokens(&class_attr) { let canonical = canonical_symbol(root, path, token); self.insert_class(canonical, token.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); let canonical = canonical_symbol(root, path, &name); self.insert_slot(canonical, name, component.clone(), keyed)?; } if let Some(name) = static_attr(&node.attrs, "data-slhx-atom") { reject_unkeyed_loop(surface, node.scope, path, "atom", &name)?; let canonical = canonical_symbol(root, path, &name); self.insert_atom(canonical, name, component.clone())?; } if let Some(name) = static_attr(&node.attrs, "data-slhx-handle") { reject_unkeyed_loop(surface, node.scope, path, "handle", &name)?; let canonical = canonical_symbol(root, path, &name); self.insert_handle(canonical, name.clone(), component.clone())?; self.insert_handle_params(&name, &node.attrs)?; if tag == "form" { let form_name = static_attr(&node.attrs, "data-slhx-form").unwrap_or_else(|| name.clone()); let controls = surface .forms .iter() .find(|form| form.node == node.id) .map(|form| { form.controls .iter() .map(|control| GeneratedControl { name: control.name.clone(), kind: control.kind.clone(), required: control.required, }) .collect() }) .unwrap_or_default(); self.insert_form(canonical_symbol(root, path, &form_name), form_name.clone(), component.clone(), controls)?; if let (Some(handle_ident), Some(form_ident)) = (rust_ident(&name), rust_ident(&form_name)) { self.handle_forms.insert(handle_ident, form_ident); } } } } Ok(()) } fn insert_slot(&mut self, symbol: String, name: String, component: String, keyed: bool) -> io::Result<()> { insert_resource(&mut self.slots, "slot", symbol, name, component, keyed) } fn insert_handle(&mut self, symbol: String, name: String, component: String) -> io::Result<()> { insert_resource(&mut self.handles, "handle", symbol, name, component, false) } fn insert_atom(&mut self, symbol: String, name: String, component: String) -> io::Result<()> { insert_resource(&mut self.atoms, "atom", symbol, name, component, false) } fn insert_class(&mut self, symbol: String, token: String, component: String) -> io::Result<()> { let ident = class_ident(&token).ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, format!("invalid CSS class `{token}`; expected an ASCII class token usable from Rust"), ) })?; let class = ClassToken { symbol, ident, component, token }; if let Some(existing) = self.classes.values().find(|existing| { existing.ident == class.ident && existing.token != class.token }) { return Err(io::Error::new( io::ErrorKind::InvalidData, format!( "duplicate generated class identifier `{}` for CSS classes `{}` and `{}`", class.ident, existing.token, class.token ), )); } match self.classes.get(&class.symbol) { Some(existing) if existing.token != class.token => Err(io::Error::new( io::ErrorKind::InvalidData, format!("conflicting CSS class token for `{}`", existing.symbol), )), Some(_) => Ok(()), None => { self.classes.insert(class.symbol.clone(), class); Ok(()) } } } fn insert_handle_params(&mut self, handle: &str, attrs: &[SurfaceAttribute]) -> io::Result<()> { let Some(handle_ident) = rust_ident(handle) else { return Ok(()); }; for attr in attrs { if !is_handle_param_attr(attr) { continue; } let Some(param) = data_param_ident(&attr.name) else { return Err(io::Error::new( io::ErrorKind::InvalidData, format!("invalid handler param attribute `{}`; expected data-* name usable from Rust", attr.name), )); }; self.handle_params.entry(handle_ident.clone()).or_default().insert(param); } Ok(()) } fn insert_form( &mut self, symbol: String, name: String, component: String, controls: Vec, ) -> io::Result<()> { let resource = make_resource("form", symbol, name, component, false)?; match self.forms.get(&resource.ident) { Some(existing) if existing.resource.symbol != resource.symbol => Err(io::Error::new( io::ErrorKind::InvalidData, format!( "duplicate generated identifier `{}` for `{}` and `{}`", resource.ident, existing.resource.symbol, resource.symbol ), )), Some(_) => Ok(()), None => { self.forms.insert(resource.ident.clone(), FormResource { resource, controls }); Ok(()) } } } fn add_stylesheet(&mut self, root: &Path, path: &Path, source: &str) -> io::Result<()> { let component = component_ident(root, path)?; for token in stylesheet_class_tokens(source) { let canonical = canonical_symbol(root, path, token); self.insert_class(canonical, token.to_owned(), component.clone())?; } Ok(()) } fn generated_rs(&self) -> String { let mut out = String::new(); out.push_str("// @generated by slhx-build. Do not edit.\n"); out.push_str(&format!( "pub const BUILD_FINGERPRINT: ::slhx::BuildFingerprint = ::slhx::BuildFingerprint::from_parts(&[{}]);\n\n", self.fingerprint_parts() .into_iter() .map(|part| part.to_string()) .collect::>() .join(", ") )); // req: build/001 self.push_resource_modules(&mut out, 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); self.push_lowering_api(&mut out, Some(&component), 1); out.push_str("}\n"); } self.push_lowering_helpers(&mut out); out } fn push_resource_modules(&self, out: &mut String, component: Option<&str>, indent: usize) { let pad = " ".repeat(indent); let inner = " ".repeat(indent + 1); let mut handle_ids = Vec::new(); out.push_str(&format!("{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod slots {{\n")); for res in self.slots.values().filter(|res| component_matches(res, component)) { if res.keyed { out.push_str(&format!( "{inner}pub const {}: ::slhx::KeyedSlot<::std::string::String, ::std::string::String> = ::slhx::KeyedSlot::new({});\n", res.ident, res.id )); } else { out.push_str(&format!( "{inner}pub const {}: ::slhx::Slot<::std::string::String> = ::slhx::Slot::new({});\n", res.ident, res.id )); } } out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!("{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod handles {{\n")); for res in self.handles.values().filter(|res| component_matches(res, component)) { handle_ids.push(res.id); let input = if self.handle_forms.contains_key(&res.ident) { "::slhx::Form<::std::string::String>" } else { "()" }; out.push_str(&format!( "{inner}pub const {}: ::slhx::Handle<{input}> = ::slhx::Handle::new({});\n", res.ident, res.id )); } out.push_str(&format!( "{inner}pub const ALL_IDS: &[u32] = &[{}];\n", handle_ids .into_iter() .map(|id| id.to_string()) .collect::>() .join(", ") )); out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!("{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod atoms {{\n")); for res in self.atoms.values().filter(|res| component_matches(res, component)) { out.push_str(&format!( "{inner}pub const {}: ::slhx::Atom<::std::string::String> = ::slhx::Atom::new({});\n", res.ident, res.id )); } out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!("{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod classes {{\n")); let mut emitted_classes = BTreeSet::new(); for class in self.classes.values().filter(|class| class_matches(class, component)) { if !emitted_classes.insert(class.ident.as_str()) { continue; } out.push_str(&format!( "{inner}pub const {}: ::slhx::CssClass = ::slhx::CssClass::new({});\n", class.ident, rust_str(&class.token) )); } 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; out.push_str(&format!( "{inner}pub const {}: ::slhx::Form<::std::string::String> = ::slhx::Form::new({});\n", res.ident, res.id )); out.push_str(&format!( "{inner}pub const {}_CONTRACT: ::slhx::FormContract = ::slhx::FormContract {{ fields: &{}_FIELDS }};\n", res.ident.to_ascii_uppercase(), res.ident.to_ascii_uppercase() )); out.push_str(&format!( "{inner}pub const {}_FIELDS: &[::slhx::FormField] = &[\n", res.ident.to_ascii_uppercase() )); for control in &form.controls { out.push_str(&format!( "{inner} ::slhx::FormField {{ name: {}, kind: {}, required: {} }},\n", rust_str(&control.name), form_control_kind_expr(&control.kind), control.required )); } out.push_str(&format!("{inner}];\n")); } out.push_str(&format!("{pad}}}\n")); } fn push_lowering_api(&self, out: &mut String, component: Option<&str>, indent: usize) { let pad = " ".repeat(indent); let table_name = match component { Some(component) => format!("__SLHX_LOWERING_TABLE_{}", component.to_ascii_uppercase()), None => "__SLHX_LOWERING_TABLE".to_string(), }; out.push_str(&format!("\n{pad}pub fn lower_html(html: impl ::std::convert::AsRef) -> ::std::string::String {{\n")); out.push_str(&format!("{pad} ")); if component.is_some() { out.push_str("super::"); } out.push_str(&format!("__slhx_lower_html(html.as_ref(), &{table_name})\n")); out.push_str(&format!("{pad}}}\n\n")); self.push_lowering_table(out, component, indent, &table_name); } fn push_lowering_table(&self, out: &mut String, component: Option<&str>, indent: usize, name: &str) { let pad = " ".repeat(indent); out.push_str(&format!("{pad}const {name}: &[(&str, &str, u32)] = &[\n")); for res in self.slots.values().filter(|res| component_matches(res, component)) { out.push_str(&format!("{pad} (\"data-slhx-slot\", {}, {}),\n", rust_str(&res.ident), res.id)); } for res in self.handles.values().filter(|res| component_matches(res, component)) { out.push_str(&format!("{pad} (\"data-slhx-handle\", {}, {}),\n", rust_str(&res.ident), res.id)); } for form in self.forms.values().filter(|form| component_matches(&form.resource, component)) { let res = &form.resource; out.push_str(&format!("{pad} (\"data-slhx-form\", {}, {}),\n", rust_str(&res.ident), res.id)); } for res in self.atoms.values().filter(|res| component_matches(res, component)) { out.push_str(&format!("{pad} (\"data-slhx-atom\", {}, {}),\n", rust_str(&res.ident), res.id)); } out.push_str(&format!("{pad}];\n")); } fn push_lowering_helpers(&self, out: &mut String) { out.push_str(r#" fn __slhx_lower_html(html: &str, table: &[(&str, &str, u32)]) -> ::std::string::String { let mut out = html.to_owned(); for (attr, name, id) in table { let runtime_attr = match *attr { "data-slhx-slot" => "data-sid", "data-slhx-handle" => "data-hid", "data-slhx-form" => "data-fid", "data-slhx-atom" => "data-aid", _ => continue, }; out = __slhx_replace_attr(out, attr, name, runtime_attr, *id); } out = __slhx_lower_static_attr(out, "data-slhx-key", "data-key"); __slhx_inject_handle_inputs(out) } fn __slhx_replace_attr(mut html: ::std::string::String, attr: &str, name: &str, runtime_attr: &str, id: u32) -> ::std::string::String { let replacement = if runtime_attr == "value" { ::std::format!("{attr}=\"{name}\" {runtime_attr}=\"{id}\"") } else { ::std::format!("{runtime_attr}=\"{id}\"") }; let double = ::std::format!("{attr}=\"{name}\""); html = html.replace(&double, &replacement); let single_replacement = replacement.replace('"', "'"); let single = ::std::format!("{attr}='{name}'"); html.replace(&single, &single_replacement) } fn __slhx_lower_static_attr(mut html: ::std::string::String, attr: &str, runtime_attr: &str) -> ::std::string::String { html = html.replace(&::std::format!("{attr}=\""), &::std::format!("{runtime_attr}=\"")); html.replace(&::std::format!("{attr}='"), &::std::format!("{runtime_attr}='")) } fn __slhx_inject_handle_inputs(html: ::std::string::String) -> ::std::string::String { let mut out = ::std::string::String::with_capacity(html.len()); let mut rest = html.as_str(); while let Some(start) = rest.find("') else { out.push_str(rest); return out; }; let opening = &rest[..=open_end]; out.push_str(opening); rest = &rest[open_end + 1..]; let Some(handle_id) = __slhx_attr(opening, "data-hid") else { continue; }; let form_body_end = rest.find("").unwrap_or(rest.len()); let form_body = &rest[..form_body_end]; if !__slhx_has_handle_input(form_body) { out.push_str(&::std::format!("", handle_id)); } } out.push_str(rest); out } fn __slhx_has_handle_input(html: &str) -> bool { html.contains("name=\"__h\"") || html.contains("name='__h'") } fn __slhx_attr(tag: &str, attr: &str) -> Option<::std::string::String> { let attr_at = tag.find(attr)?; let after_attr = &tag[attr_at + attr.len()..]; let after_equals = after_attr.trim_start().strip_prefix('=')?.trim_start(); let quote = after_equals.chars().next()?; if quote != '\"' && quote != '\'' { return None; } let value = &after_equals[quote.len_utf8()..]; let end = value.find(quote)?; Some(value[..end].to_owned()) } "#); } fn component_names(&self) -> Vec { let mut components = Vec::new(); for component in self .slots .values() .map(|res| &res.component) .chain(self.handles.values().map(|res| &res.component)) .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)) { if !components.contains(component) { components.push(component.clone()); } } components.sort(); components } fn syms(&self) -> String { let mut out = String::from("slhx-syms-v1\n"); for res in self.slots.values() { out.push_str(&format!("slot\t{}\t{}\t{}\n", res.symbol, res.ident, res.id)); } for res in self.handles.values() { out.push_str(&format!("handle\t{}\t{}\t{}\n", res.symbol, res.ident, res.id)); } for form in self.forms.values() { let res = &form.resource; out.push_str(&format!("form\t{}\t{}\t{}\n", res.symbol, res.ident, res.id)); } for (handle_ident, form_ident) in &self.handle_forms { out.push_str(&format!("handle_form\t{handle_ident}\t{form_ident}\n")); } for (handle_ident, params) in &self.handle_params { for param in params { out.push_str(&format!("handle_param\t{handle_ident}\t{param}\n")); } } for res in self.atoms.values() { out.push_str(&format!("atom\t{}\t{}\t{}\n", res.symbol, res.ident, res.id)); } for class in self.classes.values() { out.push_str(&format!("class\t{}\t{}\t{}\n", class.symbol, class.ident, class.token)); } out } fn fingerprint_parts(&self) -> Vec { let mut parts = vec![ SURFACE_SCHEMA_VERSION, EFFECT_BATCH_ABI_VERSION, RUNTIME_ABI_VERSION, ]; for res in self.slots.values() { parts.push(0); parts.push(res.id); } for res in self.handles.values() { parts.push(1); parts.push(res.id); } for res in self.atoms.values() { parts.push(3); parts.push(res.id); } for form in self.forms.values() { parts.push(2); parts.push(form.resource.id); parts.push(form.controls.len() as u32); for control in &form.controls { parts.push(stable_id("form-field", &control.name)); parts.push(control.required as u32); } } parts } } fn insert_resource( map: &mut BTreeMap, kind: &str, symbol: String, name: String, component: String, keyed: bool, ) -> io::Result<()> { let resource = make_resource(kind, symbol, name, component, keyed)?; match map.get(&resource.ident) { Some(existing) if existing.symbol != resource.symbol => Err(io::Error::new( io::ErrorKind::InvalidData, format!( "duplicate generated identifier `{}` for `{}` and `{}`", resource.ident, existing.symbol, resource.symbol ), )), Some(_) => Ok(()), None => { map.insert(resource.ident.clone(), resource); Ok(()) } } } fn make_resource( kind: &str, symbol: String, name: String, component: String, keyed: bool, ) -> io::Result { let ident = rust_ident(&name).ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, format!("invalid slhx {kind} name `{name}`; expected a Rust identifier"), ) })?; let id = stable_id(kind, &symbol); Ok(Resource { symbol, ident, component, keyed, id, }) } fn collect_heml(root: &Path) -> io::Result> { let mut paths = Vec::new(); if !root.exists() { return Ok(paths); } collect_heml_into(root, &mut paths)?; paths.sort(); Ok(paths) } fn collect_heml_into(dir: &Path, paths: &mut Vec) -> io::Result<()> { for entry in std::fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.is_dir() { collect_heml_into(&path, paths)?; } else if path.extension().and_then(|ext| ext.to_str()) == Some("heml") { paths.push(path); } } Ok(()) } fn collect_stylesheets(root: &Path) -> io::Result> { let mut paths = Vec::new(); if !root.exists() { return Ok(paths); } collect_stylesheets_into(root, &mut paths)?; paths.sort(); Ok(paths) } fn collect_stylesheets_into(dir: &Path, paths: &mut Vec) -> io::Result<()> { for entry in std::fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.is_dir() { collect_stylesheets_into(&path, paths)?; } else if matches!(path.extension().and_then(|ext| ext.to_str()), Some("css" | "scss")) { paths.push(path); } } Ok(()) } fn static_attr(attrs: &[SurfaceAttribute], name: &str) -> Option { attrs .iter() .find(|attr| attr.origin == AttributeOrigin::Static && attr.name == name) .and_then(|attr| attr.value.clone()) } fn component_matches(res: &Resource, component: Option<&str>) -> bool { match component { Some(component) => res.component == component, None => true, } } fn class_matches(class: &ClassToken, component: Option<&str>) -> bool { match component { Some(component) => class.component == component, None => true, } } fn class_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(); let mut i = 0; while i < bytes.len() { if bytes[i] != b'.' { i += 1; continue; } let prev = i.checked_sub(1).map(|idx| bytes[idx]); if prev.is_some_and(|ch| ch == b'-' || ch == b'_' || ch.is_ascii_alphanumeric()) && !preceded_by_class_in_compound(bytes, i) { i += 1; continue; } let start = i + 1; if start >= bytes.len() || !is_class_start(bytes[start]) { i += 1; continue; } let mut end = start + 1; while end < bytes.len() && is_class_continue(bytes[end]) { end += 1; } if let Ok(token) = std::str::from_utf8(&bytes[start..end]) { tokens.push(token); } i = end; } tokens.sort_unstable(); tokens.dedup(); tokens } fn preceded_by_class_in_compound(bytes: &[u8], dot: usize) -> bool { let mut i = dot; while let Some(prev) = i.checked_sub(1) { let byte = bytes[prev]; if byte == b'.' { return true; } if matches!(byte, b' ' | b'\n' | b'\r' | b'\t' | b',' | b'{' | b'}' | b'>' | b'+' | b'~' | b'(' | b')') { return false; } i = prev; } false } fn is_class_start(byte: u8) -> bool { byte == b'_' || byte == b'-' || byte.is_ascii_alphabetic() } fn is_class_continue(byte: u8) -> bool { is_class_start(byte) || byte.is_ascii_digit() } fn class_ident(token: &str) -> Option { let mut ident = String::with_capacity(token.len()); for ch in token.chars() { match ch { '-' => ident.push('_'), '_' => ident.push('_'), ch if ch.is_ascii_alphanumeric() => ident.push(ch), _ => return None, } } rust_ident(&ident) } fn is_handle_param_attr(attr: &SurfaceAttribute) -> bool { matches!(attr.origin, AttributeOrigin::Static | AttributeOrigin::Dynamic) && attr.name.starts_with("data-") && !attr.name.starts_with("data-slhx-") } fn data_param_ident(name: &str) -> Option { let data_name = name.strip_prefix("data-")?; rust_ident(&data_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 { return false; }; if matches!(current.kind, ScopeKind::For { key_expr: Some(_), .. }) { return true; } let Some(parent) = current.parent else { return false; }; scope = parent; } } fn reject_unkeyed_loop( surface: &SurfaceDocument, mut scope: ScopeId, path: &Path, kind: &str, name: &str, ) -> io::Result<()> { loop { let Some(current) = surface.scopes.get(scope.0 as usize) else { return Ok(()); }; if matches!(current.kind, ScopeKind::For { key_expr: None, .. }) { return Err(io::Error::new( io::ErrorKind::InvalidData, format!( "{}: data-slhx-{kind}=\"{name}\" is inside an h-for without h-key; add h-key=\"item.id\" to the loop", path.display() ), )); } let Some(parent) = current.parent else { return Ok(()); }; scope = parent; } } fn canonical_symbol(root: &Path, path: &Path, name: &str) -> String { let rel = path.strip_prefix(root).unwrap_or(path); format!("{}::{name}", rel.to_string_lossy().replace('\\', "/")) } fn component_ident(root: &Path, path: &Path) -> io::Result { let rel = path.strip_prefix(root).unwrap_or(path); let stem = rel .file_stem() .and_then(|stem| stem.to_str()) .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("invalid template path `{}`", path.display())))?; rust_ident(stem).ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, format!("invalid template name `{stem}`; expected a Rust module identifier"), ) }) } fn rust_ident(name: &str) -> Option { let mut chars = name.chars(); let first = chars.next()?; if !(first == '_' || first.is_ascii_alphabetic()) { return None; } if chars.clone().any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric())) { return None; } Some(name.to_string()) } fn stable_id(kind: &str, symbol: &str) -> u32 { let mut hash = 0x811c9dc5u32; for byte in kind.bytes().chain([b':']).chain(symbol.bytes()) { hash ^= byte as u32; hash = hash.wrapping_mul(0x01000193); } hash } fn form_control_kind_expr(kind: &ControlKind) -> String { match kind { ControlKind::Text => "::slhx::FormControlKind::Text".to_string(), ControlKind::Number { min, max, step } => format!( "::slhx::FormControlKind::Number {{ min: {}, max: {}, step: {} }}", rust_str_opt(min.as_deref()), rust_str_opt(max.as_deref()), rust_str_opt(step.as_deref()) ), ControlKind::Checkbox => "::slhx::FormControlKind::Checkbox".to_string(), ControlKind::Radio => "::slhx::FormControlKind::Radio".to_string(), ControlKind::Select { multiple } => { format!("::slhx::FormControlKind::Select {{ multiple: {multiple} }}") } ControlKind::TextArea => "::slhx::FormControlKind::TextArea".to_string(), ControlKind::File => "::slhx::FormControlKind::File".to_string(), ControlKind::Hidden => "::slhx::FormControlKind::Hidden".to_string(), ControlKind::Submit => "::slhx::FormControlKind::Submit".to_string(), ControlKind::Other { tag, input_type } => format!( "::slhx::FormControlKind::Other {{ tag: {}, input_type: {} }}", rust_str(tag), rust_str_opt(input_type.as_deref()) ), } } fn rust_str(value: &str) -> String { format!("{value:?}") } fn rust_str_opt(value: Option<&str>) -> String { match value { Some(value) => format!("Some({})", rust_str(value)), None => "None".to_string(), } } fn parse_error(path: &Path, err: impl std::fmt::Display) -> io::Error { io::Error::new( io::ErrorKind::InvalidData, format!("failed to parse {}: {err}", path.display()), ) } #[cfg(test)] mod tests { use super::*; #[test] fn emits_generated_resources_from_heml() { // req: codegen/003 let base = test_dir("slhx-build-test"); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); std::fs::write( templates.join("todo.heml"), r#"
    "#, ) .unwrap(); app().template_dir(&templates).out_dir(&out).run().unwrap(); let generated = std::fs::read_to_string(out.join("slhx.generated.rs")).unwrap(); assert!(generated.contains("pub mod slots")); assert!(generated.contains("pub const todos")); assert!(generated.contains("pub mod handles")); assert!(generated.contains("pub const create: ::slhx::Handle<::slhx::Form<::std::string::String>>")); assert!(generated.contains("pub mod forms")); assert!(generated.contains("pub mod atoms")); assert!(generated.contains("pub const filter")); assert!(generated.contains("pub const new_todo")); assert!(generated.contains("pub mod todo")); assert!(generated.contains("pub const ALL_IDS")); assert!(generated.contains("pub fn lower_html")); assert!(generated.contains("data-slhx-slot")); assert!(generated.contains("data-slhx-form")); assert!(generated.contains("data-sid")); assert!(generated.contains("data-fid")); assert!(generated.contains("__slhx_inject_handle_inputs")); let syms = std::fs::read_to_string(out.join("slhx.syms")).unwrap(); assert!(syms.contains("atom\t")); assert!(syms.contains("\tfilter\t")); assert!(syms.contains("handle_form\tcreate\tnew_todo\n")); assert!(syms.contains("handle_param\tdelete\ttodo_id\n")); let _ = std::fs::remove_dir_all(&base); } #[test] fn emits_checked_css_class_tokens_from_templates_and_stylesheets() { // req: style/001, req: codegen/001 let base = test_dir("slhx-build-classes-test"); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); std::fs::write( templates.join("card.heml"), r#"
    "#, ) .unwrap(); std::fs::write( templates.join("card.css"), r#".card { padding: 1rem; } .is-active:hover, .drag-handle { cursor: grab; } .work-card.is-selected { outline: 1px solid currentColor; } "#, ) .unwrap(); std::fs::write( templates.join("panel.scss"), r#".panel-shell { &.is-open { display: block; } }"#, ) .unwrap(); app().template_dir(&templates).out_dir(&out).run().unwrap(); let generated = std::fs::read_to_string(out.join("slhx.generated.rs")).unwrap(); assert!(generated.contains("pub mod classes")); assert!(generated.contains("pub const card: ::slhx::CssClass = ::slhx::CssClass::new(\"card\")")); assert!(generated.contains("pub const is_active: ::slhx::CssClass = ::slhx::CssClass::new(\"is-active\")")); assert!(generated.contains("pub const drag_handle: ::slhx::CssClass = ::slhx::CssClass::new(\"drag-handle\")")); assert!(generated.contains("pub const work_card: ::slhx::CssClass = ::slhx::CssClass::new(\"work-card\")")); assert!(generated.contains("pub const is_selected: ::slhx::CssClass = ::slhx::CssClass::new(\"is-selected\")")); assert!(generated.contains("pub mod card")); assert!(generated.contains("pub mod panel")); let syms = std::fs::read_to_string(out.join("slhx.syms")).unwrap(); assert!(syms.contains("class\t")); assert!(syms.contains("\tdrag_handle\tdrag-handle")); let source = format!( r###" #![allow(dead_code)] mod slhx {{ #[derive(Clone, Copy)] pub struct BuildFingerprint; impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }} #[derive(Clone, Copy)] pub struct Slot(::std::marker::PhantomData); impl Slot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[derive(Clone, Copy)] pub struct KeyedSlot(::std::marker::PhantomData<(K, T)>); impl KeyedSlot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[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); impl Atom {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[derive(Clone, Copy)] pub struct Form(::std::marker::PhantomData); impl Form {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[derive(Clone, Copy)] pub struct CssClass(&'static str); impl CssClass {{ pub const fn new(name: &'static str) -> Self {{ Self(name) }} pub const fn as_str(self) -> &'static str {{ self.0 }} }} pub struct FormContract {{ pub fields: &'static [FormField] }} pub struct FormField {{ pub name: &'static str, pub kind: FormControlKind, pub required: bool }} pub enum FormControlKind {{ Text, Number {{ min: Option<&'static str>, max: Option<&'static str>, step: Option<&'static str> }}, Checkbox, Radio, Select {{ multiple: bool }}, TextArea, File, Hidden, Submit, Other {{ tag: &'static str, input_type: Option<&'static str> }} }} }} {generated} fn main() {{ assert_eq!(classes::drag_handle.as_str(), "drag-handle"); assert_eq!(card::classes::is_active.as_str(), "is-active"); assert_eq!(panel::classes::panel_shell.as_str(), "panel-shell"); }} "###, ); let source_path = base.join("classes.rs"); let bin_path = base.join("classes-bin"); std::fs::write(&source_path, source).unwrap(); let status = std::process::Command::new("rustc") .arg(&source_path) .arg("-o") .arg(&bin_path) .status() .unwrap(); assert!(status.success()); let status = std::process::Command::new(&bin_path).status().unwrap(); assert!(status.success()); let _ = std::fs::remove_dir_all(&base); } #[test] fn rejects_slhx_resources_inside_unkeyed_for() { let base = test_dir("slhx-build-unkeyed-for-test"); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); std::fs::write( templates.join("todo.heml"), r#""#, ) .unwrap(); let err = app().template_dir(&templates).out_dir(&out).run().unwrap_err(); assert!(err.to_string().contains("inside an h-for without h-key")); assert!(err.to_string().contains("data-slhx-slot=\"todo_row\"")); let _ = std::fs::remove_dir_all(&base); } #[test] fn generated_lowering_injects_progressive_form_handle_and_key() { // req: form/002, req: wire/001 let base = test_dir("slhx-build-lowering-test"); let templates = base.join("templates"); let out = base.join("out"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&templates).unwrap(); std::fs::write( templates.join("todo.heml"), r#"
  • "#, ) .unwrap(); app().template_dir(&templates).out_dir(&out).run().unwrap(); let generated = std::fs::read_to_string(out.join("slhx.generated.rs")).unwrap(); assert!(generated.contains("fn __slhx_lower_html")); let source = format!( r###" #![allow(dead_code)] mod slhx {{ #[derive(Clone, Copy)] pub struct BuildFingerprint; impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }} #[derive(Clone, Copy)] pub struct Slot(::std::marker::PhantomData); impl Slot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[derive(Clone, Copy)] pub struct KeyedSlot(::std::marker::PhantomData<(K, T)>); impl KeyedSlot {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[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); impl Atom {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} #[derive(Clone, Copy)] pub struct Form(::std::marker::PhantomData); impl Form {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }} pub struct FormContract {{ pub fields: &'static [FormField] }} pub struct FormField {{ pub name: &'static str, pub kind: FormControlKind, pub required: bool }} pub enum FormControlKind {{ Text, Number {{ min: Option<&'static str>, max: Option<&'static str>, step: Option<&'static str> }}, Checkbox, Radio, Select {{ multiple: bool }}, TextArea, File, Hidden, Submit, Other {{ tag: &'static str, input_type: Option<&'static str> }} }} }} {generated} fn main() {{ let html = lower_html(r#"
  • "#); assert!(html.contains(r#"data-hid="#)); assert!(html.contains(r#"name="__h""#)); assert!(html.contains(r#"data-key="7""#)); assert!(!html.contains("data-slhx-key")); }} "###, ); let source_path = base.join("lowering.rs"); let bin_path = base.join("lowering-bin"); std::fs::write(&source_path, source).unwrap(); let status = std::process::Command::new("rustc") .arg(&source_path) .arg("-o") .arg(&bin_path) .status() .unwrap(); assert!(status.success()); let status = std::process::Command::new(&bin_path).status().unwrap(); assert!(status.success()); let _ = std::fs::remove_dir_all(&base); } fn test_dir(prefix: &str) -> PathBuf { std::env::temp_dir().join(format!("{prefix}-{}", std::process::id())) } }