4a8442c5f9
Expose generated ui::page as the canonical page-boundary rendering helper and move the Workout golden path and docs off beginner-visible ui::render calls. req: public_api/002 req: codegen/002 req: canonical_authoring/006
2402 lines
92 KiB
Rust
2402 lines
92 KiB
Rust
use hemplate_core::ast::build_ast;
|
|
use hemplate_core::surface::{
|
|
extract_surface, AttributeOrigin, ControlKind, ScopeId, ScopeKind, SurfaceAttribute,
|
|
SurfaceDocument, SurfaceNodeKind,
|
|
};
|
|
use hemx_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<PathBuf>,
|
|
template_dir: PathBuf,
|
|
global_exports: bool,
|
|
surfaces: Vec<(PathBuf, SurfaceDocument)>,
|
|
}
|
|
|
|
impl AppBuilder {
|
|
pub fn out_dir(mut self, out_dir: impl Into<PathBuf>) -> Self {
|
|
self.out_dir = Some(out_dir.into());
|
|
self
|
|
}
|
|
|
|
pub fn template_dir(mut self, template_dir: impl Into<PathBuf>) -> Self {
|
|
self.template_dir = template_dir.into();
|
|
self
|
|
}
|
|
|
|
pub fn global_exports(mut self, enabled: bool) -> Self {
|
|
self.global_exports = enabled;
|
|
self
|
|
}
|
|
|
|
pub fn surface(mut self, path: impl Into<PathBuf>, surface: SurfaceDocument) -> Self {
|
|
self.surfaces.push((path.into(), surface));
|
|
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();
|
|
if self.surfaces.is_empty() {
|
|
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)?;
|
|
}
|
|
} else {
|
|
// req: surface/008
|
|
for (path, surface) in &self.surfaces {
|
|
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("hemx.generated.rs"),
|
|
resources.generated_rs(self.global_exports),
|
|
)?;
|
|
std::fs::write(out_dir.join("hemx.syms"), resources.syms())?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub fn app() -> AppBuilder {
|
|
AppBuilder {
|
|
out_dir: None,
|
|
template_dir: PathBuf::from("templates"),
|
|
global_exports: false,
|
|
surfaces: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Resources {
|
|
slots: BTreeMap<String, Resource>,
|
|
handles: BTreeMap<String, Resource>,
|
|
handle_forms: BTreeMap<String, String>,
|
|
handle_params: BTreeMap<String, BTreeSet<String>>,
|
|
forms: BTreeMap<String, FormResource>,
|
|
atoms: BTreeMap<String, Resource>,
|
|
classes: BTreeMap<String, ClassToken>,
|
|
events: BTreeMap<String, EventToken>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct Resource {
|
|
symbol: String,
|
|
ident: String,
|
|
component: String,
|
|
keyed: bool,
|
|
id: u32,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct FormResource {
|
|
resource: Resource,
|
|
controls: Vec<GeneratedControl>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct GeneratedControl {
|
|
name: String,
|
|
kind: ControlKind,
|
|
required: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct ClassToken {
|
|
symbol: String,
|
|
ident: String,
|
|
component: String,
|
|
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)?;
|
|
for node in &surface.nodes {
|
|
let SurfaceNodeKind::Element { tag } = &node.kind else {
|
|
continue;
|
|
};
|
|
|
|
reject_selector_target_attrs(path, &node.attrs)?;
|
|
reject_unknown_hemx_attrs(path, &node.attrs)?;
|
|
reject_invalid_hemx_attr_values(path, &node.attrs)?;
|
|
reject_invalid_hemx_attr_placement(path, tag, &node.attrs)?;
|
|
|
|
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(on_attr) = static_attr(&node.attrs, "data-hemx-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-hemx-slot") {
|
|
reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?;
|
|
let keyed = is_inside_keyed_for(surface, node.scope)
|
|
|| (can_host_keyed_collection(tag)
|
|
&& has_descendant_keyed_for_scope(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-hemx-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-hemx-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-hemx-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_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 hemx 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,
|
|
name: String,
|
|
component: String,
|
|
controls: Vec<GeneratedControl>,
|
|
) -> io::Result<()> {
|
|
let controls = controls
|
|
.into_iter()
|
|
.filter(|control| control.name != "__h")
|
|
.collect();
|
|
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, global_exports: bool) -> String {
|
|
let mut out = String::new();
|
|
out.push_str("// @generated by hemx-build. Do not edit.\n");
|
|
out.push_str(&format!(
|
|
"pub const BUILD_FINGERPRINT: ::hemx::advanced::BuildFingerprint = ::hemx::advanced::BuildFingerprint::from_parts(&[{}]);\n\n",
|
|
self.fingerprint_parts()
|
|
.into_iter()
|
|
.map(|part| part.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
));
|
|
|
|
// req: build/001 req: component/003
|
|
if global_exports {
|
|
self.push_component_refs(&mut out, 0);
|
|
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_component_refs(&self, out: &mut String, indent: usize) {
|
|
let pad = " ".repeat(indent);
|
|
let inner = " ".repeat(indent + 1);
|
|
out.push_str(&format!(
|
|
"{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod components {{\n"
|
|
));
|
|
for component in self.component_names() {
|
|
out.push_str(&format!(
|
|
"{inner}pub const {component}: ::hemx::ComponentRef = ::hemx::ComponentRef::new({});\n",
|
|
rust_str(&component)
|
|
));
|
|
}
|
|
out.push_str(&format!("{pad}}}\n"));
|
|
}
|
|
|
|
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();
|
|
let mut root_exports = BTreeSet::new();
|
|
|
|
if let Some(component) = component {
|
|
out.push_str(&format!(
|
|
"{pad}pub const COMPONENT: ::hemx::ComponentRef = ::hemx::ComponentRef::new({});\n",
|
|
rust_str(component)
|
|
));
|
|
}
|
|
|
|
out.push_str(&format!("{pad}#[doc(hidden)]\n{pad}pub mod advanced {{\n"));
|
|
out.push_str(&format!(
|
|
"{inner}#[allow(non_upper_case_globals)]\n{inner}pub mod slots {{\n"
|
|
));
|
|
let slot_inner = " ".repeat(indent + 2);
|
|
for res in self
|
|
.slots
|
|
.values()
|
|
.filter(|res| component_matches(res, component))
|
|
{
|
|
if res.keyed {
|
|
out.push_str(&format!(
|
|
"{slot_inner}pub const {}: ::hemx::advanced::KeyedSlot<::std::string::String, ::std::string::String> = ::hemx::advanced::KeyedSlot::new({});\n",
|
|
res.ident, res.id
|
|
));
|
|
} else {
|
|
out.push_str(&format!(
|
|
"{slot_inner}pub const {}: ::hemx::advanced::Slot<::std::string::String> = ::hemx::advanced::Slot::new({});\n",
|
|
res.ident, res.id
|
|
));
|
|
}
|
|
}
|
|
out.push_str(&format!("{inner}}}\n"));
|
|
out.push_str(&format!("{pad}}}\n\n"));
|
|
|
|
out.push_str(&format!(
|
|
"{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod targets {{\n"
|
|
));
|
|
out.push_str(&format!("{inner}#[derive(Clone, Copy)]\n"));
|
|
out.push_str(&format!(
|
|
"{inner}pub struct SlotTarget<T> {{ slot: ::hemx::advanced::Slot<T> }}\n"
|
|
));
|
|
out.push_str(&format!("{inner}#[allow(dead_code)]\n"));
|
|
out.push_str(&format!("{inner}impl<T> SlotTarget<T> {{\n"));
|
|
out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::Slot<T>) -> 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} 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<T> ::hemx::GeneratedTarget for SlotTarget<T> {{\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<K, T> {{ slot: ::hemx::advanced::KeyedSlot<K, T> }}\n"));
|
|
out.push_str(&format!("{inner}#[allow(dead_code)]\n"));
|
|
out.push_str(&format!("{inner}impl<K, T> KeyedSlotTarget<K, T>\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<K, T>) -> Self {{ Self {{ slot }} }}\n"));
|
|
out.push_str(&format!("{inner}}}\n"));
|
|
out.push_str(&format!(
|
|
"{inner}impl<T> 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"));
|
|
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(), super::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(), super::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(), super::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(), 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"));
|
|
out.push_str(&format!(
|
|
"{inner}impl<K: ::std::string::ToString, T> ::hemx::GeneratedTarget for KeyedSlotTarget<K, T> {{\n"
|
|
));
|
|
out.push_str(&format!("{inner} fn __hemx_resource_id(self) -> ::hemx::advanced::ResourceId {{ self.slot.id() }}\n"));
|
|
out.push_str(&format!("{inner}}}\n"));
|
|
for res in self
|
|
.slots
|
|
.values()
|
|
.filter(|res| component_matches(res, component))
|
|
{
|
|
if res.keyed {
|
|
out.push_str(&format!(
|
|
"{inner}pub const {}: KeyedSlotTarget<::std::string::String, ::std::string::String> = 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",
|
|
res.ident, res.ident
|
|
));
|
|
}
|
|
}
|
|
out.push_str(&format!("{pad}}}\n"));
|
|
for res in self
|
|
.slots
|
|
.values()
|
|
.filter(|res| component_matches(res, component))
|
|
{
|
|
push_root_export(
|
|
out,
|
|
&pad,
|
|
"targets",
|
|
&mut root_exports,
|
|
&res.ident,
|
|
"target",
|
|
);
|
|
}
|
|
out.push_str("\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) {
|
|
"::hemx::Form<::std::string::String>"
|
|
} else {
|
|
"()"
|
|
};
|
|
out.push_str(&format!(
|
|
"{inner}pub const {}: ::hemx::Handle<{input}> = ::hemx::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::<Vec<_>>()
|
|
.join(", ")
|
|
));
|
|
out.push_str(&format!("{pad}}}\n"));
|
|
for res in self
|
|
.handles
|
|
.values()
|
|
.filter(|res| component_matches(res, component))
|
|
{
|
|
push_root_export(
|
|
out,
|
|
&pad,
|
|
"handles",
|
|
&mut root_exports,
|
|
&res.ident,
|
|
"handle",
|
|
);
|
|
}
|
|
out.push_str("\n");
|
|
|
|
out.push_str(&format!(
|
|
"{pad}#[allow(non_upper_case_globals)]\n{pad}pub mod params {{\n"
|
|
));
|
|
let mut emitted_params = BTreeSet::new();
|
|
for res in self
|
|
.handles
|
|
.values()
|
|
.filter(|res| component_matches(res, component))
|
|
{
|
|
if let Some(params) = self.handle_params.get(&res.ident) {
|
|
for param in params {
|
|
if emitted_params.insert(param.as_str()) {
|
|
out.push_str(&format!(
|
|
"{inner}pub const {param}: ::hemx::ParamName = ::hemx::ParamName::new({});\n",
|
|
rust_str(param)
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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 {}: ::hemx::Atom<::std::string::String> = ::hemx::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 {}: ::hemx::CssClass = ::hemx::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 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 {}: ::hemx::EventName = ::hemx::EventName::new({});\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;
|
|
out.push_str(&format!(
|
|
"{inner}pub const {}: ::hemx::Form<::std::string::String> = ::hemx::Form::new({});\n",
|
|
res.ident, res.id
|
|
));
|
|
out.push_str(&format!(
|
|
"{inner}pub const {}_CONTRACT: ::hemx::FormContract = ::hemx::FormContract {{ fields: &{}_FIELDS }};\n",
|
|
res.ident.to_ascii_uppercase(), res.ident.to_ascii_uppercase()
|
|
));
|
|
out.push_str(&format!(
|
|
"{inner}pub const {}_FIELDS: &[::hemx::FormField] = &[\n",
|
|
res.ident.to_ascii_uppercase()
|
|
));
|
|
for control in &form.controls {
|
|
out.push_str(&format!(
|
|
"{inner} ::hemx::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"));
|
|
for form in self
|
|
.forms
|
|
.values()
|
|
.filter(|form| component_matches(&form.resource, component))
|
|
{
|
|
push_root_export(
|
|
out,
|
|
&pad,
|
|
"forms",
|
|
&mut root_exports,
|
|
&form.resource.ident,
|
|
"form",
|
|
);
|
|
}
|
|
}
|
|
|
|
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!("__HEMX_LOWERING_TABLE_{}", component.to_ascii_uppercase()),
|
|
None => "__HEMX_LOWERING_TABLE".to_string(),
|
|
};
|
|
out.push_str(&format!("\n{pad}pub fn lower(html: impl ::std::convert::AsRef<str>) -> ::std::string::String {{\n"));
|
|
out.push_str(&format!("{pad} "));
|
|
if component.is_some() {
|
|
out.push_str("super::");
|
|
}
|
|
out.push_str(&format!(
|
|
"__hemx_lower_html(html.as_ref(), &{table_name})\n"
|
|
));
|
|
out.push_str(&format!("{pad}}}\n\n"));
|
|
out.push_str(&format!("{pad}#[doc(hidden)]\n"));
|
|
out.push_str(&format!("{pad}pub fn lower_html(html: impl ::std::convert::AsRef<str>) -> ::std::string::String {{ lower(html) }}\n\n"));
|
|
out.push_str(&format!(
|
|
"{pad}pub fn static_fragment(html: &'static str) -> ::hemx::Html {{\n"
|
|
));
|
|
out.push_str(&format!(
|
|
"{pad} ::hemx::__private::html_trusted(lower(html))\n"
|
|
));
|
|
out.push_str(&format!("{pad}}}\n\n"));
|
|
out.push_str(&format!(
|
|
"{pad}pub fn render(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{\n"
|
|
));
|
|
out.push_str(&format!(
|
|
"{pad} let mut html = ::std::string::String::new();\n"
|
|
));
|
|
out.push_str(&format!("{pad} ::hemplate::Hemplate::render_into(view, &mut html).expect(\"hemx hemplate view renders\");\n"));
|
|
out.push_str(&format!(
|
|
"{pad} ::hemx::__private::html_trusted(lower(html))\n"
|
|
));
|
|
out.push_str(&format!("{pad}}}\n\n"));
|
|
out.push_str(&format!(
|
|
"{pad}pub fn page(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{ render(view) }}\n\n"
|
|
));
|
|
out.push_str(&format!("{pad}#[doc(hidden)]\n"));
|
|
out.push_str(&format!("{pad}pub fn render_html(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{ render(view) }}\n\n"));
|
|
out.push_str(&format!("{pad}#[doc(hidden)]\n"));
|
|
out.push_str(&format!("{pad}pub fn put<T>(slot: ::hemx::advanced::Slot<T>, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{\n"));
|
|
out.push_str(&format!("{pad} slot.html(render(view))\n"));
|
|
out.push_str(&format!("{pad}}}\n\n"));
|
|
out.push_str(&format!("{pad}#[doc(hidden)]\n"));
|
|
out.push_str(&format!("{pad}pub fn append<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\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}#[doc(hidden)]\n"));
|
|
out.push_str(&format!("{pad}pub fn prepend<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\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}#[doc(hidden)]\n"));
|
|
out.push_str(&format!("{pad}pub fn replace<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\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);
|
|
}
|
|
|
|
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-hemx-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-hemx-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-hemx-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-hemx-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 __hemx_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-hemx-slot" => "data-sid",
|
|
"data-hemx-handle" => "data-hid",
|
|
"data-hemx-form" => "data-fid",
|
|
"data-hemx-atom" => "data-aid",
|
|
_ => continue,
|
|
};
|
|
out = __hemx_replace_attr(out, attr, name, runtime_attr, *id);
|
|
}
|
|
out = __hemx_lower_static_attr(out, "data-hemx-key", "data-key");
|
|
__hemx_inject_handle_inputs(out)
|
|
}
|
|
|
|
fn __hemx_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 __hemx_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 __hemx_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("<form") {
|
|
out.push_str(&rest[..start]);
|
|
rest = &rest[start..];
|
|
let Some(open_end) = 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) = __hemx_attr(opening, "data-hid") else {
|
|
continue;
|
|
};
|
|
let form_body_end = rest.find("</form>").unwrap_or(rest.len());
|
|
let form_body = &rest[..form_body_end];
|
|
if !__hemx_has_handle_input(form_body) {
|
|
out.push_str(&::std::format!("<input type=\"hidden\" name=\"__h\" value=\"{}\">", handle_id));
|
|
}
|
|
}
|
|
out.push_str(rest);
|
|
out
|
|
}
|
|
|
|
fn __hemx_has_handle_input(html: &str) -> bool {
|
|
html.contains("name=\"__h\"") || html.contains("name='__h'")
|
|
}
|
|
|
|
fn __hemx_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<String> {
|
|
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))
|
|
.chain(self.events.values().map(|event| &event.component))
|
|
{
|
|
if !components.contains(component) {
|
|
components.push(component.clone());
|
|
}
|
|
}
|
|
components.sort();
|
|
components
|
|
}
|
|
|
|
fn syms(&self) -> String {
|
|
let mut out = String::from("hemx-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 form in self.forms.values() {
|
|
for control in &form.controls {
|
|
out.push_str(&format!(
|
|
"form_field\t{}\t{}\t{}\t{}\n",
|
|
form.resource.ident,
|
|
control.name,
|
|
control.required,
|
|
form_control_is_multiple(&control.kind)
|
|
));
|
|
}
|
|
}
|
|
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
|
|
));
|
|
}
|
|
for event in self.events.values() {
|
|
out.push_str(&format!(
|
|
"event\t{}\t{}\t{}\n",
|
|
event.symbol, event.ident, event.name
|
|
));
|
|
}
|
|
out
|
|
}
|
|
|
|
fn fingerprint_parts(&self) -> Vec<u32> {
|
|
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<String, Resource>,
|
|
kind: &str,
|
|
symbol: String,
|
|
name: String,
|
|
component: String,
|
|
keyed: bool,
|
|
) -> io::Result<()> {
|
|
let resource = make_resource(kind, symbol, name, component, keyed)?;
|
|
match map.get_mut(&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(existing) => {
|
|
existing.keyed |= keyed;
|
|
Ok(())
|
|
}
|
|
None => {
|
|
map.insert(resource.ident.clone(), resource);
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn make_resource(
|
|
kind: &str,
|
|
symbol: String,
|
|
name: String,
|
|
component: String,
|
|
keyed: bool,
|
|
) -> io::Result<Resource> {
|
|
let ident = rust_ident(&name).ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!("invalid hemx {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<Vec<PathBuf>> {
|
|
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<PathBuf>) -> 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<Vec<PathBuf>> {
|
|
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<PathBuf>) -> 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<String> {
|
|
attrs
|
|
.iter()
|
|
.find(|attr| attr.origin == AttributeOrigin::Static && attr.name == name)
|
|
.and_then(|attr| attr.value.clone())
|
|
}
|
|
|
|
fn push_root_export(
|
|
out: &mut String,
|
|
pad: &str,
|
|
module: &str,
|
|
used: &mut BTreeSet<String>,
|
|
ident: &str,
|
|
suffix: &str,
|
|
) {
|
|
if used.insert(ident.to_owned()) {
|
|
out.push_str(&format!("{pad}pub use self::{module}::{ident};\n"));
|
|
return;
|
|
}
|
|
let alias = format!("{ident}_{suffix}");
|
|
if used.insert(alias.clone()) {
|
|
out.push_str(&format!(
|
|
"{pad}pub use self::{module}::{ident} as {alias};\n"
|
|
));
|
|
}
|
|
}
|
|
|
|
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 event_matches(event: &EventToken, component: Option<&str>) -> bool {
|
|
match component {
|
|
Some(component) => event.component == component,
|
|
None => true,
|
|
}
|
|
}
|
|
|
|
fn class_tokens(value: &str) -> impl Iterator<Item = &str> {
|
|
value
|
|
.split_ascii_whitespace()
|
|
.filter(|token| !token.is_empty())
|
|
}
|
|
|
|
fn event_tokens(value: &str) -> impl Iterator<Item = &str> {
|
|
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<String> {
|
|
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-hemx-")
|
|
}
|
|
|
|
fn data_param_ident(name: &str) -> Option<String> {
|
|
let data_name = name.strip_prefix("data-")?;
|
|
rust_ident(&data_name.replace('-', "_"))
|
|
}
|
|
|
|
fn event_ident(name: &str) -> Option<String> {
|
|
rust_ident(&name.replace(['-', ':'], "_"))
|
|
}
|
|
|
|
fn can_host_keyed_collection(tag: &str) -> bool {
|
|
matches!(
|
|
tag,
|
|
"ul" | "ol" | "tbody" | "thead" | "tfoot" | "table" | "select" | "datalist"
|
|
)
|
|
}
|
|
|
|
fn has_descendant_keyed_for_scope(surface: &SurfaceDocument, scope: ScopeId) -> bool {
|
|
surface.scopes.iter().enumerate().any(|(index, current)| {
|
|
matches!(
|
|
current.kind,
|
|
ScopeKind::For {
|
|
key_expr: Some(_),
|
|
..
|
|
}
|
|
) && is_descendant_scope(surface, ScopeId(index as u32), scope)
|
|
})
|
|
}
|
|
|
|
fn is_descendant_scope(surface: &SurfaceDocument, mut scope: ScopeId, ancestor: ScopeId) -> bool {
|
|
loop {
|
|
let Some(current) = surface.scopes.get(scope.0 as usize) else {
|
|
return false;
|
|
};
|
|
let Some(parent) = current.parent else {
|
|
return false;
|
|
};
|
|
if parent == ancestor {
|
|
return true;
|
|
}
|
|
scope = parent;
|
|
}
|
|
}
|
|
|
|
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_selector_target_attrs(path: &Path, attrs: &[SurfaceAttribute]) -> io::Result<()> {
|
|
for attr in attrs {
|
|
let name = attr.name.as_str();
|
|
if matches!(name, "data-hemx-target" | "data-hemx-select") {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"{}: `{name}` is selector-style targeting; hemx uses generated resources instead. Add data-hemx-slot to the local element and return an effect for that generated slot.",
|
|
path.display()
|
|
),
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn reject_unknown_hemx_attrs(path: &Path, attrs: &[SurfaceAttribute]) -> io::Result<()> {
|
|
for attr in attrs {
|
|
let name = attr.name.as_str();
|
|
if name.starts_with("data-hemx-") && !known_hemx_attr(name) {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"{}: unknown hemx attribute `{name}`; check the spelling or use a non-hemx data-* attribute for app-specific metadata",
|
|
path.display()
|
|
),
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn known_hemx_attr(name: &str) -> bool {
|
|
matches!(
|
|
name,
|
|
"data-hemx-root"
|
|
| "data-hemx-sse"
|
|
| "data-hemx-st"
|
|
| "data-hemx-handle"
|
|
| "data-hemx-slot"
|
|
| "data-hemx-form"
|
|
| "data-hemx-atom"
|
|
| "data-hemx-key"
|
|
| "data-hemx-on"
|
|
| "data-hemx-pending-class"
|
|
| "data-hemx-indicator"
|
|
| "data-hemx-confirm"
|
|
| "data-hemx-debounce"
|
|
| "data-hemx-throttle"
|
|
| "data-hemx-every"
|
|
| "data-hemx-disable-while-pending"
|
|
| "data-hemx-policy"
|
|
| "data-hemx-nav"
|
|
| "data-hemx-boost"
|
|
| "data-hemx-error-for"
|
|
| "data-hemx-island"
|
|
)
|
|
}
|
|
|
|
fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> io::Result<()> {
|
|
for attr in attrs
|
|
.iter()
|
|
.filter(|attr| attr.origin == AttributeOrigin::Static)
|
|
{
|
|
let value = attr.value.as_deref().unwrap_or("");
|
|
match attr.name.as_str() {
|
|
"data-hemx-policy" if !valid_policy(value) => {
|
|
return Err(invalid_hemx_value(
|
|
path,
|
|
&attr.name,
|
|
value,
|
|
"expected one of `latest`, `queue`, `drop`, or `parallel`",
|
|
));
|
|
}
|
|
"data-hemx-on" if !valid_event_list(value) => {
|
|
return Err(invalid_hemx_value(
|
|
path,
|
|
&attr.name,
|
|
value,
|
|
"expected runtime-supported events: `click`, `submit`, `input`, `change`, `dragstart`, `dragover`, or `drop`",
|
|
));
|
|
}
|
|
"data-hemx-confirm" if value.trim().is_empty() => {
|
|
return Err(invalid_hemx_value(
|
|
path,
|
|
&attr.name,
|
|
value,
|
|
"expected a non-empty confirmation message",
|
|
));
|
|
}
|
|
"data-hemx-sse" if value.trim().is_empty() => {
|
|
return Err(invalid_hemx_value(
|
|
path,
|
|
&attr.name,
|
|
value,
|
|
"expected a non-empty same-origin SSE URL",
|
|
));
|
|
}
|
|
"data-hemx-debounce" | "data-hemx-throttle" | "data-hemx-every"
|
|
if !valid_duration(value) =>
|
|
{
|
|
return Err(invalid_hemx_value(
|
|
path,
|
|
&attr.name,
|
|
value,
|
|
"expected milliseconds like `250`/`250ms` or seconds like `1s`",
|
|
));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn reject_invalid_hemx_attr_placement(
|
|
path: &Path,
|
|
tag: &str,
|
|
attrs: &[SurfaceAttribute],
|
|
) -> io::Result<()> {
|
|
if has_attr(attrs, "data-hemx-nav")
|
|
&& (tag != "a"
|
|
|| !has_attr(attrs, "href")
|
|
|| static_attr(attrs, "href").is_some_and(|href| href.trim().is_empty()))
|
|
{
|
|
return Err(invalid_hemx_placement(
|
|
path,
|
|
"data-hemx-nav",
|
|
"expected a real `<a href=...>` link so navigation works without JavaScript",
|
|
));
|
|
}
|
|
if has_attr(attrs, "data-hemx-boost") && matches!(tag, "a" | "form") {
|
|
return Err(invalid_hemx_placement(
|
|
path,
|
|
"data-hemx-boost",
|
|
"expected a container around descendant links/forms; use `data-hemx-nav` on anchors or `data-hemx-handle` on forms",
|
|
));
|
|
}
|
|
if has_attr(attrs, "data-hemx-sse") && !has_attr(attrs, "data-hemx-root") {
|
|
return Err(invalid_hemx_placement(
|
|
path,
|
|
"data-hemx-sse",
|
|
"expected placement on the same element as `data-hemx-root`",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn has_attr(attrs: &[SurfaceAttribute], name: &str) -> bool {
|
|
attrs.iter().any(|attr| attr.name == name)
|
|
}
|
|
|
|
fn invalid_hemx_placement(path: &Path, attr: &str, expectation: &str) -> io::Error {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"{}: invalid {attr} placement; {expectation}",
|
|
path.display()
|
|
),
|
|
)
|
|
}
|
|
|
|
fn valid_policy(value: &str) -> bool {
|
|
matches!(value.trim(), "latest" | "queue" | "drop" | "parallel")
|
|
}
|
|
|
|
fn valid_event_list(value: &str) -> bool {
|
|
let mut events = event_tokens(value).peekable();
|
|
events.peek().is_some() && events.all(valid_runtime_event)
|
|
}
|
|
|
|
fn valid_runtime_event(value: &str) -> bool {
|
|
matches!(
|
|
value,
|
|
"click" | "submit" | "input" | "change" | "dragstart" | "dragover" | "drop"
|
|
)
|
|
}
|
|
|
|
fn valid_duration(value: &str) -> bool {
|
|
let value = value.trim();
|
|
let digits = value
|
|
.strip_suffix("ms")
|
|
.or_else(|| value.strip_suffix('s'))
|
|
.unwrap_or(value);
|
|
!digits.is_empty() && digits.as_bytes().iter().all(u8::is_ascii_digit)
|
|
}
|
|
|
|
fn invalid_hemx_value(path: &Path, attr: &str, value: &str, expectation: &str) -> io::Error {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"{}: invalid {attr} value `{value}`; {expectation}",
|
|
path.display()
|
|
),
|
|
)
|
|
}
|
|
|
|
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-hemx-{kind}=\"{name}\" is inside an h-for without h-key; add a stable h-key=\"item.id\" to the loop that owns this generated target",
|
|
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<String> {
|
|
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<String> {
|
|
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_is_multiple(kind: &ControlKind) -> bool {
|
|
matches!(kind, ControlKind::Select { multiple: true })
|
|
}
|
|
|
|
fn form_control_kind_expr(kind: &ControlKind) -> String {
|
|
match kind {
|
|
ControlKind::Text => "::hemx::FormControlKind::Text".to_string(),
|
|
ControlKind::Number { min, max, step } => format!(
|
|
"::hemx::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 => "::hemx::FormControlKind::Checkbox".to_string(),
|
|
ControlKind::Radio => "::hemx::FormControlKind::Radio".to_string(),
|
|
ControlKind::Select { multiple } => {
|
|
format!("::hemx::FormControlKind::Select {{ multiple: {multiple} }}")
|
|
}
|
|
ControlKind::TextArea => "::hemx::FormControlKind::TextArea".to_string(),
|
|
ControlKind::File => "::hemx::FormControlKind::File".to_string(),
|
|
ControlKind::Hidden => "::hemx::FormControlKind::Hidden".to_string(),
|
|
ControlKind::Submit => "::hemx::FormControlKind::Submit".to_string(),
|
|
ControlKind::Other { tag, input_type } => format!(
|
|
"::hemx::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("hemx-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#"<form data-hemx-handle="create" data-hemx-form="new_todo"><input name="title"></form><button data-hemx-handle="delete" data-todo-id="7" data-hemx-on="click change">Delete</button><ul data-hemx-slot="todos"></ul><section data-hemx-atom="filter"></section>"#,
|
|
)
|
|
.unwrap();
|
|
|
|
app()
|
|
.template_dir(&templates)
|
|
.out_dir(&out)
|
|
.global_exports(true)
|
|
.run()
|
|
.unwrap();
|
|
|
|
let generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap();
|
|
assert!(generated.contains("pub mod components"));
|
|
assert!(generated.contains(
|
|
"pub const todo: ::hemx::ComponentRef = ::hemx::ComponentRef::new(\"todo\")"
|
|
));
|
|
assert!(generated.contains("pub mod advanced"));
|
|
assert!(generated.contains("pub mod slots"));
|
|
assert!(generated.contains("pub const todos"));
|
|
assert!(generated.contains("pub mod targets"));
|
|
assert!(generated.contains("pub struct SlotTarget<T>"));
|
|
assert!(generated.contains("impl<T> ::hemx::GeneratedTarget for SlotTarget<T>"));
|
|
assert!(generated.contains(
|
|
"impl<K: ::std::string::ToString, T> ::hemx::GeneratedTarget for KeyedSlotTarget<K, T>"
|
|
));
|
|
assert!(generated.contains(
|
|
"pub fn append(self, view: impl ::hemplate::Hemplate + ::hemx::KeyedPartial) -> ::hemx::advanced::Effect"
|
|
));
|
|
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 use self::targets::todos;"));
|
|
assert!(generated.contains(
|
|
"pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect"
|
|
));
|
|
assert!(generated.contains(
|
|
"pub fn replace(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect"
|
|
));
|
|
assert!(generated.contains(
|
|
"pub fn set(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect"
|
|
));
|
|
assert!(generated.contains("pub mod handles"));
|
|
assert!(generated
|
|
.contains("pub const create: ::hemx::Handle<::hemx::Form<::std::string::String>>"));
|
|
assert!(generated.contains("pub mod params"));
|
|
assert!(generated.contains(
|
|
"pub const todo_id: ::hemx::ParamName = ::hemx::ParamName::new(\"todo_id\")"
|
|
));
|
|
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: ::hemx::EventName = ::hemx::EventName::new(\"click\")"));
|
|
assert!(generated
|
|
.contains("pub const change: ::hemx::EventName = ::hemx::EventName::new(\"change\")"));
|
|
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: impl ::std::convert::AsRef<str>) -> ::std::string::String"
|
|
));
|
|
assert!(generated.contains("#[doc(hidden)]\npub fn lower_html"));
|
|
assert!(generated.contains("pub fn static_fragment(html: &'static str) -> ::hemx::Html"));
|
|
assert!(
|
|
generated.contains("pub fn render(view: &impl ::hemplate::Hemplate) -> ::hemx::Html")
|
|
);
|
|
assert!(generated.contains("pub fn page(view: &impl ::hemplate::Hemplate) -> ::hemx::Html"));
|
|
assert!(generated.contains(
|
|
"#[doc(hidden)]\npub fn render_html(view: &impl ::hemplate::Hemplate) -> ::hemx::Html"
|
|
));
|
|
assert!(generated.contains("pub fn put<T>(slot: ::hemx::advanced::Slot<T>, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect"));
|
|
assert!(generated.contains("pub fn append<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect"));
|
|
assert!(generated.contains("pub fn prepend<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect"));
|
|
assert!(generated.contains("pub fn replace<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect"));
|
|
assert!(generated.contains("data-hemx-slot"));
|
|
assert!(generated.contains("data-hemx-form"));
|
|
assert!(generated.contains("data-sid"));
|
|
assert!(generated.contains("data-fid"));
|
|
assert!(generated.contains("__hemx_inject_handle_inputs"));
|
|
|
|
let syms = std::fs::read_to_string(out.join("hemx.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"));
|
|
assert!(syms.contains("event\t"));
|
|
assert!(syms.contains("\tclick\tclick\n"));
|
|
assert!(syms.contains("\tchange\tchange\n"));
|
|
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_collection_slot_upgrades_to_keyed_target() {
|
|
// req: codegen/003 req: list/002
|
|
let base = test_dir("hemx-build-keyed-collection-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("todos.heml"),
|
|
r#"<ul data-hemx-slot="row"><template h-for="todo in &self.todos" h-key="todo.id"><li +data-key="todo.id">{+ todo +}</li></template></ul>"#,
|
|
)
|
|
.unwrap();
|
|
|
|
app().template_dir(&templates).out_dir(&out).run().unwrap();
|
|
|
|
let generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap();
|
|
assert!(generated.contains("pub const row: ::hemx::advanced::KeyedSlot"));
|
|
assert!(generated.contains("pub const row: KeyedSlotTarget"));
|
|
assert!(generated.contains("pub use self::targets::row;"));
|
|
assert!(!generated.contains("pub const row: SlotTarget"));
|
|
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
}
|
|
|
|
#[test]
|
|
fn app_can_consume_precomputed_surface_facts() {
|
|
// req: surface/008 req: build/001
|
|
let base = test_dir("hemx-build-precomputed-surface-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();
|
|
let path = templates.join("todo.heml");
|
|
let source = Arc::new(
|
|
r#"<button data-hemx-handle="create" data-hemx-on="click">Create</button><div data-hemx-slot="todos"></div>"#
|
|
.to_string(),
|
|
);
|
|
let ast = build_ast(source).unwrap().unwrap();
|
|
let surface = extract_surface(&ast);
|
|
|
|
app()
|
|
.template_dir(&templates)
|
|
.out_dir(&out)
|
|
.surface(&path, surface)
|
|
.run()
|
|
.unwrap();
|
|
|
|
let generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap();
|
|
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 use self::targets::todos;"));
|
|
assert!(generated
|
|
.contains("pub const click: ::hemx::EventName = ::hemx::EventName::new(\"click\")"));
|
|
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
}
|
|
|
|
#[test]
|
|
fn global_resource_exports_are_explicit_opt_in() {
|
|
// req: component/003
|
|
let base = test_dir("hemx-build-no-global-exports-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#"<button data-hemx-handle="create">Create</button><div data-hemx-slot="todos"></div>"#,
|
|
)
|
|
.unwrap();
|
|
|
|
app().template_dir(&templates).out_dir(&out).run().unwrap();
|
|
|
|
let generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap();
|
|
assert!(!generated.contains("\n#[allow(non_upper_case_globals)]\npub mod components"));
|
|
assert!(!generated.contains("\n#[allow(non_upper_case_globals)]\npub mod slots"));
|
|
assert!(generated.contains(
|
|
"\npub fn lower(html: impl ::std::convert::AsRef<str>) -> ::std::string::String"
|
|
));
|
|
assert!(generated.contains("\n#[doc(hidden)]\npub fn lower_html"));
|
|
assert!(generated.contains("\npub fn static_fragment(html: &'static str) -> ::hemx::Html"));
|
|
assert!(
|
|
generated.contains("\npub fn render(view: &impl ::hemplate::Hemplate) -> ::hemx::Html")
|
|
);
|
|
assert!(
|
|
generated.contains("\npub fn page(view: &impl ::hemplate::Hemplate) -> ::hemx::Html")
|
|
);
|
|
assert!(generated.contains("\n#[doc(hidden)]\npub fn render_html(view: &impl ::hemplate::Hemplate) -> ::hemx::Html"));
|
|
assert!(generated.contains("\npub fn put<T>(slot: ::hemx::advanced::Slot<T>, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect"));
|
|
assert!(generated.contains("\npub fn append<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect"));
|
|
assert!(generated.contains("pub mod todo"));
|
|
assert!(generated.contains(
|
|
" pub const COMPONENT: ::hemx::ComponentRef = ::hemx::ComponentRef::new(\"todo\")"
|
|
));
|
|
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 use self::targets::todos;"));
|
|
assert!(generated.contains(" #[doc(hidden)]\n pub fn lower_html"));
|
|
assert!(
|
|
generated.contains(" pub fn page(view: &impl ::hemplate::Hemplate) -> ::hemx::Html")
|
|
);
|
|
assert!(generated.contains(" #[doc(hidden)]\n pub fn render_html(view: &impl ::hemplate::Hemplate) -> ::hemx::Html"));
|
|
assert!(
|
|
generated.contains(" pub fn static_fragment(html: &'static str) -> ::hemx::Html")
|
|
);
|
|
assert!(generated.contains(" pub fn put<T>(slot: ::hemx::advanced::Slot<T>, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect"));
|
|
assert!(generated.contains(" pub fn append<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect"));
|
|
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
}
|
|
|
|
#[test]
|
|
fn emits_form_contract_metadata_from_surface_controls() {
|
|
// req: codegen/004 req: form/001
|
|
let base = test_dir("hemx-build-form-contract-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("profile.heml"),
|
|
r#"<form data-hemx-handle="save" data-hemx-form="profile">
|
|
<input type="hidden" name="__h" value="123">
|
|
<input name="title" required>
|
|
<input name="count" type="number" min="1" max="10" step="1">
|
|
<select name="labels" multiple></select>
|
|
<input name="avatar" type="file">
|
|
</form>"#,
|
|
)
|
|
.unwrap();
|
|
|
|
app().template_dir(&templates).out_dir(&out).run().unwrap();
|
|
|
|
let generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap();
|
|
assert!(generated.contains("pub const PROFILE_CONTRACT: ::hemx::FormContract"));
|
|
assert!(generated.contains("pub const PROFILE_FIELDS: &[::hemx::FormField]"));
|
|
assert!(generated.contains(
|
|
"::hemx::FormField { name: \"title\", kind: ::hemx::FormControlKind::Text, required: true }"
|
|
));
|
|
assert!(generated.contains(
|
|
"::hemx::FormField { name: \"count\", kind: ::hemx::FormControlKind::Number { min: Some(\"1\"), max: Some(\"10\"), step: Some(\"1\") }, required: false }"
|
|
));
|
|
assert!(generated.contains(
|
|
"::hemx::FormField { name: \"labels\", kind: ::hemx::FormControlKind::Select { multiple: true }, required: false }"
|
|
));
|
|
assert!(generated.contains(
|
|
"::hemx::FormField { name: \"avatar\", kind: ::hemx::FormControlKind::File, required: false }"
|
|
));
|
|
|
|
let syms = std::fs::read_to_string(out.join("hemx.syms")).unwrap();
|
|
assert!(syms.contains("handle_form\tsave\tprofile\n"));
|
|
assert!(!syms.contains("form_field\tprofile\t__h\t"));
|
|
assert!(syms.contains("form_field\tprofile\ttitle\ttrue\tfalse\n"));
|
|
assert!(syms.contains("form_field\tprofile\tlabels\tfalse\ttrue\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("hemx-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#"<article class="card is-active" data-hemx-slot="card_body"></article>"#,
|
|
)
|
|
.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)
|
|
.global_exports(true)
|
|
.run()
|
|
.unwrap();
|
|
|
|
let generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap();
|
|
assert!(generated.contains("pub mod classes"));
|
|
assert!(generated
|
|
.contains("pub const card: ::hemx::CssClass = ::hemx::CssClass::new(\"card\")"));
|
|
assert!(generated.contains(
|
|
"pub const is_active: ::hemx::CssClass = ::hemx::CssClass::new(\"is-active\")"
|
|
));
|
|
assert!(generated.contains(
|
|
"pub const drag_handle: ::hemx::CssClass = ::hemx::CssClass::new(\"drag-handle\")"
|
|
));
|
|
assert!(generated.contains(
|
|
"pub const work_card: ::hemx::CssClass = ::hemx::CssClass::new(\"work-card\")"
|
|
));
|
|
assert!(generated.contains(
|
|
"pub const is_selected: ::hemx::CssClass = ::hemx::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("hemx.syms")).unwrap();
|
|
assert!(syms.contains("class\t"));
|
|
assert!(syms.contains("\tdrag_handle\tdrag-handle"));
|
|
|
|
let source = format!(
|
|
r###"
|
|
#![allow(dead_code)]
|
|
mod hemx {{
|
|
#[derive(Clone, Copy)] pub struct BuildFingerprint;
|
|
impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }}
|
|
pub struct Effect;
|
|
#[derive(Clone, Copy)] pub struct ResourceId;
|
|
pub trait IntoEffect {{}}
|
|
impl IntoEffect for Effect {{}}
|
|
pub trait GeneratedTarget {{ fn __hemx_resource_id(self) -> ResourceId; }}
|
|
pub trait KeyedPartial {{ fn hemx_key(&self) -> String; }}
|
|
#[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 const fn id(self) -> ResourceId {{ ResourceId }} pub fn html(self, _: impl ::std::convert::Into<SafeHtml>) -> Effect {{ Effect }} pub fn text(self, _: impl ::std::string::ToString) -> Effect {{ Effect }} }}
|
|
#[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) }} pub const fn id(self) -> ResourceId {{ ResourceId }} pub fn append_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> Effect {{ Effect }} pub fn prepend_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> Effect {{ Effect }} pub fn replace_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> Effect {{ Effect }} pub fn remove(self, _: K) -> Effect {{ Effect }} }}
|
|
#[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) }} }}
|
|
#[derive(Clone, Copy)] pub struct Atom<T>(::std::marker::PhantomData<T>);
|
|
impl<T> Atom<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
|
|
#[derive(Clone, Copy)] pub struct Form<T>(::std::marker::PhantomData<T>);
|
|
impl<T> Form<T> {{ 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 }} }}
|
|
#[derive(Clone, Copy)] pub struct ComponentRef(&'static str);
|
|
impl ComponentRef {{ pub const fn new(name: &'static str) -> Self {{ Self(name) }} pub const fn as_str(self) -> &'static str {{ self.0 }} }}
|
|
pub struct SafeHtml(String);
|
|
impl SafeHtml {{ pub fn trusted(html: impl Into<String>) -> Self {{ Self(html.into()) }} }}
|
|
pub struct Html(SafeHtml);
|
|
impl ::std::convert::From<Html> for SafeHtml {{ fn from(value: Html) -> Self {{ value.0 }} }}
|
|
impl ::std::convert::AsRef<str> for Html {{ fn as_ref(&self) -> &str {{ "" }} }}
|
|
pub mod __private {{ pub fn html_trusted(html: impl Into<String>) -> super::Html {{ super::Html(super::SafeHtml::trusted(html)) }} }}
|
|
pub mod advanced {{ pub use super::*; }}
|
|
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> }} }}
|
|
}}
|
|
mod hemplate {{
|
|
pub trait Hemplate {{ fn render_into(&self, out: &mut String) -> Result<(), ()>; }}
|
|
}}
|
|
{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_hemx_resources_inside_unkeyed_for() {
|
|
// req: scope/001
|
|
for (case, template, resource) in [
|
|
(
|
|
"slot",
|
|
r#"<template h-for="todo in &self.todos"><li data-hemx-slot="todo_row">{+ todo.title +}</li></template>"#,
|
|
"data-hemx-slot=\"todo_row\"",
|
|
),
|
|
(
|
|
"slot_data_key",
|
|
r#"<template h-for="todo in &self.todos"><li data-hemx-slot="todo_row" +data-key="todo.id">{+ todo.title +}</li></template>"#,
|
|
"data-hemx-slot=\"todo_row\"",
|
|
),
|
|
(
|
|
"handle",
|
|
r#"<template h-for="todo in &self.todos"><button data-hemx-handle="delete">Delete</button></template>"#,
|
|
"data-hemx-handle=\"delete\"",
|
|
),
|
|
] {
|
|
let base = test_dir(&format!("hemx-build-unkeyed-for-{case}-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"), template).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(resource));
|
|
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_selector_style_targeting_attrs() {
|
|
// req: locality/001 req: locality/002 req: htmx_equivalents/003
|
|
for (case, template, attr) in [
|
|
(
|
|
"hemx-target",
|
|
r##"<button data-hemx-handle="save" data-hemx-target="#row">Save</button>"##,
|
|
"data-hemx-target",
|
|
),
|
|
(
|
|
"hemx-select",
|
|
r##"<button data-hemx-handle="save" data-hemx-select="closest tr">Save</button>"##,
|
|
"data-hemx-select",
|
|
),
|
|
] {
|
|
let base = test_dir(&format!("hemx-build-selector-target-{case}-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("target.heml"), template).unwrap();
|
|
|
|
let err = app()
|
|
.template_dir(&templates)
|
|
.out_dir(&out)
|
|
.run()
|
|
.unwrap_err();
|
|
assert!(
|
|
err.to_string().contains(attr),
|
|
"missing attr in diagnostic: {err}"
|
|
);
|
|
assert!(
|
|
err.to_string().contains("selector-style targeting"),
|
|
"missing selector diagnostic: {err}"
|
|
);
|
|
assert!(
|
|
err.to_string().contains("data-hemx-slot"),
|
|
"missing suggested slot fix: {err}"
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unknown_hemx_authoring_attrs() {
|
|
// req: convention/009 req: diagnostics/002
|
|
let base = test_dir("hemx-build-unknown-hemx-attr-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("typo.heml"),
|
|
r#"<button data-hemx-handle="save" data-hemx-pendig-class="busy">Save</button>"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let err = app()
|
|
.template_dir(&templates)
|
|
.out_dir(&out)
|
|
.run()
|
|
.unwrap_err();
|
|
assert!(
|
|
err.to_string().contains("data-hemx-pendig-class"),
|
|
"missing attr in diagnostic: {err}"
|
|
);
|
|
assert!(
|
|
err.to_string().contains("unknown hemx attribute"),
|
|
"missing unknown-attr diagnostic: {err}"
|
|
);
|
|
assert!(
|
|
err.to_string().contains("check the spelling"),
|
|
"missing spelling guidance: {err}"
|
|
);
|
|
assert!(
|
|
err.to_string().contains("non-hemx data-*"),
|
|
"missing app metadata guidance: {err}"
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_invalid_static_hemx_convention_values() {
|
|
// req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006 req: diagnostics/002
|
|
for (case, template, attr, guidance) in [
|
|
(
|
|
"policy",
|
|
r#"<button data-hemx-handle="save" data-hemx-policy="newest">Save</button>"#,
|
|
"data-hemx-policy",
|
|
"latest",
|
|
),
|
|
(
|
|
"debounce",
|
|
r#"<button data-hemx-handle="save" data-hemx-debounce="soon">Save</button>"#,
|
|
"data-hemx-debounce",
|
|
"250ms",
|
|
),
|
|
(
|
|
"every",
|
|
r#"<button data-hemx-handle="save" data-hemx-every="1sec">Save</button>"#,
|
|
"data-hemx-every",
|
|
"1s",
|
|
),
|
|
(
|
|
"event",
|
|
r#"<button data-hemx-handle="save" data-hemx-on="keydown">Save</button>"#,
|
|
"data-hemx-on",
|
|
"click",
|
|
),
|
|
(
|
|
"empty-event",
|
|
r#"<button data-hemx-handle="save" data-hemx-on="">Save</button>"#,
|
|
"data-hemx-on",
|
|
"click",
|
|
),
|
|
(
|
|
"empty-confirm",
|
|
r#"<button data-hemx-handle="delete" data-hemx-confirm="">Delete</button>"#,
|
|
"data-hemx-confirm",
|
|
"non-empty",
|
|
),
|
|
(
|
|
"empty-sse",
|
|
r#"<section data-hemx-root="notifications" data-hemx-sse=""></section>"#,
|
|
"data-hemx-sse",
|
|
"same-origin SSE URL",
|
|
),
|
|
] {
|
|
let base = test_dir(&format!("hemx-build-invalid-convention-{case}-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("invalid.heml"), template).unwrap();
|
|
|
|
let err = app()
|
|
.template_dir(&templates)
|
|
.out_dir(&out)
|
|
.run()
|
|
.unwrap_err();
|
|
assert!(
|
|
err.to_string().contains(attr),
|
|
"missing attr in diagnostic: {err}"
|
|
);
|
|
assert!(
|
|
err.to_string().contains("invalid"),
|
|
"missing invalid-value diagnostic: {err}"
|
|
);
|
|
assert!(
|
|
err.to_string().contains(guidance),
|
|
"missing fix guidance {guidance:?}: {err}"
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_invalid_hemx_attr_placement() {
|
|
// req: page_swap/001 req: page_swap/007 req: push/006 req: diagnostics/002
|
|
for (case, template, attr, guidance) in [
|
|
(
|
|
"nav-button",
|
|
r#"<button data-hemx-nav="" data-hemx-handle="go">Go</button>"#,
|
|
"data-hemx-nav",
|
|
"<a href=...>",
|
|
),
|
|
(
|
|
"nav-missing-href",
|
|
r#"<a data-hemx-nav="">Docs</a>"#,
|
|
"data-hemx-nav",
|
|
"<a href=...>",
|
|
),
|
|
(
|
|
"nav-empty-href",
|
|
r#"<a href="" data-hemx-nav="">Docs</a>"#,
|
|
"data-hemx-nav",
|
|
"<a href=...>",
|
|
),
|
|
(
|
|
"boost-anchor",
|
|
r#"<a href="/docs" data-hemx-boost="">Docs</a>"#,
|
|
"data-hemx-boost",
|
|
"data-hemx-nav",
|
|
),
|
|
(
|
|
"boost-form",
|
|
r#"<form data-hemx-boost=""><input name="q"></form>"#,
|
|
"data-hemx-boost",
|
|
"data-hemx-handle",
|
|
),
|
|
(
|
|
"sse-child",
|
|
r#"<section data-hemx-root="feed"><div data-hemx-sse="/events"></div></section>"#,
|
|
"data-hemx-sse",
|
|
"data-hemx-root",
|
|
),
|
|
] {
|
|
let base = test_dir(&format!("hemx-build-invalid-placement-{case}-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("placement.heml"), template).unwrap();
|
|
|
|
let err = app()
|
|
.template_dir(&templates)
|
|
.out_dir(&out)
|
|
.run()
|
|
.unwrap_err();
|
|
assert!(
|
|
err.to_string().contains(attr),
|
|
"missing attr in diagnostic: {err}"
|
|
);
|
|
assert!(
|
|
err.to_string().contains("invalid"),
|
|
"missing invalid-placement diagnostic: {err}"
|
|
);
|
|
assert!(
|
|
err.to_string().contains(guidance),
|
|
"missing placement guidance {guidance:?}: {err}"
|
|
);
|
|
|
|
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("hemx-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#"<form data-hemx-handle="create"><input name="title"></form><li data-hemx-slot="row" data-hemx-key="7"></li>"#,
|
|
)
|
|
.unwrap();
|
|
|
|
app().template_dir(&templates).out_dir(&out).run().unwrap();
|
|
let generated = std::fs::read_to_string(out.join("hemx.generated.rs")).unwrap();
|
|
assert!(generated.contains("fn __hemx_lower_html"));
|
|
let source = format!(
|
|
r###"
|
|
#![allow(dead_code)]
|
|
mod hemx {{
|
|
#[derive(Clone, Copy)] pub struct BuildFingerprint;
|
|
impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }}
|
|
pub struct Effect;
|
|
#[derive(Clone, Copy)] pub struct ResourceId;
|
|
pub trait IntoEffect {{}}
|
|
impl IntoEffect for Effect {{}}
|
|
pub trait GeneratedTarget {{ fn __hemx_resource_id(self) -> ResourceId; }}
|
|
pub trait KeyedPartial {{ fn hemx_key(&self) -> String; }}
|
|
#[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 const fn id(self) -> ResourceId {{ ResourceId }} pub fn html(self, _: impl ::std::convert::Into<SafeHtml>) -> Effect {{ Effect }} pub fn text(self, _: impl ::std::string::ToString) -> Effect {{ Effect }} }}
|
|
#[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) }} pub const fn id(self) -> ResourceId {{ ResourceId }} pub fn append_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> Effect {{ Effect }} pub fn prepend_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> Effect {{ Effect }} pub fn replace_html(self, _: K, _: impl ::std::convert::Into<SafeHtml>) -> Effect {{ Effect }} pub fn remove(self, _: K) -> Effect {{ Effect }} }}
|
|
#[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) }} }}
|
|
#[derive(Clone, Copy)] pub struct Atom<T>(::std::marker::PhantomData<T>);
|
|
impl<T> Atom<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
|
|
#[derive(Clone, Copy)] pub struct Form<T>(::std::marker::PhantomData<T>);
|
|
impl<T> Form<T> {{ pub const fn new(_: u32) -> Self {{ Self(::std::marker::PhantomData) }} }}
|
|
#[derive(Clone, Copy)] pub struct ComponentRef(&'static str);
|
|
impl ComponentRef {{ pub const fn new(name: &'static str) -> Self {{ Self(name) }} pub const fn as_str(self) -> &'static str {{ self.0 }} }}
|
|
pub struct SafeHtml(String);
|
|
impl SafeHtml {{ pub fn trusted(html: impl Into<String>) -> Self {{ Self(html.into()) }} }}
|
|
pub struct Html(SafeHtml);
|
|
impl ::std::convert::From<Html> for SafeHtml {{ fn from(value: Html) -> Self {{ value.0 }} }}
|
|
impl ::std::convert::AsRef<str> for Html {{ fn as_ref(&self) -> &str {{ "" }} }}
|
|
pub mod __private {{ pub fn html_trusted(html: impl Into<String>) -> super::Html {{ super::Html(super::SafeHtml::trusted(html)) }} }}
|
|
pub mod advanced {{ pub use super::*; }}
|
|
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> }} }}
|
|
}}
|
|
mod hemplate {{
|
|
pub trait Hemplate {{ fn render_into(&self, out: &mut String) -> Result<(), ()>; }}
|
|
}}
|
|
{generated}
|
|
fn main() {{
|
|
let html = todo::lower_html(r#"<form data-hemx-handle="create"><input name="title"></form><li data-hemx-slot="row" data-hemx-key="7"></li>"#);
|
|
assert!(html.contains(r#"data-hid="#));
|
|
assert!(html.contains(r#"name="__h""#));
|
|
assert!(html.contains(r#"data-key="7""#));
|
|
assert!(!html.contains("data-hemx-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()))
|
|
}
|
|
}
|