Isolate template authoring validation

This commit is contained in:
tmk241
2026-09-01 23:14:12 +02:00
parent 5b29100340
commit 42710354a6
6 changed files with 388 additions and 307 deletions
+314
View File
@@ -0,0 +1,314 @@
use super::*;
pub(super) fn reject_selector_target_attrs(
path: &Path,
attrs: &[SurfaceAttribute],
) -> io::Result<()> {
for attr in attrs {
let name = attr.name.as_str();
if matches!(name, "data-hemx-target" | "data-hemx-select") {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{}: `{name}` is selector-style targeting; hemx uses generated resources instead. Add data-hemx-slot to the local element and return an effect for that generated slot.",
path.display()
),
));
}
}
Ok(())
}
pub(super) fn reject_unknown_hemx_attrs(path: &Path, attrs: &[SurfaceAttribute]) -> io::Result<()> {
for attr in attrs {
let name = attr.name.as_str();
if name.starts_with("data-hemx-") && !known_hemx_attr(name) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{}: unknown hemx attribute `{name}`; check the spelling or use a non-hemx data-* attribute for app-specific metadata",
path.display()
),
));
}
}
Ok(())
}
pub(super) fn known_hemx_attr(name: &str) -> bool {
matches!(
name,
"data-hemx-root"
| "data-hemx-sse"
| "data-hemx-ws"
| "data-hemx-st"
| "data-hemx-handle"
| "data-hemx-slot"
| "data-hemx-form"
| "data-hemx-atom"
| "data-hemx-key"
| "data-hemx-on"
| "data-hemx-pending-class"
| "data-hemx-indicator"
| "data-hemx-confirm"
| "data-hemx-debounce"
| "data-hemx-throttle"
| "data-hemx-every"
| "data-hemx-interval"
| "data-hemx-revealed"
| "data-hemx-revealed-ahead"
| "data-hemx-disable-while-pending"
| "data-hemx-policy"
| "data-hemx-nav"
| "data-hemx-history"
| "data-hemx-boost"
| "data-hemx-error-for"
| "data-hemx-error"
| "data-hemx-island"
)
}
pub(super) fn reject_invalid_hemx_attr_values(
path: &Path,
attrs: &[SurfaceAttribute],
) -> io::Result<()> {
for attr in attrs
.iter()
.filter(|attr| attr.origin == AttributeOrigin::Static)
{
let value = attr.value.as_deref().unwrap_or("");
match attr.name.as_str() {
"data-hemx-policy" if !valid_policy(value) => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected one of `latest`, `queue`, `drop`, or `parallel`",
));
}
"data-hemx-history" if !matches!(value.trim(), "" | "push" | "replace") => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected `push`, `replace`, or empty for the default push behavior",
));
}
"data-hemx-on" if !valid_event_list(value) => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected runtime-supported events: `click`, `submit`, `input`, `change`, `keydown`, `dragstart`, `dragover`, or `drop`",
));
}
"data-hemx-confirm" if value.trim().is_empty() => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a non-empty confirmation message",
));
}
"data-hemx-sse" if value.trim().is_empty() => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a non-empty same-origin SSE URL",
));
}
"data-hemx-ws" if value.trim().is_empty() => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a non-empty same-origin WebSocket URL",
));
}
"data-hemx-revealed-ahead"
if !value
.trim()
.parse::<f64>()
.ok()
.is_some_and(|value| value.is_finite() && value >= 0.0) =>
{
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a non-negative number of viewports",
));
}
"data-hemx-debounce" | "data-hemx-throttle" | "data-hemx-every"
| "data-hemx-interval"
if !valid_duration(value) =>
{
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected milliseconds like `250`/`250ms` or seconds like `1s`",
));
}
_ => {}
}
}
Ok(())
}
pub(super) fn reject_invalid_hemx_attr_placement(
path: &Path,
tag: &str,
attrs: &[SurfaceAttribute],
) -> io::Result<()> {
if has_attr(attrs, "data-hemx-nav")
&& (tag != "a"
|| !has_attr(attrs, "href")
|| static_attr(attrs, "href").is_some_and(|href| href.trim().is_empty()))
{
return Err(invalid_hemx_placement(
path,
"data-hemx-nav",
"expected a real `<a href=...>` link so navigation works without JavaScript",
));
}
if has_attr(attrs, "data-hemx-boost") && matches!(tag, "a" | "form") {
return Err(invalid_hemx_placement(
path,
"data-hemx-boost",
"expected a container around descendant links/forms; use `data-hemx-nav` on anchors or `data-hemx-handle` on forms",
));
}
if has_attr(attrs, "data-hemx-sse") && !has_attr(attrs, "data-hemx-root") {
return Err(invalid_hemx_placement(
path,
"data-hemx-sse",
"expected placement on the same element as `data-hemx-root`",
));
}
if has_attr(attrs, "data-hemx-ws") && !has_attr(attrs, "data-hemx-root") {
return Err(invalid_hemx_placement(
path,
"data-hemx-ws",
"expected placement on the same element as `data-hemx-root`",
));
}
Ok(())
}
fn has_attr(attrs: &[SurfaceAttribute], name: &str) -> bool {
attrs.iter().any(|attr| attr.name == name)
}
fn invalid_hemx_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")
}
fn valid_event_list(value: &str) -> bool {
let mut events = event_tokens(value).peekable();
events.peek().is_some() && events.all(valid_runtime_event)
}
fn valid_runtime_event(value: &str) -> bool {
matches!(
value,
"click" | "submit" | "input" | "change" | "keydown" | "dragstart" | "dragover" | "drop"
)
}
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_hemx_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()
),
)
}
pub(super) fn reject_unkeyed_loop(
surface: &SurfaceDocument,
scope: ScopeId,
path: &Path,
kind: &str,
name: &str,
) -> io::Result<()> {
if let Some(diagnostic) =
unkeyed_generated_target_diagnostic_for_scope(surface, scope, path, kind, name)
{
Err(diagnostic.to_io_error())
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn static_attribute_value_validators_cover_every_boundary() {
for valid in ["latest", " queue ", "drop", "parallel"] {
assert!(valid_policy(valid), "rejected policy {valid:?}");
}
for invalid in ["", "newest", "latest queue", "LATEST"] {
assert!(!valid_policy(invalid), "accepted policy {invalid:?}");
}
for valid in [
"click",
"submit input change keydown dragstart dragover drop",
" click change ",
] {
assert!(valid_event_list(valid), "rejected event list {valid:?}");
}
for invalid in ["", " ", "blur", "click blur"] {
assert!(
!valid_event_list(invalid),
"accepted event list {invalid:?}"
);
}
for valid in [
"click",
"submit",
"input",
"change",
"keydown",
"dragstart",
"dragover",
"drop",
] {
assert!(valid_runtime_event(valid), "rejected event {valid:?}");
}
for invalid in ["", "blur", "Click"] {
assert!(!valid_runtime_event(invalid), "accepted event {invalid:?}");
}
for valid in ["0", "250", "250ms", "1s", " 5s "] {
assert!(valid_duration(valid), "rejected duration {valid:?}");
}
for invalid in ["", " ", "ms", "s", "-1", "1sec", "1.5s", "1 ms"] {
assert!(!valid_duration(invalid), "accepted duration {invalid:?}");
}
}
}
+8 -300
View File
@@ -10,6 +10,13 @@ use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
mod authoring;
use authoring::{
reject_invalid_hemx_attr_placement, reject_invalid_hemx_attr_values,
reject_selector_target_attrs, reject_unkeyed_loop, reject_unknown_hemx_attrs,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DiagnosticSeverity {
Error,
@@ -1487,262 +1494,6 @@ fn is_inside_keyed_for(surface: &SurfaceDocument, mut scope: ScopeId) -> bool {
}
}
fn reject_selector_target_attrs(path: &Path, attrs: &[SurfaceAttribute]) -> io::Result<()> {
for attr in attrs {
let name = attr.name.as_str();
if matches!(name, "data-hemx-target" | "data-hemx-select") {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{}: `{name}` is selector-style targeting; hemx uses generated resources instead. Add data-hemx-slot to the local element and return an effect for that generated slot.",
path.display()
),
));
}
}
Ok(())
}
fn reject_unknown_hemx_attrs(path: &Path, attrs: &[SurfaceAttribute]) -> io::Result<()> {
for attr in attrs {
let name = attr.name.as_str();
if name.starts_with("data-hemx-") && !known_hemx_attr(name) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{}: unknown hemx attribute `{name}`; check the spelling or use a non-hemx data-* attribute for app-specific metadata",
path.display()
),
));
}
}
Ok(())
}
fn known_hemx_attr(name: &str) -> bool {
matches!(
name,
"data-hemx-root"
| "data-hemx-sse"
| "data-hemx-ws"
| "data-hemx-st"
| "data-hemx-handle"
| "data-hemx-slot"
| "data-hemx-form"
| "data-hemx-atom"
| "data-hemx-key"
| "data-hemx-on"
| "data-hemx-pending-class"
| "data-hemx-indicator"
| "data-hemx-confirm"
| "data-hemx-debounce"
| "data-hemx-throttle"
| "data-hemx-every"
| "data-hemx-interval"
| "data-hemx-revealed"
| "data-hemx-revealed-ahead"
| "data-hemx-disable-while-pending"
| "data-hemx-policy"
| "data-hemx-nav"
| "data-hemx-history"
| "data-hemx-boost"
| "data-hemx-error-for"
| "data-hemx-error"
| "data-hemx-island"
)
}
fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> io::Result<()> {
for attr in attrs
.iter()
.filter(|attr| attr.origin == AttributeOrigin::Static)
{
let value = attr.value.as_deref().unwrap_or("");
match attr.name.as_str() {
"data-hemx-policy" if !valid_policy(value) => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected one of `latest`, `queue`, `drop`, or `parallel`",
));
}
"data-hemx-history" if !matches!(value.trim(), "" | "push" | "replace") => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected `push`, `replace`, or empty for the default push behavior",
));
}
"data-hemx-on" if !valid_event_list(value) => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected runtime-supported events: `click`, `submit`, `input`, `change`, `keydown`, `dragstart`, `dragover`, or `drop`",
));
}
"data-hemx-confirm" if value.trim().is_empty() => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a non-empty confirmation message",
));
}
"data-hemx-sse" if value.trim().is_empty() => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a non-empty same-origin SSE URL",
));
}
"data-hemx-ws" if value.trim().is_empty() => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a non-empty same-origin WebSocket URL",
));
}
"data-hemx-revealed-ahead"
if !value
.trim()
.parse::<f64>()
.ok()
.is_some_and(|value| value.is_finite() && value >= 0.0) =>
{
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a non-negative number of viewports",
));
}
"data-hemx-debounce" | "data-hemx-throttle" | "data-hemx-every"
| "data-hemx-interval"
if !valid_duration(value) =>
{
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected milliseconds like `250`/`250ms` or seconds like `1s`",
));
}
_ => {}
}
}
Ok(())
}
fn reject_invalid_hemx_attr_placement(
path: &Path,
tag: &str,
attrs: &[SurfaceAttribute],
) -> io::Result<()> {
if has_attr(attrs, "data-hemx-nav")
&& (tag != "a"
|| !has_attr(attrs, "href")
|| static_attr(attrs, "href").is_some_and(|href| href.trim().is_empty()))
{
return Err(invalid_hemx_placement(
path,
"data-hemx-nav",
"expected a real `<a href=...>` link so navigation works without JavaScript",
));
}
if has_attr(attrs, "data-hemx-boost") && matches!(tag, "a" | "form") {
return Err(invalid_hemx_placement(
path,
"data-hemx-boost",
"expected a container around descendant links/forms; use `data-hemx-nav` on anchors or `data-hemx-handle` on forms",
));
}
if has_attr(attrs, "data-hemx-sse") && !has_attr(attrs, "data-hemx-root") {
return Err(invalid_hemx_placement(
path,
"data-hemx-sse",
"expected placement on the same element as `data-hemx-root`",
));
}
if has_attr(attrs, "data-hemx-ws") && !has_attr(attrs, "data-hemx-root") {
return Err(invalid_hemx_placement(
path,
"data-hemx-ws",
"expected placement on the same element as `data-hemx-root`",
));
}
Ok(())
}
fn has_attr(attrs: &[SurfaceAttribute], name: &str) -> bool {
attrs.iter().any(|attr| attr.name == name)
}
fn invalid_hemx_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")
}
fn valid_event_list(value: &str) -> bool {
let mut events = event_tokens(value).peekable();
events.peek().is_some() && events.all(valid_runtime_event)
}
fn valid_runtime_event(value: &str) -> bool {
matches!(
value,
"click" | "submit" | "input" | "change" | "keydown" | "dragstart" | "dragover" | "drop"
)
}
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_hemx_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,
scope: ScopeId,
path: &Path,
kind: &str,
name: &str,
) -> io::Result<()> {
if let Some(diagnostic) =
unkeyed_generated_target_diagnostic_for_scope(surface, scope, path, kind, name)
{
Err(diagnostic.to_io_error())
} else {
Ok(())
}
}
fn context_type_for_heml_path(path: &Path) -> Option<String> {
let stem = path.file_stem()?.to_str()?;
let mut out = String::new();
@@ -4110,50 +3861,7 @@ fn main() {{
}
#[test]
fn static_attribute_value_validators_cover_every_boundary() {
for valid in ["latest", " queue ", "drop", "parallel"] {
assert!(valid_policy(valid), "rejected policy {valid:?}");
}
for invalid in ["", "newest", "latest queue", "LATEST"] {
assert!(!valid_policy(invalid), "accepted policy {invalid:?}");
}
for valid in [
"click",
"submit input change keydown dragstart dragover drop",
" click change ",
] {
assert!(valid_event_list(valid), "rejected event list {valid:?}");
}
for invalid in ["", " ", "blur", "click blur"] {
assert!(
!valid_event_list(invalid),
"accepted event list {invalid:?}"
);
}
for valid in [
"click",
"submit",
"input",
"change",
"keydown",
"dragstart",
"dragover",
"drop",
] {
assert!(valid_runtime_event(valid), "rejected event {valid:?}");
}
for invalid in ["", "blur", "Click"] {
assert!(!valid_runtime_event(invalid), "accepted event {invalid:?}");
}
for valid in ["0", "250", "250ms", "1s", " 5s "] {
assert!(valid_duration(valid), "rejected duration {valid:?}");
}
for invalid in ["", " ", "ms", "s", "-1", "1sec", "1.5s", "1 ms"] {
assert!(!valid_duration(invalid), "accepted duration {invalid:?}");
}
fn static_attribute_values_integrate_with_template_inspection() {
let valid_source = r#"
<main data-hemx-root="app" data-hemx-sse="/events" data-hemx-ws="/room/socket">
<button data-hemx-handle="save" data-hemx-policy="latest" data-hemx-on="click change" data-hemx-confirm="Save?" data-hemx-debounce="250ms" data-hemx-throttle="1s">Save</button>