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