refactor!: rename slhx to hemx

Rename the tracked product identity, crate/package names, Rust paths/macros, generated artifacts, runtime files, public attributes, examples, docs, requirements, and tests from slhx to hemx without compatibility shims.

Verified with cargo run -p hemx-xtask -- test, cargo test -p hemx-derive --test compile_fail, cargo test -p hemx-js, cargo test -p hemx-axum, cargo test -p hemx-v0-examples, cargo check --workspace, redgate list, redgate refs, redgate health --strict, git diff --check, and git grep/ls-files legacy-name audits.

req: misc/001

req: codegen/001

req: component/004

req: runtime/001
This commit is contained in:
slhx agent
2026-06-05 06:52:37 +02:00
parent d4e865ef92
commit c33500440e
69 changed files with 1415 additions and 1415 deletions
+3 -3
View File
@@ -1,4 +1,4 @@
# slhx — AGENTS.md
# hemx — AGENTS.md
## Purpose
@@ -48,8 +48,8 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
- Add only durable style, ownership, gotchas, and at most a few stable commands agents should actually run.
- Prefer links or pointers to canonical sources over copied lists.
- Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot.
- Stable commands: `cargo run -p slhx-xtask -- test`, `cargo check --workspace`, `redgate health --strict`. Use the xtask runner for full verification so jobs are capped from local CPU and memory. req: test/004
- slhx core stays small: effects, typed ids, registries, and wire schema only.
- Stable commands: `cargo run -p hemx-xtask -- test`, `cargo check --workspace`, `redgate health --strict`. Use the xtask runner for full verification so jobs are capped from local CPU and memory. req: test/004
- hemx core stays small: effects, typed ids, registries, and wire schema only.
- Routing, auth, sessions, transport, transitions, sync, and storage belong in integration/user crates.
- Public examples and beginner APIs should use generated resources and `IntoEffect`, not raw ids or runtime opcodes.
- Hemlate examples must use real hemplate syntax, not Vue/Handlebars sketches: `{+ expr +}` for escaped text, `{+= expr =+}` only for trusted/rendered HTML, `+attr="expr"` for dynamic attributes, and Rust-shaped `h-if`, `h-for`, `h-match`, `h-case` directives (`h-case="_"` is the default arm).
Generated
+107 -107
View File
@@ -424,6 +424,113 @@ dependencies = [
"tree-sitter-hemplate",
]
[[package]]
name = "hemx"
version = "0.1.0"
dependencies = [
"hemplate",
"hemx-core",
"hemx-derive",
]
[[package]]
name = "hemx-axum"
version = "0.1.0"
dependencies = [
"axum",
"futures-util",
"hemx-core",
"hemx-js",
"scraper",
"tokio",
]
[[package]]
name = "hemx-build"
version = "0.1.0"
dependencies = [
"hemplate-core",
"hemx-core",
]
[[package]]
name = "hemx-core"
version = "0.1.0"
dependencies = [
"postcard",
"serde",
]
[[package]]
name = "hemx-derive"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "hemx-js"
version = "0.1.0"
[[package]]
name = "hemx-kanban-example"
version = "0.1.0"
dependencies = [
"axum",
"futures-util",
"hemplate",
"hemx",
"hemx-axum",
"hemx-build",
"hemx-test",
"scraper",
"tokio",
]
[[package]]
name = "hemx-techdemo"
version = "0.1.0"
dependencies = [
"axum",
"futures-util",
"hemplate",
"hemx",
"hemx-axum",
"hemx-build",
"hemx-test",
"scraper",
"thirtyfour",
"tokio",
]
[[package]]
name = "hemx-test"
version = "0.1.0"
dependencies = [
"hemx-core",
]
[[package]]
name = "hemx-v0-examples"
version = "0.1.0"
dependencies = [
"axum",
"futures-util",
"hemplate",
"hemx",
"hemx-axum",
"hemx-build",
"hemx-test",
"scraper",
"tokio",
]
[[package]]
name = "hemx-xtask"
version = "0.1.0"
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -1355,113 +1462,6 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slhx"
version = "0.1.0"
dependencies = [
"hemplate",
"slhx-core",
"slhx-derive",
]
[[package]]
name = "slhx-axum"
version = "0.1.0"
dependencies = [
"axum",
"futures-util",
"scraper",
"slhx-core",
"slhx-js",
"tokio",
]
[[package]]
name = "slhx-build"
version = "0.1.0"
dependencies = [
"hemplate-core",
"slhx-core",
]
[[package]]
name = "slhx-core"
version = "0.1.0"
dependencies = [
"postcard",
"serde",
]
[[package]]
name = "slhx-derive"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "slhx-js"
version = "0.1.0"
[[package]]
name = "slhx-kanban-example"
version = "0.1.0"
dependencies = [
"axum",
"futures-util",
"hemplate",
"scraper",
"slhx",
"slhx-axum",
"slhx-build",
"slhx-test",
"tokio",
]
[[package]]
name = "slhx-techdemo"
version = "0.1.0"
dependencies = [
"axum",
"futures-util",
"hemplate",
"scraper",
"slhx",
"slhx-axum",
"slhx-build",
"slhx-test",
"thirtyfour",
"tokio",
]
[[package]]
name = "slhx-test"
version = "0.1.0"
dependencies = [
"slhx-core",
]
[[package]]
name = "slhx-v0-examples"
version = "0.1.0"
dependencies = [
"axum",
"futures-util",
"hemplate",
"scraper",
"slhx",
"slhx-axum",
"slhx-build",
"slhx-test",
"tokio",
]
[[package]]
name = "slhx-xtask"
version = "0.1.0"
[[package]]
name = "smallvec"
version = "1.15.1"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["slhx", "slhx-core", "slhx-derive", "slhx-js", "slhx-axum", "slhx-build", "slhx-test", "slhx-xtask", "examples/v0", "examples/kanban", "examples/techdemo"]
members = ["hemx", "hemx-core", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-xtask", "examples/v0", "examples/kanban", "examples/techdemo"]
[workspace.package]
version = "0.1.0"
+126 -126
View File
@@ -1,11 +1,11 @@
# slhx — Semantic, Laterally HX
# hemx — Semantic, Laterally HX
slhx makes server-rendered HTML feel like it grew just enough interactivity.
hemx makes server-rendered HTML feel like it grew just enough interactivity.
App authors change state in Rust, render hemplate partials, and return generated
UI intent; the runtime swaps generated slots without selectors, a VDOM, or a
client app state framework.
> **hemplate owns syntax. hemplate emits surface. slhx consumes surface. slhx owns semantics. JS applies effects.**
> **hemplate owns syntax. hemplate emits surface. hemx consumes surface. hemx owns semantics. JS applies effects.**
---
@@ -31,13 +31,13 @@ client app state framework.
## pitch
### req: pitch/001
001 slhx is checked hypermedia for Rust. Write `.heml`, write Rust handlers, return generated UI commands. The compiler checks every cross-file reference. The browser runtime only sees lowered ids and effect bytes. No JS app code is required for ordinary server-first apps. [north_star]
001 hemx is checked hypermedia for Rust. Write `.heml`, write Rust handlers, return generated UI commands. The compiler checks every cross-file reference. The browser runtime only sees lowered ids and effect bytes. No JS app code is required for ordinary server-first apps. [north_star]
### req: pitch/002
002 No CSS selectors. No hx-* strings. No virtual DOM. No client framework. No hidden global proxy magic. No component hydration. SSR state bootstrap via `data-slhx-st` is allowed, but the browser never reconstructs a component tree. [north_star]
002 No CSS selectors. No hx-* strings. No virtual DOM. No client framework. No hidden global proxy magic. No component hydration. SSR state bootstrap via `data-hemx-st` is allowed, but the browser never reconstructs a component tree. [north_star]
### req: pitch/003
003 slhx replaces React/Vue not with a UI framework, but with a compiler contract: hemplate knows the surface, Rust knows the types, slhx knows the effects, the browser only executes commands. [north_star]
003 hemx replaces React/Vue not with a UI framework, but with a compiler contract: hemplate knows the surface, Rust knows the types, hemx knows the effects, the browser only executes commands. [north_star]
### req: pitch/004
004 The north-star feel: **boring server-rendered HTML with typed, selectorless partial swaps.** Short handler bodies, compile-checked HTML contracts, plain Rust state changes, hemplate-rendered partials, and a tiny runtime that applies effects. [north_star]
@@ -47,7 +47,7 @@ client app state framework.
## canonical_authoring
### req: canonical/001
001 The canonical app shape is templates plus Rust, not a frontend folder: `.heml` files declare `data-slhx-root`, `data-slhx-slot`, `data-slhx-handle`, `data-slhx-form`, `h-key`, optional pending/page/island facts, and handlers return generated UI commands. Ordinary app code avoids selectors, numeric ids, raw effects, wire formats, manual registries/form parsing, raw `SafeHtml`, and raw render calls. Plain CSS owns appearance. [north_star]
001 The canonical app shape is templates plus Rust, not a frontend folder: `.heml` files declare `data-hemx-root`, `data-hemx-slot`, `data-hemx-handle`, `data-hemx-form`, `h-key`, optional pending/page/island facts, and handlers return generated UI commands. Ordinary app code avoids selectors, numeric ids, raw effects, wire formats, manual registries/form parsing, raw `SafeHtml`, and raw render calls. Plain CSS owns appearance. [north_star]
### req: canonical/002
002 Typed partial swaps are the primary UX, not an advanced feature: handlers change domain state in Rust, convert domain values into view values, render hemplate partials through generated helpers, and place them into generated targets. The real primitive is generated target + rendered partial + swap kind. [north_star]
@@ -65,20 +65,20 @@ client app state framework.
006 There is no separate beginner API and expert API. The simple generated shape is canonical and should scale: generated slots, partials, forms, class constants, islands/events, and page helpers are normal. Explicit primitives, raw targets, raw HTML, raw effects, manual registries/form parsing, low-level ids/opcodes, wire formats, and raw routes remain named escape hatches or internals around the same render/target/effect/transport model. [north_star]
### req: canonical/007
007 Opaque islands are explicit leaf adapters: templates declare `data-slhx-island` and optional generated handles/events; server code may emit snapshots/events such as `ui::game.emit(event)`, while island JS owns only high-frequency local behavior. Islands do not introduce a component runtime, client state graph, VDOM, or second UI model. [north_star]
007 Opaque islands are explicit leaf adapters: templates declare `data-hemx-island` and optional generated handles/events; server code may emit snapshots/events such as `ui::game.emit(event)`, while island JS owns only high-frequency local behavior. Islands do not introduce a component runtime, client state graph, VDOM, or second UI model. [north_star]
### req: canonical/008
008 Offline/PWA support is opt-in adapter territory. Server-first slhx may fail interactions while offline; cached shells and local-sync queues live in crates such as `slhx-pwa` or `slhx-sync`, reuse generated slots/effects, queue explicit patches, and reconcile with server-canonical effects. Core slhx must not gain a mandatory client state graph, scheduler, CRDT, or local app runtime. [north_star]
008 Offline/PWA support is opt-in adapter territory. Server-first hemx may fail interactions while offline; cached shells and local-sync queues live in crates such as `hemx-pwa` or `hemx-sync`, reuse generated slots/effects, queue explicit patches, and reconcile with server-canonical effects. Core hemx must not gain a mandatory client state graph, scheduler, CRDT, or local app runtime. [north_star]
---
## modes
### req: mode/001
001 slhx has one core authoring loop: render a partial and place it into a generated target with a swap kind. HTTP handlers, page navigation, push streams, and island events are transport/adapters around that loop.
001 hemx has one core authoring loop: render a partial and place it into a generated target with a swap kind. HTTP handlers, page navigation, push streams, and island events are transport/adapters around that loop.
### req: mode/002
002 Page Enhancer mode is a specialized partial swap for navigation: it updates generated page/content/title/nav targets and adds history, scroll, shell, and fallback behavior. Authors use real anchors with `data-slhx-nav` or `data-slhx-boost`; no user-authored handler is required for ordinary navigation.
002 Page Enhancer mode is a specialized partial swap for navigation: it updates generated page/content/title/nav targets and adds history, scroll, shell, and fallback behavior. Authors use real anchors with `data-hemx-nav` or `data-hemx-boost`; no user-authored handler is required for ordinary navigation.
### req: mode/003
003 Interaction Handler mode handles forms, buttons, typed params, and generated partial/text/form/island effects through Rust handlers.
@@ -97,7 +97,7 @@ client app state framework.
002 The happy path is: write `.heml`, write a Rust handler, return generated partial/text/form/page/island commands. No manual ids, no manual registry, no manual serialization, no CSS selector targets, no raw render calls, and no manual JavaScript for ordinary app UI. [north_star]
### req: dx/003
003 Public APIs are generated around the user's names. If the template declares `data-slhx-slot="todo_list"`, the user gets `slots::todo_list`, not `SlotId(12)`.
003 Public APIs are generated around the user's names. If the template declares `data-hemx-slot="todo_list"`, the user gets `slots::todo_list`, not `SlotId(12)`.
### req: dx/004
004 Common handlers must fit in a small function. Advanced contexts (`EffectWriter`, raw ops, custom encoders) exist but are not part of the beginner path.
@@ -125,7 +125,7 @@ client app state framework.
002 Generated modules are imported through a prelude or component namespace. Users should not manually include `$OUT_DIR` files in normal apps.
### req: ceremony/003
003 `build.rs` must be a one-liner for the common case: `fn main() { slhx_build::app().run().unwrap(); }`
003 `build.rs` must be a one-liner for the common case: `fn main() { hemx_build::app().run().unwrap(); }`
### req: ceremony/004
004 No API may require users to write numeric ids, raw ResourceIds, raw opcodes, or serialized payloads in normal code.
@@ -151,10 +151,10 @@ client app state framework.
## page_swap
### req: page_swap/001
001 Page swapping is a first-class specialization of partial swapping: render a page partial, place it into generated page targets, then apply history/title/scroll/shell behavior. Authors mark real anchors with `data-slhx-nav`; links keep valid `href` and work without JS. Missing or empty static `href` on a `data-slhx-nav` anchor is a build error.
001 Page swapping is a first-class specialization of partial swapping: render a page partial, place it into generated page targets, then apply history/title/scroll/shell behavior. Authors mark real anchors with `data-hemx-nav`; links keep valid `href` and work without JS. Missing or empty static `href` on a `data-hemx-nav` anchor is a build error.
### req: page_swap/002
002 A `data-slhx-nav` click fetches the target URL as a slhx partial request. The response is conceptually `ui::content.replace(page)` plus optional generated nav/title targets and a `Navigate` effect; it must not introduce selector targeting or a second page-specific UI model.
002 A `data-hemx-nav` click fetches the target URL as a hemx partial request. The response is conceptually `ui::content.replace(page)` plus optional generated nav/title targets and a `Navigate` effect; it must not introduce selector targeting or a second page-specific UI model.
### req: page_swap/003
003 Page swapping uses generated targets, not CSS selectors. The default content target is the generated slot named `content`, not `#content`; explicit page helpers such as `ui::content.page(req, view)` or `request.page_html(ui::content.render(view), shell)` are adapters around the same partial-swap primitive.
@@ -163,26 +163,26 @@ client app state framework.
004 Ordinary page navigation must not require user-authored handlers. Explicit navigation handlers are available only when custom application logic is needed, and they still return generated target/page commands.
### req: page_swap/005
005 Browser back/forward is supported. On `popstate`, slhx fetches the URL as a partial request and applies the same page-swap update without pushing a new history entry.
005 Browser back/forward is supported. On `popstate`, hemx fetches the URL as a partial request and applies the same page-swap update without pushing a new history entry.
### req: page_swap/006
006 If a page lacks the expected `content` slot, slhx-axum falls back to normal browser navigation in production and emits a diagnostic in development.
006 If a page lacks the expected `content` slot, hemx-axum falls back to normal browser navigation in production and emits a diagnostic in development.
### req: page_swap/007
007 `data-slhx-boost` progressively enhances descendant same-origin anchors and forms. It is a container convention, not a replacement for `data-slhx-nav` on an anchor or `data-slhx-handle` on a form; placing it directly on static anchors or forms is a build error. Links behave like `data-slhx-nav`; forms behave like slhx form submissions. External links, downloads, new-tab links, and modified-clicks preserve native browser behavior.
007 `data-hemx-boost` progressively enhances descendant same-origin anchors and forms. It is a container convention, not a replacement for `data-hemx-nav` on an anchor or `data-hemx-handle` on a form; placing it directly on static anchors or forms is a build error. Links behave like `data-hemx-nav`; forms behave like hemx form submissions. External links, downloads, new-tab links, and modified-clicks preserve native browser behavior.
---
## htmx_equivalents
### req: htmx/001
001 slhx replaces common HTMX use-cases through typed equivalents, not HTMX syntax.
001 hemx replaces common HTMX use-cases through typed equivalents, not HTMX syntax.
### req: htmx/002
002 Easy equivalents must exist for: generated target replacement, append/prepend/remove, boosted links/forms, page swap, form submit, loading indicators, confirmation, debounce/throttle, polling, history navigation, multi-target updates, response events, SSE/push, validation errors, modals, toasts, table rows, SVG fragments, and form error regions.
### req: htmx/003
003 slhx core deliberately does not clone selector-based HTMX features: `hx-target` selectors, `hx-select`, `hx-include` selectors, `closest/find/this` target strings, or trigger mini-languages. Equivalent patterns use generated targets, typed params, forms, explicit handlers, and page/push adapters around partial swaps.
003 hemx core deliberately does not clone selector-based HTMX features: `hx-target` selectors, `hx-select`, `hx-include` selectors, `closest/find/this` target strings, or trigger mini-languages. Equivalent patterns use generated targets, typed params, forms, explicit handlers, and page/push adapters around partial swaps.
---
@@ -192,7 +192,7 @@ client app state framework.
001 The primary authoring unit is a hemplate component plus adjacent Rust handlers. A component owns a template root, generated slots, generated handles, generated form checks, and source spans.
### req: component/002
002 slhx supports colocated layout: `todo_list.heml` beside `todo_list.rs`, with generated APIs namespaced by component to avoid global symbol soup.
002 hemx supports colocated layout: `todo_list.heml` beside `todo_list.rs`, with generated APIs namespaced by component to avoid global symbol soup.
### req: component/003
003 Generated APIs are component-namespaced by default:
@@ -200,20 +200,20 @@ client app state framework.
Category modules such as `targets`, `handles`, and `forms` remain available for organization/compatibility, while raw slot constants live under `advanced::slots`; global exports (`ui::slots::*`, `ui::handles::*`, `ui::components::*`) are opt-in only.
### req: component/004
004 `#[slhx::surface]` bridges generated code into a user module. Users write `#[slhx::surface] mod ui {}` instead of `include!(concat!(env!("OUT_DIR"), ...))`. slhx-build emits `slhx.generated.rs` which the macro expands in place. No direct `$OUT_DIR` includes in user-authored source.
004 `#[hemx::surface]` bridges generated code into a user module. Users write `#[hemx::surface] mod ui {}` instead of `include!(concat!(env!("OUT_DIR"), ...))`. hemx-build emits `hemx.generated.rs` which the macro expands in place. No direct `$OUT_DIR` includes in user-authored source.
### req: component/005
005 An optional `#[slhx::component]` macro may validate that every handle declared in the template Surface has a corresponding `#[slhx::handler]` within the annotated module. This is the only macro with cross-handler visibility inside a single module; it remains strictly local. Missing handlers without `#[slhx::component]` are caught at app mount or test time, not `cargo check`.
005 An optional `#[hemx::component]` macro may validate that every handle declared in the template Surface has a corresponding `#[hemx::handler]` within the annotated module. This is the only macro with cross-handler visibility inside a single module; it remains strictly local. Missing handlers without `#[hemx::component]` are caught at app mount or test time, not `cargo check`.
### req: component/006
006 `#[derive(Hemplate)]` structs are natural component boundaries. slhx_build discovers them automatically; no additional configuration is required for most apps.
006 `#[derive(Hemplate)]` structs are natural component boundaries. hemx_build discovers them automatically; no additional configuration is required for most apps.
---
## surface
### req: surface/001
001 `hemplate_build` may scan `.heml` files and emit `$OUT_DIR/hemplate.surface.postcard` (postcard-encoded, deterministic, versioned). `slhx_build` may also be called in-process with precomputed hemplate Surface facts or, for simple build scripts, ask hemplate to parse/extract the Surface before slhx interprets it. slhx owns no independent `.heml` parser.
001 `hemplate_build` may scan `.heml` files and emit `$OUT_DIR/hemplate.surface.postcard` (postcard-encoded, deterministic, versioned). `hemx_build` may also be called in-process with precomputed hemplate Surface facts or, for simple build scripts, ask hemplate to parse/extract the Surface before hemx interprets it. hemx owns no independent `.heml` parser.
### req: surface/002
002 The Surface contains: nodes (NodeId, parent, scope, element, attrs, source span), scopes (ScopeKind: Root | If | Match | Case | For { binding, key_expr }), forms (form controls with raw HTML types), and component uses.
@@ -225,7 +225,7 @@ Category modules such as `targets`, `handles`, and `forms` remain available for
004 Form controls in the Surface carry raw HTML facts: `ControlKind::Text`, `ControlKind::Number { min, max, step }`, `ControlKind::Checkbox`, `ControlKind::Select { multiple, options }`, etc. No Rust type mapping lives in hemplate.
### req: surface/005
005 Loop scopes expose the binding name and an optional `key_expr` (e.g. `todo.id`). hemplate does not enforce key usage; it only records it for consumers. slhx_build enforces key presence only when a slhx-addressable node appears inside the loop.
005 Loop scopes expose the binding name and an optional `key_expr` (e.g. `todo.id`). hemplate does not enforce key usage; it only records it for consumers. hemx_build enforces key presence only when a hemx-addressable node appears inside the loop.
### req: surface/006
006 Surface schema is versioned (`schema_version: u32`). Postcard encoding, no JSON. `no_std`-compatible schema definition so any tool can read it without heavy dependencies.
@@ -236,16 +236,16 @@ Category modules such as `targets`, `handles`, and `forms` remain available for
### req: surface/008
008 The Surface records hemplate structural directives as first-class facts:
`h-for`, `h-key`, `h-if`, `h-else-if`, `h-else`, `h-match`, `h-case`,
dynamic `+attr` bindings, and interpolated attr/text expressions. slhx consumes
these facts; if a build script points slhx_build at `.heml` files, hemplate still performs parsing and Surface extraction.
dynamic `+attr` bindings, and interpolated attr/text expressions. hemx consumes
these facts; if a build script points hemx_build at `.heml` files, hemplate still performs parsing and Surface extraction.
### req: surface/009
009 Raw/pre-rendered HTML insertions are opaque Surface holes. The parent
element is present; slhx_build emits the appropriate rendering call.
element is present; hemx_build emits the appropriate rendering call.
### req: surface/010
010 Attribute values preserve their origin: static literal, dynamic `+attr`
binding, or interpolated expression. slhx-build uses this to determine whether
binding, or interpolated expression. hemx-build uses this to determine whether
a `data-*` handle param is statically known or runtime-extracted.
---
@@ -253,22 +253,22 @@ a `data-*` handle param is statically known or runtime-extracted.
## codegen
### req: codegen/001
001 `slhx_build` generates three artifacts from the generic Surface IR: (a) `slhx.generated.rs` containing ergonomic resource modules (`slots`, `targets`, `handles`, `forms`, `atoms`), (b) `slhx.syms` for proc-macro validation, (c) runtime id-lowering tables. `slhx_build` interprets tool-specific conventions (`data-slhx-*`, `h-for`, `h-key`, form controls) from the Surface. [north_star]
001 `hemx_build` generates three artifacts from the generic Surface IR: (a) `hemx.generated.rs` containing ergonomic resource modules (`slots`, `targets`, `handles`, `forms`, `atoms`), (b) `hemx.syms` for proc-macro validation, (c) runtime id-lowering tables. `hemx_build` interprets tool-specific conventions (`data-hemx-*`, `h-for`, `h-key`, form controls) from the Surface. [north_star]
### req: codegen/002
002 Generated view modules expose ergonomic root-level target objects and commands that hide render/lower details: text slots provide `set(text)`, singleton partial slots provide `replace(view)`/`clear()`, keyed collection slots provide `append(view)`, `prepend(view)`, `replace(view)`, `remove(key_or_view)`, and forms provide `clear()`/`clear(field)`/`focus(field)`. String-keyed target objects accept displayable domain ids without caller-side `.to_string()` noise. Commands return `impl IntoEffect`, compose in plain Rust, preserve generated lowering, and fail to generate when the template lacks enough facts to infer the slot, key, form, or renderable view type.
### req: codegen/003
003 Generated module `handles` exports typed constants: `Handle<I>` where `I` is `Form<T>`, a param type, or `()`. Users rarely reference handles directly; they are consumed by `#[slhx::handler]` for validation.
003 Generated module `handles` exports typed constants: `Handle<I>` where `I` is `Form<T>`, a param type, or `()`. Users rarely reference handles directly; they are consumed by `#[hemx::handler]` for validation.
### req: codegen/004
004 Generated module `forms` exports `FormContract` metadata (field names, HTML control kinds, required). `#[slhx::handler]` compares the `Form<T>` type against the contract. Domain types (`Email`, `TodoId`) remain user-authored; no auto-generated structs.
004 Generated module `forms` exports `FormContract` metadata (field names, HTML control kinds, required). `#[hemx::handler]` compares the `Form<T>` type against the contract. Domain types (`Email`, `TodoId`) remain user-authored; no auto-generated structs.
### req: codegen/005
005 Generated module `atoms` exports `Atom<T>` for values that must be addressable, bootstrapped, or synced. Ordinary Rust fields on app/components are not automatically atoms.
### req: codegen/006
006 `slhx-build` discovers `data-slhx-on` event names from hemplate Surface inputs and emits generated `slhx::EventName` constants. Event constants are metadata for checked Rust authoring and diagnostics; they do not create a trigger mini-language or new browser runtime semantics. [north_star]
006 `hemx-build` discovers `data-hemx-on` event names from hemplate Surface inputs and emits generated `hemx::EventName` constants. Event constants are metadata for checked Rust authoring and diagnostics; they do not create a trigger mini-language or new browser runtime semantics. [north_star]
---
@@ -303,7 +303,7 @@ a `data-*` handle param is statically known or runtime-extracted.
003 `Insert`, `Remove`, and `Move` operate on keyed collection resources. They require a key type checked by generated `KeyedSlot<K, T>` wrappers.
### req: effect_algebra/004
004 `Navigate` changes browser history or represents a server redirect. Route matching remains outside slhx core.
004 `Navigate` changes browser history or represents a server redirect. Route matching remains outside hemx core.
### req: effect_algebra/005
005 `Emit` dispatches a native `CustomEvent` and is the only raw JS interop primitive in core.
@@ -331,7 +331,7 @@ in core. `Effect::event` lowers to the canonical `Emit` opcode.
### req: scope/001
001 `Scope` is a first-class primitive. Keyed loops (`h-for`) create keyed
dynamic scopes and require `h-key` for slhx-addressable nodes. Conditional
dynamic scopes and require `h-key` for hemx-addressable nodes. Conditional
branches (`h-if`, `h-else-if`, `h-else`, `h-match`, `h-case`) create optional
presence scopes. Component instances, modals, tabs, and nested forms are scoped
resources. Concrete runtime targets are addressed through `ResourceRef`
@@ -342,10 +342,10 @@ resources. Concrete runtime targets are addressed through `ResourceRef`
## list
### req: list/001
001 Any `data-slhx-slot` or `data-slhx-handle` inside a hemplate `h-for` scope requires a stable key. Preferred syntax: `<template h-for="item in &self.items" h-key="item.id"> ... </template>`. Without a key, slhx-addressable nodes inside the loop are rejected at build time. Keyed identity is `ResourceRef { resource: ResourceId, scope: Some(ScopeKey::KeyValue(...)) }`.
001 Any `data-hemx-slot` or `data-hemx-handle` inside a hemplate `h-for` scope requires a stable key. Preferred syntax: `<template h-for="item in &self.items" h-key="item.id"> ... </template>`. Without a key, hemx-addressable nodes inside the loop are rejected at build time. Keyed identity is `ResourceRef { resource: ResourceId, scope: Some(ScopeKey::KeyValue(...)) }`.
### req: list/002
002 Slots inside a keyed loop receive a composite identity. hemplate records `key_expr` in the Surface; slhx implements keyed slot lookups.
002 Slots inside a keyed loop receive a composite identity. hemplate records `key_expr` in the Surface; hemx implements keyed slot lookups.
### req: list/003
003 Generated helpers for keyed slots are `append(view)`, `prepend(view)`, `replace(view)`, and `remove(key_or_view)` when the template and view type provide an unambiguous `h-key`. Compatibility functions such as `append(keyed_slot, key, view)` may exist as explicit low-level forms. Mismatch between key type and slot key type is a compile-time error, and missing/ambiguous keys are build errors with template spans.
@@ -355,7 +355,7 @@ resources. Concrete runtime targets are addressed through `ResourceRef`
## form
### req: form/001
001 Forms are source of truth in HTML. hemplate Surface exports form shape (controls, names, required, types). slhx checks compatibility with Rust `Form<T>` types through user-authored `#[slhx::form("...")]` domain structs and generated form metadata. No auto-generated structs; domain types (e.g. `Email`) are first-class. The Surface describes; Rust owns; slhx checks.
001 Forms are source of truth in HTML. hemplate Surface exports form shape (controls, names, required, types). hemx checks compatibility with Rust `Form<T>` types through user-authored `#[hemx::form("...")]` domain structs and generated form metadata. No auto-generated structs; domain types (e.g. `Email`) are first-class. The Surface describes; Rust owns; hemx checks.
### req: form/002
002 The handle id is carried as `__h` in POST `application/x-www-form-urlencoded`. A JSON body is allowed at the integration boundary (`application/json`) only if the handler accepts it; core uses form encoding.
@@ -364,7 +364,7 @@ resources. Concrete runtime targets are addressed through `ResourceRef`
003 Handler receives `form: Form<CreateTodo>`. Validation errors target `(FormId, field_name)` or generated control ids. The runtime maps them to originating form controls via control ids derived from Surface `NodeId`, not via slot ids.
### req: form/004
004 Form compatibility checks validate field presence, optionality, multiplicity, and parser availability. Parser availability means the submitted value type implements `slhx::FormValue` (blanket-provided for `FromStr`, or explicitly implemented for custom parsers). Domain validation remains Rust logic (`TryFrom`, custom validators, or handler code).
004 Form compatibility checks validate field presence, optionality, multiplicity, and parser availability. Parser availability means the submitted value type implements `hemx::FormValue` (blanket-provided for `FromStr`, or explicitly implemented for custom parsers). Domain validation remains Rust logic (`TryFrom`, custom validators, or handler code).
### req: form/005
005 HTML control facts are lower bounds, not complete domain semantics.
@@ -385,39 +385,39 @@ what a valid business email is.
002 Form effects target generated form/control ids, not CSS selectors.
### req: form_effects/003
003 Templates may declare error display targets with `data-slhx-error-for="field"`. Generated form error effects render into those targets when present and fall back to control validity APIs otherwise.
003 Templates may declare error display targets with `data-hemx-error-for="field"`. Generated form error effects render into those targets when present and fall back to control validity APIs otherwise.
---
## wire
### req: wire/001
001 Authoring HTML uses symbolic `data-slhx-*` attributes. Rendered runtime HTML lowers these to compact numeric metadata: `data-hid`, `data-sid`, optional `data-key`, optional atom ids, optional form/control ids, and `data-slhx-st` for state bootstrap. The browser never sees handler or slot names. `data-slhx-root` marks a scoped root boundary.
001 Authoring HTML uses symbolic `data-hemx-*` attributes. Rendered runtime HTML lowers these to compact numeric metadata: `data-hid`, `data-sid`, optional `data-key`, optional atom ids, optional form/control ids, and `data-hemx-st` for state bootstrap. The browser never sees handler or slot names. `data-hemx-root` marks a scoped root boundary.
### req: wire/002
002 POST bodies carry `application/x-www-form-urlencoded` with distinguished field `__h` (handle id). Server routes by numeric id, not by URL path.
### req: wire/003
003 HTTP interaction responses may be `text/html` fragments containing `<template data-slhx>...`. Push streams use `application/slhx` or transport-specific event frames carrying serialized `EffectBatch`.
003 HTTP interaction responses may be `text/html` fragments containing `<template data-hemx>...`. Push streams use `application/hemx` or transport-specific event frames carrying serialized `EffectBatch`.
### req: wire/004
004 Server push is orthogonal: integration crates stream postcard `EffectBatch` over SSE or WebSocket connections. slhx core owns the effect bytes; transport and connection management are integration concerns.
004 Server push is orthogonal: integration crates stream postcard `EffectBatch` over SSE or WebSocket connections. hemx core owns the effect bytes; transport and connection management are integration concerns.
### req: wire/005
005 No JSON anywhere in slhx-internal artifacts. Public request/response envelopes use `application/x-www-form-urlencoded`; effects and symbols use postcard. `application/json` is acceptable only at integration boundaries.
005 No JSON anywhere in hemx-internal artifacts. Public request/response envelopes use `application/x-www-form-urlencoded`; effects and symbols use postcard. `application/json` is acceptable only at integration boundaries.
---
## abi
### req: abi/001
001 Surface IR, slhx symbols, generated Rust API, EffectBatch wire schema, and JS runtime each carry explicit schema/ABI versions.
001 Surface IR, hemx symbols, generated Rust API, EffectBatch wire schema, and JS runtime each carry explicit schema/ABI versions.
### req: abi/002
002 slhx_build emits a build fingerprint derived from Surface schema version, resource id allocation, EffectBatch ABI version, and runtime ABI version.
002 hemx_build emits a build fingerprint derived from Surface schema version, resource id allocation, EffectBatch ABI version, and runtime ABI version.
### req: abi/003
003 The server includes the slhx build fingerprint in initial roots. The runtime compares it with its own fingerprint before applying EffectBatches.
003 The server includes the hemx build fingerprint in initial roots. The runtime compares it with its own fingerprint before applying EffectBatches.
### req: abi/004
004 On fingerprint mismatch, the runtime refuses partial updates and falls back to full page navigation or reload. Silent mismatch is forbidden.
@@ -430,16 +430,16 @@ what a valid business email is.
## runtime
### req: runtime/001
001 Every slhx tree must declare a root boundary via `data-slhx-root` on an ancestor element. The JS runtime resolves lookups within that root only. Multiple independent slhx apps/widgets/modals may coexist on the same document without ID collision. [north_star]
001 Every hemx tree must declare a root boundary via `data-hemx-root` on an ancestor element. The JS runtime resolves lookups within that root only. Multiple independent hemx apps/widgets/modals may coexist on the same document without ID collision. [north_star]
### req: runtime/002
002 JS runtime attaches a single delegated listener per event type on each `data-slhx-root`. No per-node listeners. Dispatch resolves target via `data-hid` / `data-sid` attributes on the event path scoped to its root.
002 JS runtime attaches a single delegated listener per event type on each `data-hemx-root`. No per-node listeners. Dispatch resolves target via `data-hid` / `data-sid` attributes on the event path scoped to its root.
### req: runtime/003
003 The core JS runtime target is under 5KB minified+gzipped. It remains a tiny op interpreter (no selectors, no VDOM, no scheduler, no expressions). It reads postcard `EffectBatch` bytes and applies them as DOM operations. Optional sync/transition/WASM helpers are separate files.
### req: runtime/004
004 Core runtime exposes a minimal version/fingerprint handshake only. Capability negotiation belongs to integration crates such as `slhx-wasm`, `slhx-sync`, and `slhx-transition`.
004 Core runtime exposes a minimal version/fingerprint handshake only. Capability negotiation belongs to integration crates such as `hemx-wasm`, `hemx-sync`, and `hemx-transition`.
---
@@ -468,45 +468,45 @@ what a valid business email is.
## axum_integration
### req: axum/001
001 slhx-axum supports the common full-shell/partial pattern as an adapter around generated partial swaps. Full-page requests are wrapped in a user-provided Shell; slhx partial requests may return a rendered partial for a generated target or an EffectBatch. Page helpers add shell/title/history/fallback behavior without changing the render/target/effect model.
001 hemx-axum supports the common full-shell/partial pattern as an adapter around generated partial swaps. Full-page requests are wrapped in a user-provided Shell; hemx partial requests may return a rendered partial for a generated target or an EffectBatch. Page helpers add shell/title/history/fallback behavior without changing the render/target/effect model.
### req: axum/002
002 Existing Axum routes remain normal Axum routes. slhx does not own routing. slhx-axum only mounts handler dispatch, runtime assets, and optional push endpoints.
002 Existing Axum routes remain normal Axum routes. hemx does not own routing. hemx-axum only mounts handler dispatch, runtime assets, and optional push endpoints.
### req: axum/003
003 Interactive fragments that would traditionally be implemented as `/demo/...` HTMX endpoints should be expressible as Rust handlers returning generated target commands. Integration registration should read as interactions over generated handles and page/partial helpers, not low-level registry wiring.
### req: axum/004
004 Query-string demo endpoints may be migrated to typed handler params from `data-*` attributes or forms. `Query<T>` remains available in normal Axum routes but is not the slhx happy path.
004 Query-string demo endpoints may be migrated to typed handler params from `data-*` attributes or forms. `Query<T>` remains available in normal Axum routes but is not the hemx happy path.
---
## auth
### req: auth/001
001 Auth is not part of slhx core. Authentication, authorization, sessions, cookies, CSRF, and permissions are handled by axum/tower extractors and middleware. slhx handlers may accept typed auth/context extractors.
001 Auth is not part of hemx core. Authentication, authorization, sessions, cookies, CSRF, and permissions are handled by axum/tower extractors and middleware. hemx handlers may accept typed auth/context extractors.
### req: auth/002
002 slhx-axum preserves normal HTTP auth semantics. Unauthorized handlers may return normal HTTP 401/403, a navigation effect, or an application-defined auth failure effect.
002 hemx-axum preserves normal HTTP auth semantics. Unauthorized handlers may return normal HTTP 401/403, a navigation effect, or an application-defined auth failure effect.
### req: auth/003
003 Progressive enhancement is preserved: login/logout forms remain valid HTML forms. With JS disabled, the server performs normal redirects; with slhx enabled, handlers may return EffectBatch responses.
003 Progressive enhancement is preserved: login/logout forms remain valid HTML forms. With JS disabled, the server performs normal redirects; with hemx enabled, handlers may return EffectBatch responses.
### req: auth/004
004 CSRF is integration-level. slhx-axum must allow normal hidden form fields, cookies, and extractor-based CSRF validation. slhx core does not define CSRF policy.
004 CSRF is integration-level. hemx-axum must allow normal hidden form fields, cookies, and extractor-based CSRF validation. hemx core does not define CSRF policy.
### req: auth/005
005 slhx requests preserve standard HTTP credentials semantics. Cookies, SameSite policy, Authorization headers, and session middleware remain framework/browser concerns.
005 hemx requests preserve standard HTTP credentials semantics. Cookies, SameSite policy, Authorization headers, and session middleware remain framework/browser concerns.
---
## push
### req: push/001
001 Server push streams canonical postcard EffectBatch over SSE or WebSocket. slhx core owns the EffectBatch schema, not the transport.
001 Server push streams canonical postcard EffectBatch over SSE or WebSocket. hemx core owns the EffectBatch schema, not the transport.
### req: push/002
002 SSE/WebSocket connections are authenticated by the server framework before stream creation. slhx does not define auth semantics for streams.
002 SSE/WebSocket connections are authenticated by the server framework before stream creation. hemx does not define auth semantics for streams.
### req: push/003
003 Push swaps are ordinary partial swaps carried over SSE/WebSocket: streamed effects target generated slots, atoms, or island events such as `ui::feed.prepend(event)` or `ui::game.emit(snapshot)`. No selector-based `sse-swap` semantics in core.
@@ -518,14 +518,14 @@ 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` 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.
006 `data-hemx-sse` is declared on `data-hemx-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.
---
## sync
### req: sync/001
001 `slhx-sync` is an optional crate for collaborative / multiplayer state. Provides presence tracking, patch reconciliation, conflict resolution (server-authoritative), and offline queueing. Not part of core.
001 `hemx-sync` is an optional crate for collaborative / multiplayer state. Provides presence tracking, patch reconciliation, conflict resolution (server-authoritative), and offline queueing. Not part of core.
### req: sync/002
002 `SyncEffect::send_patch(atom, patch)` queues a state diff for server sync. If offline, the patch is stored in a local queue and sent when connection resumes. If online, it is sent immediately via WebSocket/SSE.
@@ -537,13 +537,13 @@ what a valid business email is.
004 `SyncEffect::broadcast(channel, effect_batch)` sends an `EffectBatch` to all subscribers of a named channel. Used for presence updates and live collaboration. The server framework manages the transport (WS/SSE).
### req: sync/005
005 `#[slhx_sync::presence]` is an attribute macro on functions that return `impl IntoEffect` when a user joins or leaves a shared session. Emits `SyncEffect::broadcast` over a presence channel scoped to the session.
005 `#[hemx_sync::presence]` is an attribute macro on functions that return `impl IntoEffect` when a user joins or leaves a shared session. Emits `SyncEffect::broadcast` over a presence channel scoped to the session.
### req: sync/006
006 `SyncEffect::ack(atom)` acknowledges a successful server-side mutation, allowing the client to clear its local optimistic queue for that atom.
### req: sync/007
007 `slhx-sync` uses a flat patch model per atom, not CRDT by default. Server is authoritative; clients apply server-canonical state on conflict. Optional CRDT backend may be provided by a future `slhx-crdt` crate.
007 `hemx-sync` uses a flat patch model per atom, not CRDT by default. Server is authoritative; clients apply server-canonical state on conflict. Optional CRDT backend may be provided by a future `hemx-crdt` crate.
### req: sync/008
008 Sync is bidirectional state reconciliation built on top of push/transport. It is not required for server-sent dashboards, notifications, or live status updates.
@@ -553,28 +553,28 @@ what a valid business email is.
## interop
### req: interop/001
001 `Effect::event` and generated event helpers are the single slhx-to-widget bridge. External widgets, charts, games, maps, Alpine/Svelte islands, and Web Components listen via native `CustomEvent`; slhx core does not inspect their state, rendering internals, or framework lifecycle. [north_star]
001 `Effect::event` and generated event helpers are the single hemx-to-widget bridge. External widgets, charts, games, maps, Alpine/Svelte islands, and Web Components listen via native `CustomEvent`; hemx core does not inspect their state, rendering internals, or framework lifecycle. [north_star]
### req: interop/002
002 Web Components and custom elements are valid opaque leaf nodes. slhx does not inspect shadow DOM or mutate inside custom elements unless the author explicitly exposes slhx-owned slots/handles at the boundary. Escape hatches are leaves, never app foundations.
002 Web Components and custom elements are valid opaque leaf nodes. hemx does not inspect shadow DOM or mutate inside custom elements unless the author explicitly exposes hemx-owned slots/handles at the boundary. Escape hatches are leaves, never app foundations.
### req: interop/003
003 WASM islands and third-party framework islands are explicit leaf boundaries. slhx may replace the island root as a generated target, but it does not manage inside it; slhx owns the generated slot/island boundary, Alpine/Svelte/Web Components/hand-written widgets own the inside, and events cross the boundary. Commands flow widget-to-slhx through explicit generated handles or `slhx.send(...)`, and server-to-widget through generated event helpers such as `ui::chart.emit(snapshot)`.
003 WASM islands and third-party framework islands are explicit leaf boundaries. hemx may replace the island root as a generated target, but it does not manage inside it; hemx owns the generated slot/island boundary, Alpine/Svelte/Web Components/hand-written widgets own the inside, and events cross the boundary. Commands flow widget-to-hemx through explicit generated handles or `hemx.send(...)`, and server-to-widget through generated event helpers such as `ui::chart.emit(snapshot)`.
### req: interop/004
004 Existing `hx-*` attributes are treated as ordinary raw attributes in the hemplate Surface without slhx semantics. An optional `slhx-htmx-migrate` tool may read Surface `hx-*` attrs and suggest equivalent `data-slhx-*` handlers/effects.
004 Existing `hx-*` attributes are treated as ordinary raw attributes in the hemplate Surface without hemx semantics. An optional `hemx-htmx-migrate` tool may read Surface `hx-*` attrs and suggest equivalent `data-hemx-*` handlers/effects.
### req: interop/005
005 HTMX-style response triggers and widget notifications are represented by `Effect::event` or generated event helpers. Events are native `CustomEvent`s scoped to the slhx root.
005 HTMX-style response triggers and widget notifications are represented by `Effect::event` or generated event helpers. Events are native `CustomEvent`s scoped to the hemx root.
### req: interop/006
006 `data-slhx-preserve` is an explicit preserve boundary for rare leaf-widget cases where slhx updates around a subtree without destroying it. Preserve semantics must be simple: preserve the marked subtree identity, do not diff or hydrate inside it, and require authors to mark the boundary deliberately. Preserve must not become a default lifecycle model or a workaround for unclear ownership.
006 `data-hemx-preserve` is an explicit preserve boundary for rare leaf-widget cases where hemx updates around a subtree without destroying it. Preserve semantics must be simple: preserve the marked subtree identity, do not diff or hydrate inside it, and require authors to mark the boundary deliberately. Preserve must not become a default lifecycle model or a workaround for unclear ownership.
### req: interop/007
007 The runtime emits native lifecycle events such as `slhx:before-swap`, `slhx:after-swap`, `slhx:event`, `slhx:connect`, and `slhx:disconnect` so Alpine, Svelte, Web Components, and hand-written widgets can attach at DOM/event boundaries. slhx core must not add framework-specific adapters.
007 The runtime emits native lifecycle events such as `hemx:before-swap`, `hemx:after-swap`, `hemx:event`, `hemx:connect`, and `hemx:disconnect` so Alpine, Svelte, Web Components, and hand-written widgets can attach at DOM/event boundaries. hemx core must not add framework-specific adapters.
### req: interop/008
008 Interop must prevent selector hacks, manual JS reinitialization races, lost widget state after swaps, and double-owned state by making ownership explicit: slhx owns generated server DOM targets, the external widget owns explicit leaves, and events are the supported crossing point. Core must not add selector targeting, hydration compatibility, a client store, or a framework lifecycle to make interop easy.
008 Interop must prevent selector hacks, manual JS reinitialization races, lost widget state after swaps, and double-owned state by making ownership explicit: hemx owns generated server DOM targets, the external widget owns explicit leaves, and events are the supported crossing point. Core must not add selector targeting, hydration compatibility, a client store, or a framework lifecycle to make interop easy.
---
@@ -587,10 +587,10 @@ what a valid business email is.
002 Navigation modes: `Push` (history.pushState), `Replace` (replaceState), `Redirect` (server-side 302). Scroll behaviour: `Preserve`, `Top`, `Element(ResourceRef)`. Title is optional.
### req: nav/003
003 Navigation enhancement preserves real anchors. Links keep valid `href`. slhx may intercept enhanced links through `data-slhx-handle` or `data-slhx-nav`, but without JS the browser performs normal navigation.
003 Navigation enhancement preserves real anchors. Links keep valid `href`. hemx may intercept enhanced links through `data-hemx-handle` or `data-hemx-nav`, but without JS the browser performs normal navigation.
### req: nav/004
004 slhx supports both normal HTTP redirects and navigation effects. HTTP redirects are preferred for full-page/non-enhanced flows; navigation effects are preferred for enhanced interaction responses.
004 hemx supports both normal HTTP redirects and navigation effects. HTTP redirects are preferred for full-page/non-enhanced flows; navigation effects are preferred for enhanced interaction responses.
### req: nav/005
005 Page swap preserves browser history semantics: push, replace, popstate, scroll behavior, and normal modified-click behavior. Back/forward may re-fetch partial content or restore from a bounded cache; correctness must not depend on the cache.
@@ -613,7 +613,7 @@ what a valid business email is.
## view
### req: view/001
001 Slots render view types, not necessarily domain types. Domain-to-view conversion is explicit Rust (`From`, `Into`, or constructor). slhx never assumes a domain object is its own view.
001 Slots render view types, not necessarily domain types. Domain-to-view conversion is explicit Rust (`From`, `Into`, or constructor). hemx never assumes a domain object is its own view.
### req: view/002
002 Generated slot types are allowed to target `Display`, `Hemplate`, or explicit view wrappers. Type errors should suggest the expected renderable view type.
@@ -636,10 +636,10 @@ what a valid business email is.
## test
### req: test/001
001 `EffectWriter` implements a test backend so handlers can be unit-tested without a browser: `slhx_test::run(handler, input)`, `slhx_test::inspect_batch(dispatched_batch)`, and `slhx_test::inspect_wire(bytes)` return an `EffectInspector` with generated-resource assertions such as `has_target(generated_target)`, `updates_text(generated_target)`, `updates_html(generated_target)`, `target_html_containing(generated_target, text)`, `inserts_html_containing(generated_target, key, text)`, `replaces_keyed_html_containing(generated_target, key, text)`, `removes_key(generated_target, key)`, `pushes_to(url)`, `emits(name, payload)`, `emits_containing(name, text)`, `has_slot(slot)`, `has_atom(atom)`, etc. Canonical example tests should prefer generated-target assertions so tests use the same generated target objects as handlers instead of importing raw slot constants or matching raw effects/payloads. Browser/E2E selector helpers are allowed only as low-level test adapters for driving rendered HTML and must be generated or named around public authoring concepts such as handles, targets, forms, nav links, roots, islands, class tokens, or keys; they must not become app authoring APIs or teach selector targeting.
001 `EffectWriter` implements a test backend so handlers can be unit-tested without a browser: `hemx_test::run(handler, input)`, `hemx_test::inspect_batch(dispatched_batch)`, and `hemx_test::inspect_wire(bytes)` return an `EffectInspector` with generated-resource assertions such as `has_target(generated_target)`, `updates_text(generated_target)`, `updates_html(generated_target)`, `target_html_containing(generated_target, text)`, `inserts_html_containing(generated_target, key, text)`, `replaces_keyed_html_containing(generated_target, key, text)`, `removes_key(generated_target, key)`, `pushes_to(url)`, `emits(name, payload)`, `emits_containing(name, text)`, `has_slot(slot)`, `has_atom(atom)`, etc. Canonical example tests should prefer generated-target assertions so tests use the same generated target objects as handlers instead of importing raw slot constants or matching raw effects/payloads. Browser/E2E selector helpers are allowed only as low-level test adapters for driving rendered HTML and must be generated or named around public authoring concepts such as handles, targets, forms, nav links, roots, islands, class tokens, or keys; they must not become app authoring APIs or teach selector targeting.
### req: test/002
002 `#[slhx::component]` may compile without generated Surface files for incremental module-local testing. `#[slhx::surface]` requires `slhx.generated.rs` in `$OUT_DIR` and fails with an actionable diagnostic when generation is missing, because it is the public generated API bridge.
002 `#[hemx::component]` may compile without generated Surface files for incremental module-local testing. `#[hemx::surface]` requires `hemx.generated.rs` in `$OUT_DIR` and fails with an actionable diagnostic when generation is missing, because it is the public generated API bridge.
### req: test/003
003 Generated registries are validated by compile-time tests: missing handler implementations produce test failures with actionable messages.
@@ -658,13 +658,13 @@ what a valid business email is.
001 All symbolic cross-file references visible to build/proc-macro validation are verified at `cargo check`. Unknown handle → hard error. Unknown slot → hard error. Type mismatch between slot and atom → hard error.
### req: check/002
002 Dead/missing handle diagnostics are best-effort by default. With `#[slhx::component]`, dead/missing handlers inside the component are checked at `cargo check`. Without it, missing implementations are caught at app mount or in generated registry tests.
002 Dead/missing handle diagnostics are best-effort by default. With `#[hemx::component]`, dead/missing handlers inside the component are checked at `cargo check`. Without it, missing implementations are caught at app mount or in generated registry tests.
### req: check/003
003 Renaming a slot or handle breaks `cargo check` immediately with a span pointing to the Rust handler or template source.
### req: check/004
004 Root-scoped slot lookup: JS runtime resolves `data-sid` only within the nearest/current `data-slhx-root`.
004 Root-scoped slot lookup: JS runtime resolves `data-sid` only within the nearest/current `data-hemx-root`.
---
@@ -677,13 +677,13 @@ what a valid business email is.
002 Atoms are not reactive by default. Updating an atom does not re-render anything until a handler returns an effect referencing it.
### req: state/003
003 The JS runtime may maintain a narrow atom value table keyed by `AtomId` only for explicit `Atom<T>` resources and SSR bootstrap. This is not an app state framework, component store, cache, or reactive graph. Runtime values are type-erased postcard bytes; types are compile-time only. A deterministic `TypeHash` may be generated by `slhx_build` for diagnostics, but the JS runtime does not depend on Rust `TypeId`.
003 The JS runtime may maintain a narrow atom value table keyed by `AtomId` only for explicit `Atom<T>` resources and SSR bootstrap. This is not an app state framework, component store, cache, or reactive graph. Runtime values are type-erased postcard bytes; types are compile-time only. A deterministic `TypeHash` may be generated by `hemx_build` for diagnostics, but the JS runtime does not depend on Rust `TypeId`.
### req: state/004
004 SSR roots may carry a `data-slhx-st` base64url-encoded postcard blob on the `data-slhx-root` element. Runtime decodes it into the client atom store. Atoms computed from server state are immediately available to client-side handlers without a round-trip.
004 SSR roots may carry a `data-hemx-st` base64url-encoded postcard blob on the `data-hemx-root` element. Runtime decodes it into the client atom store. Atoms computed from server state are immediately available to client-side handlers without a round-trip.
### req: state/006
006 Malformed `data-slhx-st` bootstrap state must not stop the standard runtime from binding roots, handlers, navigation, or push. The runtime reports `slhx:state-error` and continues with an empty atom store for that root.
006 Malformed `data-hemx-st` bootstrap state must not stop the standard runtime from binding roots, handlers, navigation, or push. The runtime reports `hemx:state-error` and continues with an empty atom store for that root.
### req: state/005
005 Not all state is an Atom. Ordinary Rust fields are preferred unless the value must be independently addressed, bootstrapped, synced, or subscribed. Atoms are explicit resources, not the default state container.
@@ -702,7 +702,7 @@ what a valid business email is.
003 High-frequency UI handlers (drag, pointermove, animation tick) must not require server round-trips or handwritten JS.
### req: client_local/004
004 The exact opt-in syntax for client-local handlers is not part of slhx-core v0. `#[slhx::handler(client)]` is illustrative; final syntax belongs to `slhx-wasm` integration.
004 The exact opt-in syntax for client-local handlers is not part of hemx-core v0. `#[hemx::handler(client)]` is illustrative; final syntax belongs to `hemx-wasm` integration.
---
@@ -731,17 +731,17 @@ what a valid business email is.
003 Rust handlers return effects; they do not imperatively mutate DOM.
### req: invariant/004
004 Cross-file references that are visible to build/proc-macro validation fail at `cargo check` with a precise span. Global completeness checks, such as missing handler implementations across a crate, are `cargo check` errors only inside `#[slhx::component]`; otherwise they are caught at app mount or generated registry tests.
004 Cross-file references that are visible to build/proc-macro validation fail at `cargo check` with a precise span. Global completeness checks, such as missing handler implementations across a crate, are `cargo check` errors only inside `#[hemx::component]`; otherwise they are caught at app mount or generated registry tests.
### req: invariant/005
005 slhx core owns effects, typed ids, and registries only. Routing, auth, sessions, transport, transitions, and sync are integration concerns.
005 hemx core owns effects, typed ids, and registries only. Routing, auth, sessions, transport, transitions, and sync are integration concerns.
---
## v0_scope
### req: v0/001
001 v0 stable release includes: Surface consumption, generated slots/forms/handles, `#[slhx::handler]`, tuple `IntoEffect`, form dispatch, typed data-* params, keyed slots, page swap, root-scoped runtime, EffectBatch wire schema, diagnostics, tests, and `slhx-axum` integration.
001 v0 stable release includes: Surface consumption, generated slots/forms/handles, `#[hemx::handler]`, tuple `IntoEffect`, form dispatch, typed data-* params, keyed slots, page swap, root-scoped runtime, EffectBatch wire schema, diagnostics, tests, and `hemx-axum` integration.
### req: v0/002
002 v0 excludes: sync, wasm/client-local handlers, transitions, query/cache helpers, CRDT, custom component lifecycle, built-in auth, built-in router, and HTMX compatibility mode.
@@ -757,7 +757,7 @@ what a valid business email is.
## examples
### req: examples/001
001 The repository must contain canonical examples that act as API tests. v0 examples are counter, todo CRUD, form wizard, docs-site page swap, auth action, SSE notifications, and keyed todo list; local-first kanban is a north-star milestone example. The full techdemo may include an opaque leaf-widget island that communicates through `Effect::event`, without moving island mechanics into slhx core.
001 The repository must contain canonical examples that act as API tests. v0 examples are counter, todo CRUD, form wizard, docs-site page swap, auth action, SSE notifications, and keyed todo list; local-first kanban is a north-star milestone example. The full techdemo may include an opaque leaf-widget island that communicates through `Effect::event`, without moving island mechanics into hemx core.
### req: examples/002
002 Each example must have a maximum ceremony budget. The counter example must fit in under 50 lines of user-authored Rust plus one template. Todo CRUD must fit in under 150 lines excluding model definitions.
@@ -769,68 +769,68 @@ what a valid business email is.
004 Canonical examples are compile-tested golden API contracts. Changing generated API shape requires updating the examples deliberately.
### req: examples/005
005 Canonical examples must not contain user-authored browser JavaScript. They may load the shared slhx runtime (`/slhx.js`) and may use declarative `data-slhx-*` attributes; inline `<script>`, `on*=` event handlers, and `javascript:` URLs are forbidden outside opaque leaf-widget examples.
005 Canonical examples must not contain user-authored browser JavaScript. They may load the shared hemx runtime (`/hemx.js`) and may use declarative `data-hemx-*` attributes; inline `<script>`, `on*=` event handlers, and `javascript:` URLs are forbidden outside opaque leaf-widget examples.
---
## milestone
### req: ms/001
001 **Milestone app: Local-first Multiplayer Kanban.** A board with drag-and-drop cards, 60fps pointer-follow, optimistic updates, offline queue, conflict reconciliation, live presence, and SSR-first rendering — all without React/Vue/VDOM, in a single typed Rust codebase. This acts as the north-star integration test for slhx + hemplate + slhx-sync. [north_star]
001 **Milestone app: Local-first Multiplayer Kanban.** A board with drag-and-drop cards, 60fps pointer-follow, optimistic updates, offline queue, conflict reconciliation, live presence, and SSR-first rendering — all without React/Vue/VDOM, in a single typed Rust codebase. This acts as the north-star integration test for hemx + hemplate + hemx-sync. [north_star]
---
## ts
### req: ts/001
001 TypeScript definitions for `slhx-js` runtime are shipped as a single `.d.ts` file. Types mirror the postcard `EffectBatch` schema for advanced consumers. Tooling must not depend on these types for core functionality; they are developer convenience only.
001 TypeScript definitions for `hemx-js` runtime are shipped as a single `.d.ts` file. Types mirror the postcard `EffectBatch` schema for advanced consumers. Tooling must not depend on these types for core functionality; they are developer convenience only.
---
## build
### req: build/001
001 Build order: `.heml` → hemplate Surface facts (precomputed by `hemplate_build` or extracted in-process by hemplate for `slhx_build`) → `slhx_build``slhx.generated.rs` + `slhx.syms` + diagnostics.
001 Build order: `.heml` → hemplate Surface facts (precomputed by `hemplate_build` or extracted in-process by hemplate for `hemx_build`) → `hemx_build``hemx.generated.rs` + `hemx.syms` + diagnostics.
### req: build/002
002 Proc-macros (`#[slhx::handler]`, `#[slhx::surface]`) are side-effect free. They read generated artifacts (`slhx.syms`, `slhx.generated.rs`) but never parse `.heml`, never process generic Surface IR, and never write files. Global codegen lives only in `build.rs` invoked by `slhx_build`. [north_star]
002 Proc-macros (`#[hemx::handler]`, `#[hemx::surface]`) are side-effect free. They read generated artifacts (`hemx.syms`, `hemx.generated.rs`) but never parse `.heml`, never process generic Surface IR, and never write files. Global codegen lives only in `build.rs` invoked by `hemx_build`. [north_star]
### req: build/003
003 `slhx-derive` (`#[slhx::handler]`) reads `slhx.syms` at expansion time to validate handle names, slot names, and form signatures. It generates only local glue (static fn-table entry) plus compile errors.
003 `hemx-derive` (`#[hemx::handler]`) reads `hemx.syms` at expansion time to validate handle names, slot names, and form signatures. It generates only local glue (static fn-table entry) plus compile errors.
### req: build/004
004 `#[slhx::surface]` reads `slhx.generated.rs` from `$OUT_DIR` and expands it into the annotated module. It is a pure include/bridge macro with no semantic analysis of its own.
004 `#[hemx::surface]` reads `hemx.generated.rs` from `$OUT_DIR` and expands it into the annotated module. It is a pure include/bridge macro with no semantic analysis of its own.
### req: build/005
005 A `build.rs` failure (missing Surface, version mismatch, stale hash) is a hard error before proc-macro expansion.
### req: build/006
006 Proc-macros are considered local: they have knowledge of the item they annotate, plus pre-generated symbol tables. They do not have global knowledge of all handlers across the crate. Global checks (e.g. every declared handle has an implementation) are either deferred to app-mount tests or enabled by an optional `#[slhx::component]` macro.
006 Proc-macros are considered local: they have knowledge of the item they annotate, plus pre-generated symbol tables. They do not have global knowledge of all handlers across the crate. Global checks (e.g. every declared handle has an implementation) are either deferred to app-mount tests or enabled by an optional `#[hemx::component]` macro.
---
## misc
### req: misc/001
001 Workspace layout: `slhx-core` (types + postcard schema, no_std), `slhx-derive` (proc-macros), `slhx-build` (surface consumer + code generation), `slhx-axum` (integration), `slhx-js` (runtime single file), `slhx-transition` (optional), `slhx-sync` (optional), `slhx-wasm` (optional). No kitchen-sink crate.
001 Workspace layout: `hemx-core` (types + postcard schema, no_std), `hemx-derive` (proc-macros), `hemx-build` (surface consumer + code generation), `hemx-axum` (integration), `hemx-js` (runtime single file), `hemx-transition` (optional), `hemx-sync` (optional), `hemx-wasm` (optional). No kitchen-sink crate.
### req: misc/002
002 All crates compile on stable Rust. MSRV 1.80. `slhx-core` has zero proc-macro dependencies.
002 All crates compile on stable Rust. MSRV 1.80. `hemx-core` has zero proc-macro dependencies.
### req: misc/003
003 Three execution modes supported: server-first (request/response), client-local WASM (requestAnimationFrame, no round-trip), and hybrid sync (local + remote via `slhx-sync`). Modes are opt-in per handler, not global.
003 Three execution modes supported: server-first (request/response), client-local WASM (requestAnimationFrame, no round-trip), and hybrid sync (local + remote via `hemx-sync`). Modes are opt-in per handler, not global.
### req: misc/004
004 The only required user-facing proc-macro in slhx core is `#[slhx::handler]`.
Optional ergonomic macros may exist: `#[slhx::surface]`, `#[slhx::component]`,
`#[slhx::app]`, and integration-crate macros such as `#[slhx::island]` or
`#[slhx_sync::presence]`. No `!` call-syntax macros.
004 The only required user-facing proc-macro in hemx core is `#[hemx::handler]`.
Optional ergonomic macros may exist: `#[hemx::surface]`, `#[hemx::component]`,
`#[hemx::app]`, and integration-crate macros such as `#[hemx::island]` or
`#[hemx_sync::presence]`. No `!` call-syntax macros.
### req: misc/005
005 Source spans are present on every Surface node, attribute, and scope. Error messages cite file, line, and column. This is non-negotiable for DX.
### req: misc/006
006 Authoring slhx attributes use the `data-slhx-*` prefix. Runtime lowering may emit compact `data-hid`, `data-sid`, `data-key`, atom ids, and control ids. No unprefixed custom attributes.
006 Authoring hemx attributes use the `data-hemx-*` prefix. Runtime lowering may emit compact `data-hid`, `data-sid`, `data-key`, atom ids, and control ids. No unprefixed custom attributes.
### req: misc/007
007 Id allocation is deterministic from canonical symbol paths. Ids are stable across builds unless the symbol path changes. Deploy mismatch between server and client is caught by a build-schema version check, not silent failure.
@@ -843,26 +843,26 @@ Optional ergonomic macros may exist: `#[slhx::surface]`, `#[slhx::component]`,
## boundary
### req: boundary/001
001 hemplate does not expose a slhx API. It exposes a stable, generic Template Surface IR. slhx is one consumer; a11y tools, test generators, and documentation generators are others.
001 hemplate does not expose a hemx API. It exposes a stable, generic Template Surface IR. hemx is one consumer; a11y tools, test generators, and documentation generators are others.
### req: boundary/002
002 hemplate never interprets `data-slhx-*` or any other tool-prefixed attributes. It records them faithfully as generic raw attributes in the Surface.
002 hemplate never interprets `data-hemx-*` or any other tool-prefixed attributes. It records them faithfully as generic raw attributes in the Surface.
### req: boundary/003
003 slhx never owns `.heml` parsing semantics directly. It consumes hemplate Surface facts, either from `hemplate.surface.postcard` emitted by `hemplate_build` or from in-process hemplate Surface extraction requested by `slhx_build`. slhx interprets tool-specific conventions (`data-slhx-handle`, `data-slhx-slot`, etc.) from the generic Surface.
003 hemx never owns `.heml` parsing semantics directly. It consumes hemplate Surface facts, either from `hemplate.surface.postcard` emitted by `hemplate_build` or from in-process hemplate Surface extraction requested by `hemx_build`. hemx interprets tool-specific conventions (`data-hemx-handle`, `data-hemx-slot`, etc.) from the generic Surface.
---
## derive_handler
### req: derive_handler/001
001 `#[slhx::handler]` validates: handle name exists in symbol table, params match form surface or `data-*` attributes, return type implements `IntoEffect`. Generate code registers the function in a static lookup table keyed by numeric handle id.
001 `#[hemx::handler]` validates: handle name exists in symbol table, params match form surface or `data-*` attributes, return type implements `IntoEffect`. Generate code registers the function in a static lookup table keyed by numeric handle id.
### req: derive_handler/002
002 Handler param inference from `data-*` attributes: when a template declares `data-card-id="{card.id}"` on a node with `data-slhx-handle`, the handler may declare `card_id: CardId` as a parameter. slhx-build checks attribute → param name and type mapping.
002 Handler param inference from `data-*` attributes: when a template declares `data-card-id="{card.id}"` on a node with `data-hemx-handle`, the handler may declare `card_id: CardId` as a parameter. hemx-build checks attribute → param name and type mapping.
### req: derive_handler/003
003 Handler parameters are inferred from four sources: `Form<T>`, `data-*` attributes on the triggering node, route params supplied by integration crates, and explicit app/context parameters. Missing or incompatible params are compile-time errors with source spans. slhx does not implement selector-based `hx-include`; shared params are represented by forms, hidden inputs, scoped context, or explicit `data-*` attributes.
003 Handler parameters are inferred from four sources: `Form<T>`, `data-*` attributes on the triggering node, route params supplied by integration crates, and explicit app/context parameters. Missing or incompatible params are compile-time errors with source spans. hemx does not implement selector-based `hx-include`; shared params are represented by forms, hidden inputs, scoped context, or explicit `data-*` attributes.
### req: derive_handler/004
004 The common handler signature forms are plain Rust functions, synchronous or async:
@@ -879,24 +879,24 @@ async fn delete(app: State<App>, todo_id: TodoId) -> impl IntoEffect
## derive_app
### req: derive_app/001
001 `#[slhx::app(...)]` marks an application/root registry entry point and composes generated component handler modules into one app registry from the app state. There may be one registry per app/root type, and multiple runtime instances may exist per process. slhx does not require a process-global singleton or handwritten chains of per-component registration calls in canonical app code.
001 `#[hemx::app(...)]` marks an application/root registry entry point and composes generated component handler modules into one app registry from the app state. There may be one registry per app/root type, and multiple runtime instances may exist per process. hemx does not require a process-global singleton or handwritten chains of per-component registration calls in canonical app code.
---
## locality
### req: locality/001
001 slhx does not support selector targets such as `closest`, `find`, or `this` in core. The equivalent pattern is a named generated target on the local component, keyed row, modal, toast, SVG fragment, form error region, or page content area.
001 hemx does not support selector targets such as `closest`, `find`, or `this` in core. The equivalent pattern is a named generated target on the local component, keyed row, modal, toast, SVG fragment, form error region, or page content area.
### req: locality/002
002 Diagnostics should suggest adding `data-slhx-slot` or `h-key` to the local element when users attempt a self/row update pattern, and should explain which generated target helper would become available.
002 Diagnostics should suggest adding `data-hemx-slot` or `h-key` to the local element when users attempt a self/row update pattern, and should explain which generated target helper would become available.
---
## style
### req: style/001
001 Plain CSS and SCSS own appearance. slhx-build discovers static class tokens from `.heml`, `.css`, and `.scss` build inputs and generates `CssClass` constants so Rust can reference known classes without raw strings. slhx does not parse selectors for behavior, cascade policy, or layout semantics. [north_star]
001 Plain CSS and SCSS own appearance. hemx-build discovers static class tokens from `.heml`, `.css`, and `.scss` build inputs and generates `CssClass` constants so Rust can reference known classes without raw strings. hemx does not parse selectors for behavior, cascade policy, or layout semantics. [north_star]
### req: style/002
002 Generated class constants are ergonomic references only: they do not create a CSS framework, require a framework project structure, or make dynamic class expressions compile-time facts. Unknown Rust class references fail by normal Rust name resolution when the generated constant is absent. [north_star]
@@ -909,41 +909,41 @@ async fn delete(app: State<App>, todo_id: TodoId) -> impl IntoEffect
## convention
### req: convention/001
001 slhx-axum and the JS runtime support common UX conventions as attributes, not core effects: `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`, and `data-slhx-on`. These are orthogonal to the core effect algebra.
001 hemx-axum and the JS runtime support common UX conventions as attributes, not core effects: `data-hemx-pending-class`, `data-hemx-indicator`, `data-hemx-confirm`, `data-hemx-debounce`, `data-hemx-throttle`, `data-hemx-every`, `data-hemx-disable-while-pending`, `data-hemx-policy`, and `data-hemx-on`. These are orthogonal to the core effect algebra.
### req: convention/002
002 Default event triggers: `submit` for forms, `click` for buttons and links. `data-slhx-on` overrides the default for the runtime-supported delegated events: `click`, `submit`, `input`, `change`, `dragstart`, `dragover`, and `drop`. Unsupported static event names are build errors.
002 Default event triggers: `submit` for forms, `click` for buttons and links. `data-hemx-on` overrides the default for the runtime-supported delegated events: `click`, `submit`, `input`, `change`, `dragstart`, `dragover`, and `drop`. Unsupported static event names are build errors.
### req: convention/003
003 `data-slhx-debounce` and `data-slhx-throttle` support simple millisecond values. No trigger mini-language in core.
003 `data-hemx-debounce` and `data-hemx-throttle` support simple millisecond values. No trigger mini-language in core.
### req: convention/004
004 `data-slhx-confirm` dispatches a native `confirm()` before handler dispatch. Static empty confirmation messages are build errors because they silently disable the guard in browsers. Custom confirm UI belongs to integration crates.
004 `data-hemx-confirm` dispatches a native `confirm()` before handler dispatch. Static empty confirmation messages are build errors because they silently disable the guard in browsers. Custom confirm UI belongs to integration crates.
### req: convention/005
005 `data-slhx-every` dispatches a handle at a fixed interval while the element remains in the document. Duplicate timers per root are avoided.
005 `data-hemx-every` dispatches a handle at a fixed interval while the element remains in the document. Duplicate timers per root are avoided.
### req: convention/006
006 Request concurrency policy (`latest`, `queue`, `drop`, `parallel`) may be declared per handle with `data-slhx-policy`. Default for debounced/input handlers is `latest`; default for form submit is `drop` while pending. Stale EffectBatches from superseded requests must not be applied.
006 Request concurrency policy (`latest`, `queue`, `drop`, `parallel`) may be declared per handle with `data-hemx-policy`. Default for debounced/input handlers is `latest`; default for form submit is `drop` while pending. Stale EffectBatches from superseded requests must not be applied.
### req: convention/007
007 Pending indicators are cosmetic only. The runtime toggles pending classes, indicator visibility, and disabled controls around request/effect execution; handler semantics are unchanged.
### 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.
008 `data-hemx-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.
009 Unknown `data-hemx-*` authoring attributes are build errors with a suggested fix. hemx-owned attributes are a checked contract, not a silent extension namespace; opaque/integration islands should use explicit allowed attributes or their own non-hemx `data-*` names.
---
## multipart
### req: multipart/001
001 slhx-axum supports `multipart/form-data` as an integration boundary for file uploads.
001 hemx-axum supports `multipart/form-data` as an integration boundary for file uploads.
### req: multipart/002
002 Multipart parsing belongs to slhx-axum/axum extractors, not slhx-core.
002 Multipart parsing belongs to hemx-axum/axum extractors, not hemx-core.
### req: multipart/003
003 File upload forms preserve native browser fallback behavior.
@@ -953,7 +953,7 @@ async fn delete(app: State<App>, todo_id: TodoId) -> impl IntoEffect
## target_policy
### req: target/001
001 slhx does not support response-side selector retargeting. Handlers choose targets by returning generated UI commands for generated resources.
001 hemx does not support response-side selector retargeting. Handlers choose targets by returning generated UI commands for generated resources.
### req: target/002
002 slhx does not implement response-side CSS fragment selection in core. Servers return explicit hemplate partials for generated targets or EffectBatches containing generated target effects.
002 hemx does not implement response-side CSS fragment selection in core. Servers return explicit hemplate partials for generated targets or EffectBatches containing generated target effects.
+35 -35
View File
@@ -4,18 +4,18 @@ A board with drag-and-drop cards, 60fps pointer-follow, optimistic updates,
offline queue, conflict reconciliation, live presence, and SSR-first rendering —
all without React/Vue/VDOM, in a single typed Rust codebase.
This is an explicitly advanced/low-level north-star boundary sketch for slhx + hemplate + slhx-sync, not the beginner-facing authoring path. Raw sync/effect/wire vocabulary below is excluded from beginner-facing examples by design.
This is an explicitly advanced/low-level north-star boundary sketch for hemx + hemplate + hemx-sync, not the beginner-facing authoring path. Raw sync/effect/wire vocabulary below is excluded from beginner-facing examples by design.
---
## 1. Template: `board.heml`
```html
<section data-slhx-root="board" data-slhx-slot="board" data-slhx-atom="board">
<section data-hemx-root="board" data-hemx-slot="board" data-hemx-atom="board">
<header>
<h1>{+ self.title +}</h1>
<form data-slhx-handle="create_card" data-slhx-form="create_card">
<form data-hemx-handle="create_card" data-hemx-form="create_card">
<input name="title" type="text" required>
<select name="column">
<template h-for="column in &self.columns" h-key="column.id">
@@ -26,14 +26,14 @@ This is an explicitly advanced/low-level north-star boundary sketch for slhx + h
</form>
</header>
<div class="columns" data-slhx-slot="columns">
<div class="columns" data-hemx-slot="columns">
<template h-for="column in &self.columns" h-key="column.id">
<section class="column" data-slhx-slot="column" +data-column-id="column.id">
<section class="column" data-hemx-slot="column" +data-column-id="column.id">
<h2>{+ column.title +}</h2>
<div class="cards" +data-column-id="column.id">
<template h-for="card in &column.cards" h-key="card.id">
<article class="card" data-slhx-slot="card" data-slhx-handle="drag_card" +data-card-id="card.id" draggable="true">
<article class="card" data-hemx-slot="card" data-hemx-handle="drag_card" +data-card-id="card.id" draggable="true">
<strong>{+ card.title +}</strong>
<small>{+ card.assignee +}</small>
</article>
@@ -43,7 +43,7 @@ This is an explicitly advanced/low-level north-star boundary sketch for slhx + h
</template>
</div>
<aside data-slhx-slot="presence">
<aside data-hemx-slot="presence">
<template h-for="user in &self.online_users" h-key="user.id">
<span>{+ user.name +}</span>
</template>
@@ -53,16 +53,16 @@ This is an explicitly advanced/low-level north-star boundary sketch for slhx + h
Notes on keyed scopes:
- `h-for="column in &self.columns" h-key="column.id"`**required** for slhx-addressable nodes inside
- `h-for="column in &self.columns" h-key="column.id"`**required** for hemx-addressable nodes inside
- `h-for="card in &column.cards" h-key="card.id"`**required**
- A slot inside a keyed loop is addressed as a generated keyed resource, never by selector strings or positional DOM targeting
- Without `h-key`, slhx rejects the build — no runtime selector fallback
- Without `h-key`, hemx rejects the build — no runtime selector fallback
---
## 2. What hemplate exports
hemplate does **not** interpret `data-slhx-*`. It records raw facts:
hemplate does **not** interpret `data-hemx-*`. It records raw facts:
```rust
Node {
@@ -70,8 +70,8 @@ Node {
element: "article",
attrs: [
("class", "card"),
("data-slhx-slot", "card"),
("data-slhx-handle", "drag_card"),
("data-hemx-slot", "card"),
("data-hemx-handle", "drag_card"),
("data-card-id", "{card.id}"),
("draggable", "true"),
],
@@ -87,7 +87,7 @@ FormSurface {
}
```
slhx reads this from hemplate Surface facts and generates scoped typed resources:
hemx reads this from hemplate Surface facts and generates scoped typed resources:
```rust
use ui::board::{forms, targets};
@@ -104,7 +104,7 @@ No string desync. No manual ids. The generated module owns the names.
## 3. App State
```rust
#[slhx::app]
#[hemx::app]
pub struct BoardApp {
pub board: Atom<BoardState>,
pub drag: Atom<Option<DragState>>,
@@ -119,13 +119,13 @@ The same struct runs on server (SSR) and in WASM (client-local effects).
## 4. Normal Form: Server-first
```rust
#[derive(SlhxForm)]
#[derive(HemxForm)]
pub struct CreateCardForm {
pub title: String,
pub column: ColumnId,
}
#[slhx::handler]
#[hemx::handler]
pub fn create_card(
form: Form<CreateCardForm>,
app: &mut BoardApp,
@@ -137,7 +137,7 @@ pub fn create_card(
(
targets::card.append(card.id, &CardView::from(card)),
forms::create_card.clear("title"),
// slhx-sync: queue atomic board state diff for sync
// hemx-sync: queue atomic board state diff for sync
SyncEffect::send_patch(atoms::board, Patch::insert_card(form.column, card)),
)
}
@@ -150,7 +150,7 @@ HTML submits as usual. Server returns a typed update batch. Browser applies DOM
## 5. Drag: 60fps client-local WASM
```rust
#[slhx::handler(client)]
#[hemx::handler(client)]
pub fn drag_card(
event: DragEvent,
app: &mut BoardApp,
@@ -163,7 +163,7 @@ pub fn drag_card(
}));
// Client-local extension APIs stay typed by generated resources;
// names below are illustrative until slhx-sync lands.
// names below are illustrative until hemx-sync lands.
Effect::batch((
Effect::class_keyed(slots::CARD, event.card_id, "dragging", true),
Effect::transform_keyed(
@@ -182,7 +182,7 @@ Zero round-trip. Zero custom JS. Pure Rust → typed updates → DOM.
## 6. Drop: optimistic update + sync
```rust
#[slhx::handler(client)]
#[hemx::handler(client)]
pub fn drop_card(
event: DropEvent,
app: &mut BoardApp,
@@ -194,7 +194,7 @@ pub fn drop_card(
app.drag.set(None);
// Client-local extension APIs stay typed by generated resources;
// names below are illustrative until slhx-sync lands.
// names below are illustrative until hemx-sync lands.
Effect::batch((
Effect::move_keyed(
slots::CARD,
@@ -204,7 +204,7 @@ pub fn drop_card(
InsertBefore(event.before_card),
),
Effect::class_keyed(slots::CARD, event.card_id, "dragging", false),
// slhx-sync: queue patch, send when online
// hemx-sync: queue patch, send when online
SyncEffect::send_patch(atoms::BOARD, patch),
))
}
@@ -212,14 +212,14 @@ pub fn drop_card(
A pure htmx+SSR app cannot model this: 60fps pointer → local transient drag → optimistic update → offline queue → reconciliation. You'd need custom JS or a parallel React/Vue layer.
slhx models it in one type graph.
hemx models it in one type graph.
---
## 7. Server reconciliation
```rust
#[slhx_sync::handler]
#[hemx_sync::handler]
pub fn apply_board_patch(
patch: BoardPatch,
app: &mut BoardApp,
@@ -253,7 +253,7 @@ Server-authoritative on conflict. No Redux sagas. No React Query cache fades.
## 8. Presence
```rust
#[slhx_sync::presence]
#[hemx_sync::presence]
pub fn user_joined(user: UserPresence) -> impl IntoEffect {
targets::presence_user.append(user.id, &PresenceBadge::from(user))
}
@@ -272,22 +272,22 @@ The runtime does not know "presence". It executes generated DOM updates.
## 9. What app authors write; what the browser receives
Initial SSR stays an ordinary rendered template with symbolic slhx attributes at
Initial SSR stays an ordinary rendered template with symbolic hemx attributes at
the authoring boundary:
```html
<section data-slhx-root="board" data-slhx-slot="board" data-slhx-atom="board">
<section data-hemx-root="board" data-hemx-slot="board" data-hemx-atom="board">
...
<article data-slhx-slot="card" data-slhx-handle="select_card" +data-card-id="card.id">
<article data-hemx-slot="card" data-hemx-handle="select_card" +data-card-id="card.id">
{+ card.title +}
</article>
...
</section>
<!-- the app shell loads /slhx.js and any bootstrap state -->
<!-- the app shell loads /hemx.js and any bootstrap state -->
```
The compiler lowers those symbols to compact runtime metadata, but that metadata
is not an app-authoring contract. Runtime attachment: `/slhx.js` installs
is not an app-authoring contract. Runtime attachment: `/hemx.js` installs
delegated root listeners for forms, clicks, and pointer/drag events. App authors
keep composing generated resources; they do not attach per-node listeners, copy
numeric ids, or write selector glue.
@@ -298,18 +298,18 @@ No framework download. No VDOM. No hydration. No game loop.
## 10. Why this is not a React/Vue/htmx app
| Concern | React/Vue | htmx+SSR | slhx |
| Concern | React/Vue | htmx+SSR | hemx |
|---|---|---|---|
| SSR | RSC/Vue SSR | native | native (hemplate) |
| 60fps drag | 100ms re-render + React-DnD | custom JS | WASM handler, typed update |
| Optimistic update | useOptimistic | impossible | `board.update``SyncEffect::send_patch` |
| Offline support | Service Worker + custom | impossible | patch queue in `slhx-sync` |
| Offline support | Service Worker + custom | impossible | patch queue in `hemx-sync` |
| Conflict resolution | manual / Yjs CRDT | impossible | server-authoritative patch |
| Presence | WebSocket + custom state | SSE possible | `Effect::broadcast` over channel |
| Keyed DOM | React key | not a concern | `KeyedSlot<K, T>` compile-time |
| Forms | React Hook Form | HTML native, but no validation bridge | `Form<T>` derived from `.heml` surface |
| Routing | React Router / Vue Router | HTML links, but no state routing | `Effect::navigate` with scroll/title |
| Total JS shipped | ~300KB+ | ~20KB htmx + custom | ~3KB slhx.js interpreter |
| Total JS shipped | ~300KB+ | ~20KB htmx + custom | ~3KB hemx.js interpreter |
---
@@ -317,7 +317,7 @@ No framework download. No VDOM. No hydration. No game loop.
```text
A local-first multiplayer board where all high-frequency UI runs as Rust/WASM effects,
all durable state syncs through slhx-sync,
all durable state syncs through hemx-sync,
all HTML is hemplate-rendered,
and the browser runtime only executes typed postcard DOM ops.
```
@@ -338,6 +338,6 @@ But:
```text
Rust owns types.
hemplate owns structure.
slhx owns interaction.
hemx owns interaction.
browser executes ops.
```
+5 -5
View File
@@ -1,5 +1,5 @@
[package]
name = "slhx-kanban-example"
name = "hemx-kanban-example"
version.workspace = true
edition.workspace = true
publish = false
@@ -11,13 +11,13 @@ path = "src/lib.rs"
axum = "0.7"
futures-util = "0.3"
hemplate = { path = "../../../hemplate/hemplate" }
slhx = { path = "../../slhx" }
slhx-axum = { path = "../../slhx-axum" }
hemx = { path = "../../hemx" }
hemx-axum = { path = "../../hemx-axum" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
[dev-dependencies]
scraper = "0.23"
slhx-test = { path = "../../slhx-test" }
hemx-test = { path = "../../hemx-test" }
[build-dependencies]
slhx-build = { path = "../../slhx-build" }
hemx-build = { path = "../../hemx-build" }
+2 -2
View File
@@ -1,8 +1,8 @@
# slhx Kanban browser example
# hemx Kanban browser example
Run:
cargo run -p slhx-kanban-example
cargo run -p hemx-kanban-example
Open <http://127.0.0.1:3001>.
+1 -1
View File
@@ -1,3 +1,3 @@
fn main() {
slhx_build::app().run().unwrap();
hemx_build::app().run().unwrap();
}
+6 -6
View File
@@ -1,4 +1,4 @@
#[slhx::surface]
#[hemx::surface]
pub mod ui {}
#[cfg(test)]
@@ -6,8 +6,8 @@ mod tests {
use super::ui::{board, board_card};
use hemplate::Hemplate;
use scraper::{Html, Selector};
use slhx::IntoEffect;
use slhx_test::inspect;
use hemx::IntoEffect;
use hemx_test::inspect;
#[derive(Hemplate)]
#[hemplate = "partials"]
@@ -17,7 +17,7 @@ mod tests {
#[allow(dead_code)]
#[derive(Clone, Debug)]
#[slhx::form("create_card")]
#[hemx::form("create_card")]
struct CreateCard {
title: String,
column: String,
@@ -56,8 +56,8 @@ mod tests {
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
#[test]
fn kanban_form_handler_is_checked_against_hemplate_form() {
#[slhx::handler]
fn create_card(_form: slhx::Form<CreateCard>) -> impl IntoEffect {
#[hemx::handler]
fn create_card(_form: hemx::Form<CreateCard>) -> impl IntoEffect {
board::notice.text("queued")
}
+10 -10
View File
@@ -4,14 +4,14 @@ use axum::routing::get;
use axum::Router;
use futures_util::{stream, StreamExt};
use hemplate::Hemplate;
use slhx::{Html, IntoEffect};
use slhx_axum::{
use hemx::{Html, IntoEffect};
use hemx_axum::{
interactions, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse,
InteractionRequest, PageRequest,
};
use slhx_kanban_example::ui::board::{self as board};
use slhx_kanban_example::ui::board_card as card_board;
use slhx_kanban_example::ui::{self, board as board_ui};
use hemx_kanban_example::ui::board::{self as board};
use hemx_kanban_example::ui::board_card as card_board;
use hemx_kanban_example::ui::{self, board as board_ui};
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::net::SocketAddr;
@@ -118,12 +118,12 @@ async fn main() {
let app = Router::new()
.route("/", get(home).post(interact))
.route("/events", get(events))
.route("/slhx.js", get(runtime))
.route("/hemx.js", get(runtime))
.with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], 3001));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
println!("slhx Kanban example: http://{addr}");
println!("hemx Kanban example: http://{addr}");
axum::serve(listener, app).await.unwrap();
}
@@ -132,7 +132,7 @@ async fn home(State(state): State<Arc<AppState>>, request: PageRequest) -> impl
let board = state.board.lock().unwrap().clone();
request
.page_html(page_html(&board), shell)
.title("slhx Kanban")
.title("hemx Kanban")
.fingerprint(ui::BUILD_FINGERPRINT)
}
@@ -288,7 +288,7 @@ fn page_html(board: &BoardState) -> Html {
fn shell(body: Html) -> Html {
// req: html_safety/001 req: html_safety/002 req: axum_integration/001
slhx::render(&AppShell { body })
hemx::render(&AppShell { body })
}
fn render_options() -> Html {
@@ -333,7 +333,7 @@ fn render_card(card: &Card) -> BoardCard {
mod tests {
use super::*;
use scraper::{Html, Selector};
use slhx_test::{
use hemx_test::{
class_child_selector, disabled_button_selector, element_class_selector,
escaped_markup_selector, form_selector, keyed_selector, root_element_selector,
select_options_selector, small_text_selector, strong_text_selector,
+2 -2
View File
@@ -3,8 +3,8 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>slhx Kanban</title>
<script src="/slhx.js" defer></script>
<title>hemx Kanban</title>
<script src="/hemx.js" defer></script>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; }
form { display: flex; gap: .5rem; flex-wrap: wrap; margin: 1rem 0; }
+6 -6
View File
@@ -1,15 +1,15 @@
<section data-slhx-root="kanban" data-slhx-sse="/events">
<section data-hemx-root="kanban" data-hemx-sse="/events">
<header>
<h1>slhx Kanban</h1>
<form data-slhx-handle="create_card" data-slhx-form="create_card" data-slhx-disable-while-pending>
<h1>hemx Kanban</h1>
<form data-hemx-handle="create_card" data-hemx-form="create_card" data-hemx-disable-while-pending>
<input name="title" type="text" required="required" placeholder="Card title">
<select name="column" required="required">{+= self.options =+}</select>
<button type="submit">Add card</button>
</form>
<p data-slhx-slot="notice">Ready</p>
<p data-hemx-slot="notice">Ready</p>
</header>
<div data-slhx-slot="board">{+= self.board =+}</div>
<aside data-slhx-slot="presence">Waiting for presence…</aside>
<div data-hemx-slot="board">{+= self.board =+}</div>
<aside data-hemx-slot="presence">Waiting for presence…</aside>
</section>
@@ -1,10 +1,10 @@
<article class="card" +data-key="self.id">
<strong>{+ self.title +}</strong>
<menu>
<button h-if="self.left_disabled" type="button" data-slhx-handle="move_left" +data-card-id="self.id" disabled="disabled">←</button>
<button h-else type="button" data-slhx-handle="move_left" +data-card-id="self.id">←</button>
<button h-if="self.right_disabled" type="button" data-slhx-handle="move_right" +data-card-id="self.id" disabled="disabled">→</button>
<button h-else type="button" data-slhx-handle="move_right" +data-card-id="self.id">→</button>
<button type="button" data-slhx-handle="delete_card" +data-card-id="self.id">Delete</button>
<button h-if="self.left_disabled" type="button" data-hemx-handle="move_left" +data-card-id="self.id" disabled="disabled">←</button>
<button h-else type="button" data-hemx-handle="move_left" +data-card-id="self.id">←</button>
<button h-if="self.right_disabled" type="button" data-hemx-handle="move_right" +data-card-id="self.id" disabled="disabled">→</button>
<button h-else type="button" data-hemx-handle="move_right" +data-card-id="self.id">→</button>
<button type="button" data-hemx-handle="delete_card" +data-card-id="self.id">Delete</button>
</menu>
</article>
+5 -5
View File
@@ -1,5 +1,5 @@
[package]
name = "slhx-techdemo"
name = "hemx-techdemo"
version.workspace = true
edition.workspace = true
publish = false
@@ -11,14 +11,14 @@ path = "src/lib.rs"
axum = "0.7"
futures-util = "0.3"
hemplate = { path = "../../../hemplate/hemplate" }
slhx = { path = "../../slhx" }
slhx-axum = { path = "../../slhx-axum" }
hemx = { path = "../../hemx" }
hemx-axum = { path = "../../hemx-axum" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
[dev-dependencies]
scraper = "0.23"
slhx-test = { path = "../../slhx-test" }
hemx-test = { path = "../../hemx-test" }
thirtyfour = "0.35"
[build-dependencies]
slhx-build = { path = "../../slhx-build" }
hemx-build = { path = "../../hemx-build" }
+9 -9
View File
@@ -1,12 +1,12 @@
# slhx full techdemo
# hemx full techdemo
Run:
cargo run -p slhx-techdemo
cargo run -p hemx-techdemo
Open <http://127.0.0.1:3002>.
This is a polished Linear-style product demo for planning typed work across lanes. It is tailored to showcase slhx strengths:
This is a polished Linear-style product demo for planning typed work across lanes. It is tailored to showcase hemx strengths:
- modern SSR-first UI
- generated target objects from `.heml`
@@ -17,12 +17,12 @@ This is a polished Linear-style product demo for planning typed work across lane
- root-scoped runtime application without selector lookups
- page-enhancer navigation with native link fallback
- SSE server push into a generated slot
- drag-and-drop lane moves persisted by typed server handlers through the slhx runtime
- an explicit advanced opaque canvas island fed by a generated event helper, without teaching slhx core about the widget
- no user-authored browser JavaScript in slhx-managed UI; the island JavaScript is a leaf-widget escape hatch
- drag-and-drop lane moves persisted by typed server handlers through the hemx runtime
- an explicit advanced opaque canvas island fed by a generated event helper, without teaching hemx core about the widget
- no user-authored browser JavaScript in hemx-managed UI; the island JavaScript is a leaf-widget escape hatch
Verification:
cargo test -p slhx-techdemo --test e2e
cargo test -p slhx-techdemo --test browser_e2e
mutest -p slhx-techdemo -f examples/techdemo/src/main.rs -F 'registry' -j 2 --timeout 90 -- --test e2e
cargo test -p hemx-techdemo --test e2e
cargo test -p hemx-techdemo --test browser_e2e
mutest -p hemx-techdemo -f examples/techdemo/src/main.rs -F 'registry' -j 2 --timeout 90 -- --test e2e
+1 -1
View File
@@ -1,3 +1,3 @@
fn main() {
slhx_build::app().run().unwrap();
hemx_build::app().run().unwrap();
}
+6 -6
View File
@@ -1,4 +1,4 @@
#[slhx::surface]
#[hemx::surface]
pub mod ui {}
#[cfg(test)]
@@ -7,8 +7,8 @@ mod tests {
use super::ui::issue_card::advance_work;
use super::ui::issue_lane::events as lane_events;
use hemplate::Hemplate;
use slhx::IntoEffect;
use slhx_test::inspect;
use hemx::IntoEffect;
use hemx_test::inspect;
#[derive(Hemplate)]
#[hemplate = "partials"]
@@ -18,7 +18,7 @@ mod tests {
#[allow(dead_code)]
#[derive(Clone, Debug)]
#[slhx::form("launch_work")]
#[hemx::form("launch_work")]
struct LaunchWork {
title: String,
lane: String,
@@ -43,8 +43,8 @@ mod tests {
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
#[test]
fn techdemo_form_handler_is_checked_against_hemplate_form() {
#[slhx::handler]
fn launch_work(_form: slhx::Form<LaunchWork>) -> impl IntoEffect {
#[hemx::handler]
fn launch_work(_form: hemx::Form<LaunchWork>) -> impl IntoEffect {
notice.text("queued")
}
+16 -16
View File
@@ -5,14 +5,14 @@ use axum::routing::get;
use axum::Router;
use futures_util::{stream, StreamExt};
use hemplate::Hemplate;
use slhx::{CssClass, CssClasses, EventName, Html, IntoEffect};
use slhx_axum::{
use hemx::{CssClass, CssClasses, EventName, Html, IntoEffect};
use hemx_axum::{
interactions, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse,
InteractionRequest, PageRequest,
};
use slhx_techdemo::ui;
use slhx_techdemo::ui::control_center::{self as control, classes};
use slhx_techdemo::ui::{issue_card as card_control, issue_lane as lane_control};
use hemx_techdemo::ui;
use hemx_techdemo::ui::control_center::{self as control, classes};
use hemx_techdemo::ui::{issue_card as card_control, issue_lane as lane_control};
use std::collections::{BTreeMap, VecDeque};
use std::convert::Infallible;
use std::net::SocketAddr;
@@ -24,7 +24,7 @@ const LANES: [(&str, &str, &str); 3] = [
("runtime", "Runtime", "typed updates → DOM"),
("product", "Product", "Native UX, zero app JS"),
];
const ISLAND_ORBIT: EventName = EventName::new("slhx:island-orbit");
const ISLAND_ORBIT: EventName = EventName::new("hemx:island-orbit");
#[derive(Clone)]
struct WorkItem {
@@ -226,18 +226,18 @@ async fn main() {
.route("/architecture", get(architecture))
.route("/events", get(events))
.route("/favicon.ico", get(favicon))
.route("/slhx.js", get(runtime))
.route("/hemx.js", get(runtime))
.route("/app.css", get(app_css))
.route("/control_center.css", get(control_center_css))
.route("/island.js", get(island_js))
.with_state(state);
let addr = std::env::var("SLHX_TECHDEMO_ADDR")
let addr = std::env::var("HEMX_TECHDEMO_ADDR")
.ok()
.and_then(|addr| addr.parse().ok())
.unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], 3002)));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
println!("slhx techdemo: http://{addr}");
println!("hemx techdemo: http://{addr}");
axum::serve(listener, app).await.unwrap();
}
@@ -246,7 +246,7 @@ async fn home(State(state): State<Arc<Shared>>, request: PageRequest) -> impl In
let demo = state.demo.lock().unwrap().clone();
request
.page_html(page_html(&demo), shell)
.title("slhx Techdemo")
.title("hemx Techdemo")
.fingerprint(ui::BUILD_FINGERPRINT)
}
@@ -261,7 +261,7 @@ async fn architecture(request: PageRequest) -> impl IntoResponse {
});
request
.page_html(body, shell)
.title("slhx Architecture")
.title("hemx Architecture")
.fingerprint(ui::BUILD_FINGERPRINT)
}
@@ -659,11 +659,11 @@ mod tests {
.select(&selector("title"))
.next()
.map(|title| title.text().collect::<String>()),
Some("slhx Techdemo".to_owned())
Some("hemx Techdemo".to_owned())
);
assert_eq!(
document
.select(&selector("script[src=\"/slhx.js\"]"))
.select(&selector("script[src=\"/hemx.js\"]"))
.count(),
1
);
@@ -687,7 +687,7 @@ mod tests {
);
assert_eq!(
document
.select(&selector("main[data-slhx-root=\"techdemo\"]"))
.select(&selector("main[data-hemx-root=\"techdemo\"]"))
.count(),
1
);
@@ -706,7 +706,7 @@ mod tests {
let document = Html::parse_fragment(html.as_str());
assert_eq!(
document
.select(&selector("[data-slhx-root=\"techdemo\"]"))
.select(&selector("[data-hemx-root=\"techdemo\"]"))
.count(),
1
);
@@ -853,7 +853,7 @@ mod tests {
assert_eq!(items.len(), 3);
assert_eq!(
items[1].text().collect::<String>(),
"Fetched HTML with X-SLHX-Partial"
"Fetched HTML with X-HEMX-Partial"
);
}
+1 -1
View File
@@ -7,7 +7,7 @@ main { width:min(1180px, calc(100vw - 32px)); margin:0 auto; padding:38px 0 56px
.hero-shell { display:grid; grid-template-columns:1.35fr .85fr; gap:22px; align-items:stretch; }
.hero-copy, .hero-panel, .command-card, .board-card, .glass-card, .topology { border:1px solid var(--line); background:linear-gradient(145deg, rgba(255,255,255,.14), rgba(255,255,255,.055)); box-shadow:0 24px 90px rgba(0,0,0,.36), inset 0 1px 0 rgba(255,255,255,.12); backdrop-filter: blur(22px) saturate(145%); border-radius:28px; }
.hero-copy { padding:34px; overflow:hidden; position:relative; }
.hero-copy::after { content:"slhx"; position:absolute; right:-18px; bottom:8px; font-size:86px; font-weight:900; color:rgba(255,255,255,.045); }
.hero-copy::after { content:"hemx"; position:absolute; right:-18px; bottom:8px; font-size:86px; font-weight:900; color:rgba(255,255,255,.045); }
.eyebrow { color:var(--cyan); text-transform:uppercase; letter-spacing:.2em; font-weight:800; font-size:12px; }
h1 { font-size:clamp(42px, 7vw, 84px); line-height:.88; letter-spacing:-.075em; margin:12px 0 18px; max-width:900px; text-wrap:balance; overflow-wrap:anywhere; }
h2 { margin:0 0 16px; letter-spacing:-.035em; }
+2 -2
View File
@@ -3,8 +3,8 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>slhx Techdemo</title>
<script src="/slhx.js" defer></script>
<title>hemx Techdemo</title>
<script src="/hemx.js" defer></script>
<script src="/island.js" defer></script>
<link rel="stylesheet" href="/app.css">
<link rel="stylesheet" href="/control_center.css">
+16 -16
View File
@@ -1,22 +1,22 @@
<main data-slhx-root="techdemo" data-slhx-sse="/events">
<main data-hemx-root="techdemo" data-hemx-sse="/events">
<section class="hero-shell">
<div class="hero-copy">
<p class="eyebrow">slhx Control Plane</p>
<p class="eyebrow">hemx Control Plane</p>
<h1>A Linear-class work system without a frontend framework.</h1>
<p class="lede">Create, inspect, advance, and stream work through native HTML, generated typed resources, and compact update batches. The UI feels app-grade; the model stays server-owned and boring.</p>
</div>
<div class="hero-panel" data-slhx-slot="hero_metrics">{+= self.hero =+}</div>
<div class="hero-panel" data-hemx-slot="hero_metrics">{+= self.hero =+}</div>
</section>
<nav class="topology" data-slhx-slot="nav">
<a href="/" data-slhx-nav="">Live system</a>
<a href="/architecture" data-slhx-nav="">Architecture</a>
<nav class="topology" data-hemx-slot="nav">
<a href="/" data-hemx-nav="">Live system</a>
<a href="/architecture" data-hemx-nav="">Architecture</a>
</nav>
<section class="workspace">
<aside class="command-card">
<h2>Create an issue</h2>
<form id="launch-work" method="post" data-slhx-handle="launch_work" data-slhx-form="launch_work" data-slhx-disable-while-pending>
<form id="launch-work" method="post" data-hemx-handle="launch_work" data-hemx-form="launch_work" data-hemx-disable-while-pending>
<label>Title <input name="title" required="required" value="Ship typed updates"></label>
<label>Lane
<select name="lane" required="required">
@@ -26,13 +26,13 @@
</select>
</label>
<label>Impact <input name="impact" type="number" min="1" max="9" value="7"></label>
<button class="primary-action" type="button" form="launch-work" data-slhx-handle="launch_work">Create issue</button>
<button class="primary-action" type="button" form="launch-work" data-hemx-handle="launch_work">Create issue</button>
</form>
<div class="quick-actions">
<button type="button" data-slhx-handle="simulate_push">Simulate server push</button>
<button type="button" data-slhx-handle="reset_demo">Reset demo</button>
<button type="button" data-hemx-handle="simulate_push">Simulate server push</button>
<button type="button" data-hemx-handle="reset_demo">Reset demo</button>
</div>
<p data-slhx-slot="notice" class="notice">Every control posts through a generated handle and receives typed updates.</p>
<p data-hemx-slot="notice" class="notice">Every control posts through a generated handle and receives typed updates.</p>
</aside>
<section class="board-card">
@@ -40,27 +40,27 @@
<span>Generated slots + keyed cards</span>
<strong>generated resources, no selectors</strong>
</div>
<div data-slhx-slot="board">{+= self.board =+}</div>
<div data-hemx-slot="board">{+= self.board =+}</div>
</section>
</section>
<section class="insight-grid">
<article class="glass-card">
<h2>Update inspector</h2>
<div data-slhx-slot="inspector" class="inspector-slot">{+= self.inspector =+}</div>
<div data-hemx-slot="inspector" class="inspector-slot">{+= self.inspector =+}</div>
</article>
<article class="glass-card island-card" data-slhx-island="orbit" +data-island-snapshot="self.island_snapshot">
<article class="glass-card island-card" data-hemx-island="orbit" +data-island-snapshot="self.island_snapshot">
<h2>Opaque island bridge</h2>
<canvas width="360" height="180" aria-label="Animated island orbit"></canvas>
<p data-island-readout="">Waiting for Rust snapshot…</p>
</article>
<article class="glass-card">
<h2>Activity stream</h2>
<div data-slhx-slot="activity">{+= self.activity =+}</div>
<div data-hemx-slot="activity">{+= self.activity =+}</div>
</article>
<article class="glass-card glow">
<h2>Server push</h2>
<div data-slhx-slot="live_feed" class="live-feed">Waiting for SSE heartbeat…</div>
<div data-hemx-slot="live_feed" class="live-feed">Waiting for SSE heartbeat…</div>
</article>
</section>
+6 -6
View File
@@ -1,4 +1,4 @@
// Opaque leaf-widget island. slhx talks to it only with native CustomEvent payloads.
// Opaque leaf-widget island. hemx talks to it only with native CustomEvent payloads.
// req: interop/001 req: examples/001
(() => {
const roots = new WeakMap();
@@ -20,7 +20,7 @@
function rootOf(node) {
for (let el = node; el; el = el.parentElement) {
if (el.hasAttribute && el.hasAttribute("data-slhx-root")) return el;
if (el.hasAttribute && el.hasAttribute("data-hemx-root")) return el;
}
return document.documentElement;
}
@@ -66,7 +66,7 @@
ctx.fillStyle = "#fff";
ctx.font = "700 16px system-ui, sans-serif";
ctx.fillText("slhx island", 18, 30);
ctx.fillText("hemx island", 18, 30);
ctx.font = "12px system-ui, sans-serif";
ctx.fillStyle = "rgba(255,255,255,.74)";
ctx.fillText(`cards ${state.snapshot.cards} · impact ${state.snapshot.impact} · boost ${state.snapshot.power}`, 18, 50);
@@ -85,11 +85,11 @@
state.snapshot = parseSnapshot(event.detail);
island.setAttribute("data-island-snapshot", event.detail);
};
root.addEventListener("slhx:island-orbit", update);
root.addEventListener("hemx:island-orbit", update);
function tick() {
if (!document.contains(island)) {
root.removeEventListener("slhx:island-orbit", update);
root.removeEventListener("hemx:island-orbit", update);
return;
}
state.frame += 1;
@@ -101,7 +101,7 @@
function scan() {
forEachElement(document, (el) => {
if (el.hasAttribute("data-slhx-island")) boot(el);
if (el.hasAttribute("data-hemx-island")) boot(el);
});
}
@@ -1,5 +1,5 @@
<ol class="activity">
<li>Clicked a real anchor</li>
<li>Fetched HTML with X-SLHX-Partial</li>
<li>Fetched HTML with X-HEMX-Partial</li>
<li>Preserved native fallback semantics</li>
</ol>
@@ -1,5 +1,5 @@
<div class="lanes">
<section class="lane"><h3>hemplate</h3><p>Owns syntax and Surface facts.</p></section>
<section class="lane"><h3>slhx-build</h3><p>Generates resources and lowering tables.</p></section>
<section class="lane"><h3>hemx-build</h3><p>Generates resources and lowering tables.</p></section>
<section class="lane"><h3>runtime</h3><p>Executes compact typed DOM ops.</p></section>
</div>
@@ -2,8 +2,8 @@
<header><strong>{+ self.title +}</strong><span class="pill">{+ self.stage +}</span></header>
<div class="impact"><i +style="self.impact_style"></i></div>
<div class="card-actions">
<button type="button" data-slhx-handle="spotlight_work" +data-work-id="self.id" name="work_id" +value="self.id">Inspect</button>
<button type="button" data-slhx-handle="advance_work" +data-work-id="self.id" name="work_id" +value="self.id">Advance</button>
<button type="button" data-slhx-handle="delete_work" +data-work-id="self.id" name="work_id" +value="self.id">Delete</button>
<button type="button" data-hemx-handle="spotlight_work" +data-work-id="self.id" name="work_id" +value="self.id">Inspect</button>
<button type="button" data-hemx-handle="advance_work" +data-work-id="self.id" name="work_id" +value="self.id">Advance</button>
<button type="button" data-hemx-handle="delete_work" +data-work-id="self.id" name="work_id" +value="self.id">Delete</button>
</div>
</article>
@@ -1,4 +1,4 @@
<section +class="self.class" +data-lane="self.lane_id" data-slhx-handle="move_to_lane" data-slhx-on="drop">
<section +class="self.class" +data-lane="self.lane_id" data-hemx-handle="move_to_lane" data-hemx-on="drop">
<h3>{+ self.title +}</h3>
<p>{+ self.description +}</p>
<template h-for="card in &self.cards">
+6 -6
View File
@@ -1,5 +1,5 @@
use slhx_techdemo::ui::control_center::{self as control, launch_work, simulate_push};
use slhx_test::{
use hemx_techdemo::ui::control_center::{self as control, launch_work, simulate_push};
use hemx_test::{
any_root_selector, document_body_selector, handle_button_selector, handle_selector,
island_probe_script, island_readout_selector, keyed_selector, nav_link_selector,
scoped_island_readout_selector, target_selector,
@@ -36,8 +36,8 @@ impl Drop for ChildProcess {
#[tokio::test]
async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> {
// req: examples/001 req: dx/008 req: form/002 req: page_swap/002 req: push/003
let mut app = Command::new(env!("CARGO_BIN_EXE_slhx-techdemo"));
app.env("SLHX_TECHDEMO_ADDR", APP_ADDR);
let mut app = Command::new(env!("CARGO_BIN_EXE_hemx-techdemo"));
app.env("HEMX_TECHDEMO_ADDR", APP_ADDR);
let _app = ChildProcess::spawn(app);
wait_for_tcp(APP_ADDR);
@@ -153,7 +153,7 @@ async fn wait_for_runtime(driver: &WebDriver) -> WebDriverResult<()> {
loop {
let loaded = driver
.execute(
"return !!window.slhx && window.slhx.roots().length > 0",
"return !!window.hemx && window.hemx.roots().length > 0",
Vec::new(),
)
.await?
@@ -164,7 +164,7 @@ async fn wait_for_runtime(driver: &WebDriver) -> WebDriverResult<()> {
return Ok(());
}
if Instant::now() >= deadline {
panic!("timed out waiting for slhx runtime");
panic!("timed out waiting for hemx runtime");
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
+16 -16
View File
@@ -1,9 +1,9 @@
use scraper::{Html, Selector};
use slhx_techdemo::ui::control_center::{self as control, launch_work, reset_demo, simulate_push};
use slhx_techdemo::ui::issue_card::{advance_work, delete_work, spotlight_work};
use slhx_techdemo::ui::issue_lane::move_to_lane as move_to_lane_handle;
use slhx_techdemo::ui::BUILD_FINGERPRINT;
use slhx_test::{
use hemx_techdemo::ui::control_center::{self as control, launch_work, reset_demo, simulate_push};
use hemx_techdemo::ui::issue_card::{advance_work, delete_work, spotlight_work};
use hemx_techdemo::ui::issue_lane::move_to_lane as move_to_lane_handle;
use hemx_techdemo::ui::BUILD_FINGERPRINT;
use hemx_test::{
class_descendant_selector, class_selector, handle_form_body, inspect_wire,
island_attribute_name, island_event_name, island_selector, island_snapshot_marker,
root_selector, sse_endpoint_marker, strong_text_selector, unknown_handle_form_body,
@@ -22,12 +22,12 @@ struct Server {
impl Server {
fn start() -> Self {
let bin = env!("CARGO_BIN_EXE_slhx-techdemo");
let bin = env!("CARGO_BIN_EXE_hemx-techdemo");
let child = Command::new(bin)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("start slhx-techdemo");
.expect("start hemx-techdemo");
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
@@ -36,7 +36,7 @@ impl Server {
}
std::thread::sleep(Duration::from_millis(25));
}
panic!("slhx-techdemo did not listen on {ADDR}");
panic!("hemx-techdemo did not listen on {ADDR}");
}
}
@@ -73,7 +73,7 @@ fn product_is_e2e_working_over_http() {
let favicon = get("/favicon.ico");
assert_eq!(favicon.status, 204);
let runtime = get("/slhx.js");
let runtime = get("/hemx.js");
assert_eq!(runtime.status, 200);
assert!(runtime.header("content-type").contains("javascript"));
@@ -85,12 +85,12 @@ fn product_is_e2e_working_over_http() {
assert!(island.text().contains("MutationObserver"));
assert!(island.text().contains("removeEventListener"));
let architecture = request("GET", "/architecture", &[("X-SLHX-Partial", "1")], "");
let architecture = request("GET", "/architecture", &[("X-HEMX-Partial", "1")], "");
assert_eq!(architecture.status, 200);
assert!(architecture.header("x-slhx-partial").contains("true"));
assert!(architecture.header("x-hemx-partial").contains("true"));
let architecture_doc = Html::parse_document(architecture.text());
assert_text(&architecture_doc, "Page swap");
assert_text(&architecture_doc, "slhx-build");
assert_text(&architecture_doc, "hemx-build");
let launch = post(
"/",
@@ -277,19 +277,19 @@ fn product_is_e2e_working_over_http() {
let sse = get("/events?once=1");
assert_eq!(sse.status, 200);
assert!(sse.header("content-type").contains("text/event-stream"));
assert!(sse.text().contains("event: slhx"));
assert!(sse.text().contains("event: hemx"));
assert!(sse.text().contains("data: "));
let unknown = post("/", &unknown_handle_form_body(999999));
assert_eq!(unknown.status, 404);
assert!(unknown.text().contains("unknown slhx handle id 999999"));
assert!(unknown.text().contains("unknown hemx handle id 999999"));
}
fn assert_effect_response(response: &Response) {
assert_eq!(response.status, 200);
assert!(response.header("content-type").contains("application/slhx"));
assert!(response.header("content-type").contains("application/hemx"));
assert_eq!(
response.header("x-slhx-fingerprint"),
response.header("x-hemx-fingerprint"),
BUILD_FINGERPRINT.0.to_string()
);
assert!(!response.effects().is_empty());
+5 -5
View File
@@ -1,5 +1,5 @@
[package]
name = "slhx-v0-examples"
name = "hemx-v0-examples"
version.workspace = true
edition.workspace = true
publish = false
@@ -11,13 +11,13 @@ path = "src/lib.rs"
axum = "0.7"
futures-util = "0.3"
hemplate = { path = "../../../hemplate/hemplate" }
slhx = { path = "../../slhx" }
slhx-axum = { path = "../../slhx-axum" }
hemx = { path = "../../hemx" }
hemx-axum = { path = "../../hemx-axum" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
[dev-dependencies]
scraper = "0.23"
slhx-test = { path = "../../slhx-test" }
hemx-test = { path = "../../hemx-test" }
[build-dependencies]
slhx-build = { path = "../../slhx-build" }
hemx-build = { path = "../../hemx-build" }
+2 -2
View File
@@ -1,9 +1,9 @@
# slhx v0 browser examples
# hemx v0 browser examples
Run the examples server:
```sh
cargo run -p slhx-v0-examples
cargo run -p hemx-v0-examples
```
Open <http://127.0.0.1:3000>.
+1 -1
View File
@@ -1,3 +1,3 @@
fn main() {
slhx_build::app().run().unwrap();
hemx_build::app().run().unwrap();
}
+14 -14
View File
@@ -1,12 +1,12 @@
#[slhx::surface]
#[hemx::surface]
pub mod ui {}
#[cfg(test)]
mod tests {
use super::ui::{auth, counter, notifications, page_swap, todos, wizard};
use hemplate::Hemplate;
use slhx::{push, IntoEffect};
use slhx_test::inspect;
use hemx::{push, IntoEffect};
use hemx_test::inspect;
#[derive(Clone, Debug)]
struct Todo {
@@ -15,19 +15,19 @@ mod tests {
}
#[derive(Clone, Debug)]
#[slhx::form("new_todo")]
#[hemx::form("new_todo")]
struct TodoInput {
title: String,
}
#[derive(Clone, Debug)]
#[slhx::form("wizard_input")]
#[hemx::form("wizard_input")]
struct WizardInput {
step: u8,
}
#[derive(Clone, Debug)]
#[slhx::form("credentials")]
#[hemx::form("credentials")]
struct Credentials {
email: String,
password: String,
@@ -40,8 +40,8 @@ mod tests {
title: String,
}
impl slhx::KeyedPartial for TodoRow {
fn slhx_key(&self) -> String {
impl hemx::KeyedPartial for TodoRow {
fn hemx_key(&self) -> String {
self.id.to_string()
}
}
@@ -67,8 +67,8 @@ mod tests {
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
#[test]
fn form_handler_is_checked_against_hemplate_form() {
#[slhx::handler]
fn add_todo(_form: slhx::Form<TodoInput>) -> impl IntoEffect {
#[hemx::handler]
fn add_todo(_form: hemx::Form<TodoInput>) -> impl IntoEffect {
todos::todo_list.set("queued")
}
@@ -101,8 +101,8 @@ mod tests {
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
#[test]
fn wizard_form_handler_is_checked_against_hemplate_form() {
#[slhx::handler]
fn next_step(_form: slhx::Form<WizardInput>) -> impl IntoEffect {
#[hemx::handler]
fn next_step(_form: hemx::Form<WizardInput>) -> impl IntoEffect {
wizard::wizard_step.set("queued")
}
@@ -146,8 +146,8 @@ mod tests {
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
#[test]
fn auth_form_handler_is_checked_against_hemplate_form() {
#[slhx::handler]
fn login(_form: slhx::Form<Credentials>) -> impl IntoEffect {
#[hemx::handler]
fn login(_form: hemx::Form<Credentials>) -> impl IntoEffect {
auth::login_status.set("queued")
}
+32 -32
View File
@@ -4,12 +4,12 @@ use axum::routing::get;
use axum::Router;
use futures_util::{stream, StreamExt};
use hemplate::Hemplate;
use slhx::{Html, IntoEffect};
use slhx_axum::{
use hemx::{Html, IntoEffect};
use hemx_axum::{
interactions, runtime_js, sse, EffectResponse, Form, InteractionRequest, PageRequest, Registry,
};
use slhx_v0_examples::ui;
use slhx_v0_examples::ui::{auth, counter, notifications, page_swap, todo_row, todos, wizard};
use hemx_v0_examples::ui;
use hemx_v0_examples::ui::{auth, counter, notifications, page_swap, todo_row, todos, wizard};
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::net::SocketAddr;
@@ -31,28 +31,28 @@ struct TodoRecord {
title: String,
}
#[slhx::form("new_todo")]
#[hemx::form("new_todo")]
struct NewTodo {
title: String,
}
#[slhx::form("rename_todo")]
#[hemx::form("rename_todo")]
struct RenameTodo {
id: u64,
title: String,
}
#[slhx::form("delete_todo")]
#[hemx::form("delete_todo")]
struct DeleteTodo {
id: u64,
}
#[slhx::form("wizard_input")]
#[hemx::form("wizard_input")]
struct WizardInput {
step: String,
}
#[slhx::form("credentials")]
#[hemx::form("credentials")]
struct Credentials {
email: String,
password: String,
@@ -82,8 +82,8 @@ struct TodoRow {
title: String,
}
impl slhx::KeyedPartial for TodoRow {
fn slhx_key(&self) -> String {
impl hemx::KeyedPartial for TodoRow {
fn hemx_key(&self) -> String {
self.id.to_string()
}
}
@@ -129,12 +129,12 @@ async fn main() {
.route("/", get(home).post(interact))
.route("/docs", get(docs).post(interact))
.route("/events", get(events))
.route("/slhx.js", get(runtime))
.route("/hemx.js", get(runtime))
.with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
println!("slhx v0 examples: http://{addr}");
println!("hemx v0 examples: http://{addr}");
axum::serve(listener, app).await.unwrap();
}
@@ -142,7 +142,7 @@ async fn main() {
async fn home(request: PageRequest) -> impl IntoResponse {
request
.page_html(all_examples(), shell)
.title("slhx v0 examples")
.title("hemx v0 examples")
.fingerprint(ui::BUILD_FINGERPRINT)
}
@@ -188,7 +188,7 @@ async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResp
sse(batches)
}
#[slhx::app(
#[hemx::app(
counter_handlers,
todo_handlers,
todo_row_handlers,
@@ -216,11 +216,11 @@ fn shell(body: Html) -> Html {
ui::render(&AppShell { body })
}
#[slhx::component("counter")]
#[hemx::component("counter")]
mod counter_handlers {
use super::*;
#[slhx::handler]
#[hemx::handler]
async fn increment(State(state): State<Arc<ExampleState>>) -> impl IntoEffect {
// req: examples/001
let mut counter = state.counter.lock().unwrap();
@@ -229,11 +229,11 @@ mod counter_handlers {
}
}
#[slhx::component("todos")]
#[hemx::component("todos")]
mod todo_handlers {
use super::*;
#[slhx::handler]
#[hemx::handler]
async fn add_todo(
State(state): State<Arc<ExampleState>>,
Form(form): Form<NewTodo>,
@@ -261,7 +261,7 @@ mod todo_handlers {
))
}
#[slhx::handler]
#[hemx::handler]
async fn rename_todo(
State(state): State<Arc<ExampleState>>,
Form(form): Form<RenameTodo>,
@@ -269,7 +269,7 @@ mod todo_handlers {
rename_todo_effect(state, form)
}
#[slhx::handler]
#[hemx::handler]
async fn delete_todo(
State(state): State<Arc<ExampleState>>,
Form(form): Form<DeleteTodo>,
@@ -278,11 +278,11 @@ mod todo_handlers {
}
}
#[slhx::component("todo_row")]
#[hemx::component("todo_row")]
mod todo_row_handlers {
use super::*;
#[slhx::handler]
#[hemx::handler]
async fn rename_todo_row(
State(state): State<Arc<ExampleState>>,
Form(form): Form<RenameTodo>,
@@ -290,7 +290,7 @@ mod todo_row_handlers {
rename_todo_effect(state, form)
}
#[slhx::handler]
#[hemx::handler]
async fn delete_todo_row(
State(state): State<Arc<ExampleState>>,
Form(form): Form<DeleteTodo>,
@@ -299,11 +299,11 @@ mod todo_row_handlers {
}
}
#[slhx::component("wizard")]
#[hemx::component("wizard")]
mod wizard_handlers {
use super::*;
#[slhx::handler]
#[hemx::handler]
async fn next_step(
State(state): State<Arc<ExampleState>>,
Form(form): Form<WizardInput>,
@@ -316,11 +316,11 @@ mod wizard_handlers {
}
}
#[slhx::component("auth")]
#[hemx::component("auth")]
mod auth_handlers {
use super::*;
#[slhx::handler]
#[hemx::handler]
async fn login(
State(_state): State<Arc<ExampleState>>,
Form(credentials): Form<Credentials>,
@@ -401,7 +401,7 @@ fn render_docs_content(message: &'static str) -> Html {
mod tests {
use super::*;
use scraper::{Html, Selector};
use slhx_test::{
use hemx_test::{
article_selector, document_title_selector, escaped_markup_selector, heading_selector,
inspect_batch, keyed_items_selector, keyed_selector, list_item_selector,
page_nav_link_selector, prose_selector, root_element_selector, runtime_script_selector,
@@ -411,8 +411,8 @@ mod tests {
Selector::parse(value).expect("test selector parses")
}
fn form<I>(handle: slhx::Handle<I>, fields: &[(&str, &str)]) -> slhx_axum::InteractionForm {
slhx_axum::InteractionForm::for_handle(
fn form<I>(handle: hemx::Handle<I>, fields: &[(&str, &str)]) -> hemx_axum::InteractionForm {
hemx_axum::InteractionForm::for_handle(
handle,
fields
.iter()
@@ -430,7 +430,7 @@ mod tests {
.select(&selector(document_title_selector()))
.next()
.map(|title| title.text().collect::<String>()),
Some("slhx v0 examples".to_owned())
Some("hemx v0 examples".to_owned())
);
assert_eq!(
document
+3 -3
View File
@@ -3,8 +3,8 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>slhx v0 examples</title>
<script src="/slhx.js" defer></script>
<title>hemx v0 examples</title>
<script src="/hemx.js" defer></script>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; max-width: 54rem; }
section, main { border: 1px solid #ddd; border-radius: .5rem; margin: 1rem 0; padding: 1rem; }
@@ -13,7 +13,7 @@
</style>
</head>
<body>
<h1>slhx v0 browser examples</h1>
<h1>hemx v0 browser examples</h1>
<p>Try the counter, todo form, wizard, login, page swap, and live SSE notifications.</p>
{+= self.body =+}
</body>
+3 -3
View File
@@ -1,8 +1,8 @@
<section data-slhx-root="auth">
<form data-slhx-handle="login" data-slhx-form="credentials">
<section data-hemx-root="auth">
<form data-hemx-handle="login" data-hemx-form="credentials">
<input name="email" type="email" autocomplete="username" required>
<input name="password" type="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
</form>
<p data-slhx-slot="login_status">Signed out</p>
<p data-hemx-slot="login_status">Signed out</p>
</section>
+3 -3
View File
@@ -1,4 +1,4 @@
<section data-slhx-root="counter">
<output data-slhx-slot="counter_value">0</output>
<button type="button" data-slhx-handle="increment">+</button>
<section data-hemx-root="counter">
<output data-hemx-slot="counter_value">0</output>
<button type="button" data-hemx-handle="increment">+</button>
</section>
+2 -2
View File
@@ -1,4 +1,4 @@
<section data-slhx-root="notifications" data-slhx-sse="/events">
<section data-hemx-root="notifications" data-hemx-sse="/events">
<h2>Notifications</h2>
<div data-slhx-slot="notifications">No notifications</div>
<div data-hemx-slot="notifications">No notifications</div>
</section>
+5 -5
View File
@@ -1,7 +1,7 @@
<main data-slhx-root="docs">
<nav data-slhx-slot="nav">
<a href="/docs" data-slhx-nav="">Docs</a>
<main data-hemx-root="docs">
<nav data-hemx-slot="nav">
<a href="/docs" data-hemx-nav="">Docs</a>
</nav>
<article data-slhx-slot="content">{+= self.content =+}</article>
<title data-slhx-slot="title">{+ self.title +}</title>
<article data-hemx-slot="content">{+= self.content =+}</article>
<title data-hemx-slot="title">{+ self.title +}</title>
</main>
+2 -2
View File
@@ -1,10 +1,10 @@
<li>
<span>{+ self.title +}</span>
<form data-slhx-handle="rename_todo_row">
<form data-hemx-handle="rename_todo_row">
<input type="hidden" name="id" +value="self.id">
<button type="submit" name="title" value="Renamed todo">Rename</button>
</form>
<form data-slhx-handle="delete_todo_row">
<form data-hemx-handle="delete_todo_row">
<button type="submit" name="id" +value="self.id">Delete</button>
</form>
</li>
+8 -8
View File
@@ -1,19 +1,19 @@
<section data-slhx-root="todos">
<form data-slhx-handle="add_todo" data-slhx-form="new_todo">
<section data-hemx-root="todos">
<form data-hemx-handle="add_todo" data-hemx-form="new_todo">
<input name="title" required="required">
<button type="submit">Add</button>
</form>
<p data-slhx-slot="summary">{+ self.summary +}</p>
<div data-slhx-slot="todo_list">
<ul data-slhx-slot="todo_row">
<p data-hemx-slot="summary">{+ self.summary +}</p>
<div data-hemx-slot="todo_list">
<ul data-hemx-slot="todo_row">
<template h-for="todo in &self.items" h-key="todo.id">
<li data-slhx-slot="todo_row" +data-key="todo.id">
<li data-hemx-slot="todo_row" +data-key="todo.id">
<span>{+ todo.title +}</span>
<form data-slhx-handle="rename_todo">
<form data-hemx-handle="rename_todo">
<input type="hidden" name="id" +value="todo.id">
<button type="submit" name="title" value="Renamed todo">Rename</button>
</form>
<form data-slhx-handle="delete_todo">
<form data-hemx-handle="delete_todo">
<button type="submit" name="id" +value="todo.id">Delete</button>
</form>
</li>
+3 -3
View File
@@ -1,7 +1,7 @@
<section data-slhx-root="wizard">
<form data-slhx-handle="next_step" data-slhx-form="wizard_input">
<section data-hemx-root="wizard">
<form data-hemx-handle="next_step" data-hemx-form="wizard_input">
<input type="hidden" name="step" value="1" required>
<div data-slhx-slot="wizard_step">Step 1</div>
<div data-hemx-slot="wizard_step">Step 1</div>
<button type="submit">Next</button>
</form>
</section>
@@ -1,5 +1,5 @@
[package]
name = "slhx-axum"
name = "hemx-axum"
version.workspace = true
edition.workspace = true
@@ -9,8 +9,8 @@ path = "src/lib.rs"
[dependencies]
axum = { version = "0.7", default-features = false, features = ["multipart", "tokio"] }
futures-util = { version = "0.3", default-features = false }
slhx-core = { path = "../slhx-core" }
slhx-js = { path = "../slhx-js" }
hemx-core = { path = "../hemx-core" }
hemx-js = { path = "../hemx-js" }
[dev-dependencies]
scraper = "0.23"
+42 -42
View File
@@ -6,20 +6,20 @@ use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Respon
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::IntoResponse;
use futures_util::{Stream, StreamExt};
use slhx_core::{BuildFingerprint, EffectBatch, FromForm, Handle, IntoEffect, SafeHtml};
use hemx_core::{BuildFingerprint, EffectBatch, FromForm, Handle, IntoEffect, SafeHtml};
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::fmt;
use std::future::Future;
use std::pin::Pin;
pub const SLHX_PARTIAL_HEADER: &str = "x-slhx-partial";
pub const SLHX_FINGERPRINT_HEADER: &str = "x-slhx-fingerprint";
pub const SLHX_TITLE_HEADER: &str = "x-slhx-title";
pub const SLHX_CONTENT_TYPE: &str = "application/slhx";
pub const SLHX_HANDLE_FIELD: &str = "__h";
pub const SLHX_RUNTIME_CONTENT_TYPE: &str = "application/javascript; charset=utf-8";
pub const SLHX_SSE_EVENT: &str = "slhx";
pub const HEMX_PARTIAL_HEADER: &str = "x-hemx-partial";
pub const HEMX_FINGERPRINT_HEADER: &str = "x-hemx-fingerprint";
pub const HEMX_TITLE_HEADER: &str = "x-hemx-title";
pub const HEMX_CONTENT_TYPE: &str = "application/hemx";
pub const HEMX_HANDLE_FIELD: &str = "__h";
pub const HEMX_RUNTIME_CONTENT_TYPE: &str = "application/javascript; charset=utf-8";
pub const HEMX_SSE_EVENT: &str = "hemx";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct RuntimeJs;
@@ -333,7 +333,7 @@ impl InteractionForm {
) -> Result<Self, InteractionFormRejection> {
let Some(handle) = fields
.iter()
.find_map(|(name, value)| (name == SLHX_HANDLE_FIELD).then_some(value))
.find_map(|(name, value)| (name == HEMX_HANDLE_FIELD).then_some(value))
else {
return Err(InteractionFormRejection::MissingHandle);
};
@@ -966,9 +966,9 @@ impl DispatchRegistry for HandlerRegistry {
impl IntoResponse for InteractionFormRejection {
fn into_response(self) -> axum::response::Response {
let (status, message) = match self {
Self::InvalidBody => (StatusCode::BAD_REQUEST, "invalid slhx form body"),
Self::MissingHandle => (StatusCode::BAD_REQUEST, "missing __h slhx handle field"),
Self::InvalidHandle => (StatusCode::BAD_REQUEST, "invalid __h slhx handle field"),
Self::InvalidBody => (StatusCode::BAD_REQUEST, "invalid hemx form body"),
Self::MissingHandle => (StatusCode::BAD_REQUEST, "missing __h hemx handle field"),
Self::InvalidHandle => (StatusCode::BAD_REQUEST, "invalid __h hemx handle field"),
};
(status, message).into_response()
}
@@ -1025,7 +1025,7 @@ fn is_multipart(headers: &HeaderMap) -> bool {
impl PageMode {
pub fn from_headers(headers: &HeaderMap) -> Self {
match headers
.get(SLHX_PARTIAL_HEADER)
.get(HEMX_PARTIAL_HEADER)
.and_then(|value| value.to_str().ok())
{
Some("1" | "true") => Self::Partial,
@@ -1048,28 +1048,28 @@ impl IntoResponse for PageResponse {
if self.mode == PageMode::Partial {
response
.headers_mut()
.insert(SLHX_PARTIAL_HEADER, HeaderValue::from_static("true"));
.insert(HEMX_PARTIAL_HEADER, HeaderValue::from_static("true"));
}
if let Some(fingerprint) = self.fingerprint.and_then(fingerprint_header) {
response
.headers_mut()
.insert(SLHX_FINGERPRINT_HEADER, fingerprint);
.insert(HEMX_FINGERPRINT_HEADER, fingerprint);
}
if let Some(title) = self
.title
.and_then(|title| HeaderValue::from_str(&title).ok())
{
response.headers_mut().insert(SLHX_TITLE_HEADER, title);
response.headers_mut().insert(HEMX_TITLE_HEADER, title);
}
response
}
}
fn html_with_root_fingerprint(mut html: String, fingerprint: BuildFingerprint) -> String {
if html.contains("data-slhx-fp=") {
if html.contains("data-hemx-fp=") {
return html;
}
let Some(root_attr) = html.find("data-slhx-root") else {
let Some(root_attr) = html.find("data-hemx-root") else {
return html;
};
let Some(tag_start) = html[..root_attr].rfind('<') else {
@@ -1079,7 +1079,7 @@ fn html_with_root_fingerprint(mut html: String, fingerprint: BuildFingerprint) -
return html;
};
let insert_at = tag_start + tag_end;
html.insert_str(insert_at, &format!(" data-slhx-fp=\"{}\"", fingerprint.0));
html.insert_str(insert_at, &format!(" data-hemx-fp=\"{}\"", fingerprint.0));
html
}
@@ -1093,12 +1093,12 @@ impl IntoResponse for EffectResponse {
let mut response = Response::new(Body::from(bytes));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static(SLHX_CONTENT_TYPE),
HeaderValue::from_static(HEMX_CONTENT_TYPE),
);
if let Some(fingerprint) = fingerprint_header(self.batch.fingerprint) {
response
.headers_mut()
.insert(SLHX_FINGERPRINT_HEADER, fingerprint);
.insert(HEMX_FINGERPRINT_HEADER, fingerprint);
}
response
}
@@ -1109,17 +1109,17 @@ impl IntoResponse for DispatchRejection {
match self {
Self::UnknownHandle(handle_id) => (
StatusCode::NOT_FOUND,
format!("unknown slhx handle id {handle_id}"),
format!("unknown hemx handle id {handle_id}"),
)
.into_response(),
Self::InvalidForm { handle_id, message } => (
StatusCode::BAD_REQUEST,
format!("invalid slhx form for handle id {handle_id}: {message}"),
format!("invalid hemx form for handle id {handle_id}: {message}"),
)
.into_response(),
Self::HandlerError { handle_id, message } => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("slhx handler for handle id {handle_id} failed: {message}"),
format!("hemx handler for handle id {handle_id} failed: {message}"),
)
.into_response(),
}
@@ -1128,10 +1128,10 @@ impl IntoResponse for DispatchRejection {
impl IntoResponse for RuntimeJs {
fn into_response(self) -> axum::response::Response {
let mut response = Response::new(Body::from(slhx_js::RUNTIME_JS));
let mut response = Response::new(Body::from(hemx_js::RUNTIME_JS));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static(SLHX_RUNTIME_CONTENT_TYPE),
HeaderValue::from_static(HEMX_RUNTIME_CONTENT_TYPE),
);
response.headers_mut().insert(
header::CACHE_CONTROL,
@@ -1142,7 +1142,7 @@ impl IntoResponse for RuntimeJs {
}
pub fn runtime_js_source() -> &'static str {
slhx_js::RUNTIME_JS
hemx_js::RUNTIME_JS
}
// req: push/001, req: push/003, req: push/004
@@ -1156,7 +1156,7 @@ where
pub fn sse_event(batch: EffectBatch) -> Event {
Event::default()
.event(SLHX_SSE_EVENT)
.event(HEMX_SSE_EVENT)
.data(encode_sse_batch(&batch))
}
@@ -1248,7 +1248,7 @@ fn hex(byte: u8) -> Option<u8> {
mod tests {
use super::{
encode_sse_batch, html_with_root_fingerprint, sse, BuildFingerprint, InteractionForm,
SLHX_SSE_EVENT,
HEMX_SSE_EVENT,
};
use axum::{
body::{to_bytes, Body},
@@ -1258,7 +1258,7 @@ mod tests {
};
use futures_util::stream;
use scraper::{Html, Selector};
use slhx_core::{EffectBatch, EFFECT_BATCH_ABI_VERSION};
use hemx_core::{EffectBatch, EFFECT_BATCH_ABI_VERSION};
use std::convert::Infallible;
fn selector(value: &str) -> Selector {
@@ -1269,16 +1269,16 @@ mod tests {
fn root_fingerprint_is_added_to_initial_root() {
// req: test/005
let html = html_with_root_fingerprint(
"<html><body><main data-slhx-root>Docs</main></body></html>".into(),
"<html><body><main data-hemx-root>Docs</main></body></html>".into(),
BuildFingerprint(99),
);
let document = Html::parse_document(&html);
let root = document
.select(&selector("main[data-slhx-root]"))
.select(&selector("main[data-hemx-root]"))
.next()
.expect("root element is rendered");
assert_eq!(root.value().attr("data-slhx-fp"), Some("99"));
assert_eq!(root.value().attr("data-hemx-fp"), Some("99"));
assert_eq!(root.text().collect::<String>(), "Docs");
}
@@ -1286,16 +1286,16 @@ mod tests {
fn existing_root_fingerprint_is_preserved() {
// req: test/005
let html = html_with_root_fingerprint(
"<main data-slhx-root data-slhx-fp=\"1\">Docs</main>".into(),
"<main data-hemx-root data-hemx-fp=\"1\">Docs</main>".into(),
BuildFingerprint(99),
);
let document = Html::parse_fragment(&html);
let root = document
.select(&selector("main[data-slhx-root]"))
.select(&selector("main[data-hemx-root]"))
.next()
.expect("root element is rendered");
assert_eq!(root.value().attr("data-slhx-fp"), Some("1"));
assert_eq!(root.value().attr("data-hemx-fp"), Some("1"));
assert_eq!(root.text().collect::<String>(), "Docs");
}
@@ -1317,26 +1317,26 @@ mod tests {
assert_eq!(
String::from_utf8(body.to_vec()).unwrap(),
format!("event: {SLHX_SSE_EVENT}\ndata: {encoded}\n\n")
format!("event: {HEMX_SSE_EVENT}\ndata: {encoded}\n\n")
);
assert!(!encoded.contains('='));
}
#[tokio::test]
async fn interaction_form_extracts_multipart_fields_and_files() {
let boundary = "slhx-test-boundary";
let boundary = "hemx-test-boundary";
let body = concat!(
"--slhx-test-boundary\r\n",
"--hemx-test-boundary\r\n",
"Content-Disposition: form-data; name=\"__h\"\r\n\r\n",
"7\r\n",
"--slhx-test-boundary\r\n",
"--hemx-test-boundary\r\n",
"Content-Disposition: form-data; name=\"title\"\r\n\r\n",
"Report\r\n",
"--slhx-test-boundary\r\n",
"--hemx-test-boundary\r\n",
"Content-Disposition: form-data; name=\"upload\"; filename=\"a.txt\"\r\n",
"Content-Type: text/plain\r\n\r\n",
"hello\r\n",
"--slhx-test-boundary--\r\n",
"--hemx-test-boundary--\r\n",
);
let request = Request::builder()
.header(
@@ -2,13 +2,13 @@ use axum::extract::State;
use axum::http::{header, HeaderMap};
use axum::response::IntoResponse;
use scraper::{Html, Selector};
use slhx_axum::{
use hemx_axum::{
interactions, runtime_js, DispatchRejection, EffectResponse, Form, InteractionForm,
InteractionFormRejection, InteractionRequest, PageMode, PageRequest, PageResponse,
SLHX_CONTENT_TYPE, SLHX_FINGERPRINT_HEADER, SLHX_PARTIAL_HEADER, SLHX_RUNTIME_CONTENT_TYPE,
SLHX_TITLE_HEADER,
HEMX_CONTENT_TYPE, HEMX_FINGERPRINT_HEADER, HEMX_PARTIAL_HEADER, HEMX_RUNTIME_CONTENT_TYPE,
HEMX_TITLE_HEADER,
};
use slhx_core::{push, BuildFingerprint, Handle, IntoEffect, SafeHtml, Slot};
use hemx_core::{push, BuildFingerprint, Handle, IntoEffect, SafeHtml, Slot};
fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses")
@@ -29,18 +29,18 @@ struct OpenProject {
project_id: ProjectId,
}
impl slhx_core::FromForm for OpenProject {
fn from_form_fields(fields: &[(String, String)]) -> Result<Self, slhx_core::FormError> {
impl hemx_core::FromForm for OpenProject {
fn from_form_fields(fields: &[(String, String)]) -> Result<Self, hemx_core::FormError> {
let Some(value) = fields
.iter()
.find_map(|(name, value)| (name == "project_id").then_some(value.as_str()))
else {
return Err(slhx_core::FormError::new("missing form field `project_id`"));
return Err(hemx_core::FormError::new("missing form field `project_id`"));
};
Ok(Self {
project_id: value
.parse()
.map_err(|_| slhx_core::FormError::new("invalid form field `project_id`"))?,
.map_err(|_| hemx_core::FormError::new("invalid form field `project_id`"))?,
})
}
}
@@ -50,7 +50,7 @@ fn page_mode_detects_partial_header() {
let mut headers = HeaderMap::new();
assert_eq!(PageMode::from_headers(&headers), PageMode::Full);
headers.insert(SLHX_PARTIAL_HEADER, "true".parse().unwrap());
headers.insert(HEMX_PARTIAL_HEADER, "true".parse().unwrap());
assert_eq!(PageMode::from_headers(&headers), PageMode::Partial);
}
@@ -158,16 +158,16 @@ fn partial_page_response_sets_partial_and_title_headers() {
response.headers()[header::CONTENT_TYPE],
"text/html; charset=utf-8"
);
assert_eq!(response.headers()[SLHX_PARTIAL_HEADER], "true");
assert_eq!(response.headers()[SLHX_TITLE_HEADER], "Docs");
assert_eq!(response.headers()[HEMX_PARTIAL_HEADER], "true");
assert_eq!(response.headers()[HEMX_TITLE_HEADER], "Docs");
}
#[test]
fn effect_response_is_wire_batch_with_fingerprint_header() {
let response = EffectResponse::new(push("/docs"), BuildFingerprint(99)).into_response();
assert_eq!(response.headers()[header::CONTENT_TYPE], SLHX_CONTENT_TYPE);
assert_eq!(response.headers()[SLHX_FINGERPRINT_HEADER], "99");
assert_eq!(response.headers()[header::CONTENT_TYPE], HEMX_CONTENT_TYPE);
assert_eq!(response.headers()[HEMX_FINGERPRINT_HEADER], "99");
}
#[test]
@@ -404,7 +404,7 @@ fn runtime_js_response_serves_embedded_runtime() {
assert_eq!(
response.headers()[header::CONTENT_TYPE],
SLHX_RUNTIME_CONTENT_TYPE
HEMX_RUNTIME_CONTENT_TYPE
);
assert!(response.headers().contains_key(header::CACHE_CONTROL));
}
@@ -1,5 +1,5 @@
[package]
name = "slhx-build"
name = "hemx-build"
version.workspace = true
edition.workspace = true
@@ -8,4 +8,4 @@ path = "src/lib.rs"
[dependencies]
hemplate-core = { path = "../../hemplate/hemplate-core", features = ["surface"] }
slhx-core = { path = "../slhx-core" }
hemx-core = { path = "../hemx-core" }
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
[package]
name = "slhx-core"
name = "hemx-core"
version.workspace = true
edition.workspace = true
@@ -72,7 +72,7 @@ impl ResourceId {
/// req: dx/006 req: test/001
pub trait GeneratedTarget {
#[doc(hidden)]
fn __slhx_resource_id(self) -> ResourceId;
fn __hemx_resource_id(self) -> ResourceId;
}
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
@@ -178,7 +178,7 @@ impl core::fmt::Display for SafeHtml {
/// A generated, checked CSS class token.
///
/// Plain CSS/SCSS owns appearance; slhx only gives Rust a typed reference to
/// Plain CSS/SCSS owns appearance; hemx only gives Rust a typed reference to
/// class names discovered from build inputs. req: style/001
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct CssClass {
@@ -491,7 +491,7 @@ pub enum WireError {
TrailingBytes,
}
const WIRE_MAGIC: &[u8; 4] = b"SLHX";
const WIRE_MAGIC: &[u8; 4] = b"HEMX";
fn write_batch(batch: &EffectBatch, out: &mut Vec<u8>) {
out.extend_from_slice(WIRE_MAGIC);
@@ -1190,7 +1190,7 @@ impl<T> Form<T> {
pub fn reset(self) -> Effect {
Effect::Emit {
name: String::from("slhx:form-reset"),
name: String::from("hemx:form-reset"),
payload: self.id.id.to_string(),
}
}
@@ -1215,7 +1215,7 @@ impl<T> Form<T> {
payload.push('\u{1f}');
payload.push_str(&message);
Effect::Emit {
name: String::from("slhx:form-error"),
name: String::from("hemx:form-error"),
payload,
}
}
@@ -1228,7 +1228,7 @@ impl<T> Form<T> {
pub fn disable_while_pending(self) -> Effect {
Effect::Emit {
name: String::from("slhx:form-disable-while-pending"),
name: String::from("hemx:form-disable-while-pending"),
payload: self.id.id.to_string(),
}
}
@@ -1,4 +1,4 @@
use slhx_core::{
use hemx_core::{
event, navigate, redirect, replace, Atom, AtomSnapshot, AtomState, BuildFingerprint,
ComponentRef, CssClass, CssClasses, Effect, EffectBatch, Form, Handle, IntoEffect, KeyedSlot,
NavigateMode, ParamName, Payload, ResourceKind, SafeHtml, ScopeKey, Slot,
@@ -21,7 +21,7 @@ fn effect_batch_wire_round_trips() {
.into_batch(BuildFingerprint(42));
let bytes = batch.to_wire();
assert_eq!(&bytes[..4], b"SLHX");
assert_eq!(&bytes[..4], b"HEMX");
let decoded = EffectBatch::from_wire(&bytes).unwrap();
assert_eq!(decoded, batch);
@@ -84,7 +84,7 @@ fn generated_form_helpers_target_form_fields() {
panic!("expected Emit");
};
assert_eq!(name, "slhx:form-error");
assert_eq!(name, "hemx:form-error");
assert_eq!(payload, "4\u{1f}email\u{1f}Use your work email");
let Effect::Focus { target } = signup.focus("email") else {
@@ -1,5 +1,5 @@
[package]
name = "slhx-derive"
name = "hemx-derive"
version.workspace = true
edition.workspace = true
@@ -1,4 +1,4 @@
fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(out.join("slhx.generated.rs"), "").unwrap();
std::fs::write(out.join("hemx.generated.rs"), "").unwrap();
}
@@ -14,7 +14,7 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
let name = function.sig.ident.to_string();
let Some(syms_path) = syms_path() else {
let message = "#[slhx::handler] requires generated slhx files; add slhx_build::app().run()? to build.rs or run inside a Cargo crate";
let message = "#[hemx::handler] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate";
return quote!(
#function
compile_error!(#message);
@@ -22,7 +22,7 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
.into();
};
if !syms_path.exists() {
let message = "#[slhx::handler] could not find generated slhx symbols; add slhx_build::app().run()? to build.rs or check template generation";
let message = "#[hemx::handler] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation";
return quote!(
#function
compile_error!(#message);
@@ -31,7 +31,7 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
}
if !syms_contains_handle(&syms_path, &name) {
let message = format!(
"unknown slhx handle `{name}`; add `data-slhx-handle=\"{name}\"` to a template or rename this handler"
"unknown hemx handle `{name}`; add `data-hemx-handle=\"{name}\"` to a template or rename this handler"
);
return quote!(
#function
@@ -41,7 +41,7 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
}
if !has_form_param(&function) && !has_non_unit_return(&function) {
let message = format!(
"slhx handler `{name}` must accept a form/context parameter or return a value implementing IntoEffect"
"hemx handler `{name}` must accept a form/context parameter or return a value implementing IntoEffect"
);
return quote!(
#function
@@ -51,7 +51,7 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
}
if handle_requires_form(&syms_path, &name) && !has_form_param(&function) {
let message = format!(
"slhx handler `{name}` handles a generated form and must accept a typed form argument"
"hemx handler `{name}` handles a generated form and must accept a typed form argument"
);
return quote!(
#function
@@ -62,7 +62,7 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
let missing_params = missing_handle_params(&syms_path, &name, &function);
if !missing_params.is_empty() {
let message = format!(
"slhx handler `{name}` is missing generated param argument(s): {}",
"hemx handler `{name}` is missing generated param argument(s): {}",
missing_params.join(", ")
);
return quote!(
@@ -85,7 +85,7 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
let form_name = parse_macro_input!(attr as LitStr).value();
let form_struct = parse_macro_input!(item as ItemStruct);
let Some(syms_path) = syms_path() else {
let message = "#[slhx::form] requires generated slhx files; add slhx_build::app().run()? to build.rs or run inside a Cargo crate";
let message = "#[hemx::form] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate";
return quote!(
#form_struct
compile_error!(#message);
@@ -96,30 +96,30 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
if errors.is_empty() {
let ident = &form_struct.ident;
let resource_id =
form_resource_id(&syms_path, &form_name).expect("checked form exists in slhx.syms");
form_resource_id(&syms_path, &form_name).expect("checked form exists in hemx.syms");
let mut generics = form_struct.generics.clone();
for ty in form_parser_types(&form_struct) {
generics
.make_where_clause()
.predicates
.push(parse_quote!(#ty: ::slhx::FormValue));
.push(parse_quote!(#ty: ::hemx::FormValue));
}
let decode_fields = form_decode_fields(&syms_path, &form_name, &form_struct);
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
quote!(
#form_struct
impl #impl_generics ::slhx::FormModel for #ident #ty_generics #where_clause {}
impl #impl_generics ::slhx::FromForm for #ident #ty_generics #where_clause {
impl #impl_generics ::hemx::FormModel for #ident #ty_generics #where_clause {}
impl #impl_generics ::hemx::FromForm for #ident #ty_generics #where_clause {
fn from_form_fields(
__slhx_fields: &[(String, String)],
) -> Result<Self, ::slhx::FormError> {
__hemx_fields: &[(String, String)],
) -> Result<Self, ::hemx::FormError> {
Ok(Self {
#(#decode_fields),*
})
}
}
impl #impl_generics #ident #ty_generics #where_clause {
pub const FORM: ::slhx::Form<Self> = ::slhx::Form::new(#resource_id);
pub const FORM: ::hemx::Form<Self> = ::hemx::Form::new(#resource_id);
}
)
.into()
@@ -144,7 +144,7 @@ pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream {
let Some((_, items)) = &module.content else {
return quote!(
#module
compile_error!("#[slhx::component] must be used on an inline module");
compile_error!("#[hemx::component] must be used on an inline module");
)
.into();
};
@@ -189,21 +189,21 @@ pub fn app(attr: TokenStream, item: TokenStream) -> TokenStream {
fn inject_surface_include(item: TokenStream) -> TokenStream {
let item = item.to_string();
let Some(insert_at) = item.rfind('}') else {
return compile_error("#[slhx::surface] must be used on an inline module");
return compile_error("#[hemx::surface] must be used on an inline module");
};
let include = surface_include();
let expanded = format!("{}{}{}", &item[..insert_at], include, &item[insert_at..]);
expanded
.parse()
.unwrap_or_else(|_| compile_error("#[slhx::surface] could not expand this module"))
.unwrap_or_else(|_| compile_error("#[hemx::surface] could not expand this module"))
}
fn surface_include() -> String {
let Some(path) = generated_path("slhx.generated.rs") else {
let Some(path) = generated_path("hemx.generated.rs") else {
return format!(
" compile_error!({:?}); ",
"#[slhx::surface] requires generated slhx files; add slhx_build::app().run()? to build.rs or run inside a Cargo crate"
"#[hemx::surface] requires generated hemx files; add hemx_build::app().run()? to build.rs or run inside a Cargo crate"
);
};
@@ -213,14 +213,14 @@ fn surface_include() -> String {
format!(
" compile_error!({:?}); ",
format!(
"#[slhx::surface] could not find generated slhx module; add slhx_build::app().run()? to build.rs or check template generation"
"#[hemx::surface] could not find generated hemx module; add hemx_build::app().run()? to build.rs or check template generation"
)
)
}
}
fn syms_path() -> Option<PathBuf> {
generated_path("slhx.syms")
generated_path("hemx.syms")
}
fn generated_path(file: &str) -> Option<PathBuf> {
@@ -245,7 +245,7 @@ fn form_model_type(ty: &Type) -> Option<Type> {
let path_is_form = if path.path.segments.len() == 1 {
first.ident == "Form"
} else {
first.ident == "slhx" && last.ident == "Form"
first.ident == "hemx" && last.ident == "Form"
};
if !path_is_form {
return None;
@@ -323,19 +323,19 @@ fn form_contract_errors(
) -> Vec<String> {
if !syms_path.exists() {
return vec![
"#[slhx::form] could not find generated slhx symbols; add slhx_build::app().run()? to build.rs or check template generation"
"#[hemx::form] could not find generated hemx symbols; add hemx_build::app().run()? to build.rs or check template generation"
.to_owned(),
];
}
let expected = form_fields(syms_path, form_name);
if expected.is_empty() {
return vec![format!(
"unknown slhx form `{form_name}`; add data-slhx-form=\"{form_name}\" to a template or rename this form binding"
"unknown hemx form `{form_name}`; add data-hemx-form=\"{form_name}\" to a template or rename this form binding"
)];
}
let Fields::Named(fields) = &form_struct.fields else {
return vec![format!(
"slhx form `{form_name}` must be a struct with named fields"
"hemx form `{form_name}` must be a struct with named fields"
)];
};
let actual = fields
@@ -352,7 +352,7 @@ fn form_contract_errors(
for field in expected {
let Some((_, ty)) = actual.iter().find(|(ident, _)| ident == &field.ident) else {
errors.push(format!(
"slhx form `{form_name}` is missing field `{}` for form control `{}`",
"hemx form `{form_name}` is missing field `{}` for form control `{}`",
field.ident, field.name
));
continue;
@@ -361,19 +361,19 @@ fn form_contract_errors(
let multiple = is_type_named(ty, "Vec");
if field.required && optional {
errors.push(format!(
"slhx form `{form_name}` field `{}` is required in HTML and must not be Option<_>",
"hemx form `{form_name}` field `{}` is required in HTML and must not be Option<_>",
field.ident
));
}
if field.multiple && !multiple {
errors.push(format!(
"slhx form `{form_name}` field `{}` accepts multiple values and must be Vec<_>",
"hemx form `{form_name}` field `{}` accepts multiple values and must be Vec<_>",
field.ident
));
}
if !field.multiple && multiple {
errors.push(format!(
"slhx form `{form_name}` field `{}` accepts one value and must not be Vec<_>",
"hemx form `{form_name}` field `{}` accepts one value and must not be Vec<_>",
field.ident
));
}
@@ -414,28 +414,28 @@ fn form_decode_fields(
let parser = parser_type(ty);
Some(if field.multiple {
quote! {
#ident: __slhx_fields
#ident: __hemx_fields
.iter()
.filter_map(|(__slhx_name, __slhx_value)|
(__slhx_name == #control_name).then_some(__slhx_value.as_str())
.filter_map(|(__hemx_name, __hemx_value)|
(__hemx_name == #control_name).then_some(__hemx_value.as_str())
)
.map(|__slhx_value| {
<#parser as ::slhx::FormValue>::parse_form_value(__slhx_value)
.map_err(|_| ::slhx::FormError::new(format!("invalid form field `{}`", #control_name)))
.map(|__hemx_value| {
<#parser as ::hemx::FormValue>::parse_form_value(__hemx_value)
.map_err(|_| ::hemx::FormError::new(format!("invalid form field `{}`", #control_name)))
})
.collect::<Result<Vec<_>, _>>()?
}
} else if is_type_named(ty, "Option") {
quote! {
#ident: match __slhx_fields
#ident: match __hemx_fields
.iter()
.find_map(|(__slhx_name, __slhx_value)|
(__slhx_name == #control_name).then_some(__slhx_value.as_str())
.find_map(|(__hemx_name, __hemx_value)|
(__hemx_name == #control_name).then_some(__hemx_value.as_str())
)
{
Some(__slhx_value) => Some(
<#parser as ::slhx::FormValue>::parse_form_value(__slhx_value)
.map_err(|_| ::slhx::FormError::new(format!("invalid form field `{}`", #control_name)))?
Some(__hemx_value) => Some(
<#parser as ::hemx::FormValue>::parse_form_value(__hemx_value)
.map_err(|_| ::hemx::FormError::new(format!("invalid form field `{}`", #control_name)))?
),
None => None,
}
@@ -443,16 +443,16 @@ fn form_decode_fields(
} else {
quote! {
#ident: {
let Some(__slhx_value) = __slhx_fields
let Some(__hemx_value) = __hemx_fields
.iter()
.find_map(|(__slhx_name, __slhx_value)|
(__slhx_name == #control_name).then_some(__slhx_value.as_str())
.find_map(|(__hemx_name, __hemx_value)|
(__hemx_name == #control_name).then_some(__hemx_value.as_str())
)
else {
return Err(::slhx::FormError::new(format!("missing form field `{}`", #control_name)));
return Err(::hemx::FormError::new(format!("missing form field `{}`", #control_name)));
};
<#parser as ::slhx::FormValue>::parse_form_value(__slhx_value)
.map_err(|_| ::slhx::FormError::new(format!("invalid form field `{}`", #control_name)))?
<#parser as ::hemx::FormValue>::parse_form_value(__hemx_value)
.map_err(|_| ::hemx::FormError::new(format!("invalid form field `{}`", #control_name)))?
}
}
})
@@ -595,7 +595,7 @@ fn handler_arg_names(function: &ItemFn) -> Vec<String> {
fn add_app_registry_helper(mut function: ItemFn, components: Vec<Path>) -> Result<ItemFn, String> {
if components.is_empty() {
return Err("#[slhx::app] requires component registry module(s), for example #[slhx::app(todo_handlers, auth_handlers)]".to_owned());
return Err("#[hemx::app] requires component registry module(s), for example #[hemx::app(todo_handlers, auth_handlers)]".to_owned());
}
let Some(state) = function.sig.inputs.iter().find_map(|arg| match arg {
FnArg::Typed(arg) => match arg.pat.as_ref() {
@@ -605,20 +605,20 @@ fn add_app_registry_helper(mut function: ItemFn, components: Vec<Path>) -> Resul
FnArg::Receiver(_) => None,
}) else {
return Err(
"#[slhx::app] must be used on a registry function with a named app state argument"
"#[hemx::app] must be used on a registry function with a named app state argument"
.to_owned(),
);
};
let body = function.block;
function.block = syn::parse2(quote!({
let __slhx_registry = (|| #body)();
let __hemx_registry = (|| #body)();
#(
let __slhx_registry = #components::register_with_state(
__slhx_registry,
::slhx_axum::State(#state.clone()),
let __hemx_registry = #components::register_with_state(
__hemx_registry,
::hemx_axum::State(#state.clone()),
);
)*
__slhx_registry
__hemx_registry
}))
.expect("generated app registry helper parses");
Ok(function)
@@ -667,8 +667,8 @@ fn add_component_register_helper(mut module: ItemMod, component: &str) -> ItemMo
});
let register: Item = syn::parse2(quote! {
pub fn register(
registry: ::slhx_axum::StateHandlerRegistry<#state_ty>,
) -> ::slhx_axum::StateHandlerRegistry<#state_ty>
registry: ::hemx_axum::StateHandlerRegistry<#state_ty>,
) -> ::hemx_axum::StateHandlerRegistry<#state_ty>
where
#state_ty: Clone + Send + Sync + 'static,
{
@@ -692,9 +692,9 @@ fn add_component_register_helper(mut module: ItemMod, component: &str) -> ItemMo
});
let register_with_state: Item = syn::parse2(quote! {
pub fn register_with_state(
registry: ::slhx_axum::HandlerRegistry,
registry: ::hemx_axum::HandlerRegistry,
state: #state_ty,
) -> ::slhx_axum::HandlerRegistry
) -> ::hemx_axum::HandlerRegistry
where
#state_ty: Clone + Send + Sync + 'static,
{
@@ -721,7 +721,7 @@ fn component_contract_errors(
if component.is_some() && !implemented.is_empty() && generated.is_empty() {
errors.push(format!(
"#[slhx::component({:?})] does not match any generated handles; check the .heml file name or component name",
"#[hemx::component({:?})] does not match any generated handles; check the .heml file name or component name",
component.unwrap()
));
}
@@ -729,7 +729,7 @@ fn component_contract_errors(
let ambiguous = duplicate_names(&generated);
if !ambiguous.is_empty() {
errors.push(format!(
"#[slhx::component] ambiguous generated handle name(s): {}; make handle names unique for this component before generated registration",
"#[hemx::component] ambiguous generated handle name(s): {}; make handle names unique for this component before generated registration",
ambiguous.join(", ")
));
}
@@ -741,7 +741,7 @@ fn component_contract_errors(
.collect::<Vec<_>>();
if !missing.is_empty() {
errors.push(format!(
"#[slhx::component] missing handler implementation(s): {}",
"#[hemx::component] missing handler implementation(s): {}",
missing.join(", ")
));
}
@@ -753,7 +753,7 @@ fn component_contract_errors(
.collect::<Vec<_>>();
if !extras.is_empty() {
errors.push(format!(
"#[slhx::component] handler(s) not declared by this component's generated handles: {}; move them to the matching component module or add data-slhx-handle in .heml",
"#[hemx::component] handler(s) not declared by this component's generated handles: {}; move them to the matching component module or add data-hemx-handle in .heml",
extras.join(", ")
));
}
@@ -771,7 +771,7 @@ fn missing_component_handlers(
.into_iter()
.find_map(|error| {
error
.strip_prefix("#[slhx::component] missing handler implementation(s): ")
.strip_prefix("#[hemx::component] missing handler implementation(s): ")
.map(|missing| missing.split(", ").map(ToOwned::to_owned).collect())
})
.unwrap_or_default()
@@ -882,10 +882,10 @@ mod tests {
#[test]
fn syms_lookup_matches_handle_ident() {
let path = std::env::temp_dir().join("slhx-derive-syms-test.syms");
let path = std::env::temp_dir().join("hemx-derive-syms-test.syms");
std::fs::write(
&path,
"slhx-syms-v1\nslot\ttemplates/a.heml::count\tcount\t1\nhandle\ttemplates/a.heml::create\tcreate\t2\nhandle_form\tcreate\tnew_todo\nhandle_param\tcreate\ttodo_id\n",
"hemx-syms-v1\nslot\ttemplates/a.heml::count\tcount\t1\nhandle\ttemplates/a.heml::create\tcreate\t2\nhandle_form\tcreate\tnew_todo\nhandle_param\tcreate\ttodo_id\n",
)
.unwrap();
@@ -903,11 +903,11 @@ mod tests {
fn handler_shape_accepts_form_or_effect_return() {
// req: derive_handler/001
let with_form = parse_quote!(
fn save(form: slhx::Form<String>) {}
fn save(form: hemx::Form<String>) {}
);
let with_return = parse_quote!(
fn ping() -> impl slhx::IntoEffect {
slhx::advanced::EffectBatch::default()
fn ping() -> impl hemx::IntoEffect {
hemx::advanced::EffectBatch::default()
}
);
let empty = parse_quote!(
@@ -948,7 +948,7 @@ mod tests {
#[test]
fn form_param_matches_form_type_not_name_suffix() {
// req: form/004 req: form/006
let qualified_form: Type = parse_quote!(slhx::Form<CreateTodo>);
let qualified_form: Type = parse_quote!(hemx::Form<CreateTodo>);
let imported_form: Type = parse_quote!(Form<CreateTodo>);
let name_suffix_impostor: Type = parse_quote!(CreateTodoForm);
let nongeneric_impostor: Type = parse_quote!(Form);
@@ -963,20 +963,20 @@ mod tests {
#[test]
fn handler_params_match_generated_param_names() {
let path = std::env::temp_dir().join("slhx-derive-param-test.syms");
let path = std::env::temp_dir().join("hemx-derive-param-test.syms");
std::fs::write(
&path,
"slhx-syms-v1\nhandle_param\tshow\ttodo_id\nhandle_param\tshow\tmode\n",
"hemx-syms-v1\nhandle_param\tshow\ttodo_id\nhandle_param\tshow\tmode\n",
)
.unwrap();
let complete: ItemFn = parse_quote!(
fn show(todo_id: String, mode: String) -> impl slhx::IntoEffect {
slhx::advanced::EffectBatch::default()
fn show(todo_id: String, mode: String) -> impl hemx::IntoEffect {
hemx::advanced::EffectBatch::default()
}
);
let missing: ItemFn = parse_quote!(
fn show(todo_id: String) -> impl slhx::IntoEffect {
slhx::advanced::EffectBatch::default()
fn show(todo_id: String) -> impl hemx::IntoEffect {
hemx::advanced::EffectBatch::default()
}
);
@@ -988,16 +988,16 @@ mod tests {
#[test]
fn component_handlers_match_generated_handles() {
let path = std::env::temp_dir().join("slhx-derive-component-test.syms");
let path = std::env::temp_dir().join("hemx-derive-component-test.syms");
std::fs::write(
&path,
"slhx-syms-v1\nhandle\ttemplates/a.heml::create\tcreate\t1\nhandle\ttemplates/a.heml::delete\tdelete\t2\nhandle\ttemplates/other.heml::archive\tarchive\t3\n",
"hemx-syms-v1\nhandle\ttemplates/a.heml::create\tcreate\t1\nhandle\ttemplates/a.heml::delete\tdelete\t2\nhandle\ttemplates/other.heml::archive\tarchive\t3\n",
)
.unwrap();
let module: syn::ItemMod = parse_quote! {
mod component {
#[slhx::handler]
fn create() -> impl slhx::IntoEffect { slhx::advanced::EffectBatch::default() }
#[hemx::handler]
fn create() -> impl hemx::IntoEffect { hemx::advanced::EffectBatch::default() }
}
};
let (_, items) = module.content.expect("inline module");
@@ -1019,8 +1019,8 @@ mod tests {
fn app_macro_generates_single_registry_entry_point() {
// req: derive_app/001 req: component/003
let function = parse_quote! {
fn registry(state: std::sync::Arc<App>) -> slhx_axum::HandlerRegistry {
slhx_axum::interactions(ui::BUILD_FINGERPRINT)
fn registry(state: std::sync::Arc<App>) -> hemx_axum::HandlerRegistry {
hemx_axum::interactions(ui::BUILD_FINGERPRINT)
}
};
let function = add_app_registry_helper(
@@ -1038,7 +1038,7 @@ mod tests {
generated.contains("todo_handlers :: register_with_state"),
"{generated}"
);
assert!(generated.contains("slhx_axum :: State"), "{generated}");
assert!(generated.contains("hemx_axum :: State"), "{generated}");
assert!(generated.contains("state . clone"), "{generated}");
}
@@ -1047,19 +1047,19 @@ mod tests {
// req: component/003 req: derive_handler/003
let module = parse_quote! {
mod handlers {
#[slhx::handler]
fn create(app: super::App, form: super::NewTodo) -> impl slhx::IntoEffect {
slhx::EventName::new("created").emit("")
#[hemx::handler]
fn create(app: super::App, form: super::NewTodo) -> impl hemx::IntoEffect {
hemx::EventName::new("created").emit("")
}
#[slhx::handler]
async fn increment(app: super::App) -> impl slhx::IntoEffect {
slhx::EventName::new("incremented").emit("")
#[hemx::handler]
async fn increment(app: super::App) -> impl hemx::IntoEffect {
hemx::EventName::new("incremented").emit("")
}
#[slhx::handler]
async fn save(app: super::App, form: super::NewTodo) -> Result<impl slhx::IntoEffect, super::Error> {
Ok(slhx::EventName::new("saved").emit(""))
#[hemx::handler]
async fn save(app: super::App, form: super::NewTodo) -> Result<impl hemx::IntoEffect, super::Error> {
Ok(hemx::EventName::new("saved").emit(""))
}
}
};
@@ -4,12 +4,12 @@ use std::process::Command;
#[test]
fn component_macro_reports_missing_handler_implementation() {
// req: component/005 req: check/002 req: test/003
let fixture = Fixture::new("slhx-derive-component-missing-handler-fail");
let fixture = Fixture::new("hemx-derive-component-missing-handler-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-component-missing-handler-fail"
name = "hemx-derive-component-missing-handler-fail"
version = "0.0.0"
edition = "2021"
@@ -17,9 +17,9 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
@@ -27,8 +27,8 @@ slhx = {{ path = {:?} }}
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle\ttemplates/app.heml::delete\tdelete\t2\n",
out.join("hemx.syms"),
"hemx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle\ttemplates/app.heml::delete\tdelete\t2\n",
)
.unwrap();
}
@@ -36,11 +36,11 @@ slhx = {{ path = {:?} }}
);
fixture.write(
"src/lib.rs",
r#"#[slhx::component]
r#"#[hemx::component]
mod todos {
#[slhx::handler]
fn create() -> impl slhx::IntoEffect {
slhx::advanced::EffectBatch::default()
#[hemx::handler]
fn create() -> impl hemx::IntoEffect {
hemx::advanced::EffectBatch::default()
}
}
"#,
@@ -51,7 +51,7 @@ mod todos {
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("#[slhx::component] missing handler implementation(s): delete"),
stderr.contains("#[hemx::component] missing handler implementation(s): delete"),
"missing component diagnostic in stderr:\n{stderr}"
);
}
@@ -59,12 +59,12 @@ mod todos {
#[test]
fn component_macro_can_validate_one_generated_component() {
// req: component/003 req: component/005 req: test/003
let fixture = Fixture::new("slhx-derive-component-scoped-pass");
let fixture = Fixture::new("hemx-derive-component-scoped-pass");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-component-scoped-pass"
name = "hemx-derive-component-scoped-pass"
version = "0.0.0"
edition = "2021"
@@ -72,9 +72,9 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
@@ -82,8 +82,8 @@ slhx = {{ path = {:?} }}
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/todos.heml::create\tcreate\t1\nhandle\ttemplates/admin.heml::delete\tdelete\t2\n",
out.join("hemx.syms"),
"hemx-syms-v1\nhandle\ttemplates/todos.heml::create\tcreate\t1\nhandle\ttemplates/admin.heml::delete\tdelete\t2\n",
)
.unwrap();
}
@@ -91,11 +91,11 @@ slhx = {{ path = {:?} }}
);
fixture.write(
"src/lib.rs",
r#"#[slhx::component("todos")]
r#"#[hemx::component("todos")]
mod todos {
#[slhx::handler]
fn create() -> impl slhx::IntoEffect {
slhx::EventName::new("created").emit("")
#[hemx::handler]
fn create() -> impl hemx::IntoEffect {
hemx::EventName::new("created").emit("")
}
}
"#,
@@ -113,12 +113,12 @@ mod todos {
#[test]
fn component_macro_rejects_handlers_outside_scoped_component() {
// req: component/003 req: component/005 req: test/003
let fixture = Fixture::new("slhx-derive-component-extra-handler-fail");
let fixture = Fixture::new("hemx-derive-component-extra-handler-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-component-extra-handler-fail"
name = "hemx-derive-component-extra-handler-fail"
version = "0.0.0"
edition = "2021"
@@ -126,9 +126,9 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
@@ -136,8 +136,8 @@ slhx = {{ path = {:?} }}
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/todos.heml::create\tcreate\t1\nhandle\ttemplates/admin.heml::delete\tdelete\t2\n",
out.join("hemx.syms"),
"hemx-syms-v1\nhandle\ttemplates/todos.heml::create\tcreate\t1\nhandle\ttemplates/admin.heml::delete\tdelete\t2\n",
)
.unwrap();
}
@@ -145,16 +145,16 @@ slhx = {{ path = {:?} }}
);
fixture.write(
"src/lib.rs",
r#"#[slhx::component("todos")]
r#"#[hemx::component("todos")]
mod handlers {
#[slhx::handler]
fn create() -> impl slhx::IntoEffect {
slhx::EventName::new("created").emit("")
#[hemx::handler]
fn create() -> impl hemx::IntoEffect {
hemx::EventName::new("created").emit("")
}
#[slhx::handler]
fn delete() -> impl slhx::IntoEffect {
slhx::EventName::new("deleted").emit("")
#[hemx::handler]
fn delete() -> impl hemx::IntoEffect {
hemx::EventName::new("deleted").emit("")
}
}
"#,
@@ -173,12 +173,12 @@ mod handlers {
#[test]
fn component_macro_rejects_unknown_scoped_component() {
// req: component/003 req: component/005 req: test/003
let fixture = Fixture::new("slhx-derive-component-unknown-scope-fail");
let fixture = Fixture::new("hemx-derive-component-unknown-scope-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-component-unknown-scope-fail"
name = "hemx-derive-component-unknown-scope-fail"
version = "0.0.0"
edition = "2021"
@@ -186,9 +186,9 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
@@ -196,8 +196,8 @@ slhx = {{ path = {:?} }}
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/admin.heml::create\tcreate\t1\n",
out.join("hemx.syms"),
"hemx-syms-v1\nhandle\ttemplates/admin.heml::create\tcreate\t1\n",
)
.unwrap();
}
@@ -205,11 +205,11 @@ slhx = {{ path = {:?} }}
);
fixture.write(
"src/lib.rs",
r#"#[slhx::component("todos")]
r#"#[hemx::component("todos")]
mod handlers {
#[slhx::handler]
fn create() -> impl slhx::IntoEffect {
slhx::EventName::new("created").emit("")
#[hemx::handler]
fn create() -> impl hemx::IntoEffect {
hemx::EventName::new("created").emit("")
}
}
"#,
@@ -220,7 +220,7 @@ mod handlers {
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("#[slhx::component(\"todos\")] does not match any generated handles"),
stderr.contains("#[hemx::component(\"todos\")] does not match any generated handles"),
"missing unknown component diagnostic in stderr:\n{stderr}"
);
}
@@ -228,12 +228,12 @@ mod handlers {
#[test]
fn component_macro_rejects_ambiguous_generated_handles() {
// req: component/003 req: component/005 req: test/003
let fixture = Fixture::new("slhx-derive-component-ambiguous-handle-fail");
let fixture = Fixture::new("hemx-derive-component-ambiguous-handle-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-component-ambiguous-handle-fail"
name = "hemx-derive-component-ambiguous-handle-fail"
version = "0.0.0"
edition = "2021"
@@ -241,9 +241,9 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
@@ -251,8 +251,8 @@ slhx = {{ path = {:?} }}
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/todos.heml::save\tsave\t1\nhandle\ttemplates/todos.heml::section::save\tsave\t2\n",
out.join("hemx.syms"),
"hemx-syms-v1\nhandle\ttemplates/todos.heml::save\tsave\t1\nhandle\ttemplates/todos.heml::section::save\tsave\t2\n",
)
.unwrap();
}
@@ -260,11 +260,11 @@ slhx = {{ path = {:?} }}
);
fixture.write(
"src/lib.rs",
r#"#[slhx::component("todos")]
r#"#[hemx::component("todos")]
mod handlers {
#[slhx::handler]
fn save() -> impl slhx::IntoEffect {
slhx::EventName::new("saved").emit("")
#[hemx::handler]
fn save() -> impl hemx::IntoEffect {
hemx::EventName::new("saved").emit("")
}
}
"#,
@@ -283,12 +283,12 @@ mod handlers {
#[test]
fn handler_macro_reports_unknown_handle_and_bad_shape() {
// req: derive_handler/001 req: test/003
let fixture = Fixture::new("slhx-derive-handler-fail");
let fixture = Fixture::new("hemx-derive-handler-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-handler-fail"
name = "hemx-derive-handler-fail"
version = "0.0.0"
edition = "2021"
@@ -296,9 +296,9 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
@@ -306,8 +306,8 @@ slhx = {{ path = {:?} }}
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/app.heml::known\tknown\t1\n",
out.join("hemx.syms"),
"hemx-syms-v1\nhandle\ttemplates/app.heml::known\tknown\t1\n",
)
.unwrap();
}
@@ -315,12 +315,12 @@ slhx = {{ path = {:?} }}
);
fixture.write(
"src/lib.rs",
r#"#[slhx::handler]
fn missing() -> impl slhx::IntoEffect {
slhx::advanced::EffectBatch::default()
r#"#[hemx::handler]
fn missing() -> impl hemx::IntoEffect {
hemx::advanced::EffectBatch::default()
}
#[slhx::handler]
#[hemx::handler]
fn known() {}
"#,
);
@@ -330,11 +330,11 @@ fn known() {}
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("unknown slhx handle `missing`; add `data-slhx-handle=\"missing\"`"),
stderr.contains("unknown hemx handle `missing`; add `data-hemx-handle=\"missing\"`"),
"missing unknown-handle diagnostic in stderr:\n{stderr}"
);
assert!(
stderr.contains("slhx handler `known` must accept a form/context parameter or return a value implementing IntoEffect"),
stderr.contains("hemx handler `known` must accept a form/context parameter or return a value implementing IntoEffect"),
"missing handler-shape diagnostic in stderr:\n{stderr}"
);
}
@@ -342,12 +342,12 @@ fn known() {}
#[test]
fn handler_macro_reports_missing_syms() {
// req: build/003 req: test/003
let fixture = Fixture::new("slhx-derive-handler-syms-fail");
let fixture = Fixture::new("hemx-derive-handler-syms-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-handler-syms-fail"
name = "hemx-derive-handler-syms-fail"
version = "0.0.0"
edition = "2021"
@@ -355,9 +355,9 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
@@ -369,9 +369,9 @@ slhx = {{ path = {:?} }}
);
fixture.write(
"src/lib.rs",
r#"#[slhx::handler]
fn create() -> impl slhx::IntoEffect {
slhx::advanced::EffectBatch::default()
r#"#[hemx::handler]
fn create() -> impl hemx::IntoEffect {
hemx::advanced::EffectBatch::default()
}
"#,
);
@@ -381,11 +381,11 @@ fn create() -> impl slhx::IntoEffect {
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("#[slhx::handler] could not find"),
stderr.contains("#[hemx::handler] could not find"),
"missing handler syms diagnostic in stderr:\n{stderr}"
);
assert!(
stderr.contains("add slhx_build::app().run()? to build.rs"),
stderr.contains("add hemx_build::app().run()? to build.rs"),
"missing build.rs hint in stderr:\n{stderr}"
);
assert!(
@@ -397,12 +397,12 @@ fn create() -> impl slhx::IntoEffect {
#[test]
fn handler_requires_generated_param_arguments() {
// req: derive_handler/001 req: test/003
let fixture = Fixture::new("slhx-derive-param-handler-fail");
let fixture = Fixture::new("hemx-derive-param-handler-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-param-handler-fail"
name = "hemx-derive-param-handler-fail"
version = "0.0.0"
edition = "2021"
@@ -410,9 +410,9 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
@@ -420,8 +420,8 @@ slhx = {{ path = {:?} }}
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/app.heml::show\tshow\t1\nhandle_param\tshow\ttodo_id\nhandle_param\tshow\tmode\n",
out.join("hemx.syms"),
"hemx-syms-v1\nhandle\ttemplates/app.heml::show\tshow\t1\nhandle_param\tshow\ttodo_id\nhandle_param\tshow\tmode\n",
)
.unwrap();
}
@@ -429,9 +429,9 @@ slhx = {{ path = {:?} }}
);
fixture.write(
"src/lib.rs",
r#"#[slhx::handler]
fn show(todo_id: String) -> impl slhx::IntoEffect {
slhx::advanced::EffectBatch::default()
r#"#[hemx::handler]
fn show(todo_id: String) -> impl hemx::IntoEffect {
hemx::advanced::EffectBatch::default()
}
"#,
);
@@ -441,7 +441,7 @@ fn show(todo_id: String) -> impl slhx::IntoEffect {
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("slhx handler `show` is missing generated param argument(s): mode"),
stderr.contains("hemx handler `show` is missing generated param argument(s): mode"),
"missing param diagnostic in stderr:\n{stderr}"
);
}
@@ -449,12 +449,12 @@ fn show(todo_id: String) -> impl slhx::IntoEffect {
#[test]
fn form_struct_rejects_missing_generated_field() {
// req: form/001 req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-struct-missing-field-fail");
let fixture = Fixture::new("hemx-derive-form-struct-missing-field-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-struct-missing-field-fail"
name = "hemx-derive-form-struct-missing-field-fail"
version = "0.0.0"
edition = "2021"
@@ -462,9 +462,9 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
@@ -472,8 +472,8 @@ slhx = {{ path = {:?} }}
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nform\ttemplates/app.heml::new_todo\tnew_todo\t1\nform_field\tnew_todo\ttitle\ttrue\tfalse\n",
out.join("hemx.syms"),
"hemx-syms-v1\nform\ttemplates/app.heml::new_todo\tnew_todo\t1\nform_field\tnew_todo\ttitle\ttrue\tfalse\n",
)
.unwrap();
}
@@ -481,7 +481,7 @@ slhx = {{ path = {:?} }}
);
fixture.write(
"src/lib.rs",
r#"#[slhx::form("new_todo")]
r#"#[hemx::form("new_todo")]
struct CreateTodo {}
"#,
);
@@ -491,7 +491,7 @@ struct CreateTodo {}
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("slhx form `new_todo` is missing field `title` for form control `title`"),
stderr.contains("hemx form `new_todo` is missing field `title` for form control `title`"),
"missing form field diagnostic in stderr:\n{stderr}"
);
}
@@ -499,12 +499,12 @@ struct CreateTodo {}
#[test]
fn form_struct_rejects_wrong_optionality_and_multiplicity() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-struct-shape-fail");
let fixture = Fixture::new("hemx-derive-form-struct-shape-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-struct-shape-fail"
name = "hemx-derive-form-struct-shape-fail"
version = "0.0.0"
edition = "2021"
@@ -512,9 +512,9 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
@@ -522,8 +522,8 @@ slhx = {{ path = {:?} }}
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nform\ttemplates/app.heml::profile\tprofile\t1\nform_field\tprofile\ttitle\ttrue\tfalse\nform_field\tprofile\tlabels\tfalse\ttrue\n",
out.join("hemx.syms"),
"hemx-syms-v1\nform\ttemplates/app.heml::profile\tprofile\t1\nform_field\tprofile\ttitle\ttrue\tfalse\nform_field\tprofile\tlabels\tfalse\ttrue\n",
)
.unwrap();
}
@@ -531,7 +531,7 @@ slhx = {{ path = {:?} }}
);
fixture.write(
"src/lib.rs",
r#"#[slhx::form("profile")]
r#"#[hemx::form("profile")]
struct Profile {
title: Option<String>,
labels: Option<String>,
@@ -556,12 +556,12 @@ struct Profile {
#[test]
fn form_struct_requires_field_parser() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-struct-parser-fail");
let fixture = Fixture::new("hemx-derive-form-struct-parser-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-struct-parser-fail"
name = "hemx-derive-form-struct-parser-fail"
version = "0.0.0"
edition = "2021"
@@ -569,9 +569,9 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
@@ -579,8 +579,8 @@ slhx = {{ path = {:?} }}
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nform\ttemplates/app.heml::profile\tprofile\t1\nform_field\tprofile\temail\ttrue\tfalse\n",
out.join("hemx.syms"),
"hemx-syms-v1\nform\ttemplates/app.heml::profile\tprofile\t1\nform_field\tprofile\temail\ttrue\tfalse\n",
)
.unwrap();
}
@@ -590,7 +590,7 @@ slhx = {{ path = {:?} }}
"src/lib.rs",
r#"struct Email;
#[slhx::form("profile")]
#[hemx::form("profile")]
struct Profile {
email: Email,
}
@@ -602,7 +602,7 @@ struct Profile {
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Email: FormValue") || stderr.contains("Email: slhx::FormValue"),
stderr.contains("Email: FormValue") || stderr.contains("Email: hemx::FormValue"),
"missing parser availability diagnostic in stderr:\n{stderr}"
);
}
@@ -610,12 +610,12 @@ struct Profile {
#[test]
fn form_handle_accepts_checked_form_model() {
// req: form/001 req: form/004 req: form/006
let fixture = Fixture::new("slhx-derive-form-handler-checked-model-pass");
let fixture = Fixture::new("hemx-derive-form-handler-checked-model-pass");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-handler-checked-model-pass"
name = "hemx-derive-form-handler-checked-model-pass"
version = "0.0.0"
edition = "2021"
@@ -623,9 +623,9 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
@@ -633,8 +633,8 @@ slhx = {{ path = {:?} }}
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\nform\ttemplates/app.heml::new_todo\tnew_todo\t1\nform_field\tnew_todo\ttitle\ttrue\tfalse\n",
out.join("hemx.syms"),
"hemx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\nform\ttemplates/app.heml::new_todo\tnew_todo\t1\nform_field\tnew_todo\ttitle\ttrue\tfalse\n",
)
.unwrap();
}
@@ -642,14 +642,14 @@ slhx = {{ path = {:?} }}
);
fixture.write(
"src/lib.rs",
r#"#[slhx::form("new_todo")]
r#"#[hemx::form("new_todo")]
struct CreateTodo {
title: String,
}
#[slhx::handler]
fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
slhx::EventName::new("created").emit("")
#[hemx::handler]
fn create(_form: hemx::Form<CreateTodo>) -> impl hemx::IntoEffect {
hemx::EventName::new("created").emit("")
}
fn smoke() {
@@ -670,12 +670,12 @@ fn smoke() {
#[test]
fn form_handle_requires_form_parameter() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-handler-fail");
let fixture = Fixture::new("hemx-derive-form-handler-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-handler-fail"
name = "hemx-derive-form-handler-fail"
version = "0.0.0"
edition = "2021"
@@ -683,9 +683,9 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
@@ -693,8 +693,8 @@ slhx = {{ path = {:?} }}
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\n",
out.join("hemx.syms"),
"hemx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\n",
)
.unwrap();
}
@@ -702,9 +702,9 @@ slhx = {{ path = {:?} }}
);
fixture.write(
"src/lib.rs",
r#"#[slhx::handler]
fn create() -> impl slhx::IntoEffect {
slhx::advanced::EffectBatch::default()
r#"#[hemx::handler]
fn create() -> impl hemx::IntoEffect {
hemx::advanced::EffectBatch::default()
}
"#,
);
@@ -715,7 +715,7 @@ fn create() -> impl slhx::IntoEffect {
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains(
"slhx handler `create` handles a generated form and must accept a typed form argument"
"hemx handler `create` handles a generated form and must accept a typed form argument"
),
"missing form-handler diagnostic in stderr:\n{stderr}"
);
@@ -724,12 +724,12 @@ fn create() -> impl slhx::IntoEffect {
#[test]
fn form_handle_rejects_state_only_handler() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-handler-state-only-fail");
let fixture = Fixture::new("hemx-derive-form-handler-state-only-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-handler-state-only-fail"
name = "hemx-derive-form-handler-state-only-fail"
version = "0.0.0"
edition = "2021"
@@ -737,9 +737,9 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
@@ -747,8 +747,8 @@ slhx = {{ path = {:?} }}
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\n",
out.join("hemx.syms"),
"hemx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\n",
)
.unwrap();
}
@@ -759,9 +759,9 @@ slhx = {{ path = {:?} }}
r#"struct App;
struct State<T>(T);
#[slhx::handler]
fn create(_state: State<App>) -> impl slhx::IntoEffect {
slhx::advanced::EffectBatch::default()
#[hemx::handler]
fn create(_state: State<App>) -> impl hemx::IntoEffect {
hemx::advanced::EffectBatch::default()
}
"#,
);
@@ -772,7 +772,7 @@ fn create(_state: State<App>) -> impl slhx::IntoEffect {
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains(
"slhx handler `create` handles a generated form and must accept a typed form argument"
"hemx handler `create` handles a generated form and must accept a typed form argument"
),
"missing state-only form-handler diagnostic in stderr:\n{stderr}"
);
@@ -781,12 +781,12 @@ fn create(_state: State<App>) -> impl slhx::IntoEffect {
#[test]
fn form_handle_still_requires_generated_param_arguments() {
// req: form/006 req: derive_handler/003 req: test/003
let fixture = Fixture::new("slhx-derive-form-handler-param-fail");
let fixture = Fixture::new("hemx-derive-form-handler-param-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-handler-param-fail"
name = "hemx-derive-form-handler-param-fail"
version = "0.0.0"
edition = "2021"
@@ -794,9 +794,9 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
@@ -804,8 +804,8 @@ slhx = {{ path = {:?} }}
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\nhandle_param\tcreate\ttodo_id\n",
out.join("hemx.syms"),
"hemx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\nhandle_param\tcreate\ttodo_id\n",
)
.unwrap();
}
@@ -815,9 +815,9 @@ slhx = {{ path = {:?} }}
"src/lib.rs",
r#"struct CreateTodo;
#[slhx::handler]
fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
slhx::advanced::EffectBatch::default()
#[hemx::handler]
fn create(_form: hemx::Form<CreateTodo>) -> impl hemx::IntoEffect {
hemx::advanced::EffectBatch::default()
}
"#,
);
@@ -827,7 +827,7 @@ fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("slhx handler `create` is missing generated param argument(s): todo_id"),
stderr.contains("hemx handler `create` is missing generated param argument(s): todo_id"),
"missing form-param diagnostic in stderr:\n{stderr}"
);
}
@@ -835,12 +835,12 @@ fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
#[test]
fn surface_macro_reports_missing_generated_include() {
// req: build/004 req: test/003
let fixture = Fixture::new("slhx-derive-surface-include-fail");
let fixture = Fixture::new("hemx-derive-surface-include-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-surface-include-fail"
name = "hemx-derive-surface-include-fail"
version = "0.0.0"
edition = "2021"
@@ -848,9 +848,9 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
@@ -862,7 +862,7 @@ slhx = {{ path = {:?} }}
);
fixture.write(
"src/lib.rs",
r#"#[slhx::surface]
r#"#[hemx::surface]
pub mod ui {}
"#,
);
@@ -872,11 +872,11 @@ pub mod ui {}
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("#[slhx::surface] could not find"),
stderr.contains("#[hemx::surface] could not find"),
"missing surface include diagnostic in stderr:\n{stderr}"
);
assert!(
stderr.contains("add slhx_build::app().run()? to build.rs"),
stderr.contains("add hemx_build::app().run()? to build.rs"),
"missing build.rs hint in stderr:\n{stderr}"
);
assert!(
@@ -888,12 +888,12 @@ pub mod ui {}
#[test]
fn surface_macro_requires_inline_module() {
// req: codegen/001 req: test/003
let fixture = Fixture::new("slhx-derive-surface-inline-fail");
let fixture = Fixture::new("hemx-derive-surface-inline-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-surface-inline-fail"
name = "hemx-derive-surface-inline-fail"
version = "0.0.0"
edition = "2021"
@@ -901,14 +901,14 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
"src/lib.rs",
r#"#[slhx::surface]
r#"#[hemx::surface]
pub mod ui;
"#,
);
@@ -918,7 +918,7 @@ pub mod ui;
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("#[slhx::surface] must be used on an inline module"),
stderr.contains("#[hemx::surface] must be used on an inline module"),
"missing inline-module diagnostic in stderr:\n{stderr}"
);
}
@@ -926,12 +926,12 @@ pub mod ui;
#[test]
fn generated_resource_references_fail_when_name_is_absent() {
// req: style/002 req: codegen/001 req: test/003
let fixture = Fixture::new("slhx-derive-generated-resource-fail");
let fixture = Fixture::new("hemx-derive-generated-resource-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-generated-resource-fail"
name = "hemx-derive-generated-resource-fail"
version = "0.0.0"
edition = "2021"
@@ -939,9 +939,9 @@ edition = "2021"
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
hemx = {{ path = {:?} }}
"#,
repo_path("slhx")
repo_path("hemx")
),
);
fixture.write(
@@ -949,8 +949,8 @@ slhx = {{ path = {:?} }}
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.generated.rs"),
"pub mod classes { pub const card: ::slhx::CssClass = ::slhx::CssClass::new(\"card\"); }\n",
out.join("hemx.generated.rs"),
"pub mod classes { pub const card: ::hemx::CssClass = ::hemx::CssClass::new(\"card\"); }\n",
)
.unwrap();
}
@@ -958,11 +958,11 @@ slhx = {{ path = {:?} }}
);
fixture.write(
"src/lib.rs",
r#"#[slhx::surface]
r#"#[hemx::surface]
pub mod ui {}
pub const KNOWN: slhx::CssClass = ui::classes::card;
pub const MISSING: slhx::CssClass = ui::classes::missing;
pub const KNOWN: hemx::CssClass = ui::classes::card;
pub const MISSING: hemx::CssClass = ui::classes::missing;
"#,
);
@@ -1,4 +1,4 @@
use slhx_derive::{component, surface};
use hemx_derive::{component, surface};
#[component]
mod local_component {
+1 -1
View File
@@ -1,5 +1,5 @@
[package]
name = "slhx-js"
name = "hemx-js"
version.workspace = true
edition.workspace = true
+4 -4
View File
@@ -45,7 +45,7 @@ export interface AtomSnapshot {
bytes: Uint8Array;
}
export interface SlhxRuntime {
export interface HemxRuntime {
readonly runtimeAbiVersion: number;
roots(): Element[];
rootOf(node: Element | null): Element | null;
@@ -58,9 +58,9 @@ export interface SlhxRuntime {
declare global {
interface Window {
slhx: SlhxRuntime;
hemx: HemxRuntime;
}
}
declare const slhx: SlhxRuntime;
export default slhx;
declare const hemx: HemxRuntime;
export default hemx;
@@ -1,10 +1,10 @@
(() => {
const ROOT = "data-slhx-root";
const ROOT = "data-hemx-root";
const HID = "data-hid";
const SID = "data-sid";
const runtimeAbiVersion = 1;
const FINGERPRINT = "data-slhx-fp";
const STATE = "data-slhx-st";
const FINGERPRINT = "data-hemx-fp";
const STATE = "data-hemx-st";
const pending = new WeakMap();
const queues = new WeakMap();
const timers = new WeakMap();
@@ -65,19 +65,19 @@
}
function requestPolicy(el, eventName) {
const policy = normalizedPolicy(el.getAttribute("data-slhx-policy"));
const policy = normalizedPolicy(el.getAttribute("data-hemx-policy"));
if (policy) return policy;
if (el.hasAttribute("data-slhx-debounce") || eventName === "input") return "latest";
if (el.hasAttribute("data-hemx-debounce") || eventName === "input") return "latest";
if (el.tagName === "FORM") return "drop";
return "parallel";
}
function showPending(el, on) {
const klass = el.getAttribute("data-slhx-pending-class");
const klass = el.getAttribute("data-hemx-pending-class");
if (klass) togglePendingClass(el, klass, on);
const root = rootOf(el) || document;
forEachElement(root, (i) => { if (i.hasAttribute("data-slhx-indicator")) toggleIndicator(i, on); });
if (el.hasAttribute("data-slhx-disable-while-pending")) {
forEachElement(root, (i) => { if (i.hasAttribute("data-hemx-indicator")) toggleIndicator(i, on); });
if (el.hasAttribute("data-hemx-disable-while-pending")) {
const controls = [];
if (isDisableControl(el)) controls.push(el);
forEachElement(el, (child) => { if (isDisableControl(child)) controls.push(child); });
@@ -143,7 +143,7 @@
const dragKey = eventName === "drop" && dragKeys.get(rootOf(el));
if (dragKey && !data.has("work_id")) data.set("work_id", dragKey);
for (const { name, value } of Array.from(el.attributes || [])) {
if (name.startsWith("data-") && !name.startsWith("data-slhx-") && name !== HID && name !== SID) {
if (name.startsWith("data-") && !name.startsWith("data-hemx-") && name !== HID && name !== SID) {
data.set(name.slice(5).replace(/-/g, "_"), value);
}
}
@@ -170,7 +170,7 @@
}
async function send(el, eventName, source = el) {
if (el.getAttribute("data-slhx-confirm") && !confirm(el.getAttribute("data-slhx-confirm"))) return;
if (el.getAttribute("data-hemx-confirm") && !confirm(el.getAttribute("data-hemx-confirm"))) return;
const { form, data, multipart } = formDataFor(el, eventName, source);
const target = form || el;
const policy = requestPolicy(target, eventName);
@@ -196,7 +196,7 @@
const done = new Promise((resolve) => { finish = resolve; });
const method = String((form && form.getAttribute("method")) || "POST").toUpperCase();
const body = method === "GET" || method === "HEAD" ? undefined : requestBody(data, multipart);
const headers = { "X-SLHX-Partial": "1", "Accept": "application/slhx, text/html" };
const headers = { "X-HEMX-Partial": "1", "Accept": "application/hemx, text/html" };
if (body instanceof URLSearchParams) headers["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8";
pending.set(target, { abort, done });
showPending(target, true);
@@ -211,7 +211,7 @@
if (pending.get(target)?.abort !== abort && policy === "latest") return;
await applyResponse(response, rootOf(target));
} catch (error) {
if (error.name !== "AbortError") emit(rootOf(target), "slhx:error", String(error));
if (error.name !== "AbortError") emit(rootOf(target), "hemx:error", String(error));
} finally {
if (pending.get(target)?.abort === abort) {
pending.delete(target);
@@ -236,7 +236,7 @@
async function navigateUrl(href, root, mode = "replace") {
const response = await fetch(href, {
headers: { "X-SLHX-Partial": "1", "Accept": "text/html" },
headers: { "X-HEMX-Partial": "1", "Accept": "text/html" },
credentials: "same-origin",
});
if (!await applyResponse(response, root)) {
@@ -244,8 +244,8 @@
else location.href = href;
return;
}
if (mode === "push") history.pushState({ slhx: true }, "", href);
else if (mode === "replace") history.replaceState({ slhx: true }, "", href);
if (mode === "push") history.pushState({ hemx: true }, "", href);
else if (mode === "replace") history.replaceState({ hemx: true }, "", href);
}
async function applyResponse(response, root) {
@@ -258,8 +258,8 @@
return false;
}
const type = response.headers.get("content-type") || "";
if (type.includes("text/html")) return applyHtml(await response.text(), root, response.headers.get("x-slhx-title"));
if (type.includes("application/slhx")) {
if (type.includes("text/html")) return applyHtml(await response.text(), root, response.headers.get("x-hemx-title"));
if (type.includes("application/hemx")) {
applyBatch(await response.arrayBuffer(), root);
return true;
}
@@ -267,7 +267,7 @@
}
function compatibleFingerprint(response, root) {
const received = response.headers.get("x-slhx-fingerprint");
const received = response.headers.get("x-hemx-fingerprint");
const expected = root && root.getAttribute(FINGERPRINT);
return !received || !expected || received === expected;
}
@@ -334,7 +334,7 @@
} else if (op.kind === "navigate") {
if (op.mode === "redirect") location.href = op.url;
else {
history[op.mode === "replace" ? "replaceState" : "pushState"]({ slhx: true }, "", op.url);
history[op.mode === "replace" ? "replaceState" : "pushState"]({ hemx: true }, "", op.url);
if (op.scroll === "top") scrollTo(0, 0);
else if (op.scroll && op.scroll.kind === "element") {
const target = targetFor(scope, op.scroll.target);
@@ -435,7 +435,7 @@
}
function formErrorTarget(scope, id, field) {
return firstElement(scope, (el) => attrEquals(el, "data-slhx-error-for", field) && withinGeneratedForm(el, scope, id)) ||
return firstElement(scope, (el) => attrEquals(el, "data-hemx-error-for", field) && withinGeneratedForm(el, scope, id)) ||
firstElement(scope, (el) => attrEquals(el, "name", field) && withinGeneratedForm(el, scope, id));
}
@@ -468,21 +468,21 @@
}
function handleRuntimeEvent(scope, name, payload) {
if (name === "slhx:form-reset") {
if (name === "hemx:form-reset") {
const form = generatedFormTarget(scope, payload);
if (form && form.reset) form.reset();
} else if (name === "slhx:form-error") {
} else if (name === "hemx:form-error") {
const [id, field, message] = String(payload).split("\u001f");
const target = formErrorTarget(scope, id, field);
if (target) putFormError(target, message || "");
} else if (name === "slhx:form-disable-while-pending") {
} else if (name === "hemx:form-disable-while-pending") {
const form = generatedFormTarget(scope, payload);
if (form) form.setAttribute("data-slhx-disable-while-pending", "");
if (form) form.setAttribute("data-hemx-disable-while-pending", "");
}
}
function missing(root, target) {
emit(root, "slhx:missing-target", target);
emit(root, "hemx:missing-target", target);
return false;
}
@@ -493,11 +493,11 @@
function applyHtml(html, root, title) {
const scope = root || document;
const doc = new DOMParser().parseFromString(html, "text/html");
const template = firstElement(doc, (el) => el.tagName === "TEMPLATE" && el.hasAttribute("data-slhx"));
const template = firstElement(doc, (el) => el.tagName === "TEMPLATE" && el.hasAttribute("data-hemx"));
const lowered = replaceLoweredSlots(scope, doc);
const named = replaceSlot(scope, doc, "content", lowered ? undefined : (template ? template.innerHTML : html));
if (!lowered && !named) {
emit(scope, "slhx:missing-content-slot", null);
emit(scope, "hemx:missing-content-slot", null);
return false;
}
replaceSlot(scope, doc, "nav");
@@ -534,7 +534,7 @@
}
function namedSlot(el, name) {
return attrEquals(el, "data-slhx-slot", name) || attrEquals(el, "data-slot", name);
return attrEquals(el, "data-hemx-slot", name) || attrEquals(el, "data-slot", name);
}
function replaceSlot(scope, doc, name, fallback) {
@@ -551,7 +551,7 @@
}
function defaultEvent(el) {
if (el.getAttribute("data-slhx-on")) return el.getAttribute("data-slhx-on");
if (el.getAttribute("data-hemx-on")) return el.getAttribute("data-hemx-on");
if (el.tagName === "FORM") return "submit";
return "click";
}
@@ -568,13 +568,13 @@
return;
}
if (name === "dragover") {
const drop = closestInRoot(event.target, root, (el) => el.hasAttribute(HID) && el.getAttribute("data-slhx-on") === "drop");
const drop = closestInRoot(event.target, root, (el) => el.hasAttribute(HID) && el.getAttribute("data-hemx-on") === "drop");
if (drop) event.preventDefault();
return;
}
const nav = closestInRoot(event.target, root, (el) =>
(el.tagName === "A" && el.hasAttribute("data-slhx-nav")) ||
(el.tagName === "A" && el.hasAttribute("href") && closestInRoot(el.parentElement, root, (node) => node.hasAttribute("data-slhx-boost")))
(el.tagName === "A" && el.hasAttribute("data-hemx-nav")) ||
(el.tagName === "A" && el.hasAttribute("href") && closestInRoot(el.parentElement, root, (node) => node.hasAttribute("data-hemx-boost")))
);
if (name === "click" && nav && sameOriginNav(event, nav)) {
event.preventDefault();
@@ -604,7 +604,7 @@
}
let el = closestInRoot(event.target, root, (node) =>
node.hasAttribute(HID) ||
(node.tagName === "FORM" && closestInRoot(node.parentElement, root, (parent) => parent.hasAttribute("data-slhx-boost")))
(node.tagName === "FORM" && closestInRoot(node.parentElement, root, (parent) => parent.hasAttribute("data-hemx-boost")))
);
if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && formHandleId(event.target)) el = event.target;
if (!el || defaultEvent(el) !== name) return;
@@ -616,8 +616,8 @@
}
function schedule(el, eventName, source = el) {
const debounce = duration(el.getAttribute("data-slhx-debounce"));
const throttle = duration(el.getAttribute("data-slhx-throttle"));
const debounce = duration(el.getAttribute("data-hemx-debounce"));
const throttle = duration(el.getAttribute("data-hemx-throttle"));
if (debounce) {
clearTimeout(timers.get(el));
timers.set(el, setTimeout(() => send(el, eventName, source), debounce));
@@ -631,8 +631,8 @@
function bindPolling(root) {
forEachElement(root, (el) => {
if (!el.hasAttribute("data-slhx-every") || everyTimers.has(el)) return;
const ms = duration(el.getAttribute("data-slhx-every"));
if (!el.hasAttribute("data-hemx-every") || everyTimers.has(el)) return;
const ms = duration(el.getAttribute("data-hemx-every"));
if (!ms) return;
everyTimers.set(el, setInterval(() => document.contains(el) ? send(el, "every") : stopPolling(el), ms));
});
@@ -644,17 +644,17 @@
}
function bindSse(root) {
const url = root.getAttribute("data-slhx-sse");
const url = root.getAttribute("data-hemx-sse");
if (!url || sseSources.has(root) || typeof EventSource === "undefined") return;
const href = new URL(url, location.href);
if (href.origin !== location.origin) {
emit(root, "slhx:sse-error", url);
emit(root, "hemx:sse-error", url);
return;
}
const source = new EventSource(href.href);
source.addEventListener("slhx", (event) => applySseMessage(root, event));
source.addEventListener("hemx", (event) => applySseMessage(root, event));
source.addEventListener("message", (event) => applySseMessage(root, event));
source.addEventListener("error", () => emit(root, "slhx:sse-error", url));
source.addEventListener("error", () => emit(root, "hemx:sse-error", url));
sseSources.set(root, source);
}
@@ -663,7 +663,7 @@
const bytes = base64UrlBytes(event.data);
applyBatch(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), root);
} catch (error) {
emit(root, "slhx:error", String(error));
emit(root, "hemx:error", String(error));
}
}
@@ -682,7 +682,7 @@
for (const atom of decodeAtomState(encoded)) store.set(String(atom.id), atom.bytes);
} catch (error) {
atomStores.delete(root);
emit(root, "slhx:state-error", String(error));
emit(root, "hemx:state-error", String(error));
}
}
@@ -690,7 +690,7 @@
const bytes = base64UrlBytes(encoded);
const d = postcardDecoder(bytes);
const atoms = d.vec(() => ({ id: d.varint(), bytes: d.bytes() }));
if (!d.done()) throw new Error("trailing slhx state bytes");
if (!d.done()) throw new Error("trailing hemx state bytes");
return atoms;
}
@@ -704,7 +704,7 @@
let offset = 0;
const need = (len) => {
const end = offset + len;
if (end > bytes.length) throw new Error("truncated slhx state");
if (end > bytes.length) throw new Error("truncated hemx state");
const slice = bytes.subarray(offset, end);
offset = end;
return slice;
@@ -729,7 +729,7 @@
let offset = 0;
const need = (len) => {
const end = offset + len;
if (end > bytes.length) throw new Error("truncated slhx batch");
if (end > bytes.length) throw new Error("truncated hemx batch");
const slice = bytes.subarray(offset, end);
offset = end;
return slice;
@@ -770,7 +770,7 @@
if (kind === 5) return { kind: "focus", target: ref() };
if (kind === 6) return { kind: "navigate", url: str(), mode: ["push", "replace", "redirect"][u8()], scroll: scroll(), title: option(str) };
if (kind === 7) return { kind: "emit", name: str(), payload: str() };
throw new Error(`unknown slhx effect ${kind}`);
throw new Error(`unknown hemx effect ${kind}`);
};
const vec = (read) => Array.from({ length: u32() }, read);
return { u8, u32, u64, vec, effect, done: () => offset === bytes.length };
@@ -778,9 +778,9 @@
function decodeBatch(buffer) {
const d = decoder(buffer);
if (String.fromCharCode(d.u8(), d.u8(), d.u8(), d.u8()) !== "SLHX") throw new Error("bad slhx batch magic");
if (String.fromCharCode(d.u8(), d.u8(), d.u8(), d.u8()) !== "HEMX") throw new Error("bad hemx batch magic");
const batch = { abiVersion: d.u32(), fingerprint: d.u64(), ops: d.vec(d.effect) };
if (!d.done()) throw new Error("trailing slhx batch bytes");
if (!d.done()) throw new Error("trailing hemx batch bytes");
return batch;
}
@@ -795,16 +795,16 @@
bindRoot(root);
bindSse(root);
});
history.replaceState(history.state || { slhx: true }, "", location.href);
history.replaceState(history.state || { hemx: true }, "", location.href);
}
addEventListener("popstate", () => {
const root = roots()[0];
if (root) navigateUrl(location.href, root, "none").catch((error) => emit(root, "slhx:error", String(error)));
if (root) navigateUrl(location.href, root, "none").catch((error) => emit(root, "hemx:error", String(error)));
});
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
else start();
window.slhx = Object.freeze({ runtimeAbiVersion, roots, rootOf, applyHtml, applyBatch, decodeBatch, atomValue, decodeAtomState });
window.hemx = Object.freeze({ runtimeAbiVersion, roots, rootOf, applyHtml, applyBatch, decodeBatch, atomValue, decodeAtomState });
})();
+3
View File
@@ -0,0 +1,3 @@
pub const RUNTIME_ABI_VERSION: u32 = 1;
pub const RUNTIME_JS: &str = include_str!("../runtime/hemx.js");
pub const RUNTIME_D_TS: &str = include_str!("../runtime/hemx.d.ts");
@@ -1,6 +1,6 @@
#[test]
fn runtime_posts_urlencoded_forms_by_default() {
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("new URLSearchParams()"));
assert!(source.contains("form.getAttribute(\"method\")"));
@@ -12,7 +12,7 @@ fn runtime_posts_urlencoded_forms_by_default() {
#[test]
fn runtime_preserves_multipart_file_upload_fallback_shape() {
// req: multipart/003
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains(
"multipart: form && String(form.enctype).toLowerCase() === \"multipart/form-data\""
@@ -25,7 +25,7 @@ fn runtime_preserves_multipart_file_upload_fallback_shape() {
#[test]
fn runtime_uses_root_scoped_walks_not_dom_selector_apis() {
// req: pitch/002 req: runtime/001 req: target_policy/001
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function firstElement(scope, predicate)"));
assert!(source.contains("function closestInRoot(start, root, predicate)"));
@@ -41,7 +41,7 @@ fn runtime_uses_root_scoped_walks_not_dom_selector_apis() {
#[test]
fn runtime_targets_generated_resources_not_response_selectors() {
// req: target_policy/001 req: target_policy/002
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function targetFor(scope, ref)"));
assert!(source.contains("return generatedTarget(scope, ref.resource.id)"));
@@ -57,20 +57,20 @@ fn runtime_targets_generated_resources_not_response_selectors() {
assert!(source.contains("firstElement.setAttribute(\"data-sid\", resourceId)"));
assert!(source.contains("const target = generatedTarget(scope, id)"));
assert!(source.contains("if (!target) return missing(scope, op.target)"));
assert!(!source.contains("data-slhx-target"));
assert!(!source.contains("data-slhx-select"));
assert!(!source.contains("data-hemx-target"));
assert!(!source.contains("data-hemx-select"));
}
#[test]
fn runtime_interval_dispatch_avoids_duplicate_timers() {
// req: convention/005
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("const everyTimers = new WeakMap()"));
assert!(source.contains("forEachElement(root, (el) =>"));
assert!(source.contains("!el.hasAttribute(\"data-slhx-every\") || everyTimers.has(el)"));
assert!(source.contains("!el.hasAttribute(\"data-hemx-every\") || everyTimers.has(el)"));
assert!(
source.contains("if (!el.hasAttribute(\"data-slhx-every\") || everyTimers.has(el)) return")
source.contains("if (!el.hasAttribute(\"data-hemx-every\") || everyTimers.has(el)) return")
);
assert!(source.contains(
"setInterval(() => document.contains(el) ? send(el, \"every\") : stopPolling(el), ms)"
@@ -83,10 +83,10 @@ fn runtime_interval_dispatch_avoids_duplicate_timers() {
#[test]
fn runtime_toggles_pending_conventions_around_requests() {
// req: convention/007 req: convention/008
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function showPending(el, on)"));
assert!(source.contains("el.getAttribute(\"data-slhx-pending-class\")"));
assert!(source.contains("el.getAttribute(\"data-hemx-pending-class\")"));
assert!(source.contains("const pendingClassStates = new WeakMap()"));
assert!(source.contains("function togglePendingClass(el, klass, on)"));
assert!(source.contains("hadClass: el.classList.contains(klass)"));
@@ -95,7 +95,7 @@ fn runtime_toggles_pending_conventions_around_requests() {
assert!(source.contains("function toggleIndicator(indicator, on)"));
assert!(source.contains("toggleIndicator(i, on)"));
assert!(source.contains("indicator.hidden = state.hidden"));
assert!(source.contains("el.hasAttribute(\"data-slhx-disable-while-pending\")"));
assert!(source.contains("el.hasAttribute(\"data-hemx-disable-while-pending\")"));
assert!(source.contains("if (isDisableControl(el)) controls.push(el)"));
assert!(source.contains(
"forEachElement(el, (child) => { if (isDisableControl(child)) controls.push(child); })"
@@ -113,23 +113,23 @@ fn runtime_toggles_pending_conventions_around_requests() {
#[test]
fn runtime_confirms_before_handler_dispatch() {
// req: convention/004
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("el.getAttribute(\"data-slhx-confirm\") && !confirm(el.getAttribute(\"data-slhx-confirm\"))"));
assert!(source.contains("el.getAttribute(\"data-hemx-confirm\") && !confirm(el.getAttribute(\"data-hemx-confirm\"))"));
assert!(source.contains("return;"));
}
#[test]
fn runtime_fetches_with_same_origin_credentials() {
// req: auth/005
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert_eq!(source.matches("credentials: \"same-origin\"").count(), 2);
}
#[test]
fn runtime_handles_get_forms_without_request_body() {
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function requestUrl(form, data, method)"));
assert!(source.contains("method === \"GET\" || method === \"HEAD\" ? undefined : requestBody"));
@@ -139,7 +139,7 @@ fn runtime_handles_get_forms_without_request_body() {
#[test]
fn runtime_clicking_submitter_schedules_form_submit() {
// req: convention/004 req: runtime/001
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function formOwner(el)"));
assert!(source.contains("function formHandleId(form)"));
@@ -163,7 +163,7 @@ fn runtime_clicking_submitter_schedules_form_submit() {
#[test]
fn runtime_supports_drag_drop_params_without_user_js() {
// req: htmx_equivalents/002, req: dx/008
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("const dragKeys = new WeakMap()"));
assert!(source.contains("dragstart"));
@@ -175,12 +175,12 @@ fn runtime_supports_drag_drop_params_without_user_js() {
#[test]
fn runtime_supports_queued_request_policy() {
// req: convention/006
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function normalizedPolicy(value)"));
assert!(source.contains("value === \"latest\" || value === \"queue\" || value === \"drop\" || value === \"parallel\""));
assert!(
source.contains("const policy = normalizedPolicy(el.getAttribute(\"data-slhx-policy\"))")
source.contains("const policy = normalizedPolicy(el.getAttribute(\"data-hemx-policy\"))")
);
assert!(source.contains("const queues = new WeakMap()"));
assert!(source.contains("policy === \"queue\""));
@@ -190,7 +190,7 @@ fn runtime_supports_queued_request_policy() {
#[test]
fn runtime_latest_request_policy_releases_superseded_pending_state() {
// req: convention/006 req: convention/007
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("if (active && policy === \"latest\") {\n active.abort.abort();\n showPending(target, false);\n }"));
assert!(source.contains("if (pending.get(target)?.abort === abort)"));
@@ -199,7 +199,7 @@ fn runtime_latest_request_policy_releases_superseded_pending_state() {
#[test]
fn runtime_parallel_request_policy_releases_each_pending_state() {
// req: convention/006 req: convention/007 req: convention/008
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains(
"else if (policy === \"parallel\") {\n showPending(target, false);\n }"
@@ -211,22 +211,22 @@ fn runtime_parallel_request_policy_releases_each_pending_state() {
#[test]
fn runtime_exposes_page_swap_hooks() {
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("data-slhx-nav"));
assert!(source.contains("data-slhx-boost"));
assert!(source.contains("data-hemx-nav"));
assert!(source.contains("data-hemx-boost"));
assert!(source.contains("history.pushState"));
assert!(source.contains("popstate"));
assert!(source.contains("x-slhx-title"));
assert!(source.contains("x-slhx-fingerprint"));
assert!(source.contains("slhx:missing-content-slot"));
assert!(source.contains("x-hemx-title"));
assert!(source.contains("x-hemx-fingerprint"));
assert!(source.contains("hemx:missing-content-slot"));
assert!(source.contains("else location.href = href"));
}
#[test]
fn runtime_popstate_failed_partials_reload_instead_of_stale_ui() {
// req: page_swap/005 req: page_swap/006 req: runtime/004
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("if (mode === \"none\") location.reload();"));
assert!(source.contains("if (root) navigateUrl(location.href, root, \"none\")"));
@@ -235,7 +235,7 @@ fn runtime_popstate_failed_partials_reload_instead_of_stale_ui() {
#[test]
fn runtime_preserves_native_navigation_escape_hatches() {
// req: page_swap/007 req: failure/006
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function sameOriginNav(event, anchor)"));
assert!(source.contains("!event.defaultPrevented"));
@@ -249,10 +249,10 @@ fn runtime_preserves_native_navigation_escape_hatches() {
#[test]
fn runtime_refuses_partial_updates_on_fingerprint_mismatch() {
// req: abi/003 req: abi/004 req: runtime/004
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function compatibleFingerprint(response, root)"));
assert!(source.contains("const received = response.headers.get(\"x-slhx-fingerprint\")"));
assert!(source.contains("const received = response.headers.get(\"x-hemx-fingerprint\")"));
assert!(source.contains("const expected = root && root.getAttribute(FINGERPRINT)"));
assert!(source.contains("if (!compatibleFingerprint(response, root))"));
assert!(source.contains("location.reload()"));
@@ -262,12 +262,12 @@ fn runtime_refuses_partial_updates_on_fingerprint_mismatch() {
#[test]
fn runtime_malformed_bootstrap_state_reports_and_continues() {
// req: state/004 req: state/006 req: runtime/001
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function bootstrapState(root)"));
assert!(source.contains("try {\n const store = atomStore(root);"));
assert!(source.contains("atomStores.delete(root)"));
assert!(source.contains("emit(root, \"slhx:state-error\", String(error))"));
assert!(source.contains("emit(root, \"hemx:state-error\", String(error))"));
assert!(source.contains("bindRoot(root)"));
assert!(source.contains("bindSse(root)"));
}
@@ -275,26 +275,26 @@ fn runtime_malformed_bootstrap_state_reports_and_continues() {
#[test]
fn runtime_applies_sse_effect_batches_inside_roots() {
// req: push/001 req: push/003 req: push/006 req: runtime/001
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("const sseSources = new WeakMap()"));
assert!(source.contains("const url = root.getAttribute(\"data-slhx-sse\")"));
assert!(source.contains("const url = root.getAttribute(\"data-hemx-sse\")"));
assert!(source.contains("const href = new URL(url, location.href)"));
assert!(source.contains("if (href.origin !== location.origin)"));
assert!(source.contains("emit(root, \"slhx:sse-error\", url)"));
assert!(source.contains("emit(root, \"hemx:sse-error\", url)"));
assert!(source.contains("new EventSource(href.href)"));
assert!(source
.contains("source.addEventListener(\"slhx\", (event) => applySseMessage(root, event))"));
.contains("source.addEventListener(\"hemx\", (event) => applySseMessage(root, event))"));
assert!(source
.contains("source.addEventListener(\"message\", (event) => applySseMessage(root, event))"));
assert!(source.contains("applyBatch(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), root)"));
assert!(source.contains("emit(root, \"slhx:sse-error\", url)"));
assert!(source.contains("emit(root, \"hemx:sse-error\", url)"));
}
#[test]
fn runtime_page_swaps_lowered_slot_ids() {
// req: wire/001
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function replaceLoweredSlots(scope, doc)"));
assert!(source.contains("forEachElement(doc.body || doc, (source)"));
@@ -304,7 +304,7 @@ fn runtime_page_swaps_lowered_slot_ids() {
#[test]
fn runtime_keeps_form_field_targets_separate_from_error_targets() {
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function fieldTarget(scope, id, field)"));
assert!(
@@ -312,13 +312,13 @@ fn runtime_keeps_form_field_targets_separate_from_error_targets() {
);
assert!(source.contains("function formErrorTarget(scope, id, field)"));
assert!(source.contains(
"attrEquals(el, \"data-slhx-error-for\", field) && withinGeneratedForm(el, scope, id)"
"attrEquals(el, \"data-hemx-error-for\", field) && withinGeneratedForm(el, scope, id)"
));
}
#[test]
fn runtime_preflights_batches_before_applying_ops() {
let source = slhx_js::RUNTIME_JS;
let source = hemx_js::RUNTIME_JS;
assert!(source.contains(
"const missingTarget = batch.ops.map((op) => canApplyOp(scope, op)).find(Boolean)"
@@ -329,7 +329,7 @@ fn runtime_preflights_batches_before_applying_ops() {
#[test]
fn runtime_ships_typescript_definitions() {
let source = slhx_js::RUNTIME_D_TS;
let source = hemx_js::RUNTIME_D_TS;
assert!(source.contains("req: ts/001"));
assert!(source.contains("export interface EffectBatch"));
@@ -1,7 +1,7 @@
[package]
name = "slhx-test"
name = "hemx-test"
version.workspace = true
edition.workspace = true
[dependencies]
slhx-core = { path = "../slhx-core" }
hemx-core = { path = "../hemx-core" }
+30 -30
View File
@@ -1,4 +1,4 @@
use slhx_core::{
use hemx_core::{
Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, IntoEffect, KeyedSlot,
NavigateMode, Payload, ResourceId, ResourceKind, ResourceRef, ScopeKey, Slot,
};
@@ -24,13 +24,13 @@ pub fn inspect_batch(batch: EffectBatch) -> EffectInspector {
/// Decode and inspect an effect wire response without exposing `EffectBatch` in tests.
/// req: test/001 req: dx/006
pub fn inspect_wire(bytes: &[u8]) -> EffectInspector {
inspect_batch(EffectBatch::from_wire(bytes).expect("slhx effect wire response"))
inspect_batch(EffectBatch::from_wire(bytes).expect("hemx effect wire response"))
}
/// Return the resource id behind a generated target for low-level test assertions.
/// req: test/001 req: dx/006
pub fn target_resource(target: impl GeneratedTarget) -> ResourceId {
target.__slhx_resource_id()
target.__hemx_resource_id()
}
/// Return the unscoped resource reference behind a generated target for low-level test assertions.
@@ -41,7 +41,7 @@ pub fn target_ref(target: impl GeneratedTarget) -> ResourceRef {
/// Build an interaction request body from a generated handle and form fields.
/// req: test/001 req: dx/006
pub fn handle_form_body<I>(handle: slhx_core::Handle<I>, fields: &[(&str, &str)]) -> String {
pub fn handle_form_body<I>(handle: hemx_core::Handle<I>, fields: &[(&str, &str)]) -> String {
let mut body = form_pair("__h", &handle.to_string());
for (name, value) in fields {
body.push('&');
@@ -58,13 +58,13 @@ pub fn unknown_handle_form_body(id: u32) -> String {
/// Build a browser-driver selector from a generated handle without exposing runtime ids in tests.
/// req: test/001 req: dx/006
pub fn handle_selector<I>(handle: slhx_core::Handle<I>) -> String {
pub fn handle_selector<I>(handle: hemx_core::Handle<I>) -> String {
attr_selector("data-hid", &handle.to_string())
}
/// Build a selector for a clickable button with a generated handle.
/// req: test/001 req: dx/006
pub fn handle_button_selector<I>(handle: slhx_core::Handle<I>) -> String {
pub fn handle_button_selector<I>(handle: hemx_core::Handle<I>) -> String {
format!("button{}", handle_selector(handle))
}
@@ -188,7 +188,7 @@ pub fn nav_link_selector(href: &str) -> String {
/// req: test/001 req: dx/006
pub fn page_nav_link_selector(href: &str) -> String {
format!(
"{}[data-slhx-nav]:not([data-slhx-handle])",
"{}[data-hemx-nav]:not([data-hemx-handle])",
nav_link_selector(href)
)
}
@@ -196,7 +196,7 @@ pub fn page_nav_link_selector(href: &str) -> String {
/// Build a browser-driver selector from a generated target without exposing runtime ids in tests.
/// req: test/001 req: dx/006
pub fn target_selector(target: impl GeneratedTarget) -> String {
let resource = target.__slhx_resource_id();
let resource = target.__hemx_resource_id();
let attr = match resource.kind {
ResourceKind::Slot => "data-sid",
ResourceKind::Atom => "data-aid",
@@ -206,10 +206,10 @@ pub fn target_selector(target: impl GeneratedTarget) -> String {
attr_selector(attr, &resource.id.to_string())
}
/// Build a selector for an slhx root from its authoring name.
/// Build a selector for an hemx root from its authoring name.
/// req: test/001 req: dx/006
pub fn root_selector(name: &str) -> String {
attr_selector("data-slhx-root", name)
attr_selector("data-hemx-root", name)
}
/// Build a selector for a specific root element from its authoring name.
@@ -230,16 +230,16 @@ pub fn document_title_selector() -> &'static str {
"title"
}
/// Build a selector for the slhx runtime script without exposing its asset path in tests.
/// Build a selector for the hemx runtime script without exposing its asset path in tests.
/// req: test/001 req: dx/006
pub fn runtime_script_selector() -> &'static str {
"script[src=\"/slhx.js\"]"
"script[src=\"/hemx.js\"]"
}
/// Build a selector for any slhx root without spelling the attribute in tests.
/// Build a selector for any hemx root without spelling the attribute in tests.
/// req: test/001 req: dx/006
pub fn any_root_selector() -> &'static str {
"[data-slhx-root]"
"[data-hemx-root]"
}
/// Build a selector for a keyed generated row without spelling runtime key metadata.
@@ -261,25 +261,25 @@ pub fn keyed_items_selector(base_selector: &str) -> String {
/// Build a selector for an island from its authoring name.
/// req: test/001 req: dx/006
pub fn island_selector(name: &str) -> String {
attr_selector("data-slhx-island", name)
attr_selector("data-hemx-island", name)
}
/// Return the island metadata attribute name without spelling it in product tests.
/// req: test/001 req: dx/006
pub fn island_attribute_name() -> &'static str {
"data-slhx-island"
"data-hemx-island"
}
/// Return the runtime island event name for an authoring island name.
/// req: test/001 req: dx/006
pub fn island_event_name(name: &str) -> String {
format!("slhx:island-{name}")
format!("hemx:island-{name}")
}
/// Return an SSE enhancement marker without spelling framework metadata in tests.
/// req: test/001 req: dx/006
pub fn sse_endpoint_marker(path: &str) -> String {
format!("data-slhx-sse=\"{path}\"")
format!("data-hemx-sse=\"{path}\"")
}
/// Return the island snapshot marker without spelling island metadata in tests.
@@ -296,7 +296,7 @@ pub fn island_readout_selector() -> &'static str {
/// Build browser-driver JavaScript for injecting a synthetic island probe.
///
/// This lets product tests exercise the island bridge without spelling slhx island
/// This lets product tests exercise the island bridge without spelling hemx island
/// metadata attributes in the test body. req: test/001 req: dx/006
pub fn island_probe_script(
element_id: &str,
@@ -310,7 +310,7 @@ const root = arguments[0];
const islandName = {island_name};
const island = document.createElement('article');
island.id = {element_id};
island.setAttribute('data-slhx-island', islandName);
island.setAttribute('data-hemx-island', islandName);
island.setAttribute('data-island-snapshot', {snapshot});
const canvas = document.createElement('canvas');
@@ -325,7 +325,7 @@ island.appendChild(readout);
root.appendChild(island);
setTimeout(() => {{
root.dispatchEvent(new CustomEvent('slhx:island-' + islandName, {{ bubbles: true, detail: {event_detail} }}));
root.dispatchEvent(new CustomEvent('hemx:island-' + islandName, {{ bubbles: true, detail: {event_detail} }}));
}}, 25);
return true;
"#,
@@ -426,13 +426,13 @@ impl EffectInspector {
/// Assert against the same generated target object application handlers use.
/// req: test/001 req: dx/006
pub fn has_target(&self, target: impl GeneratedTarget) -> bool {
self.has_resource(target.__slhx_resource_id())
self.has_resource(target.__hemx_resource_id())
}
/// Assert that a generated target receives a text update, without matching raw effects.
/// req: test/001 req: dx/006
pub fn updates_text(&self, target: impl GeneratedTarget) -> bool {
let resource = target.__slhx_resource_id();
let resource = target.__hemx_resource_id();
self.batch.ops.iter().any(|op| {
matches!(
op,
@@ -447,7 +447,7 @@ impl EffectInspector {
/// Assert that a generated target receives an HTML update, without matching raw effects.
/// req: test/001 req: dx/006
pub fn updates_html(&self, target: impl GeneratedTarget) -> bool {
let resource = target.__slhx_resource_id();
let resource = target.__hemx_resource_id();
self.batch.ops.iter().any(|op| {
matches!(
op,
@@ -462,7 +462,7 @@ impl EffectInspector {
/// Assert that a generated target receives an HTML update containing text.
/// req: test/001 req: dx/006
pub fn updates_html_containing(&self, target: impl GeneratedTarget, needle: &str) -> bool {
let resource = target.__slhx_resource_id();
let resource = target.__hemx_resource_id();
self.batch.ops.iter().any(|op| {
matches!(
op,
@@ -482,7 +482,7 @@ impl EffectInspector {
key: impl ToString,
needle: &str,
) -> bool {
let resource = target.__slhx_resource_id();
let resource = target.__hemx_resource_id();
let scope = Some(ScopeKey::KeyValue(key.to_string()));
self.batch.ops.iter().any(|op| {
matches!(
@@ -503,7 +503,7 @@ impl EffectInspector {
key: impl ToString,
needle: &str,
) -> bool {
let resource = target.__slhx_resource_id();
let resource = target.__hemx_resource_id();
let key = key.to_string();
self.batch.ops.iter().any(|op| {
matches!(
@@ -520,7 +520,7 @@ impl EffectInspector {
/// Assert that a keyed generated target removes a key.
/// req: test/001 req: dx/006
pub fn removes_key(&self, target: impl GeneratedTarget, key: impl ToString) -> bool {
let resource = target.__slhx_resource_id();
let resource = target.__hemx_resource_id();
let key = key.to_string();
self.batch.ops.iter().any(|op| {
matches!(
@@ -579,7 +579,7 @@ impl EffectInspector {
target: impl GeneratedTarget,
needle: &str,
) -> Option<&str> {
let resource = target.__slhx_resource_id();
let resource = target.__hemx_resource_id();
self.batch.ops.iter().find_map(|op| match op {
Effect::Put {
target,
@@ -658,7 +658,7 @@ impl EffectInspector {
matches!(
op,
Effect::Emit { name, payload }
if name == "slhx:form-reset" && payload == &form_id
if name == "hemx:form-reset" && payload == &form_id
)
})
}
@@ -14,7 +14,7 @@ fn canonical_examples_do_not_author_browser_javascript() {
let trimmed = line.trim();
if trimmed.contains("<script") && !allowed_example_script(path, trimmed) {
failures.push(format!(
"{}:{}: only the slhx runtime or explicit opaque-island scripts are allowed",
"{}:{}: only the hemx runtime or explicit opaque-island scripts are allowed",
path.display(),
line_no + 1
));
@@ -67,7 +67,7 @@ fn canonical_examples_do_not_author_low_level_resource_plumbing() {
"InteractionForm",
"register_handle(",
"SafeHtml",
"slhx::advanced",
"hemx::advanced",
"advanced::slots",
"KeyedSlot",
"Slot<",
@@ -82,12 +82,12 @@ fn canonical_examples_do_not_author_low_level_resource_plumbing() {
"RenderKeyedSlotExt",
".render(&",
".html(ui::render",
".html(slhx::render",
".html(hemx::render",
".html(super::ui::render",
"ui::put(",
"ui::append(",
"ui::replace(",
"application/slhx",
"application/hemx",
"EffectBatch",
"__h=",
"name=\"__h\"",
@@ -127,12 +127,12 @@ fn canonical_examples_do_not_author_low_level_resource_plumbing() {
fn public_prelude_does_not_export_low_level_primitives() {
// req: public_api/005 req: dx/006
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let facade = std::fs::read_to_string(root.join("slhx/src/lib.rs")).unwrap();
let facade = std::fs::read_to_string(root.join("hemx/src/lib.rs")).unwrap();
let prelude = facade
.split("pub mod prelude {")
.nth(1)
.and_then(|tail| tail.split("\n}").next())
.expect("slhx facade exposes prelude module");
.expect("hemx facade exposes prelude module");
let exported = prelude
.split(|c: char| !(c == '_' || c.is_ascii_alphanumeric()))
.filter(|part| !part.is_empty())
@@ -148,13 +148,13 @@ fn public_prelude_does_not_export_low_level_primitives() {
] {
assert!(
!exported.contains(&token),
"slhx::prelude must not export low-level `{token}`"
"hemx::prelude must not export low-level `{token}`"
);
}
assert!(prelude.contains("Html"), "slhx::prelude should export Html");
assert!(prelude.contains("Html"), "hemx::prelude should export Html");
assert!(
prelude.contains("IntoEffect"),
"slhx::prelude should export IntoEffect"
"hemx::prelude should export IntoEffect"
);
}
@@ -165,11 +165,11 @@ fn canonical_example_docs_do_not_teach_low_level_plumbing() {
let examples = root.join("examples");
let forbidden = [
"generated handles and slots",
"generated slhx handles",
"generated hemx handles",
"generated handle ids",
"numeric handle ids",
"runtime lowering",
"application/slhx responses",
"application/hemx responses",
"EffectBatch",
"manual registry",
"HandlerRegistry",
@@ -298,7 +298,7 @@ fn is_example_source(path: &PathBuf) -> bool {
fn allowed_example_script(path: &Path, line: &str) -> bool {
// req: examples/005
line.contains(r#"<script src="/slhx.js" defer></script>"#)
line.contains(r#"<script src="/hemx.js" defer></script>"#)
|| (path.ends_with("examples/techdemo/templates/app_shell.heml")
&& line.contains(r#"<script src="/island.js" defer></script>"#))
}
+140
View File
@@ -0,0 +1,140 @@
use hemx_core::{
Atom, Effect, GeneratedTarget, KeyedSlot, Payload, ResourceId, ResourceKind, ResourceRef, Slot,
};
#[test]
fn inspects_tuple_effects() {
let count = Slot::<u32>::new(1);
let user = Atom::<String>::new(2);
let inspected = hemx_test::run(|value| (count.text(value), user.set("alice")), 42);
assert!(inspected.has_slot(count));
assert!(inspected.has_atom(user));
assert!(inspected.contains(&Effect::Put {
target: ResourceRef::unscoped(count.id()),
payload: Payload::text(42),
}));
}
#[test]
fn finds_keyed_slot_targets() {
let rows = KeyedSlot::<u32, String>::new(9);
let inspected = hemx_test::inspect(rows.append_text(7, String::from("row")));
assert!(inspected.has_keyed_slot(rows));
assert_eq!(inspected.ops().len(), 1);
}
#[test]
fn builds_generated_handle_form_bodies() {
let handle = hemx_core::Handle::<()>::new(7);
let body = hemx_test::handle_form_body(handle, &[("title", "hello world"), ("tag", "a&b")]);
assert_eq!(body, "__h=7&title=hello+world&tag=a%26b");
assert_eq!(hemx_test::unknown_handle_form_body(99), "__h=99");
}
#[test]
fn builds_authoring_boundary_selectors() {
assert_eq!(
hemx_test::root_selector("techdemo"),
r#"[data-hemx-root="techdemo"]"#
);
assert_eq!(
hemx_test::island_selector("orbit"),
r#"[data-hemx-island="orbit"]"#
);
assert_eq!(hemx_test::island_attribute_name(), "data-hemx-island");
assert_eq!(hemx_test::island_event_name("orbit"), "hemx:island-orbit");
assert_eq!(
hemx_test::sse_endpoint_marker("/events"),
r#"data-hemx-sse="/events""#
);
assert_eq!(hemx_test::any_root_selector(), "[data-hemx-root]");
assert_eq!(
hemx_test::root_element_selector("main", "docs"),
r#"main[data-hemx-root="docs"]"#
);
assert_eq!(hemx_test::document_body_selector(), "body");
assert_eq!(hemx_test::document_title_selector(), "title");
assert_eq!(
hemx_test::runtime_script_selector(),
r#"script[src="/hemx.js"]"#
);
assert_eq!(
hemx_test::target_selector(TestTarget(ResourceKind::Slot, 42)),
r#"[data-sid="42"]"#
);
assert_eq!(
hemx_test::handle_button_selector(hemx_core::Handle::<()>::new(7)),
r#"button[data-hid="7"]"#
);
assert_eq!(hemx_test::article_selector(), "article");
assert_eq!(hemx_test::strong_text_selector(), "strong");
assert_eq!(hemx_test::small_text_selector(), "small");
assert_eq!(hemx_test::escaped_markup_selector("b"), "b");
assert_eq!(hemx_test::heading_selector("article", 1), "article h1");
assert_eq!(hemx_test::list_item_selector("ul"), "ul li");
assert_eq!(hemx_test::prose_selector("article"), "article p");
assert_eq!(hemx_test::form_selector("header"), "header form");
assert_eq!(
hemx_test::select_options_selector("column"),
r#"select[name="column"] > option"#
);
assert_eq!(hemx_test::class_selector("lane"), ".lane");
assert_eq!(
hemx_test::element_class_selector("span", "presence"),
"span.presence"
);
assert_eq!(
hemx_test::class_child_selector("columns", "section", "column"),
".columns > section.column"
);
assert_eq!(
hemx_test::class_descendant_selector("impact", "i"),
".impact i"
);
assert_eq!(hemx_test::disabled_button_selector(), "button[disabled]");
assert_eq!(
hemx_test::nav_link_selector("/architecture"),
r#"a[href="/architecture"]"#
);
assert_eq!(
hemx_test::page_nav_link_selector("/docs"),
r#"a[href="/docs"][data-hemx-nav]:not([data-hemx-handle])"#
);
assert_eq!(hemx_test::island_snapshot_marker(), "data-island-snapshot=");
assert_eq!(
hemx_test::island_readout_selector(),
"[data-island-readout]"
);
assert_eq!(
hemx_test::scoped_island_readout_selector("#probe-island"),
"#probe-island [data-island-readout]"
);
assert_eq!(
hemx_test::keyed_selector(".work-card", 4),
r#".work-card[data-key="4"]"#
);
assert_eq!(hemx_test::keyed_items_selector("li"), "li[data-key]");
let probe = hemx_test::island_probe_script(
"probe-island",
"orbit",
"1|1|1|probe waiting",
"7|2|8|probe live",
);
assert!(probe.contains("probe-island"));
assert!(probe.contains("probe live"));
}
#[derive(Clone, Copy)]
struct TestTarget(ResourceKind, u32);
impl GeneratedTarget for TestTarget {
fn __hemx_resource_id(self) -> ResourceId {
ResourceId::new(self.0, self.1)
}
}
@@ -1,9 +1,9 @@
[package]
name = "slhx-xtask"
name = "hemx-xtask"
version.workspace = true
edition.workspace = true
publish = false
[[bin]]
name = "slhx-ci"
name = "hemx-ci"
path = "src/main.rs"
@@ -13,7 +13,7 @@ fn main() -> ExitCode {
ExitCode::SUCCESS
}
Some(command) => {
eprintln!("unknown slhx-ci command `{command}`\n");
eprintln!("unknown hemx-ci command `{command}`\n");
print_help();
ExitCode::from(2)
}
@@ -22,7 +22,7 @@ fn main() -> ExitCode {
fn print_help() {
println!(
"slhx-ci — resource-aware project checks\n\n cargo run -p slhx-xtask -- test\n cargo run -p slhx-xtask -- bench\n\nEnvironment overrides:\n SLHX_CI_JOBS=N compile jobs, capped by detected resources\n SLHX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n SLHX_CI_SKIP_BROWSER=1 skip browser E2E"
"hemx-ci — resource-aware project checks\n\n cargo run -p hemx-xtask -- test\n cargo run -p hemx-xtask -- bench\n\nEnvironment overrides:\n HEMX_CI_JOBS=N compile jobs, capped by detected resources\n HEMX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n HEMX_CI_SKIP_BROWSER=1 skip browser E2E"
);
}
@@ -34,11 +34,11 @@ fn run_test_plan() -> ExitCode {
let mut steps = vec![
Step::new(
"workspace-no-techdemo",
["test", "--workspace", "--exclude", "slhx-techdemo"],
["test", "--workspace", "--exclude", "hemx-techdemo"],
),
Step::new(
"techdemo-unit-http",
["test", "-p", "slhx-techdemo", "--test", "e2e"],
["test", "-p", "hemx-techdemo", "--test", "e2e"],
),
Step::new("redgate", ["health", "--strict"]).tool("redgate"),
];
@@ -48,7 +48,7 @@ fn run_test_plan() -> ExitCode {
2,
Step::new(
"techdemo-browser",
["test", "-p", "slhx-techdemo", "--test", "browser_e2e"],
["test", "-p", "hemx-techdemo", "--test", "browser_e2e"],
)
.test_threads(1),
);
@@ -68,13 +68,13 @@ fn run_bench_plan() -> ExitCode {
let budget = Budget::detect();
budget.report();
eprintln!(
"slhx-ci: benchmarking small safe test slices from 1 to {} job(s)",
"hemx-ci: benchmarking small safe test slices from 1 to {} job(s)",
budget.jobs
);
let steps = [
Step::new("bench-xtask", ["test", "-p", "slhx-xtask"]),
Step::new("bench-runtime", ["test", "-p", "slhx-js"]),
Step::new("bench-xtask", ["test", "-p", "hemx-xtask"]),
Step::new("bench-runtime", ["test", "-p", "hemx-js"]),
];
for jobs in bench_values(budget.jobs) {
@@ -112,14 +112,14 @@ impl Budget {
.map_or(1, usize::from)
.max(1);
let mem_gib = available_mem_gib();
let skip_browser = env::var_os("SLHX_CI_SKIP_BROWSER").is_some()
let skip_browser = env::var_os("HEMX_CI_SKIP_BROWSER").is_some()
|| env::var_os("CI_NO_BROWSER").is_some()
|| !has_command("geckodriver");
Self::from_resources(
cpus,
mem_gib,
env_usize("SLHX_CI_JOBS"),
env_usize("SLHX_CI_TEST_THREADS"),
env_usize("HEMX_CI_JOBS"),
env_usize("HEMX_CI_TEST_THREADS"),
skip_browser,
)
}
@@ -150,7 +150,7 @@ impl Budget {
fn report(&self) {
eprintln!(
"slhx-ci: cpus={} mem={}GiB jobs={} test_threads={} browser={}",
"hemx-ci: cpus={} mem={}GiB jobs={} test_threads={} browser={}",
self.cpus,
self.mem_gib
.map(|mem| mem.to_string())
+3 -3
View File
@@ -1,5 +1,5 @@
[package]
name = "slhx"
name = "hemx"
version.workspace = true
edition.workspace = true
@@ -8,5 +8,5 @@ path = "src/lib.rs"
[dependencies]
hemplate = { path = "../../hemplate/hemplate" }
slhx-core = { path = "../slhx-core" }
slhx-derive = { path = "../slhx-derive" }
hemx-core = { path = "../hemx-core" }
hemx-derive = { path = "../hemx-derive" }
+12 -12
View File
@@ -1,26 +1,26 @@
//! Public authoring facade for slhx applications.
//! Public authoring facade for hemx applications.
//!
//! Most application code should depend on this crate, use the proc-macros from
//! here, and import generated resources through `#[slhx::surface]`.
//! here, and import generated resources through `#[hemx::surface]`.
use slhx_core::{Effect, KeyedSlot, SafeHtml, Slot};
use hemx_core::{Effect, KeyedSlot, SafeHtml, Slot};
pub use slhx_core::{
pub use hemx_core::{
navigate, push, redirect, replace, Atom, ComponentRef, CssClass, CssClasses, EventName, Form,
FormContract, FormControlKind, FormError, FormField, FormModel, FormValue, FromForm,
GeneratedTarget, Handle, IntoEffect, ParamName,
};
pub use slhx_derive::{app, component, form, handler, surface};
pub use hemx_derive::{app, component, form, handler, surface};
/// Advanced/raw slhx primitives used by generated code, integrations, and tests.
/// Advanced/raw hemx primitives used by generated code, integrations, and tests.
///
/// Beginner-facing application code should prefer generated targets, generated
/// handles/forms/classes, `Html`, `IntoEffect`, and tuple composition. req: dx/001 req: public_api/005
pub mod advanced {
pub use slhx_core::*;
pub use hemx_core::*;
}
/// Rendered, checked HTML produced by hemplate/slhx rendering helpers.
/// Rendered, checked HTML produced by hemplate/hemx rendering helpers.
///
/// Raw trusted HTML construction remains an advanced boundary; beginner-facing
/// code should receive `Html` values from generated render helpers. req: public_api/005
@@ -71,7 +71,7 @@ pub mod __private {
pub fn render(view: &impl hemplate::Hemplate) -> Html {
let mut html = String::new();
view.render_into(&mut html)
.expect("hemplate view renders into slhx effect payload");
.expect("hemplate view renders into hemx effect payload");
__private::html_trusted(html)
}
@@ -81,7 +81,7 @@ pub fn render(view: &impl hemplate::Hemplate) -> Html {
/// level of `ui::row.replace(row)` instead of `ui::row.replace(row.id, &row)`.
/// req: canonical_authoring/003 req: codegen/002
pub trait KeyedPartial {
fn slhx_key(&self) -> String;
fn hemx_key(&self) -> String;
}
#[doc(hidden)]
@@ -163,11 +163,11 @@ where
pub mod prelude {
pub use crate::render;
pub use crate::Html;
pub use slhx_core::{
pub use hemx_core::{
navigate, push, redirect, replace, Atom, ComponentRef, CssClass, CssClasses, EventName,
Form, FormModel, FormValue, Handle, IntoEffect, ParamName,
};
pub use slhx_derive::{app, component, form, handler, surface};
pub use hemx_derive::{app, component, form, handler, surface};
}
#[cfg(test)]
-3
View File
@@ -1,3 +0,0 @@
pub const RUNTIME_ABI_VERSION: u32 = 1;
pub const RUNTIME_JS: &str = include_str!("../runtime/slhx.js");
pub const RUNTIME_D_TS: &str = include_str!("../runtime/slhx.d.ts");
-140
View File
@@ -1,140 +0,0 @@
use slhx_core::{
Atom, Effect, GeneratedTarget, KeyedSlot, Payload, ResourceId, ResourceKind, ResourceRef, Slot,
};
#[test]
fn inspects_tuple_effects() {
let count = Slot::<u32>::new(1);
let user = Atom::<String>::new(2);
let inspected = slhx_test::run(|value| (count.text(value), user.set("alice")), 42);
assert!(inspected.has_slot(count));
assert!(inspected.has_atom(user));
assert!(inspected.contains(&Effect::Put {
target: ResourceRef::unscoped(count.id()),
payload: Payload::text(42),
}));
}
#[test]
fn finds_keyed_slot_targets() {
let rows = KeyedSlot::<u32, String>::new(9);
let inspected = slhx_test::inspect(rows.append_text(7, String::from("row")));
assert!(inspected.has_keyed_slot(rows));
assert_eq!(inspected.ops().len(), 1);
}
#[test]
fn builds_generated_handle_form_bodies() {
let handle = slhx_core::Handle::<()>::new(7);
let body = slhx_test::handle_form_body(handle, &[("title", "hello world"), ("tag", "a&b")]);
assert_eq!(body, "__h=7&title=hello+world&tag=a%26b");
assert_eq!(slhx_test::unknown_handle_form_body(99), "__h=99");
}
#[test]
fn builds_authoring_boundary_selectors() {
assert_eq!(
slhx_test::root_selector("techdemo"),
r#"[data-slhx-root="techdemo"]"#
);
assert_eq!(
slhx_test::island_selector("orbit"),
r#"[data-slhx-island="orbit"]"#
);
assert_eq!(slhx_test::island_attribute_name(), "data-slhx-island");
assert_eq!(slhx_test::island_event_name("orbit"), "slhx:island-orbit");
assert_eq!(
slhx_test::sse_endpoint_marker("/events"),
r#"data-slhx-sse="/events""#
);
assert_eq!(slhx_test::any_root_selector(), "[data-slhx-root]");
assert_eq!(
slhx_test::root_element_selector("main", "docs"),
r#"main[data-slhx-root="docs"]"#
);
assert_eq!(slhx_test::document_body_selector(), "body");
assert_eq!(slhx_test::document_title_selector(), "title");
assert_eq!(
slhx_test::runtime_script_selector(),
r#"script[src="/slhx.js"]"#
);
assert_eq!(
slhx_test::target_selector(TestTarget(ResourceKind::Slot, 42)),
r#"[data-sid="42"]"#
);
assert_eq!(
slhx_test::handle_button_selector(slhx_core::Handle::<()>::new(7)),
r#"button[data-hid="7"]"#
);
assert_eq!(slhx_test::article_selector(), "article");
assert_eq!(slhx_test::strong_text_selector(), "strong");
assert_eq!(slhx_test::small_text_selector(), "small");
assert_eq!(slhx_test::escaped_markup_selector("b"), "b");
assert_eq!(slhx_test::heading_selector("article", 1), "article h1");
assert_eq!(slhx_test::list_item_selector("ul"), "ul li");
assert_eq!(slhx_test::prose_selector("article"), "article p");
assert_eq!(slhx_test::form_selector("header"), "header form");
assert_eq!(
slhx_test::select_options_selector("column"),
r#"select[name="column"] > option"#
);
assert_eq!(slhx_test::class_selector("lane"), ".lane");
assert_eq!(
slhx_test::element_class_selector("span", "presence"),
"span.presence"
);
assert_eq!(
slhx_test::class_child_selector("columns", "section", "column"),
".columns > section.column"
);
assert_eq!(
slhx_test::class_descendant_selector("impact", "i"),
".impact i"
);
assert_eq!(slhx_test::disabled_button_selector(), "button[disabled]");
assert_eq!(
slhx_test::nav_link_selector("/architecture"),
r#"a[href="/architecture"]"#
);
assert_eq!(
slhx_test::page_nav_link_selector("/docs"),
r#"a[href="/docs"][data-slhx-nav]:not([data-slhx-handle])"#
);
assert_eq!(slhx_test::island_snapshot_marker(), "data-island-snapshot=");
assert_eq!(
slhx_test::island_readout_selector(),
"[data-island-readout]"
);
assert_eq!(
slhx_test::scoped_island_readout_selector("#probe-island"),
"#probe-island [data-island-readout]"
);
assert_eq!(
slhx_test::keyed_selector(".work-card", 4),
r#".work-card[data-key="4"]"#
);
assert_eq!(slhx_test::keyed_items_selector("li"), "li[data-key]");
let probe = slhx_test::island_probe_script(
"probe-island",
"orbit",
"1|1|1|probe waiting",
"7|2|8|probe live",
);
assert!(probe.contains("probe-island"));
assert!(probe.contains("probe live"));
}
#[derive(Clone, Copy)]
struct TestTarget(ResourceKind, u32);
impl GeneratedTarget for TestTarget {
fn __slhx_resource_id(self) -> ResourceId {
ResourceId::new(self.0, self.1)
}
}