Implement closed typed effect path

This commit is contained in:
tmk241
2026-09-01 00:58:29 +02:00
parent 353174604e
commit 31a0f02211
58 changed files with 4247 additions and 5933 deletions
+9 -3
View File
@@ -2,13 +2,19 @@
name = "hemx-build"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme = "README.md"
description = "Build-time resource generation for Hemx"
keywords = ["hypermedia", "codegen", "html", "web"]
categories = ["development-tools::build-utils", "web-programming"]
[lib]
path = "src/lib.rs"
[dependencies]
hemplate-core = { path = "../../hemplate/hemplate-core", features = ["surface"] }
hemx-core = { path = "../hemx-core" }
hemplate-core = { version = "0.2.0", features = ["surface"] }
hemx-core = { version = "0.3.0", path = "../hemx-core" }
quote = "1"
serde_json = "1"
syn = { version = "2", features = ["full"] }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Thomas Hain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7
View File
@@ -0,0 +1,7 @@
# hemx-build
Build-time Hemplate analysis and typed resource generation for Hemx applications.
## License
MIT
+10 -315
View File
@@ -212,7 +212,6 @@ impl AppBuilder {
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)?;
}
@@ -232,10 +231,6 @@ impl AppBuilder {
resources.generated_rs(self.global_exports).as_bytes(),
)?;
write_if_changed(&out_dir.join("hemx.syms"), resources.syms().as_bytes())?;
write_if_changed(
&out_dir.join("hemx.client.js"),
resources.client_bootstrap()?.as_bytes(),
)?;
Ok(())
}
}
@@ -269,8 +264,6 @@ struct Resources {
atoms: BTreeMap<String, Resource>,
classes: BTreeMap<String, ClassToken>,
events: BTreeMap<String, EventToken>,
client_handlers: BTreeSet<String>,
client_modules: BTreeSet<String>,
}
#[derive(Clone, Debug)]
@@ -343,21 +336,6 @@ impl Resources {
}
}
if let Some(handler) = static_attr(&node.attrs, "data-hemx-client") {
let Some(handler) = rust_ident(&handler) else {
return Err(invalid_hemx_value(
path,
"data-hemx-client",
&handler,
"expected a Rust handler identifier",
));
};
self.client_handlers.insert(handler);
}
if let Some(module) = static_attr(&node.attrs, "data-hemx-client-module") {
self.client_modules.insert(module);
}
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)
@@ -547,7 +525,6 @@ impl Resources {
.join(", ")
));
// req: build/001 req: component/003
let component_names: BTreeSet<String> = self.component_names().into_iter().collect();
let root_indent = usize::default();
@@ -644,7 +621,6 @@ impl Resources {
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}#[cfg(not(target_arch = \"wasm32\"))]\n"));
out.push_str(&format!("{inner}impl<T> SlotTarget<T, ()> {{\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"));
@@ -656,7 +632,6 @@ impl Resources {
.filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component)
{
let child = &res.ident;
out.push_str(&format!("{inner}#[cfg(not(target_arch = \"wasm32\"))]\n"));
out.push_str(&format!(
"{inner}impl<T> SlotTarget<T, {child_prefix}{child}::Component> {{\n"
));
@@ -685,7 +660,6 @@ impl Resources {
out.push_str(&format!("{inner} pub fn move_to_end(self, key: K) -> ::hemx::advanced::Effect {{ self.slot.move_to_end(key) }}\n"));
out.push_str(&format!("{inner} pub fn remove_key(self, key: K) -> ::hemx::advanced::Effect {{ self.slot.remove(key) }}\n"));
out.push_str(&format!("{inner}}}\n"));
out.push_str(&format!("{inner}#[cfg(not(target_arch = \"wasm32\"))]\n"));
out.push_str(&format!(
"{inner}impl<T> KeyedSlotTarget<::std::string::String, T, ()> {{\n"
));
@@ -705,7 +679,6 @@ impl Resources {
.filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component)
{
let child = &res.ident;
out.push_str(&format!("{inner}#[cfg(not(target_arch = \"wasm32\"))]\n"));
out.push_str(&format!(
"{inner}impl<T> KeyedSlotTarget<::std::string::String, T, {child_prefix}{child}::Component> {{\n"
));
@@ -948,7 +921,6 @@ impl Resources {
"{pad} ::hemx::__private::html_trusted(lower(html))\n"
));
out.push_str(&format!("{pad}}}\n\n"));
out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n"));
out.push_str(&format!("{pad}#[doc(hidden)]\n"));
out.push_str(&format!(
"{pad}pub fn render(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{\n"
@@ -962,17 +934,14 @@ impl Resources {
));
out.push_str(&format!("{pad}}}\n\n"));
out.push_str(&format!(
"{pad}#[cfg(not(target_arch = \"wasm32\"))]\n{pad}pub fn page(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{ render(view) }}\n\n"
"{pad}pub fn page(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{ render(view) }}\n\n"
));
out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\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}#[cfg(not(target_arch = \"wasm32\"))]\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}#[cfg(not(target_arch = \"wasm32\"))]\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"));
@@ -980,7 +949,6 @@ impl Resources {
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}#[cfg(not(target_arch = \"wasm32\"))]\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"));
@@ -988,7 +956,6 @@ impl Resources {
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}#[cfg(not(target_arch = \"wasm32\"))]\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"));
@@ -1160,50 +1127,6 @@ fn __hemx_attr(tag: &str, attr: &str) -> Option<::std::string::String> {
components
}
fn client_bootstrap(&self) -> io::Result<String> {
if self.client_handlers.is_empty() {
return Ok(String::new());
}
let module = match self.client_modules.len() {
1 => self.client_modules.iter().next().unwrap(),
0 => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"client-local handlers require one data-hemx-client-module on a hemx root",
));
}
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"client-local handlers must share one data-hemx-client-module per generated application",
));
}
};
let exports = self
.client_handlers
.iter()
.map(|handler| format!("__hemx_client_{handler}"))
.collect::<Vec<_>>();
let mut out = format!(
"import init, {{ {} }} from {};\nawait init();\n",
exports.join(", "),
serde_json::to_string(module).expect("serialize client module")
);
for handler in &self.client_handlers {
out.push_str(&format!(
"window.hemx.registerClientHandler({}, __hemx_client_{});\n",
serde_json::to_string(handler).expect("serialize client handler"),
handler
));
}
let fingerprint = hemx_core::BuildFingerprint::from_parts(&self.fingerprint_parts()).0;
out.push_str(&format!(
"document.querySelectorAll('[data-hemx-root]').forEach((root) => {{ root.setAttribute('data-hemx-build', '{}'); root.setAttribute('data-hemx-client-ready', ''); }});\n",
fingerprint
));
Ok(out)
}
fn syms(&self) -> String {
let mut out = String::from("hemx-syms-v1\n");
for res in self.slots.values() {
@@ -1609,12 +1532,6 @@ fn known_hemx_attr(name: &str) -> bool {
| "data-hemx-atom"
| "data-hemx-key"
| "data-hemx-on"
| "data-hemx-client"
| "data-hemx-client-event"
| "data-hemx-client-fallback"
| "data-hemx-client-module"
| "data-hemx-client-policy"
| "data-hemx-client-state-version"
| "data-hemx-pending-class"
| "data-hemx-indicator"
| "data-hemx-confirm"
@@ -1659,52 +1576,6 @@ fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> i
"expected `push`, `replace`, or empty for the default push behavior",
));
}
"data-hemx-client" if value.trim().is_empty() => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a non-empty client handler name",
));
}
"data-hemx-client-policy" if !matches!(value.trim(), "latest" | "drop") => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected `latest` or `drop`",
));
}
"data-hemx-client-module" if !valid_client_module(value) => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a same-origin module specifier beginning with `/`, `./`, or `../`",
));
}
"data-hemx-client-event" if !valid_event_list(value) => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a runtime-supported event",
));
}
"data-hemx-client-state-version"
if value
.parse::<u32>()
.ok()
.filter(|version| *version > 0)
.is_none() =>
{
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a positive client state ABI version",
));
}
"data-hemx-on" if !valid_event_list(value) => {
return Err(invalid_hemx_value(
path,
@@ -1791,13 +1662,6 @@ fn reject_invalid_hemx_attr_placement(
"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-client-module") && !has_attr(attrs, "data-hemx-root") {
return Err(invalid_hemx_placement(
path,
"data-hemx-client-module",
"expected placement on the same element as `data-hemx-root`",
));
}
if has_attr(attrs, "data-hemx-sse") && !has_attr(attrs, "data-hemx-root") {
return Err(invalid_hemx_placement(
path,
@@ -1833,14 +1697,6 @@ fn valid_policy(value: &str) -> bool {
matches!(value.trim(), "latest" | "queue" | "drop" | "parallel")
}
fn valid_client_module(value: &str) -> bool {
let value = value.trim();
!value.is_empty()
&& !value.starts_with("//")
&& !value.contains(':')
&& (value.starts_with('/') || value.starts_with("./") || value.starts_with("../"))
}
fn valid_event_list(value: &str) -> bool {
let mut events = event_tokens(value).peekable();
events.peek().is_some() && events.all(valid_runtime_event)
@@ -2215,7 +2071,7 @@ fn rust_ident(name: &str) -> Option<String> {
fn stable_id(kind: &str, symbol: &str) -> u32 {
let mut hash = 0x811c9dc5u32;
for byte in kind.bytes().chain([b':']).chain(symbol.bytes()) {
for byte in kind.bytes().chain(*b":").chain(symbol.bytes()) {
hash ^= byte as u32;
hash = hash.wrapping_mul(0x01000193);
}
@@ -2429,7 +2285,6 @@ mod tests {
assert_eq!(error.kind(), io::ErrorKind::NotFound);
}
let _ = std::fs::remove_dir_all(root);
// test req: diagnostics/004 req: diagnostics/006 req: surface/008
}
#[test]
@@ -2470,7 +2325,6 @@ mod tests {
"accepted non-vector type {invalid:?}"
);
}
// test req: diagnostics/006
}
#[test]
@@ -2493,7 +2347,6 @@ mod tests {
.unwrap()
.is_empty());
let _ = std::fs::remove_dir_all(base);
// test req: diagnostics/006
}
#[test]
@@ -2628,9 +2481,7 @@ mod tests {
)
.unwrap_err()
.to_string(),
format!(
"duplicate generated identifier `shared` for `first.heml::shared` and `second.heml::shared`"
),
"duplicate generated identifier `shared` for `first.heml::shared` and `second.heml::shared`",
"{kind} collision must propagate"
);
}
@@ -2661,11 +2512,10 @@ mod tests {
io::ErrorKind::InvalidData
);
}
// test req: codegen/005 req: diagnostics/004
}
#[test]
fn generated_contract_fingerprint_and_client_bootstrap_are_deterministic() {
fn generated_contract_fingerprint_is_deterministic() {
let resource = |symbol: &str, component: &str, id| Resource {
symbol: symbol.into(),
ident: symbol.into(),
@@ -2805,12 +2655,12 @@ mod tests {
let canonical_syms = resources.syms();
assert_eq!(
stable_id("generated-rs", &canonical_generated),
4_047_122_428,
262_445_078,
"canonical generated Rust changed"
);
assert_eq!(
stable_id("generated-rs-global", &canonical_globals),
1_196_973_467,
3_211_924_671,
"canonical global-export Rust changed"
);
assert_eq!(
@@ -2818,34 +2668,10 @@ mod tests {
984_222_700,
"canonical symbol manifest changed"
);
assert_eq!(Resources::default().client_bootstrap().unwrap(), "");
resources
.client_handlers
.extend(["save".into(), "toggle".into()]);
assert_eq!(
resources.client_bootstrap().unwrap_err().to_string(),
"client-local handlers require one data-hemx-client-module on a hemx root"
);
resources.client_modules.insert("/app.wasm".into());
let bootstrap = resources.client_bootstrap().unwrap();
assert!(bootstrap.starts_with(
"import init, { __hemx_client_save, __hemx_client_toggle } from \"/app.wasm\";\nawait init();\n"
));
assert!(
bootstrap.contains("window.hemx.registerClientHandler(\"save\", __hemx_client_save);")
);
assert!(bootstrap.contains("data-hemx-client-ready"));
resources.client_modules.insert("/other.wasm".into());
assert_eq!(
resources.client_bootstrap().unwrap_err().to_string(),
"client-local handlers must share one data-hemx-client-module per generated application"
);
// test req: build/007 req: abi/003 req: client_local/011
}
#[test]
fn app_builder_propagates_output_and_client_contract_errors_without_panicking() {
fn app_builder_propagates_output_and_contract_errors_without_panicking() {
let root = std::env::temp_dir().join(format!(
"hemx-build-error-{}-{}",
std::process::id(),
@@ -2865,49 +2691,10 @@ mod tests {
assert!(result.unwrap().is_err());
std::fs::remove_dir_all(out.join("hemx.syms")).unwrap();
std::fs::create_dir_all(out.join("hemx.client.js")).unwrap();
let result = std::panic::catch_unwind(|| app().out_dir(&out).run());
assert!(result.is_ok(), "client output errors must not panic");
assert!(result.unwrap().is_err());
let templates = root.join("templates");
let invalid_out = root.join("invalid-out");
std::fs::create_dir_all(&templates).unwrap();
std::fs::write(
templates.join("client.heml"),
"<button data-hemx-client=\"save\">Save</button>",
)
.unwrap();
let result =
std::panic::catch_unwind(|| app().template_dir(&templates).out_dir(&invalid_out).run());
assert!(result.is_ok(), "invalid client contracts must not panic");
assert_eq!(
result.unwrap().unwrap_err().to_string(),
"client-local handlers require one data-hemx-client-module on a hemx root"
);
let invalid_handler = templates.join("invalid_handler.heml");
std::fs::write(
&invalid_handler,
r#"<main data-hemx-root="app" data-hemx-client-module="/app.js"><button data-hemx-client="save-item">Save</button></main>"#,
)
.unwrap();
std::fs::remove_file(templates.join("client.heml")).unwrap();
let error = app()
.template_dir(&templates)
.out_dir(&invalid_out)
.run()
.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert_eq!(
error.to_string(),
format!(
"{}: invalid data-hemx-client value `save-item`; expected a Rust handler identifier",
invalid_handler.display()
)
);
std::fs::remove_file(invalid_handler).unwrap();
std::fs::write(
templates.join("invalid_param.heml"),
r#"<button data-hemx-handle="save" data-123="value">Save</button>"#,
@@ -2941,7 +2728,6 @@ mod tests {
"invalid hemx slot name `123`; expected a Rust identifier"
);
let _ = std::fs::remove_dir_all(root);
// test req: build/004 req: client_local/011 req: diagnostics/004
}
#[test]
@@ -3087,12 +2873,10 @@ mod tests {
);
let _ = std::fs::remove_dir_all(base);
// test req: build/003 req: build/004 req: build/009 req: diagnostics/004
}
#[test]
fn no_op_build_preserves_generated_artifact_timestamps() {
// req: build/009
let base = test_dir("hemx-build-no-op");
let templates = base.join("templates");
let out = base.join("out");
@@ -3162,12 +2946,10 @@ mod tests {
assert!(refreshed_symbols.contains("cancel"));
assert!(!refreshed_symbols.contains("save"));
let _ = std::fs::remove_dir_all(&base);
// test req: build/009
}
#[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");
@@ -3327,7 +3109,6 @@ mod tests {
"sibling", "space", "tab"
]
);
// test req: style/001
}
#[test]
@@ -3432,12 +3213,10 @@ mod tests {
);
let _ = std::fs::remove_dir_all(&base);
// test req: style/001 req: form/001 req: diagnostics/004
}
#[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");
@@ -3462,7 +3241,6 @@ mod tests {
#[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");
@@ -3497,7 +3275,6 @@ mod tests {
#[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");
@@ -3556,7 +3333,6 @@ mod tests {
#[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");
@@ -3619,7 +3395,7 @@ mod tests {
.count(),
1,
"conditional controls with one submitted name are one Rust form field"
); // req: form/004 test
);
assert!(syms.contains("form_field\tprofile\tlabels\tfalse\ttrue\n"));
let _ = std::fs::remove_dir_all(&base);
@@ -3627,7 +3403,6 @@ mod tests {
#[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");
@@ -3748,7 +3523,6 @@ fn main() {{
#[test]
fn diagnostics_for_heml_file_reports_unkeyed_generated_target() {
// req: diagnostics/002 req: diagnostics/004
let dir = test_dir("diagnostics_for_heml_file_reports_unkeyed_generated_target");
std::fs::create_dir_all(&dir).expect("create test dir");
let template = dir.join("todo.heml");
@@ -3769,7 +3543,6 @@ fn main() {{
#[test]
fn generated_targets_for_heml_source_uses_surface_facts() {
// req: diagnostics/004 req: diagnostics/005
let targets = generated_targets_for_heml_source(
"inline.heml",
r#"
@@ -3819,7 +3592,6 @@ fn main() {{
#[test]
fn template_context_facts_include_self_fields_and_h_for_local_fields() {
// req: diagnostics/006
let dir = test_dir("template_context_facts_include_self_fields_and_h_for_local_fields");
std::fs::create_dir_all(dir.join("src")).expect("create src dir");
std::fs::write(
@@ -3932,7 +3704,6 @@ fn main() {{
#[test]
fn unkeyed_generated_target_diagnostic_is_structured() {
// req: diagnostics/002
let diagnostic = unkeyed_generated_target_diagnostic(
Path::new("templates/todo.heml"),
"slot",
@@ -3957,7 +3728,6 @@ fn main() {{
#[test]
fn rejects_hemx_resources_inside_unkeyed_for() {
// req: scope/001 req: diagnostics/002
for (case, template, resource, helper) in [
(
"slot",
@@ -4107,7 +3877,6 @@ fn main() {{
#[test]
fn rejects_selector_style_targeting_attrs() {
// req: locality/001 req: locality/002 req: htmx_equivalents/003
for (case, template, attr) in [
(
"hemx-target",
@@ -4148,7 +3917,6 @@ fn main() {{
#[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");
@@ -4180,7 +3948,6 @@ fn main() {{
#[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",
@@ -4194,42 +3961,6 @@ fn main() {{
"data-hemx-policy",
"latest",
),
(
"empty-client",
r#"<button data-hemx-client="">Save</button>"#,
"data-hemx-client",
"non-empty",
),
(
"client-module-scheme",
r#"<main data-hemx-root="app" data-hemx-client-module="https://example.com/app.js"></main>"#,
"data-hemx-client-module",
"same-origin",
),
(
"client-event",
r#"<button data-hemx-client="save" data-hemx-client-event="blur">Save</button>"#,
"data-hemx-client-event",
"runtime-supported",
),
(
"client-version-zero",
r#"<button data-hemx-client="save" data-hemx-client-state-version="0">Save</button>"#,
"data-hemx-client-state-version",
"positive",
),
(
"client-version-invalid",
r#"<button data-hemx-client="save" data-hemx-client-state-version="one">Save</button>"#,
"data-hemx-client-state-version",
"positive",
),
(
"client-policy-invalid",
r#"<button data-hemx-client="save" data-hemx-client-policy="queue">Save</button>"#,
"data-hemx-client-policy",
"latest",
),
(
"events-mixed",
r#"<button data-hemx-handle="save" data-hemx-on="click blur">Save</button>"#,
@@ -4367,15 +4098,6 @@ fn main() {{
"data-hemx-history" => {
"expected `push`, `replace`, or empty for the default push behavior"
}
"data-hemx-client" => "expected a non-empty client handler name",
"data-hemx-client-policy" => "expected `latest` or `drop`",
"data-hemx-client-module" => {
"expected a same-origin module specifier beginning with `/`, `./`, or `../`"
}
"data-hemx-client-event" => "expected a runtime-supported event",
"data-hemx-client-state-version" => {
"expected a positive client state ABI version"
}
"data-hemx-revealed-ahead" => "expected a non-negative number of viewports",
"data-hemx-on" => "expected runtime-supported events: `click`, `submit`, `input`, `change`, `keydown`, `dragstart`, `dragover`, or `drop`",
"data-hemx-confirm" => "expected a non-empty confirmation message",
@@ -4410,19 +4132,6 @@ fn main() {{
assert!(!valid_policy(invalid), "accepted policy {invalid:?}");
}
for valid in ["/app.js", " ./app.js ", "../app.js"] {
assert!(valid_client_module(valid), "rejected module {valid:?}");
}
for invalid in [
"",
" ",
"//cdn/app.js",
"https://example.com/app.js",
"app.js",
] {
assert!(!valid_client_module(invalid), "accepted module {invalid:?}");
}
for valid in [
"click",
"submit input change keydown dragstart dragover drop",
@@ -4460,9 +4169,8 @@ fn main() {{
}
let valid_source = r#"
<main data-hemx-root="app" data-hemx-client-module="/app.js" data-hemx-sse="/events" data-hemx-ws="/room/socket">
<main data-hemx-root="app" data-hemx-sse="/events" data-hemx-ws="/room/socket">
<button data-hemx-handle="save" data-hemx-policy="latest" data-hemx-on="click change" data-hemx-confirm="Save?" data-hemx-delay="250ms" data-hemx-throttle="1s">Save</button>
<button data-hemx-client="save" data-hemx-client-event="click" data-hemx-client-policy="drop" data-hemx-client-state-version="1">Client</button>
<form method="get" action="/search" data-hemx-history="replace"><input name="q"></form>
</main>
"#;
@@ -4474,12 +4182,10 @@ fn main() {{
std::fs::write(templates.join("valid.heml"), valid_source).unwrap();
app().template_dir(&templates).out_dir(&out).run().unwrap();
let _ = std::fs::remove_dir_all(base);
// test req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006
}
#[test]
fn placement_contracts_fail_closed_with_exact_diagnostics() {
// req: page_swap/001 req: page_swap/007 req: push/006 req: diagnostics/002
for (case, template, attr, expectation) in [
(
"nav-button",
@@ -4511,12 +4217,6 @@ fn main() {{
"data-hemx-boost",
"expected a container around descendant links/forms; use `data-hemx-nav` on anchors or `data-hemx-handle` on forms",
),
(
"client-module-child",
r#"<section data-hemx-root="app"><div data-hemx-client-module="/app.js"></div></section>"#,
"data-hemx-client-module",
"expected placement on the same element as `data-hemx-root`",
),
(
"sse-child",
r#"<section data-hemx-root="feed"><div data-hemx-sse="/events"></div></section>"#,
@@ -4561,10 +4261,6 @@ fn main() {{
"boost-container",
r#"<nav data-hemx-boost=""><a href="/docs">Docs</a></nav>"#,
),
(
"client-module-root",
r#"<main data-hemx-root="app" data-hemx-client-module="/app.js"></main>"#,
),
(
"sse-root",
r#"<main data-hemx-root="feed" data-hemx-sse="/events"></main>"#,
@@ -4587,7 +4283,6 @@ fn main() {{
#[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");
@@ -4645,7 +4340,7 @@ fn main() {{
let html = todo::lower_html(r#"<form data-hemx-handle="create"><input name="title"></form><li data-hemx-slot="row" h-key="7"></li>"#);
assert!(html.contains(r#"data-hid="#));
assert!(html.contains(r#"name="__h""#));
assert!(html.contains(r#"data-key="7""#)); // req: list/002 test
assert!(html.contains(r#"data-key="7""#));
assert!(!html.contains("h-key"));
}}
"###,