Isolate template authoring validation
This commit is contained in:
@@ -5,3 +5,5 @@ protocol/001 Ad hoc browser commands create ambiguous ordering and compatibility
|
||||
web/001 Framework abstractions often replace native web semantics unnecessarily People using Hemx applications Retain semantic HTML, accessibility, URLs, forms, and browser fallback
|
||||
portable/001 Host-only parsing dependencies prevent portable server-side rendering Rust applications targeting Wasm and constrained servers Render through normal Hemx APIs without host parser dependencies
|
||||
scope/001 Universal extension systems turn a small interaction layer into a client framework Maintainers and application authors Compose ordinary behavior from a small kernel, direct adapters, and explicit islands
|
||||
maintainability/001 Build and adapter responsibilities are interleaved, making safe changes costly Hemx maintainers and downstream users relying on stable generated contracts Cohesive private ownership seams preserve behavior, diagnostics, and portability
|
||||
assurance/001 Boundary changes can pass broad suites without focused semantic drift checks Maintainers changing generated APIs, handlers, adapters, and browser runtime Focused checks expose contract and integration drift at the owning seam
|
||||
|
||||
|
@@ -1,9 +1,57 @@
|
||||
# Current plan
|
||||
# PLAN
|
||||
|
||||
No live Hemx slices remain.
|
||||
Current outcome: make Hemx internals easier to change without altering public behavior, generated contracts, diagnostics, or portability.
|
||||
|
||||
## Closed receipt — HMX-001–004
|
||||
## HMX-M01 — Isolate template authoring validation
|
||||
|
||||
Result: The closed typed effect path, native interaction recovery, root-owned adapters, deterministic generation, and portable server build are delivered.
|
||||
Proof: `redgate check`, focused requirement checks, workspace format/clippy/tests/license gates, Wasm target/tree checks, archive checks, and fresh-context reviews passed.
|
||||
Residual risk: Browser behavior is deterministically checked at generated-markup, runtime, and Axum boundaries rather than through an external browser harness.
|
||||
Outcome: template authoring validation is privately owned without public, diagnostic, ordering, or fingerprint drift.
|
||||
Delta: architecture/001 assurance/001
|
||||
Checks: `cargo test -p hemx-build`; focused contract/fingerprint/diagnostic checks; Redgate; diff check; fresh-context review.
|
||||
State: Done
|
||||
Blocked by: none
|
||||
|
||||
## HMX-M02 — Isolate Rust source fact extraction
|
||||
|
||||
|
||||
Outcome: `hemx-build` Rust/syn fact collection has one private owner independent of template validation and emission.
|
||||
Delta: architecture/002 assurance/001
|
||||
Path: application Rust source -> syntax facts -> generated component/form/handler contract -> deterministic artifact.
|
||||
Build: move the whole fact model, syn traversal, and related tests together; retain the existing host-only dependency boundary.
|
||||
Risk: reordered facts or changed path handling alters generated contracts or fingerprints.
|
||||
Checks: `cargo test -p hemx-build`; generated-contract semantic and fingerprint fixtures; Wasm target tree gate; `redgate check`.
|
||||
Non-goals: replacing syn, changing generated vocabulary, or introducing a generic analysis framework.
|
||||
Residual risk: overlaps `hemx-build/src/lib.rs`; implement after HMX-M01 to avoid conflicting movement.
|
||||
State: Draft
|
||||
Blocked by: HMX-M01
|
||||
|
||||
## HMX-M03 — Isolate artifact emission and diagnostics
|
||||
|
||||
|
||||
Outcome: generated Rust, metadata, lowering tables, and contract diagnostics are emitted through one private boundary behind the public builder.
|
||||
Delta: architecture/003 assurance/001
|
||||
Path: validated template and Rust facts -> stable IDs/metadata -> generated files -> downstream compilation diagnostics.
|
||||
Build: move artifact assembly and diagnostic formatting as one responsibility; keep public entry points and no-op write behavior in place.
|
||||
Risk: byte, ordering, fingerprint, or diagnostic drift breaks downstream builds despite compiling locally.
|
||||
Checks: `cargo test -p hemx-build`; exact generated API/diagnostic/fingerprint fixtures; no-op rewrite check; package archive checks; `redgate check`.
|
||||
Non-goals: a new IR, new serialization, public API changes, or formatting-only rewrites.
|
||||
Residual risk: this is the broadest movement slice and requires fresh-context review after integrated proof.
|
||||
State: Draft
|
||||
Blocked by: HMX-M02
|
||||
|
||||
## HMX-M04 — Localize Axum interaction forms
|
||||
|
||||
|
||||
Outcome: media-type enforcement, body limits, URL-encoded/multipart extraction, typed decoding, and rejections have one private Axum owner.
|
||||
Delta: architecture/004 assurance/002
|
||||
Path: HTTP request -> `InteractionRequest` extraction -> typed `Form<T>` or custom multipart model -> registered handler/rejection.
|
||||
Build: move the complete form boundary with its tests; add compile coverage for documented `Form<T>` spellings and custom `FromInteractionForm` extraction.
|
||||
Risk: extraction order, limits, rejection status/body, or public adapter signatures change.
|
||||
Checks: `cargo test -p hemx-axum`; `cargo test -p hemx-derive`; focused URL-encoded, multipart, limit, rejection, and compile-pass/fail assertions; `redgate check`.
|
||||
Non-goals: framework-owned CSRF/auth policy, a general extractor abstraction, or changing native form semantics.
|
||||
Residual risk: none beyond the existing application-owned security policy boundary after focused route proof.
|
||||
State: Ready
|
||||
Blocked by: none
|
||||
|
||||
## Closure
|
||||
|
||||
Run all repository-required Redgate, fmt, clippy, workspace test, license, Wasm graph, and package archive gates. Finish with a fresh-context blocker-only review against INTENT.tsv, SPEC.tsv, this plan, the actual diff, and proof outputs. Keep `hemx-core`, `hemx-js`, `hemx-derive`, and public package boundaries cohesive unless a later validated responsibility seam requires change.
|
||||
|
||||
@@ -58,3 +58,9 @@ test/002 A failed HTML update assertion must report both expected and actual eff
|
||||
test/003 Public test utilities must inspect complete documents with owned HTML structure. web/001
|
||||
test/004 With Axum support enabled, public test utilities must inspect effect responses through a real router. protocol/001 server/001
|
||||
test/005 The public process helper must wait for delayed TCP readiness and capture child output on failure. server/001
|
||||
architecture/001 hemx-build template authoring validation must live in a private module separate from artifact emission. maintainability/001
|
||||
architecture/002 hemx-build Rust source fact extraction must live in a private module separate from validation and emission. maintainability/001
|
||||
architecture/003 hemx-build artifact emission and contract diagnostics must live behind the public builder in a private module. maintainability/001
|
||||
architecture/004 hemx-axum interaction-form extraction and decoding must live in one private module behind public adapter types. maintainability/001
|
||||
assurance/001 Generated-contract checks must compare resource capabilities, stable fingerprints, and source diagnostics. assurance/001 resource/001
|
||||
assurance/002 Handler compile checks must cover each documented Form<T> spelling and custom multipart extraction. assurance/001 server/001
|
||||
|
||||
|
@@ -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
@@ -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>
|
||||
|
||||
@@ -11,7 +11,10 @@ cargo test -p hemx-core --test effect_batch generated_resource_helpers_preserve_
|
||||
cargo test -p hemx-js --test runtime runtime_executes_every_closed_effect_with_owned_targets -- --exact # spec: kernel/006 check # spec: kernel/013 check # spec: kernel/014 check # spec: kernel/015 check # spec: kernel/016 check
|
||||
cargo test -p hemx-js --test runtime runtime_applies_batches_in_order_and_stops_on_failure -- --exact # spec: kernel/004 check # spec: kernel/005 check # spec: runtime/005 check
|
||||
cargo test -p hemx-build public_heml_inspection_entry_points_preserve_paths_and_io_diagnostics --lib # spec: build/001 check
|
||||
cargo test -p hemx-build generated_contract_fingerprint_is_deterministic --lib # spec: build/002 check
|
||||
cargo test -p hemx-build generated_contract_fingerprint_is_deterministic --lib # spec: build/002 check # spec: assurance/001 check
|
||||
cargo test -p hemx-build emits_generated_resources_from_heml --lib # spec: assurance/001 check
|
||||
cargo test -p hemx-build unkeyed_generated_target_diagnostic_is_structured --lib # spec: assurance/001 check
|
||||
test -f hemx-build/src/authoring.rs && grep -q '^mod authoring;$' hemx-build/src/lib.rs && ! grep -Eq '^fn (reject_selector_target_attrs|reject_unknown_hemx_attrs|known_hemx_attr|reject_invalid_hemx_attr_values|reject_invalid_hemx_attr_placement|reject_unkeyed_loop)\(' hemx-build/src/lib.rs # spec: architecture/001 check
|
||||
cargo test -p hemx-build no_op_build_preserves_generated_artifact_timestamps --lib # spec: build/003 check # spec: resource/005 check
|
||||
cargo test -p hemx-build changed_template_refreshes_generated_artifacts --lib # spec: resource/005 check
|
||||
cargo test -p hemx-derive --test surface surface_macro_preserves_inline_module_with_generated_file -- --exact # spec: derive/001 check
|
||||
|
||||
Reference in New Issue
Block a user