From efd4e2f36927cd70dd5d6c07fd91ecb331bff8ea Mon Sep 17 00:00:00 2001 From: slhx agent Date: Mon, 1 Jun 2026 21:13:04 +0200 Subject: [PATCH] fix(build): validate slhx convention placement Reject data-slhx-nav on non-anchor elements and data-slhx-sse away from its root, so standard conventions keep progressive navigation and root-scoped push semantics instead of silently doing nothing. req: page_swap/001 req: push/006 req: diagnostics/002 --- REQUIREMENTS.md | 2 +- slhx-build/src/lib.rs | 66 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 45744b7..e79c2ad 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -485,7 +485,7 @@ what a valid business email is. 005 Push is one-way server-to-client delivery of EffectBatch. It does not define client mutation, optimistic queues, reconciliation, or conflict handling. ### req: push/006 -006 `data-slhx-sse` opens only non-empty same-origin SSE URLs by default. Empty static URLs are build errors; cross-origin streams belong to explicit integration code rather than the standard runtime convention. +006 `data-slhx-sse` is declared on `data-slhx-root` and opens only non-empty same-origin SSE URLs by default. Empty static URLs and non-root placement are build errors; cross-origin streams belong to explicit integration code rather than the standard runtime convention. --- diff --git a/slhx-build/src/lib.rs b/slhx-build/src/lib.rs index 3385d0c..f180266 100644 --- a/slhx-build/src/lib.rs +++ b/slhx-build/src/lib.rs @@ -152,6 +152,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)?; + reject_invalid_slhx_attr_placement(path, tag, &node.attrs)?; if let Some(class_attr) = static_attr(&node.attrs, "class") { for token in class_tokens(&class_attr) { @@ -1065,6 +1066,38 @@ fn reject_invalid_slhx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> i Ok(()) } +fn reject_invalid_slhx_attr_placement(path: &Path, tag: &str, attrs: &[SurfaceAttribute]) -> io::Result<()> { + if has_attr(attrs, "data-slhx-nav") && tag != "a" { + return Err(invalid_slhx_placement( + path, + "data-slhx-nav", + "expected a real `` link so navigation works without JavaScript", + )); + } + if has_attr(attrs, "data-slhx-sse") && !has_attr(attrs, "data-slhx-root") { + return Err(invalid_slhx_placement( + path, + "data-slhx-sse", + "expected placement on the same element as `data-slhx-root`", + )); + } + Ok(()) +} + +fn has_attr(attrs: &[SurfaceAttribute], name: &str) -> bool { + attrs.iter().any(|attr| attr.name == name) +} + +fn invalid_slhx_placement(path: &Path, attr: &str, expectation: &str) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "{}: invalid {attr} placement; {expectation}", + path.display() + ), + ) +} + fn valid_policy(value: &str) -> bool { matches!(value.trim(), "latest" | "queue" | "drop" | "parallel") } @@ -1609,6 +1642,39 @@ fn main() {{ } } + #[test] + fn rejects_invalid_slhx_attr_placement() { + // req: page_swap/001 req: push/006 req: diagnostics/002 + for (case, template, attr, guidance) in [ + ( + "nav-button", + r#""#, + "data-slhx-nav", + "", + ), + ( + "sse-child", + r#"
"#, + "data-slhx-sse", + "data-slhx-root", + ), + ] { + let base = test_dir(&format!("slhx-build-invalid-placement-{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("placement.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-placement diagnostic: {err}"); + assert!(err.to_string().contains(guidance), "missing placement 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