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
+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"));
}}
"###,