diff --git a/slhx-build/src/lib.rs b/slhx-build/src/lib.rs
index 6747371..c72537f 100644
--- a/slhx-build/src/lib.rs
+++ b/slhx-build/src/lib.rs
@@ -151,6 +151,7 @@ impl Resources {
reject_selector_target_attrs(path, &node.attrs)?;
reject_unknown_slhx_attrs(path, &node.attrs)?;
+ reject_invalid_slhx_attr_values(path, &node.attrs)?;
if let Some(class_attr) = static_attr(&node.attrs, "class") {
for token in class_tokens(&class_attr) {
@@ -1012,6 +1013,59 @@ fn known_slhx_attr(name: &str) -> bool {
)
}
+fn reject_invalid_slhx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> io::Result<()> {
+ for attr in attrs.iter().filter(|attr| attr.origin == AttributeOrigin::Static) {
+ let Some(value) = attr.value.as_deref() else {
+ continue;
+ };
+ match attr.name.as_str() {
+ "data-slhx-policy" if !valid_policy(value) => {
+ return Err(invalid_slhx_value(
+ path,
+ &attr.name,
+ value,
+ "expected one of `latest`, `queue`, `drop`, or `parallel`",
+ ));
+ }
+ "data-slhx-debounce" | "data-slhx-throttle" | "data-slhx-every"
+ if !valid_duration(value) =>
+ {
+ return Err(invalid_slhx_value(
+ path,
+ &attr.name,
+ value,
+ "expected milliseconds like `250`/`250ms` or seconds like `1s`",
+ ));
+ }
+ _ => {}
+ }
+ }
+ Ok(())
+}
+
+fn valid_policy(value: &str) -> bool {
+ matches!(value.trim(), "latest" | "queue" | "drop" | "parallel")
+}
+
+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_slhx_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,
@@ -1461,6 +1515,45 @@ fn main() {{
let _ = std::fs::remove_dir_all(&base);
}
+ #[test]
+ fn rejects_invalid_static_slhx_convention_values() {
+ // req: convention/003 req: convention/005 req: convention/006 req: diagnostics/002
+ for (case, template, attr, guidance) in [
+ (
+ "policy",
+ r#""#,
+ "data-slhx-policy",
+ "latest",
+ ),
+ (
+ "debounce",
+ r#""#,
+ "data-slhx-debounce",
+ "250ms",
+ ),
+ (
+ "every",
+ r#""#,
+ "data-slhx-every",
+ "1s",
+ ),
+ ] {
+ let base = test_dir(&format!("slhx-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 generated_lowering_injects_progressive_form_handle_and_key() {
// req: form/002, req: wire/001