Implement closed typed effect path
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# hemx-build
|
||||
|
||||
Build-time Hemplate analysis and typed resource generation for Hemx applications.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+10
-315
@@ -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"));
|
||||
}}
|
||||
"###,
|
||||
|
||||
Reference in New Issue
Block a user