diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 7752dd3..c926d63 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -884,6 +884,9 @@ All forms support returning `impl IntoEffect` and compose through tuples. ### req: convention/008 008 `data-slhx-disable-while-pending` disables the triggering form controls or button while the request is active and restores them afterward. +### req: convention/009 +009 Unknown `data-slhx-*` authoring attributes are build errors with a suggested fix. slhx-owned attributes are a checked contract, not a silent extension namespace; opaque/integration islands should use explicit allowed attributes or their own non-slhx `data-*` names. + --- ## multipart diff --git a/slhx-build/src/lib.rs b/slhx-build/src/lib.rs index 94dc7ed..6747371 100644 --- a/slhx-build/src/lib.rs +++ b/slhx-build/src/lib.rs @@ -150,6 +150,7 @@ impl Resources { }; reject_selector_target_attrs(path, &node.attrs)?; + reject_unknown_slhx_attrs(path, &node.attrs)?; if let Some(class_attr) = static_attr(&node.attrs, "class") { for token in class_tokens(&class_attr) { @@ -968,6 +969,49 @@ fn reject_selector_target_attrs(path: &Path, attrs: &[SurfaceAttribute]) -> io:: Ok(()) } +fn reject_unknown_slhx_attrs(path: &Path, attrs: &[SurfaceAttribute]) -> io::Result<()> { + for attr in attrs { + let name = attr.name.as_str(); + if name.starts_with("data-slhx-") && !known_slhx_attr(name) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "{}: unknown slhx attribute `{name}`; check the spelling or use a non-slhx data-* attribute for app-specific metadata", + path.display() + ), + )); + } + } + Ok(()) +} + +fn known_slhx_attr(name: &str) -> bool { + matches!( + name, + "data-slhx-root" + | "data-slhx-sse" + | "data-slhx-st" + | "data-slhx-handle" + | "data-slhx-slot" + | "data-slhx-form" + | "data-slhx-atom" + | "data-slhx-key" + | "data-slhx-on" + | "data-slhx-pending-class" + | "data-slhx-indicator" + | "data-slhx-confirm" + | "data-slhx-debounce" + | "data-slhx-throttle" + | "data-slhx-every" + | "data-slhx-disable-while-pending" + | "data-slhx-policy" + | "data-slhx-nav" + | "data-slhx-boost" + | "data-slhx-error-for" + | "data-slhx-island" + ) +} + fn reject_unkeyed_loop( surface: &SurfaceDocument, mut scope: ScopeId, @@ -1394,6 +1438,29 @@ fn main() {{ } } + #[test] + fn rejects_unknown_slhx_authoring_attrs() { + // req: convention/009 req: diagnostics/002 + let base = test_dir("slhx-build-unknown-slhx-attr-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("typo.heml"), + r#""#, + ) + .unwrap(); + + let err = app().template_dir(&templates).out_dir(&out).run().unwrap_err(); + assert!(err.to_string().contains("data-slhx-pendig-class"), "missing attr in diagnostic: {err}"); + assert!(err.to_string().contains("unknown slhx attribute"), "missing unknown-attr diagnostic: {err}"); + assert!(err.to_string().contains("check the spelling"), "missing spelling guidance: {err}"); + assert!(err.to_string().contains("non-slhx data-*"), "missing app metadata 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