fix(build): validate slhx convention values
Reject invalid static data-slhx-policy and timing convention values at build time, so typos fail with author-facing guidance instead of silently falling back or disabling runtime behavior. req: convention/003 req: convention/005 req: convention/006 req: diagnostics/002
This commit is contained in:
@@ -151,6 +151,7 @@ impl Resources {
|
|||||||
|
|
||||||
reject_selector_target_attrs(path, &node.attrs)?;
|
reject_selector_target_attrs(path, &node.attrs)?;
|
||||||
reject_unknown_slhx_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") {
|
if let Some(class_attr) = static_attr(&node.attrs, "class") {
|
||||||
for token in class_tokens(&class_attr) {
|
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(
|
fn reject_unkeyed_loop(
|
||||||
surface: &SurfaceDocument,
|
surface: &SurfaceDocument,
|
||||||
mut scope: ScopeId,
|
mut scope: ScopeId,
|
||||||
@@ -1461,6 +1515,45 @@ fn main() {{
|
|||||||
let _ = std::fs::remove_dir_all(&base);
|
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#"<button data-slhx-handle="save" data-slhx-policy="newest">Save</button>"#,
|
||||||
|
"data-slhx-policy",
|
||||||
|
"latest",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"debounce",
|
||||||
|
r#"<button data-slhx-handle="save" data-slhx-debounce="soon">Save</button>"#,
|
||||||
|
"data-slhx-debounce",
|
||||||
|
"250ms",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"every",
|
||||||
|
r#"<button data-slhx-handle="save" data-slhx-every="1sec">Save</button>"#,
|
||||||
|
"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]
|
#[test]
|
||||||
fn generated_lowering_injects_progressive_form_handle_and_key() {
|
fn generated_lowering_injects_progressive_form_handle_and_key() {
|
||||||
// req: form/002, req: wire/001
|
// req: form/002, req: wire/001
|
||||||
|
|||||||
Reference in New Issue
Block a user