feat(api): streamline generated app authoring

Move the canonical examples toward generated component-root helpers, typed form decoding, async/state handler registration, and derive-driven app/component registry wiring. Tighten requirements and diagnostics for the server-first, selectorless authoring path.

Verified with cargo run -p slhx-xtask -- test, cargo check --workspace, redgate list, redgate refs, redgate health --strict, and git diff --check.

req: canonical/001

req: canonical/003

req: canonical/004

req: dx/002

req: derive_app/001

req: component/003

req: form/004

req: axum_integration/003
This commit is contained in:
slhx agent
2026-06-05 06:33:41 +02:00
parent eb6086616c
commit d4e865ef92
34 changed files with 4573 additions and 1156 deletions
Generated
+1 -1
View File
@@ -401,7 +401,6 @@ dependencies = [
name = "hemplate-core" name = "hemplate-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"cc",
"hemplate-parser", "hemplate-parser",
"thiserror 2.0.18", "thiserror 2.0.18",
"tree-sitter", "tree-sitter",
@@ -1397,6 +1396,7 @@ dependencies = [
name = "slhx-derive" name = "slhx-derive"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"proc-macro2",
"quote", "quote",
"syn", "syn",
] ]
+89 -47
View File
@@ -1,9 +1,11 @@
# slhx — Semantic, Laterally HX # slhx — Semantic, Laterally HX
slhx does not compete with React by becoming a better frontend framework. slhx makes server-rendered HTML feel like it grew just enough interactivity.
slhx competes with React by making frontend frameworks unnecessary for most apps. 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 executes bytecode.** > **hemplate owns syntax. hemplate emits surface. slhx consumes surface. slhx owns semantics. JS applies effects.**
--- ---
@@ -29,7 +31,7 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
## pitch ## pitch
### req: pitch/001 ### req: pitch/001
001 slhx is checked hypermedia for Rust. Write `.heml`, write `#[slhx::handler]`, return commands. The compiler checks every cross-file reference. The browser runtime only sees ids and effect bytes. No JS app code required. [north_star] 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]
### req: pitch/002 ### 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-slhx-st` is allowed, but the browser never reconstructs a component tree. [north_star]
@@ -38,33 +40,61 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
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 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]
### req: pitch/004 ### req: pitch/004
004 The north-star feel: **Svelte at the call site, Rust at the boundary.** Short handler bodies, compile-checked HTML contracts, one language for server and client logic. [north_star] 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]
---
## 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]
### 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]
### req: canonical/003
003 Canonical keyed-row CRUD reads like ordinary Rust intent: create appends a rendered row partial, update/toggle replaces a keyed row partial, delete removes a keyed row, summary/text/form effects compose in tuples or arrays implementing `IntoEffect`, and no handler chooses a target with a CSS selector. [north_star]
### req: canonical/004
004 Generated helpers may compose only facts uniquely known from templates and checked Rust types: template, slot, optional key, form/control, class token, explicit island/event marker, and effect kind. If a handler parameter, key, form, target, raw route, or legacy target would require guessing, the user must say it explicitly and diagnostics must point to the Rust and hemplate spans. [north_star]
### req: canonical/005
005 Good generated helpers name UI intent without mixing domain work: `ui::content.replace(page)`, `ui::todos.append(todo)`, `ui::todo_row.replace(todo)`, `ui::todo_row.remove(todo_id)`, `ui::summary.set(text)`, `ui::notice.set("Saved")`, `ui::new_todo.clear()`, `ui::new_todo.focus("title")`, `ui::modal.replace(view)`, `ui::errors.set(errors)`, `ui::chart_path.set_attr("d", path)`, and `ui::game.emit(event)`. Helpers such as `refresh`, `save_and_update`, `sync_component`, and generic `rerender` are forbidden because they mix persistence, routing, rendering, target selection, or domain policy. [north_star]
### req: canonical/006
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]
### 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]
--- ---
## modes ## modes
### req: mode/001 ### req: mode/001
001 slhx has two happy paths: Page Enhancer and Interaction Handler. 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.
### req: mode/002 ### req: mode/002
002 Page Enhancer mode replaces minimal HTMX page swapping. Authors use real anchors with `data-slhx-nav` or `data-slhx-boost`; no user-authored handler is required. 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.
### req: mode/003 ### req: mode/003
003 Interaction Handler mode handles forms, buttons, typed params, and targeted updates through `#[slhx::handler]`. 003 Interaction Handler mode handles forms, buttons, typed params, and generated partial/text/form/island effects through Rust handlers.
### req: mode/004 ### req: mode/004
004 Beginner docs must teach Page Enhancer first, Interaction Handler second, Atoms third, client-local/WASM fourth, sync last. 004 Beginner docs must teach server-first typed partial swaps first, Page Enhancer as navigation around the same slot/effect model, explicit islands for leaf widgets, client-local/WASM only for local high-frequency behavior, and sync/offline last as opt-in adapters.
--- ---
## dx ## dx
### req: dx/001 ### req: dx/001
001 The common case must feel like writing a Svelte/Vue component: template, state, handlers, and targeted updates. Users should not need to understand Surface IR, ResourceId, EffectWriter, postcard, or runtime opcodes for basic apps. [north_star] 001 The common case must feel like writing server-rendered HTML plus tiny Rust handlers: template, state, hemplate partials, and generated UI swaps. Users should not need to understand Surface IR, ResourceId, EffectWriter, postcard, runtime opcodes, selector targeting, or manual registries for basic apps. [north_star]
### req: dx/002 ### req: dx/002
002 The happy path is: write `.heml`, write `#[slhx::handler]`, return generated slot/atom commands. No manual ids, no manual registry, no manual serialization, no manual JavaScript. [north_star] 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 ### 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-slhx-slot="todo_list"`, the user gets `slots::todo_list`, not `SlotId(12)`.
@@ -76,7 +106,7 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
005 Error messages must explain fixes in author language, not internal language. Say “add `h-key="todo.id"` to this `h-for`”, not “missing ScopeKey for ResourceRef”. 005 Error messages must explain fixes in author language, not internal language. Say “add `h-key="todo.id"` to this `h-for`”, not “missing ScopeKey for ResourceRef”.
### req: dx/006 ### req: dx/006
006 Generated target objects are the preferred authoring API: `targets::todo_list.put(&view)`, `targets::card.append(key, &view)`, `targets::count.text(42)`, `atoms::user.set(user)`. The public facade exposes `render(view)` for trusted hemplate-to-`SafeHtml` page and fragment composition; generated view modules expose lower-aware target methods plus compatibility functions `render(view)`, `put(slot, view)`, `append/prepend/replace(keyed_slot, key, view)`, `static_fragment(include_str!(...))` for prototype/static `.heml` fragments that need generated resource lowering as `SafeHtml`, and `lower(html)` for callers that need the lowered string. `render_html(view)` and `lower_html(html)` remain doc-hidden compatibility aliases and are not beginner-prelude exports. The beginner prelude should not expose lower-level slot render shortcuts that bypass generated lowering. These return `impl IntoEffect`, `SafeHtml`, or lowered HTML at the boundary. Raw `Effect` constructors, opcodes, and `EffectWriter` remain low-level. [north_star] 006 Generated object-like helpers are the preferred authoring API and are re-exported at the component root: `todos.append(todo)`, `todo_row.replace(todo)`, `todo_row.remove(todo_id)`, `summary.set(text)`, `new_todo.clear()`, `new_todo.focus("title")`, `page.replace(view)`, and `game.emit(event)`. These helpers hide hemplate rendering and resource lowering in the common path. Namespaced `targets`, `handles`, `forms`, and raw `advanced::slots` modules remain compatibility/organization and escape-hatch surfaces, not the ordinary call-site shape. The public facade may expose explicit `render(view)`, `target(name)`, `html(value)`, `lower(html)`, or raw effect constructors only as named escape hatches; they must not appear in beginner-prelude exports, canonical handler examples, or ordinary docs. [north_star]
### req: dx/007 ### req: dx/007
007 Tuple composition of `IntoEffect` is the canonical batch syntax: `(a, b, c)` implements `IntoEffect` up to arity 12. `Effect::batch((...))` is available but not required for the happy path. 007 Tuple composition of `IntoEffect` is the canonical batch syntax: `(a, b, c)` implements `IntoEffect` up to arity 12. `Effect::batch((...))` is available but not required for the happy path.
@@ -105,7 +135,7 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
## progressive_disclosure ## progressive_disclosure
### req: pd/001 ### req: pd/001
001 A beginner can build CRUD with only: `.heml`, `#[slhx::handler]`, `Form<T>`, generated `slots::*` methods, and `impl IntoEffect`. 001 A beginner can build CRUD with only: `.heml`, Rust handlers, `Form<T>` or typed params, generated object-like UI helpers, and `impl IntoEffect`.
### req: pd/002 ### req: pd/002
002 Atoms are not required for basic server-first apps. They appear only when client-local state, SSR bootstrapped state, or WASM handlers are used. 002 Atoms are not required for basic server-first apps. They appear only when client-local state, SSR bootstrapped state, or WASM handlers are used.
@@ -114,23 +144,23 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
003 Sync, transitions, resources/queries, islands, capabilities, and raw EffectWriter are advanced layers. They must not appear in starter examples. 003 Sync, transitions, resources/queries, islands, capabilities, and raw EffectWriter are advanced layers. They must not appear in starter examples.
### req: pd/004 ### req: pd/004
004 Documentation must present three levels: server-first, client-local, hybrid-sync. Each level introduces only the new primitive it needs. 004 Documentation must present levels as adapters around the same core: server-first partial swaps; cached Page/PWA shell; explicit leaf islands or client-local handlers for high-frequency behavior; hybrid sync/offline queues last. Each level introduces only the new primitive it needs.
--- ---
## page_swap ## page_swap
### req: page_swap/001 ### req: page_swap/001
001 Minimal page swapping is a first-class slhx-axum happy path. 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-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.
### req: page_swap/002 ### req: page_swap/002
002 A `data-slhx-nav` click fetches the target URL as a slhx partial request. The response updates the canonical content slot, optionally navigation and title, then applies a `Navigate` effect. 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.
### req: page_swap/003 ### req: page_swap/003
003 Page swapping uses generated slots, not CSS selectors. The default content target is the slot named `content`, not `#content`. 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.
### req: page_swap/004 ### req: page_swap/004
004 Minimal page swap must not require user-authored `#[slhx::handler]`. Explicit navigation handlers are available only when custom application logic is needed. 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 ### 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`, slhx fetches the URL as a partial request and applies the same page-swap update without pushing a new history entry.
@@ -149,10 +179,10 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
001 slhx replaces common HTMX use-cases through typed equivalents, not HTMX syntax. 001 slhx replaces common HTMX use-cases through typed equivalents, not HTMX syntax.
### req: htmx/002 ### req: htmx/002
002 Easy equivalents must exist for: boosted links/forms, page swap, form submit, targeted replacement, append/prepend/remove, loading indicators, confirmation, debounce/throttle, polling, history navigation, multi-target updates, response events, SSE/push, and validation errors. 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 ### 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 slots, typed params, forms, and explicit handlers. 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.
--- ---
@@ -166,8 +196,8 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
### req: component/003 ### req: component/003
003 Generated APIs are component-namespaced by default: 003 Generated APIs are component-namespaced by default:
`ui::todo_list::slots::todo_row`, `ui::todo_list::handles::create`, `ui::todo_list::forms::create`, and `ui::todo_list::COMPONENT` as a checked `ComponentRef`. `ui::todo_list::todo_row`, `ui::todo_list::create`, `ui::todo_list::new_todo`, and `ui::todo_list::COMPONENT` as a checked `ComponentRef`.
Global exports (`ui::slots::*`, `ui::handles::*`, `ui::components::*`) are opt-in only. 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 ### 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 `#[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.
@@ -226,7 +256,7 @@ a `data-*` handle param is statically known or runtime-extracted.
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 `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]
### req: codegen/002 ### req: codegen/002
002 Generated view modules expose ergonomic target objects and resource commands: `targets::list.put(value)`, `targets::row.append(key, value)`, `targets::row.replace(key, value)`, plus compatibility commands `put(slot, value)`, `append(keyed_slot, key, value)`, `prepend(keyed_slot, key, value)`, and `replace(keyed_slot, key, value)`. String-keyed target objects accept displayable domain ids without caller-side `.to_string()` noise. Commands return `impl IntoEffect` and preserve generated lowering. 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 ### 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 `#[slhx::handler]` for validation.
@@ -245,7 +275,7 @@ a `data-*` handle param is statically known or runtime-extracted.
## public_api ## public_api
### req: public_api/001 ### req: public_api/001
001 The generated API is the primary public authoring API. Most user code should return generated slot/atom/form/nav commands, not raw `Effect` constructors. 001 The generated API is the primary public authoring API. Most user code should return generated partial, text, keyed-row, form, page, nav, or island/event commands, not raw `Effect` constructors or raw render/lower calls.
### req: public_api/002 ### req: public_api/002
002 `Effect`, `EffectWriter`, `ResourceId`, `ResourceRef`, and raw opcodes are advanced APIs. They must not appear in beginner docs, generated examples, or common diagnostics. 002 `Effect`, `EffectWriter`, `ResourceId`, `ResourceRef`, and raw opcodes are advanced APIs. They must not appear in beginner docs, generated examples, or common diagnostics.
@@ -256,6 +286,9 @@ a `data-*` handle param is statically known or runtime-extracted.
### req: public_api/004 ### req: public_api/004
004 If a common UI operation requires raw `EffectWriter`, the public API is considered incomplete. 004 If a common UI operation requires raw `EffectWriter`, the public API is considered incomplete.
### req: public_api/005
005 Beginner-facing page/template composition uses generated render or page helpers. Direct `SafeHtml` construction, raw `html(...)`, raw `target(...)`, raw route fragments, and explicit `ui::render(...)` calls are advanced escape hatches and must not appear in beginner examples or docs.
--- ---
## effect_algebra ## effect_algebra
@@ -315,7 +348,7 @@ resources. Concrete runtime targets are addressed through `ResourceRef`
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; slhx implements keyed slot lookups.
### req: list/003 ### req: list/003
003 Effects on keyed slots: `replace_keyed(slot, key, value)`, `remove_keyed(slot, key)`, `append_keyed(slot, key, value)`, `prepend_keyed(slot, key, value)`. Mismatch between key type and slot key type is compile-time error. 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.
--- ---
@@ -346,7 +379,7 @@ what a valid business email is.
## form_effects ## form_effects
### req: form_effects/001 ### req: form_effects/001
001 Generated form APIs provide common commands: `reset()`, `clear(field)`, `error(field, message)`, `focus(field)`, and `disable_while_pending()`. 001 Generated form APIs provide common commands: `clear()`, `clear(field)`, `reset()`, `error(field, message)`, `focus(field)`, and `disable_while_pending()`. `form.clear()` clears the canonical generated form without requiring callers to name raw control ids.
### req: form_effects/002 ### req: form_effects/002
002 Form effects target generated form/control ids, not CSS selectors. 002 Form effects target generated form/control ids, not CSS selectors.
@@ -435,13 +468,13 @@ what a valid business email is.
## axum_integration ## axum_integration
### req: axum/001 ### req: axum/001
001 slhx-axum supports the common shell/partial pattern. Full-page requests are wrapped in a user-provided Shell; slhx/partial requests may return only the rendered component or an EffectBatch. The shell/partial helper has a `SafeHtml` path so already-rendered hemplate fragments can cross the page boundary without downgrading to unchecked strings. 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.
### req: axum/002 ### 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. slhx does not own routing. slhx-axum only mounts handler dispatch, runtime assets, and optional push endpoints.
### req: axum/003 ### req: axum/003
003 Interactive fragments that would traditionally be implemented as `/demo/...` HTMX endpoints should be expressible as `#[slhx::handler]` functions returning generated slot commands. Integration registration should read as interactions over generated handles, not low-level registry wiring. 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 ### 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 slhx happy path.
@@ -476,10 +509,10 @@ what a valid business email is.
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. slhx does not define auth semantics for streams.
### req: push/003 ### req: push/003
003 HTMX-style SSE swaps are represented as streamed effects targeting generated slots/atoms. No selector-based `sse-swap` semantics in core. 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.
### req: push/004 ### req: push/004
004 Out-of-band updates are ordinary multi-target EffectBatches. 004 Out-of-band updates are ordinary multi-target partial swaps/effects, not a separate response model.
### req: push/005 ### req: push/005
005 Push is one-way server-to-client delivery of EffectBatch. It does not define client mutation, optimistic queues, reconciliation, or conflict handling. 005 Push is one-way server-to-client delivery of EffectBatch. It does not define client mutation, optimistic queues, reconciliation, or conflict handling.
@@ -520,19 +553,28 @@ what a valid business email is.
## interop ## interop
### req: interop/001 ### req: interop/001
001 `Effect::event` is the single bridge between slhx and third-party JS. External widgets, charts, and maps listen via native `CustomEvent`. slhx core does not inspect or manage them. [north_star] 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]
### req: interop/002 ### req: interop/002
002 Web Components and custom elements are valid opaque leaf nodes. slhx does not inspect shadow DOM. Escape hatches are leaves, never app foundations. 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.
### req: interop/003 ### req: interop/003
003 WASM islands (`#[slhx::island]`) compile handler code to WASM for client-local execution. The island is a leaf in the DOM; slhx core is unaware of WASM except via the same `EffectBatch` contract. 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)`.
### req: interop/004 ### 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 slhx semantics. An optional `slhx-htmx-migrate` tool may read Surface `hx-*` attrs and suggest equivalent `data-slhx-*` handlers/effects.
### req: interop/005 ### req: interop/005
005 HTMX-style response triggers 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 slhx 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.
### 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.
### 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.
--- ---
@@ -594,7 +636,7 @@ what a valid business email is.
## test ## test
### req: test/001 ### req: test/001
001 `EffectWriter` implements a test backend so handlers can be unit-tested without a browser: `slhx_test::run(handler, input)` returns an `EffectInspector` with `contains(op)`, `has_slot(slot)`, `has_atom(atom)`, etc. 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.
### req: test/002 ### 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 `#[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.
@@ -635,7 +677,7 @@ 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. 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 ### req: state/003
003 The JS runtime maintains a client-side atom store keyed by `AtomId`. 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 `slhx_build` for diagnostics, but the JS runtime does not depend on Rust `TypeId`.
### req: state/004 ### 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-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.
@@ -823,31 +865,31 @@ Optional ergonomic macros may exist: `#[slhx::surface]`, `#[slhx::component]`,
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. slhx 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 ### req: derive_handler/004
004 The common handler signature forms are: 004 The common handler signature forms are plain Rust functions, synchronous or async:
```rust ```rust
fn my_handler() -> impl IntoEffect fn ping() -> impl IntoEffect
fn my_handler(form: Form<CreateTodo>) -> impl IntoEffect async fn add(app: State<App>, form: Form<NewTodo>) -> impl IntoEffect
fn my_handler(app: &mut AppState) -> impl IntoEffect async fn rename(app: State<App>, todo_id: TodoId, title: Title) -> impl IntoEffect
fn my_handler(card_id: CardId, app: &mut AppState) -> impl IntoEffect async fn delete(app: State<App>, todo_id: TodoId) -> impl IntoEffect
``` ```
All forms support returning `impl IntoEffect` and compose through tuples. `State<App>` is illustrative integration context; equivalent framework extractors or app references are adapter concerns. Form fields and `data-*` params parse through normal Rust `FromForm`/`FormValue`/`FromStr`-style traits, so domain newtypes remain user-authored. Handlers may return `impl IntoEffect` or `Result<impl IntoEffect, E>` for fallible database/domain work; `IntoEffect` values compose through tuples while `Result` paths preserve a typed error boundary for integrations to map to form errors, toasts, events, or HTTP responses.
--- ---
## derive_app ## derive_app
### req: derive_app/001 ### req: derive_app/001
001 `#[slhx::app]` marks an application/root state type and registry entry point. 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. 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.
--- ---
## locality ## locality
### req: locality/001 ### 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 slot on the local component or keyed row. 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.
### req: locality/002 ### req: locality/002
002 Diagnostics should suggest adding `data-slhx-slot` to the local element when users attempt a self/row update pattern. 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.
--- ---
@@ -911,7 +953,7 @@ All forms support returning `impl IntoEffect` and compose through tuples.
## target_policy ## target_policy
### req: target/001 ### req: target/001
001 slhx does not support response-side selector retargeting. Handlers choose targets by returning effects for generated resources. 001 slhx does not support response-side selector retargeting. Handlers choose targets by returning generated UI commands for generated resources.
### req: target/002 ### req: target/002
002 slhx does not implement response-side CSS fragment selection in core. Servers return explicit component fragments or EffectBatches. 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.
+12 -9
View File
@@ -4,7 +4,7 @@ A board with drag-and-drop cards, 60fps pointer-follow, optimistic updates,
offline queue, conflict reconciliation, live presence, and SSR-first rendering — offline queue, conflict reconciliation, live presence, and SSR-first rendering —
all without React/Vue/VDOM, in a single typed Rust codebase. all without React/Vue/VDOM, in a single typed Rust codebase.
This is the north-star integration test for slhx + hemplate + slhx-sync. 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.
--- ---
@@ -270,24 +270,27 @@ The runtime does not know "presence". It executes generated DOM updates.
--- ---
## 9. What the browser receives ## 9. What app authors write; what the browser receives
Initial SSR: Initial SSR stays an ordinary rendered template with symbolic slhx attributes at
the authoring boundary:
```html ```html
<section data-slhx-root data-sid="0" data-aid="0"> <section data-slhx-root="board" data-slhx-slot="board" data-slhx-atom="board">
... ...
<article data-sid="3" data-key="42" data-hid="1"> <article data-slhx-slot="card" data-slhx-handle="select_card" +data-card-id="card.id">
Fix login bug {+ card.title +}
</article> </article>
... ...
</section> </section>
<!-- the app shell loads /slhx.js and any bootstrap state --> <!-- the app shell loads /slhx.js and any bootstrap state -->
``` ```
Runtime attachment: `/slhx.js` installs delegated root listeners for forms, The compiler lowers those symbols to compact runtime metadata, but that metadata
clicks, and pointer/drag events. App authors keep composing generated resources; is not an app-authoring contract. Runtime attachment: `/slhx.js` installs
they do not attach per-node listeners or write selector glue. 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.
No framework download. No VDOM. No hydration. No game loop. No framework download. No VDOM. No hydration. No game loop.
+13 -10
View File
@@ -6,7 +6,7 @@ mod tests {
use super::ui::{board, board_card}; use super::ui::{board, board_card};
use hemplate::Hemplate; use hemplate::Hemplate;
use scraper::{Html, Selector}; use scraper::{Html, Selector};
use slhx::{Effect, IntoEffect, Payload}; use slhx::IntoEffect;
use slhx_test::inspect; use slhx_test::inspect;
#[derive(Hemplate)] #[derive(Hemplate)]
@@ -27,12 +27,11 @@ mod tests {
#[test] #[test]
fn kanban_board_updates_generated_slot() { fn kanban_board_updates_generated_slot() {
fn render_board() -> impl IntoEffect { fn render_board() -> impl IntoEffect {
board::targets::board.put(&empty_board()) board::board.put(&empty_board())
} }
let effect = inspect(render_board()); let effect = inspect(render_board());
assert!(effect.has_slot(board::slots::board)); assert!(effect.updates_html(board::board));
assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Html(_), .. }]));
} }
// req: html_safety/002 req: view/001 req: test/005 // req: html_safety/002 req: view/001 req: test/005
@@ -45,7 +44,9 @@ mod tests {
fn empty_board() -> BoardColumns { fn empty_board() -> BoardColumns {
// req: html_safety/002 req: view/001 // req: html_safety/002 req: view/001
BoardColumns { columns: Vec::new() } BoardColumns {
columns: Vec::new(),
}
} }
fn selector(value: &str) -> Selector { fn selector(value: &str) -> Selector {
@@ -57,19 +58,21 @@ mod tests {
fn kanban_form_handler_is_checked_against_hemplate_form() { fn kanban_form_handler_is_checked_against_hemplate_form() {
#[slhx::handler] #[slhx::handler]
fn create_card(_form: slhx::Form<CreateCard>) -> impl IntoEffect { fn create_card(_form: slhx::Form<CreateCard>) -> impl IntoEffect {
board::slots::notice.text("queued") board::notice.text("queued")
} }
let effect = inspect(create_card(CreateCard::FORM)); let effect = inspect(create_card(CreateCard::FORM));
assert!(effect.has_slot(board::slots::notice)); assert!(effect.updates_text(board::notice));
assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }]));
} }
// req: examples/001 req: form/002 req: codegen/003 // req: examples/001 req: form/002 req: codegen/003
#[test] #[test]
fn kanban_template_exports_form_and_card_handles() { fn kanban_template_exports_form_and_card_handles() {
assert_ne!(board::handles::create_card.id(), board_card::handles::move_right.id()); assert_ne!(board::create_card.id(), board_card::move_right.id());
assert_eq!(board::forms::create_card.field("title").resource, board::forms::create_card.id()); assert_eq!(
board::create_card_form.field("title").resource,
board::create_card_form.id()
);
} }
} }
+119 -38
View File
@@ -4,14 +4,14 @@ use axum::routing::get;
use axum::Router; use axum::Router;
use futures_util::{stream, StreamExt}; use futures_util::{stream, StreamExt};
use hemplate::Hemplate; use hemplate::Hemplate;
use slhx::{IntoEffect, SafeHtml}; use slhx::{Html, IntoEffect};
use slhx_axum::{ use slhx_axum::{
interactions, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse, interactions, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse,
InteractionRequest, PageRequest, 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 slhx_kanban_example::ui::{self, board as board_ui};
use slhx_kanban_example::ui::board::{forms, handles, slots, targets};
use slhx_kanban_example::ui::board_card::handles as card_handles;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::convert::Infallible; use std::convert::Infallible;
use std::net::SocketAddr; use std::net::SocketAddr;
@@ -40,7 +40,7 @@ struct Card {
#[derive(Hemplate)] #[derive(Hemplate)]
struct AppShell { struct AppShell {
body: SafeHtml, body: Html,
} }
#[derive(Hemplate)] #[derive(Hemplate)]
@@ -67,8 +67,8 @@ struct BoardCard {
#[derive(Hemplate)] #[derive(Hemplate)]
struct Board { struct Board {
options: SafeHtml, options: Html,
board: SafeHtml, board: Html,
} }
#[derive(Hemplate)] #[derive(Hemplate)]
@@ -96,9 +96,21 @@ async fn main() {
board: Mutex::new(BoardState { board: Mutex::new(BoardState {
next_id: 4, next_id: 4,
cards: vec![ cards: vec![
Card { id: 1, title: "Write requirements".into(), column: 0 }, Card {
Card { id: 2, title: "Build browser example".into(), column: 1 }, id: 1,
Card { id: 3, title: "Verify with HTTP".into(), column: 2 }, title: "Write requirements".into(),
column: 0,
},
Card {
id: 2,
title: "Build browser example".into(),
column: 1,
},
Card {
id: 3,
title: "Verify with HTTP".into(),
column: 2,
},
], ],
}), }),
}); });
@@ -138,14 +150,20 @@ async fn interact(
// req: push/001 req: push/003 req: examples/001 // req: push/001 req: push/003 req: examples/001
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse { async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
if params.contains_key("once") { if params.contains_key("once") {
let effect = targets::presence.put(&Presence { count: 1 }); let effect = board::presence.put(&Presence { count: 1 });
return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed()); return sse(stream::iter([Ok::<_, Infallible>(
effect.into_batch(ui::BUILD_FINGERPRINT),
)])
.boxed());
} }
let batches = stream::unfold(1_u64, |count| async move { let batches = stream::unfold(1_u64, |count| async move {
tokio::time::sleep(Duration::from_secs(4)).await; tokio::time::sleep(Duration::from_secs(4)).await;
let effect = targets::presence.put(&Presence { count }); let effect = board::presence.put(&Presence { count });
Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1)) Some((
Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)),
count + 1,
))
}) })
.boxed(); .boxed();
sse(batches) sse(batches)
@@ -153,7 +171,7 @@ async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResp
fn registry(state: Arc<AppState>) -> impl DispatchRegistry { fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
interactions(ui::BUILD_FINGERPRINT) interactions(ui::BUILD_FINGERPRINT)
.on(handles::create_card, { .on(board::create_card, {
let state = state.clone(); let state = state.clone();
move |form| { move |form| {
// req: examples/001 req: form/002 // req: examples/001 req: form/002
@@ -163,12 +181,16 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
if !title.is_empty() { if !title.is_empty() {
let id = board.next_id; let id = board.next_id;
board.next_id += 1; board.next_id += 1;
board.cards.push(Card { id, title: title.into(), column }); board.cards.push(Card {
id,
title: title.into(),
column,
});
} }
board_effects(&board, "Card added") board_effects(&board, "Card added")
} }
}) })
.on(card_handles::move_left, { .on(card_board::move_left, {
let state = state.clone(); let state = state.clone();
move |form| { move |form| {
// req: examples/001 req: list/003 // req: examples/001 req: list/003
@@ -176,10 +198,17 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
let moved = update_card(&mut board, form.parse("card_id"), |card| { let moved = update_card(&mut board, form.parse("card_id"), |card| {
card.column = card.column.saturating_sub(1); card.column = card.column.saturating_sub(1);
}); });
board_effects(&board, if moved { "Card moved left" } else { "Card not found" }) board_effects(
&board,
if moved {
"Card moved left"
} else {
"Card not found"
},
)
} }
}) })
.on(card_handles::move_right, { .on(card_board::move_right, {
let state = state.clone(); let state = state.clone();
move |form| { move |form| {
// req: examples/001 req: list/003 // req: examples/001 req: list/003
@@ -187,10 +216,17 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
let moved = update_card(&mut board, form.parse("card_id"), |card| { let moved = update_card(&mut board, form.parse("card_id"), |card| {
card.column = (card.column + 1).min(COLUMNS.len() - 1); card.column = (card.column + 1).min(COLUMNS.len() - 1);
}); });
board_effects(&board, if moved { "Card moved right" } else { "Card not found" }) board_effects(
&board,
if moved {
"Card moved right"
} else {
"Card not found"
},
)
} }
}) })
.on(card_handles::delete_card, { .on(card_board::delete_card, {
let state = state.clone(); let state = state.clone();
move |form| { move |form| {
// req: examples/001 req: list/003 // req: examples/001 req: list/003
@@ -199,16 +235,23 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
if let Some(id) = form.parse::<u64>("card_id") { if let Some(id) = form.parse::<u64>("card_id") {
board.cards.retain(|card| card.id != id); board.cards.retain(|card| card.id != id);
} }
board_effects(&board, if board.cards.len() < before { "Card deleted" } else { "Card not found" }) board_effects(
&board,
if board.cards.len() < before {
"Card deleted"
} else {
"Card not found"
},
)
} }
}) })
} }
fn board_effects(board: &BoardState, notice: &'static str) -> impl IntoEffect { fn board_effects(board: &BoardState, notice: &'static str) -> impl IntoEffect {
( (
targets::board.put(&board_view(board)), board::board.put(&board_view(board)),
slots::notice.text(notice), board::notice.text(notice),
forms::create_card.clear("title"), board::create_card_form.clear(),
) )
} }
@@ -235,7 +278,7 @@ fn parse_column(value: Option<&str>) -> usize {
.unwrap_or(0) .unwrap_or(0)
} }
fn page_html(board: &BoardState) -> SafeHtml { fn page_html(board: &BoardState) -> Html {
// req: html_safety/002 req: view/001 // req: html_safety/002 req: view/001
board_ui::render(&Board { board_ui::render(&Board {
options: render_options(), options: render_options(),
@@ -243,12 +286,12 @@ fn page_html(board: &BoardState) -> SafeHtml {
}) })
} }
fn shell(body: SafeHtml) -> SafeHtml { fn shell(body: Html) -> Html {
// req: html_safety/001 req: html_safety/002 req: axum_integration/001 // req: html_safety/001 req: html_safety/002 req: axum_integration/001
slhx::render(&AppShell { body }) slhx::render(&AppShell { body })
} }
fn render_options() -> SafeHtml { fn render_options() -> Html {
// req: html_safety/002 req: view/001 // req: html_safety/002 req: view/001
ui::render(&ColumnOptions { ui::render(&ColumnOptions {
options: COLUMNS options: COLUMNS
@@ -290,6 +333,11 @@ fn render_card(card: &Card) -> BoardCard {
mod tests { mod tests {
use super::*; use super::*;
use scraper::{Html, Selector}; use scraper::{Html, Selector};
use slhx_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,
};
fn selector(value: &str) -> Selector { fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses") Selector::parse(value).expect("test selector parses")
@@ -303,10 +351,22 @@ mod tests {
assert!(!html.as_str().contains("__BOARD__")); assert!(!html.as_str().contains("__BOARD__"));
let document = Html::parse_fragment(html.as_str()); let document = Html::parse_fragment(html.as_str());
assert_eq!(document.select(&selector("section[data-slhx-root=\"kanban\"]")).count(), 1); assert_eq!(
assert_eq!(document.select(&selector("select[name=\"column\"] > option")).count(), 3); document
assert_eq!(document.select(&selector("[data-sid]")).count(), 3); .select(&selector(&root_element_selector("section", "kanban")))
assert_eq!(document.select(&selector("[data-hid]")).count(), 1); .count(),
1
);
assert_eq!(
document
.select(&selector(&select_options_selector("column")))
.count(),
3
);
assert_eq!(
document.select(&selector(&form_selector("header"))).count(),
1
);
} }
// req: html_safety/002 req: view/001 req: test/005 // req: html_safety/002 req: view/001 req: test/005
@@ -323,15 +383,31 @@ mod tests {
let html = ui::render(&board_view(&board)); let html = ui::render(&board_view(&board));
let document = Html::parse_fragment(html.as_str()); let document = Html::parse_fragment(html.as_str());
assert_eq!(document.select(&selector(".columns > section.column")).count(), 3); assert_eq!(
document
.select(&selector(&class_child_selector(
"columns", "section", "column"
)))
.count(),
3
);
let card = document let card = document
.select(&selector("article.card[data-key=\"1\"]")) .select(&selector(&keyed_selector("article.card", 1)))
.next() .next()
.expect("card renders"); .expect("card renders");
let title = card.select(&selector("strong")).next().expect("card title renders"); let title = card
.select(&selector(strong_text_selector()))
.next()
.expect("card title renders");
assert_eq!(title.text().collect::<String>(), "<b>Compile checked</b>"); assert_eq!(title.text().collect::<String>(), "<b>Compile checked</b>");
assert!(card.select(&selector("b")).next().is_none()); assert!(card
assert_eq!(card.select(&selector("button[disabled]")).count(), 1); .select(&selector(&escaped_markup_selector("b")))
.next()
.is_none());
assert_eq!(
card.select(&selector(disabled_button_selector())).count(),
1
);
} }
// req: html_safety/002 req: view/001 req: test/005 // req: html_safety/002 req: view/001 req: test/005
@@ -339,10 +415,15 @@ mod tests {
fn presence_payload_is_rendered_by_a_hemplate_view() { fn presence_payload_is_rendered_by_a_hemplate_view() {
let html = ui::render(&Presence { count: 7 }); let html = ui::render(&Presence { count: 7 });
let document = Html::parse_fragment(html.as_str()); let document = Html::parse_fragment(html.as_str());
assert_eq!(document.select(&selector("span.presence")).count(), 2);
assert_eq!( assert_eq!(
document document
.select(&selector("small")) .select(&selector(&element_class_selector("span", "presence")))
.count(),
2
);
assert_eq!(
document
.select(&selector(small_text_selector()))
.next() .next()
.map(|small| small.text().collect::<String>()), .map(|small| small.text().collect::<String>()),
Some("tick #7".to_owned()) Some("tick #7".to_owned())
+1 -1
View File
@@ -18,7 +18,7 @@ This is a polished Linear-style product demo for planning typed work across lane
- page-enhancer navigation with native link fallback - page-enhancer navigation with native link fallback
- SSE server push into a generated slot - SSE server push into a generated slot
- drag-and-drop lane moves persisted by typed server handlers through the slhx runtime - drag-and-drop lane moves persisted by typed server handlers through the slhx runtime
- an opaque canvas island fed by `Effect::event`/`CustomEvent`, without teaching slhx core about the widget - 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 - no user-authored browser JavaScript in slhx-managed UI; the island JavaScript is a leaf-widget escape hatch
Verification: Verification:
+14 -17
View File
@@ -3,11 +3,11 @@ pub mod ui {}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::ui::control_center::{forms, handles, slots, targets}; use super::ui::control_center::{hero_metrics, launch_work, launch_work_form, notice};
use super::ui::issue_card::handles as card_handles; use super::ui::issue_card::advance_work;
use super::ui::issue_lane::events as lane_events; use super::ui::issue_lane::events as lane_events;
use hemplate::Hemplate; use hemplate::Hemplate;
use slhx::{Effect, IntoEffect, Payload}; use slhx::IntoEffect;
use slhx_test::inspect; use slhx_test::inspect;
#[derive(Hemplate)] #[derive(Hemplate)]
@@ -30,14 +30,14 @@ mod tests {
fn techdemo_uses_generated_slots_for_multi_target_updates() { fn techdemo_uses_generated_slots_for_multi_target_updates() {
fn update() -> impl IntoEffect { fn update() -> impl IntoEffect {
( (
targets::hero_metrics.put(&FastMetric { label: "fast" }), hero_metrics.put(&FastMetric { label: "fast" }),
slots::notice.text("typed"), notice.text("typed"),
) )
} }
let batch = inspect(update()); let batch = inspect(update());
assert!(batch.has_slot(slots::hero_metrics)); assert!(batch.has_target(hero_metrics));
assert!(batch.has_slot(slots::notice)); assert!(batch.has_target(notice));
} }
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003 // req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
@@ -45,22 +45,21 @@ mod tests {
fn techdemo_form_handler_is_checked_against_hemplate_form() { fn techdemo_form_handler_is_checked_against_hemplate_form() {
#[slhx::handler] #[slhx::handler]
fn launch_work(_form: slhx::Form<LaunchWork>) -> impl IntoEffect { fn launch_work(_form: slhx::Form<LaunchWork>) -> impl IntoEffect {
slots::notice.text("queued") notice.text("queued")
} }
let batch = inspect(launch_work(LaunchWork::FORM)); let batch = inspect(launch_work(LaunchWork::FORM));
assert!(batch.has_slot(slots::notice)); assert!(batch.updates_text(notice));
assert!(matches!(batch.ops(), [Effect::Put { payload: Payload::Text(_), .. }]));
} }
// req: examples/001 req: form/002 req: codegen/003 // req: examples/001 req: form/002 req: codegen/003
#[test] #[test]
fn techdemo_exports_form_and_interaction_handles() { fn techdemo_exports_form_and_interaction_handles() {
assert_ne!(handles::launch_work.id(), card_handles::advance_work.id()); assert_ne!(launch_work.id(), advance_work.id());
assert_eq!( assert_eq!(
forms::launch_work.field("title").resource, launch_work_form.field("title").resource,
forms::launch_work.id() launch_work_form.id()
); );
} }
@@ -68,9 +67,7 @@ mod tests {
#[test] #[test]
fn techdemo_exports_generated_event_constants() { fn techdemo_exports_generated_event_constants() {
assert_eq!(lane_events::drop.as_str(), "drop"); assert_eq!(lane_events::drop.as_str(), "drop");
assert!(matches!( let event = inspect(lane_events::drop.emit("card-1"));
slhx::event(lane_events::drop, "card-1"), assert!(event.emits("drop", "card-1"));
Effect::Emit { name, payload } if name == "drop" && payload == "card-1"
));
} }
} }
+197 -79
View File
@@ -1,18 +1,18 @@
use axum::extract::{Query, State}; use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::routing::get; use axum::routing::get;
use axum::Router; use axum::Router;
use futures_util::{stream, StreamExt}; use futures_util::{stream, StreamExt};
use hemplate::Hemplate; use hemplate::Hemplate;
use slhx::{CssClass, CssClasses, IntoEffect, SafeHtml}; use slhx::{CssClass, CssClasses, EventName, Html, IntoEffect};
use slhx_axum::{ use slhx_axum::{
interactions, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse, interactions, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse,
InteractionRequest, PageRequest, InteractionRequest, PageRequest,
}; };
use slhx_techdemo::ui; use slhx_techdemo::ui;
use slhx_techdemo::ui::control_center::{classes, forms, handles, slots, targets}; use slhx_techdemo::ui::control_center::{self as control, classes};
use slhx_techdemo::ui::issue_card::handles as card_handles; use slhx_techdemo::ui::{issue_card as card_control, issue_lane as lane_control};
use slhx_techdemo::ui::issue_lane::handles as lane_handles;
use std::collections::{BTreeMap, VecDeque}; use std::collections::{BTreeMap, VecDeque};
use std::convert::Infallible; use std::convert::Infallible;
use std::net::SocketAddr; use std::net::SocketAddr;
@@ -24,6 +24,7 @@ const LANES: [(&str, &str, &str); 3] = [
("runtime", "Runtime", "typed updates → DOM"), ("runtime", "Runtime", "typed updates → DOM"),
("product", "Product", "Native UX, zero app JS"), ("product", "Product", "Native UX, zero app JS"),
]; ];
const ISLAND_ORBIT: EventName = EventName::new("slhx:island-orbit");
#[derive(Clone)] #[derive(Clone)]
struct WorkItem { struct WorkItem {
@@ -73,9 +74,27 @@ impl Default for DemoState {
let mut state = Self { let mut state = Self {
next_id: 4, next_id: 4,
work: vec![ work: vec![
WorkItem { id: 1, title: "Compile checked handles".into(), lane: 0, impact: 9, stage: Stage::Shipped }, WorkItem {
WorkItem { id: 2, title: "Stream typed presence".into(), lane: 1, impact: 7, stage: Stage::Active }, id: 1,
WorkItem { id: 3, title: "Replace dashboard widgets".into(), lane: 2, impact: 8, stage: Stage::Draft }, title: "Compile checked handles".into(),
lane: 0,
impact: 9,
stage: Stage::Shipped,
},
WorkItem {
id: 2,
title: "Stream typed presence".into(),
lane: 1,
impact: 7,
stage: Stage::Active,
},
WorkItem {
id: 3,
title: "Replace dashboard widgets".into(),
lane: 2,
impact: 8,
stage: Stage::Draft,
},
], ],
activity: VecDeque::new(), activity: VecDeque::new(),
spotlight: "No selectors. Generated resources address every target.".into(), spotlight: "No selectors. Generated resources address every target.".into(),
@@ -128,22 +147,22 @@ struct BoardLanes {
#[derive(Hemplate)] #[derive(Hemplate)]
struct ControlCenter { struct ControlCenter {
hero: SafeHtml, hero: Html,
board: SafeHtml, board: Html,
inspector: SafeHtml, inspector: Html,
activity: SafeHtml, activity: Html,
island_snapshot: String, island_snapshot: String,
} }
#[derive(Hemplate)] #[derive(Hemplate)]
struct AppShell { struct AppShell {
body: SafeHtml, body: Html,
} }
#[derive(Hemplate)] #[derive(Hemplate)]
#[hemplate = "partials"] #[hemplate = "partials"]
struct InspectorPanel { struct InspectorPanel {
selected: SafeHtml, selected: Html,
spotlight: String, spotlight: String,
} }
@@ -199,11 +218,14 @@ struct HeroMetrics {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let state = Arc::new(Shared { demo: Mutex::new(DemoState::default()) }); let state = Arc::new(Shared {
demo: Mutex::new(DemoState::default()),
});
let app = Router::new() let app = Router::new()
.route("/", get(home).post(interact)) .route("/", get(home).post(interact))
.route("/architecture", get(architecture)) .route("/architecture", get(architecture))
.route("/events", get(events)) .route("/events", get(events))
.route("/favicon.ico", get(favicon))
.route("/slhx.js", get(runtime)) .route("/slhx.js", get(runtime))
.route("/app.css", get(app_css)) .route("/app.css", get(app_css))
.route("/control_center.css", get(control_center_css)) .route("/control_center.css", get(control_center_css))
@@ -247,16 +269,29 @@ async fn runtime() -> impl IntoResponse {
runtime_js() runtime_js()
} }
async fn favicon() -> StatusCode {
StatusCode::NO_CONTENT
}
async fn app_css() -> impl IntoResponse { async fn app_css() -> impl IntoResponse {
([("content-type", "text/css; charset=utf-8")], include_str!("../templates/app_shell.css")) (
[("content-type", "text/css; charset=utf-8")],
include_str!("../templates/app_shell.css"),
)
} }
async fn control_center_css() -> impl IntoResponse { async fn control_center_css() -> impl IntoResponse {
([("content-type", "text/css; charset=utf-8")], include_str!("../templates/control_center.css")) (
[("content-type", "text/css; charset=utf-8")],
include_str!("../templates/control_center.css"),
)
} }
async fn island_js() -> impl IntoResponse { async fn island_js() -> impl IntoResponse {
([("content-type", "text/javascript; charset=utf-8")], include_str!("../templates/island.js")) (
[("content-type", "text/javascript; charset=utf-8")],
include_str!("../templates/island.js"),
)
} }
async fn interact( async fn interact(
@@ -269,14 +304,20 @@ async fn interact(
// req: push/001 req: push/003 req: examples/001 // req: push/001 req: push/003 req: examples/001
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse { async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
if params.contains_key("once") { if params.contains_key("once") {
let effect = targets::live_feed.put(&LiveFeed { tick: 1 }); let effect = control::live_feed.put(&LiveFeed { tick: 1 });
return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed()); return sse(stream::iter([Ok::<_, Infallible>(
effect.into_batch(ui::BUILD_FINGERPRINT),
)])
.boxed());
} }
let batches = stream::unfold(1_u64, |tick| async move { let batches = stream::unfold(1_u64, |tick| async move {
tokio::time::sleep(Duration::from_secs(4)).await; tokio::time::sleep(Duration::from_secs(4)).await;
let effect = targets::live_feed.put(&LiveFeed { tick }); let effect = control::live_feed.put(&LiveFeed { tick });
Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), tick + 1)) Some((
Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)),
tick + 1,
))
}) })
.boxed(); .boxed();
sse(batches) sse(batches)
@@ -284,7 +325,7 @@ async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResp
fn registry(shared: Arc<Shared>) -> impl DispatchRegistry { fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
interactions(ui::BUILD_FINGERPRINT) interactions(ui::BUILD_FINGERPRINT)
.on(handles::launch_work, { .on(control::launch_work, {
let shared = shared.clone(); let shared = shared.clone();
move |form| { move |form| {
// req: form/002 req: examples/001 // req: form/002 req: examples/001
@@ -303,7 +344,7 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
demo_effects(&demo, "Launch accepted · 4 targets updated") demo_effects(&demo, "Launch accepted · 4 targets updated")
} }
}) })
.on(card_handles::advance_work, { .on(card_control::advance_work, {
let shared = shared.clone(); let shared = shared.clone();
move |form| { move |form| {
// req: list/003 req: examples/001 // req: list/003 req: examples/001
@@ -321,7 +362,7 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
demo_effects(&demo, "Pipeline advanced") demo_effects(&demo, "Pipeline advanced")
} }
}) })
.on(lane_handles::move_to_lane, { .on(lane_control::move_to_lane, {
let shared = shared.clone(); let shared = shared.clone();
move |form| { move |form| {
// req: list/003 req: examples/001 // req: list/003 req: examples/001
@@ -343,7 +384,7 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
demo_effects(&demo, "Drag-and-drop move persisted") demo_effects(&demo, "Drag-and-drop move persisted")
} }
}) })
.on(card_handles::delete_work, { .on(card_control::delete_work, {
let shared = shared.clone(); let shared = shared.clone();
move |form| { move |form| {
// req: list/003 req: examples/001 // req: list/003 req: examples/001
@@ -362,7 +403,7 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
demo_effects(&demo, "Card removed") demo_effects(&demo, "Card removed")
} }
}) })
.on(card_handles::spotlight_work, { .on(card_control::spotlight_work, {
let shared = shared.clone(); let shared = shared.clone();
move |form| { move |form| {
// req: examples/001 // req: examples/001
@@ -377,23 +418,23 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
demo_effects(&demo, "Inspector focused") demo_effects(&demo, "Inspector focused")
} }
}) })
.on(handles::simulate_push, { .on(control::simulate_push, {
let shared = shared.clone(); let shared = shared.clone();
move |_| { move |_| {
// req: push/003 req: examples/001 // req: push/003 req: examples/001
let mut demo = shared.demo.lock().unwrap(); let mut demo = shared.demo.lock().unwrap();
demo.log("Simulated push event produced the same generated update shape"); demo.log("Simulated push event produced the same generated update shape");
( (
targets::live_feed.put(&LiveFeed { control::live_feed.put(&LiveFeed {
tick: demo.activity.len() as u64, tick: demo.activity.len() as u64,
}), }),
targets::activity.put(&activity_view(&demo)), control::activity.put(&activity_view(&demo)),
slots::notice.text("Push simulated · no client app code"), control::notice.text("Push simulated · no client app code"),
slhx::event("slhx:island-orbit", island_snapshot(&demo)), ISLAND_ORBIT.emit(island_snapshot(&demo)),
) )
} }
}) })
.on(handles::reset_demo, { .on(control::reset_demo, {
let shared = shared.clone(); let shared = shared.clone();
move |_| { move |_| {
// req: examples/001 // req: examples/001
@@ -404,7 +445,11 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
}) })
} }
fn update_work(demo: &mut DemoState, id: Option<u64>, update: impl FnOnce(&mut WorkItem)) -> Option<String> { fn update_work(
demo: &mut DemoState,
id: Option<u64>,
update: impl FnOnce(&mut WorkItem),
) -> Option<String> {
let id = id?; let id = id?;
let item = demo.work.iter_mut().find(|item| item.id == id)?; let item = demo.work.iter_mut().find(|item| item.id == id)?;
let title = item.title.clone(); let title = item.title.clone();
@@ -414,26 +459,37 @@ fn update_work(demo: &mut DemoState, id: Option<u64>, update: impl FnOnce(&mut W
fn demo_effects(demo: &DemoState, notice: &'static str) -> impl IntoEffect { fn demo_effects(demo: &DemoState, notice: &'static str) -> impl IntoEffect {
( (
targets::hero_metrics.put(&hero_view(demo)), control::hero_metrics.put(&hero_view(demo)),
targets::board.put(&board_view(demo)), control::board.put(&board_view(demo)),
targets::activity.put(&activity_view(demo)), control::activity.put(&activity_view(demo)),
targets::inspector.put(&inspector_view(demo)), control::inspector.put(&inspector_view(demo)),
slots::notice.text(notice), control::notice.text(notice),
forms::launch_work.clear("title"), control::launch_work_form.clear(),
slhx::event("slhx:island-orbit", island_snapshot(demo)), ISLAND_ORBIT.emit(island_snapshot(demo)),
) )
} }
fn parse_lane(value: Option<&str>) -> usize { fn parse_lane(value: Option<&str>) -> usize {
let value = value.unwrap_or(LANES[0].0); let value = value.unwrap_or(LANES[0].0);
LANES.iter().position(|(id, _, _)| *id == value).unwrap_or(0) LANES
.iter()
.position(|(id, _, _)| *id == value)
.unwrap_or(0)
} }
fn island_snapshot(demo: &DemoState) -> String { fn island_snapshot(demo: &DemoState) -> String {
// Opaque leaf-widget bridge: compact server snapshot in, native CustomEvent out. // Opaque leaf-widget bridge: compact server snapshot in, native CustomEvent out.
// req: interop/001 req: examples/001 // req: interop/001 req: examples/001
let active = demo.work.iter().filter(|item| item.stage == Stage::Active).count(); let active = demo
let shipped = demo.work.iter().filter(|item| item.stage == Stage::Shipped).count(); .work
.iter()
.filter(|item| item.stage == Stage::Active)
.count();
let shipped = demo
.work
.iter()
.filter(|item| item.stage == Stage::Shipped)
.count();
let impact: u64 = demo.work.iter().map(|item| item.impact as u64).sum(); let impact: u64 = demo.work.iter().map(|item| item.impact as u64).sum();
format!( format!(
"{}|{}|{}|{} active · {} shipped · {} activity rows", "{}|{}|{}|{} active · {} shipped · {} activity rows",
@@ -446,7 +502,7 @@ fn island_snapshot(demo: &DemoState) -> String {
) )
} }
fn page_html(demo: &DemoState) -> SafeHtml { fn page_html(demo: &DemoState) -> Html {
// req: html_safety/002 req: view/001 // req: html_safety/002 req: view/001
render_control_center(ControlCenter { render_control_center(ControlCenter {
hero: render_hero(demo), hero: render_hero(demo),
@@ -457,15 +513,23 @@ fn page_html(demo: &DemoState) -> SafeHtml {
}) })
} }
fn shell(body: SafeHtml) -> SafeHtml { fn shell(body: Html) -> Html {
// req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003 // req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003
ui::app_shell::render(&AppShell { body }) ui::app_shell::render(&AppShell { body })
} }
fn hero_view(demo: &DemoState) -> HeroMetrics { fn hero_view(demo: &DemoState) -> HeroMetrics {
// req: html_safety/002 req: view/001 // req: html_safety/002 req: view/001
let shipped = demo.work.iter().filter(|item| item.stage == Stage::Shipped).count(); let shipped = demo
let active = demo.work.iter().filter(|item| item.stage == Stage::Active).count(); .work
.iter()
.filter(|item| item.stage == Stage::Shipped)
.count();
let active = demo
.work
.iter()
.filter(|item| item.stage == Stage::Active)
.count();
let impact: u64 = demo.work.iter().map(|item| item.impact as u64).sum(); let impact: u64 = demo.work.iter().map(|item| item.impact as u64).sum();
HeroMetrics { HeroMetrics {
resource_count: 13, resource_count: 13,
@@ -475,7 +539,7 @@ fn hero_view(demo: &DemoState) -> HeroMetrics {
} }
} }
fn render_hero(demo: &DemoState) -> SafeHtml { fn render_hero(demo: &DemoState) -> Html {
// req: html_safety/002 req: view/001 req: component/003 // req: html_safety/002 req: view/001 req: component/003
ui::render(&hero_view(demo)) ui::render(&hero_view(demo))
} }
@@ -519,7 +583,7 @@ fn activity_view(demo: &DemoState) -> ActivityFeed {
} }
} }
fn render_activity(demo: &DemoState) -> SafeHtml { fn render_activity(demo: &DemoState) -> Html {
// req: html_safety/002 req: view/001 req: component/003 // req: html_safety/002 req: view/001 req: component/003
ui::render(&activity_view(demo)) ui::render(&activity_view(demo))
} }
@@ -546,32 +610,32 @@ fn inspector_view(demo: &DemoState) -> InspectorPanel {
} }
} }
fn render_inspector(demo: &DemoState) -> SafeHtml { fn render_inspector(demo: &DemoState) -> Html {
// req: html_safety/002 req: view/001 req: component/003 // req: html_safety/002 req: view/001 req: component/003
ui::render(&inspector_view(demo)) ui::render(&inspector_view(demo))
} }
fn architecture_hero() -> SafeHtml { fn architecture_hero() -> Html {
// req: html_safety/002 req: view/001 // req: html_safety/002 req: view/001
ui::render(&ArchitectureHero) ui::render(&ArchitectureHero)
} }
fn architecture_board() -> SafeHtml { fn architecture_board() -> Html {
// req: html_safety/002 req: view/001 // req: html_safety/002 req: view/001
ui::render(&ArchitectureBoard) ui::render(&ArchitectureBoard)
} }
fn architecture_inspector() -> SafeHtml { fn architecture_inspector() -> Html {
// req: html_safety/002 req: view/001 // req: html_safety/002 req: view/001
ui::render(&ArchitectureInspector) ui::render(&ArchitectureInspector)
} }
fn architecture_activity() -> SafeHtml { fn architecture_activity() -> Html {
// req: html_safety/002 req: view/001 // req: html_safety/002 req: view/001
ui::render(&ArchitectureActivity) ui::render(&ArchitectureActivity)
} }
fn render_control_center(page: ControlCenter) -> SafeHtml { fn render_control_center(page: ControlCenter) -> Html {
// req: html_safety/002 req: view/001 req: component/003 // req: html_safety/002 req: view/001 req: component/003
ui::render(&page) ui::render(&page)
} }
@@ -597,11 +661,36 @@ mod tests {
.map(|title| title.text().collect::<String>()), .map(|title| title.text().collect::<String>()),
Some("slhx Techdemo".to_owned()) Some("slhx Techdemo".to_owned())
); );
assert_eq!(document.select(&selector("script[src=\"/slhx.js\"]")).count(), 1); assert_eq!(
assert_eq!(document.select(&selector("link[rel=\"stylesheet\"]")).count(), 2); document
assert_eq!(document.select(&selector("link[href=\"/app.css\"]")).count(), 1); .select(&selector("script[src=\"/slhx.js\"]"))
assert_eq!(document.select(&selector("link[href=\"/control_center.css\"]")).count(), 1); .count(),
assert_eq!(document.select(&selector("main[data-slhx-root=\"techdemo\"]")).count(), 1); 1
);
assert_eq!(
document
.select(&selector("link[rel=\"stylesheet\"]"))
.count(),
2
);
assert_eq!(
document
.select(&selector("link[href=\"/app.css\"]"))
.count(),
1
);
assert_eq!(
document
.select(&selector("link[href=\"/control_center.css\"]"))
.count(),
1
);
assert_eq!(
document
.select(&selector("main[data-slhx-root=\"techdemo\"]"))
.count(),
1
);
assert!(!html.contains("{+=")); assert!(!html.contains("{+="));
} }
@@ -615,13 +704,29 @@ mod tests {
assert!(!html.contains("__ACTIVITY__")); assert!(!html.contains("__ACTIVITY__"));
let document = Html::parse_fragment(html.as_str()); let document = Html::parse_fragment(html.as_str());
assert_eq!(document.select(&selector("[data-slhx-root=\"techdemo\"]")).count(), 1); assert_eq!(
assert!(document.select(&selector("[data-sid]")).count() >= 7); document
assert!(document.select(&selector("[data-hid]")).count() >= 7); .select(&selector("[data-slhx-root=\"techdemo\"]"))
assert_eq!(document.select(&selector(".hero-panel .metrics")).count(), 1); .count(),
1
);
assert_eq!(
document.select(&selector(".hero-panel .metrics")).count(),
1
);
assert_eq!(document.select(&selector(".board-card .lanes")).count(), 1); assert_eq!(document.select(&selector(".board-card .lanes")).count(), 1);
assert_eq!(document.select(&selector(".glass-card .inspector-hero")).count(), 1); assert_eq!(
assert_eq!(document.select(&selector(".glass-card ol.activity")).count(), 1); document
.select(&selector(".glass-card .inspector-hero"))
.count(),
1
);
assert_eq!(
document
.select(&selector(".glass-card ol.activity"))
.count(),
1
);
} }
// req: html_safety/002 req: view/001 req: test/005 // req: html_safety/002 req: view/001 req: test/005
@@ -629,7 +734,9 @@ mod tests {
fn hero_metrics_are_rendered_by_a_hemplate_view() { fn hero_metrics_are_rendered_by_a_hemplate_view() {
let html = render_hero(&DemoState::default()); let html = render_hero(&DemoState::default());
let document = Html::parse_fragment(html.as_str()); let document = Html::parse_fragment(html.as_str());
let metrics = document.select(&selector(".metrics > .metric")).collect::<Vec<_>>(); let metrics = document
.select(&selector(".metrics > .metric"))
.collect::<Vec<_>>();
assert_eq!(metrics.len(), 4); assert_eq!(metrics.len(), 4);
assert_eq!( assert_eq!(
metrics[0] metrics[0]
@@ -638,9 +745,10 @@ mod tests {
.map(|span| span.text().collect::<String>()), .map(|span| span.text().collect::<String>()),
Some("generated resources on this page".to_owned()) Some("generated resources on this page".to_owned())
); );
assert!(metrics assert!(metrics.iter().any(|metric| metric
.iter() .text()
.any(|metric| metric.text().collect::<String>().contains("aggregate impact score"))); .collect::<String>()
.contains("aggregate impact score")));
} }
// req: style/001 req: style/002 req: style/003 req: test/005 // req: style/001 req: style/002 req: style/003 req: test/005
@@ -652,7 +760,10 @@ mod tests {
assert_eq!(classes::is_selected.as_str(), "is-selected"); assert_eq!(classes::is_selected.as_str(), "is-selected");
let document = Html::parse_fragment(board.as_str()); let document = Html::parse_fragment(board.as_str());
assert_eq!(document.select(&selector(".lanes > section.lane")).count(), 3); assert_eq!(
document.select(&selector(".lanes > section.lane")).count(),
3
);
let lane = document let lane = document
.select(&selector(r#"section.lane[data-lane="compiler"]"#)) .select(&selector(r#"section.lane[data-lane="compiler"]"#))
.next() .next()
@@ -663,7 +774,10 @@ mod tests {
.select(&selector(r#"article.work-card.is-selected[data-key="2"]"#)) .select(&selector(r#"article.work-card.is-selected[data-key="2"]"#))
.next() .next()
.expect("selected work card renders"); .expect("selected work card renders");
assert_eq!(selected_card.value().attr("class"), Some("work-card is-selected")); assert_eq!(
selected_card.value().attr("class"),
Some("work-card is-selected")
);
} }
// req: html_safety/002 req: view/001 req: test/005 // req: html_safety/002 req: view/001 req: test/005
@@ -684,7 +798,7 @@ mod tests {
assert_eq!(document.select(&selector(".inspector-row")).count(), 3); assert_eq!(document.select(&selector(".inspector-row")).count(), 3);
assert!(document assert!(document
.select(&selector("code")) .select(&selector("code"))
.any(|code| code.text().collect::<String>().contains("targets::*"))); .any(|code| code.text().collect::<String>().contains("control::*")));
} }
// req: html_safety/002 req: view/001 req: test/005 // req: html_safety/002 req: view/001 req: test/005
@@ -698,16 +812,16 @@ mod tests {
.expect("live feed row renders"); .expect("live feed row renders");
let text = row.text().collect::<String>(); let text = row.text().collect::<String>();
assert!(text.contains("SSE tick #7")); assert!(text.contains("SSE tick #7"));
assert!(row assert!(text.contains("live feed target"));
.select(&selector("code")) assert_eq!(row.select(&selector("code")).count(), 0);
.any(|code| code.text().collect::<String>() == "slots::live_feed"));
} }
// req: html_safety/002 req: view/001 req: test/005 // req: html_safety/002 req: view/001 req: test/005
#[test] #[test]
fn activity_payload_is_rendered_by_a_hemplate_view() { fn activity_payload_is_rendered_by_a_hemplate_view() {
let mut demo = DemoState::default(); let mut demo = DemoState::default();
demo.activity.push_back("<b>escaped activity</b>".to_owned()); demo.activity
.push_back("<b>escaped activity</b>".to_owned());
let html = render_activity(&demo); let html = render_activity(&demo);
let document = Html::parse_fragment(html.as_str()); let document = Html::parse_fragment(html.as_str());
@@ -753,7 +867,9 @@ mod tests {
.next() .next()
.expect("architecture inspector row renders"); .expect("architecture inspector row renders");
assert_eq!( assert_eq!(
row.select(&selector("b")).next().map(|b| b.text().collect::<String>()), row.select(&selector("b"))
.next()
.map(|b| b.text().collect::<String>()),
Some("Page swap".to_owned()) Some("Page swap".to_owned())
); );
assert!(row assert!(row
@@ -786,7 +902,9 @@ mod tests {
fn architecture_board_is_rendered_by_a_hemplate_view() { fn architecture_board_is_rendered_by_a_hemplate_view() {
let html = architecture_board(); let html = architecture_board();
let document = Html::parse_fragment(html.as_str()); let document = Html::parse_fragment(html.as_str());
let lanes = document.select(&selector(".lanes > section.lane")).collect::<Vec<_>>(); let lanes = document
.select(&selector(".lanes > section.lane"))
.collect::<Vec<_>>();
assert_eq!(lanes.len(), 3); assert_eq!(lanes.len(), 3);
assert_eq!( assert_eq!(
lanes[0] lanes[0]
@@ -17,7 +17,7 @@
<aside class="command-card"> <aside class="command-card">
<h2>Create an issue</h2> <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-slhx-handle="launch_work" data-slhx-form="launch_work" data-slhx-disable-while-pending>
<label>Title <input name="title" required="required" value="Ship typed effects"></label> <label>Title <input name="title" required="required" value="Ship typed updates"></label>
<label>Lane <label>Lane
<select name="lane" required="required"> <select name="lane" required="required">
<option value="compiler">Compiler</option> <option value="compiler">Compiler</option>
@@ -32,7 +32,7 @@
<button type="button" data-slhx-handle="simulate_push">Simulate server push</button> <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-slhx-handle="reset_demo">Reset demo</button>
</div> </div>
<p data-slhx-slot="notice" class="notice">Every control posts a numeric handle id and receives typed effects.</p> <p data-slhx-slot="notice" class="notice">Every control posts through a generated handle and receives typed updates.</p>
</aside> </aside>
<section class="board-card"> <section class="board-card">
@@ -46,8 +46,8 @@
<section class="insight-grid"> <section class="insight-grid">
<article class="glass-card"> <article class="glass-card">
<h2>Effect inspector</h2> <h2>Update inspector</h2>
<div data-slhx-slot="inspector">{+= self.inspector =+}</div> <div data-slhx-slot="inspector" class="inspector-slot">{+= self.inspector =+}</div>
</article> </article>
<article class="glass-card island-card" data-slhx-island="orbit" +data-island-snapshot="self.island_snapshot"> <article class="glass-card island-card" data-slhx-island="orbit" +data-island-snapshot="self.island_snapshot">
<h2>Opaque island bridge</h2> <h2>Opaque island bridge</h2>
@@ -60,7 +60,7 @@
</article> </article>
<article class="glass-card glow"> <article class="glass-card glow">
<h2>Server push</h2> <h2>Server push</h2>
<div data-slhx-slot="live_feed">Waiting for SSE heartbeat…</div> <div data-slhx-slot="live_feed" class="live-feed">Waiting for SSE heartbeat…</div>
</article> </article>
</section> </section>
@@ -1,4 +1,4 @@
{+= self.selected =+} {+= self.selected =+}
<div class="inspector-row"><b>What happened</b><br>{+ self.spotlight +}</div> <div class="inspector-row"><b>What happened</b><br>{+ self.spotlight +}</div>
<div class="inspector-row"><b>Update path</b><br><code>targets::* → tuple effects → DOM</code></div> <div class="inspector-row"><b>Update path</b><br><code>generated targets → tuple updates → DOM</code></div>
<div class="inspector-row"><b>Runtime</b><br>Root-scoped delegated listeners; numeric targets only.</div> <div class="inspector-row"><b>Runtime</b><br>Root-scoped delegated listeners; generated targets only.</div>
@@ -2,8 +2,8 @@
<header><strong>{+ self.title +}</strong><span class="pill">{+ self.stage +}</span></header> <header><strong>{+ self.title +}</strong><span class="pill">{+ self.stage +}</span></header>
<div class="impact"><i +style="self.impact_style"></i></div> <div class="impact"><i +style="self.impact_style"></i></div>
<div class="card-actions"> <div class="card-actions">
<button type="button" data-slhx-handle="spotlight_work" +data-work-id="self.id">Inspect</button> <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">Advance</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">Delete</button> <button type="button" data-slhx-handle="delete_work" +data-work-id="self.id" name="work_id" +value="self.id">Delete</button>
</div> </div>
</article> </article>
@@ -1 +1 @@
<div class="live-row"><strong>SSE tick #{+ self.tick +}</strong><br>Server streamed a generated update into <code>slots::live_feed</code>.</div> <div class="live-row"><strong>SSE tick #{+ self.tick +}</strong><br>Server streamed a generated update into the live feed target.</div>
+59 -93
View File
@@ -1,5 +1,9 @@
use slhx_techdemo::ui::control_center::{handles, slots}; use slhx_techdemo::ui::control_center::{self as control, launch_work, simulate_push};
use slhx_techdemo::ui::issue_card::handles as card_handles; use slhx_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,
};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use thirtyfour::prelude::*; use thirtyfour::prelude::*;
@@ -48,63 +52,70 @@ async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> {
let result = async { let result = async {
driver.goto(&format!("http://{APP_ADDR}/")).await?; driver.goto(&format!("http://{APP_ADDR}/")).await?;
assert_text(&driver, "A Linear-class work system without a frontend framework").await?; assert_text(
&driver,
"A Linear-class work system without a frontend framework",
)
.await?;
assert_text(&driver, "Compile checked handles").await?; assert_text(&driver, "Compile checked handles").await?;
assert_text(&driver, "No selectors. Generated resources address every target.").await?; assert_text(
&driver,
"No selectors. Generated resources address every target.",
)
.await?;
assert_text(&driver, "Opaque island bridge").await?; assert_text(&driver, "Opaque island bridge").await?;
wait_for_runtime(&driver).await?; wait_for_runtime(&driver).await?;
insert_probe_island(&driver).await?; insert_probe_island(&driver).await?;
wait_for_text(&driver, "#probe-island [data-island-readout]", "probe live").await?; wait_for_text(
&driver,
&scoped_island_readout_selector("#probe-island"),
"probe live",
)
.await?;
driver driver
.find(By::Css(&handle_selector(handles::simulate_push))) .find(By::Css(&handle_selector(simulate_push)))
.await? .await?
.click() .click()
.await?; .await?;
wait_for_text(&driver, &slot_selector(slots::notice), "Push simulated").await?; wait_for_text(&driver, &target_selector(control::notice), "Push simulated").await?;
wait_for_text(&driver, "[data-island-readout]", "activity rows").await?; wait_for_text(&driver, island_readout_selector(), "activity rows").await?;
driver.find(By::Css("button.primary-action")).await?.click().await?;
wait_for_text(&driver, ".work-card[data-key='4']", "Ship typed effects").await?;
assert_text(&driver, "width:77%").await?;
drag_card_to_lane(&driver, 2, "product").await?;
wait_for_text(&driver, ".lane[data-lane='product'] .work-card[data-key='2']", "Shipped").await?;
wait_for_text(&driver, &slot_selector(slots::notice), "Drag-and-drop move persisted").await?;
driver driver
.find(By::Css(&card_button_selector(card_handles::spotlight_work, 2))) .find(By::Css(&handle_button_selector(launch_work)))
.await? .await?
.click() .click()
.await?; .await?;
wait_for_text( wait_for_text(
&driver, &driver,
&slot_selector(slots::inspector), &keyed_selector(".work-card", 4),
"Stream typed presence · lane=Product · stage=Shipped · impact=7", "Ship typed updates",
) )
.await?; .await?;
assert_text(&driver, "width:77%").await?;
driver driver
.find(By::Css(&card_button_selector(card_handles::advance_work, 3))) .find(By::Css(&handle_selector(simulate_push)))
.await? .await?
.click() .click()
.await?; .await?;
wait_for_text(&driver, ".lane[data-lane='product'] .work-card[data-key='3']", "Active").await?; wait_for_text(&driver, &target_selector(control::live_feed), "SSE tick").await?;
driver driver
.find(By::Css(&handle_selector(handles::simulate_push))) .find(By::Css(&nav_link_selector("/architecture")))
.await? .await?
.click() .click()
.await?; .await?;
wait_for_text(&driver, &slot_selector(slots::live_feed), "SSE tick").await?; wait_for_text(&driver, &target_selector(control::inspector), "Page swap").await?;
wait_for_text(&driver, island_readout_selector(), "activity rows").await?;
driver.find(By::Css("a[href='/architecture']")).await?.click().await?; assert!(driver
wait_for_text(&driver, &slot_selector(slots::inspector), "Page swap").await?; .current_url()
wait_for_text(&driver, "[data-island-readout]", "activity rows").await?; .await?
assert!(driver.current_url().await?.as_str().ends_with("/architecture")); .as_str()
.ends_with("/architecture"));
driver.goto(&format!("http://{APP_ADDR}/")).await?; driver.goto(&format!("http://{APP_ADDR}/")).await?;
wait_for_text(&driver, &slot_selector(slots::live_feed), "SSE tick").await?; wait_for_text(&driver, &target_selector(control::live_feed), "SSE tick").await?;
Ok::<(), WebDriverError>(()) Ok::<(), WebDriverError>(())
} }
@@ -125,68 +136,15 @@ fn wait_for_tcp(addr: &str) {
panic!("timed out waiting for {addr}"); panic!("timed out waiting for {addr}");
} }
fn handle_selector(handle: impl std::fmt::Display) -> String {
format!(r#"[data-hid="{handle}"]"#)
}
fn slot_selector(slot: impl std::fmt::Display) -> String {
format!(r#"[data-sid="{slot}"]"#)
}
fn card_button_selector(handle_id: impl std::fmt::Display, work_id: u64) -> String {
format!(r#"[data-hid="{handle_id}"][data-work-id="{work_id}"]"#)
}
async fn insert_probe_island(driver: &WebDriver) -> WebDriverResult<()> { async fn insert_probe_island(driver: &WebDriver) -> WebDriverResult<()> {
let root = driver.find(By::Css("[data-slhx-root]")).await?; let root = driver.find(By::Css(any_root_selector())).await?;
driver let script = island_probe_script(
.execute( "probe-island",
r#" "orbit",
const root = arguments[0]; "1|1|1|probe waiting",
const island = document.createElement('article'); "7|2|8|probe live",
island.id = 'probe-island'; );
island.setAttribute('data-slhx-island', 'orbit'); driver.execute(&script, vec![root.to_json()?]).await?;
island.setAttribute('data-island-snapshot', '1|1|1|probe waiting');
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 16;
island.appendChild(canvas);
const readout = document.createElement('p');
readout.setAttribute('data-island-readout', '');
readout.textContent = 'waiting';
island.appendChild(readout);
root.appendChild(island);
setTimeout(() => {
root.dispatchEvent(new CustomEvent('slhx:island-orbit', { bubbles: true, detail: '7|2|8|probe live' }));
}, 25);
return true;
"#,
vec![root.to_json()?],
)
.await?;
Ok(())
}
async fn drag_card_to_lane(driver: &WebDriver, work_id: u64, lane: &str) -> WebDriverResult<()> {
let card = driver.find(By::Css(format!(".work-card[data-key='{work_id}']"))).await?;
let lane = driver.find(By::Css(format!(".lane[data-lane='{lane}']"))).await?;
driver
.execute(
r#"
const card = arguments[0];
const lane = arguments[1];
const data = new DataTransfer();
card.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: data }));
lane.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: data }));
lane.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: data }));
return true;
"#,
vec![card.to_json()?, lane.to_json()?],
)
.await?;
Ok(()) Ok(())
} }
@@ -194,7 +152,10 @@ async fn wait_for_runtime(driver: &WebDriver) -> WebDriverResult<()> {
let deadline = Instant::now() + Duration::from_secs(8); let deadline = Instant::now() + Duration::from_secs(8);
loop { loop {
let loaded = driver let loaded = driver
.execute("return !!window.slhx && window.slhx.roots().length > 0", Vec::new()) .execute(
"return !!window.slhx && window.slhx.roots().length > 0",
Vec::new(),
)
.await? .await?
.json() .json()
.as_bool() .as_bool()
@@ -210,7 +171,7 @@ async fn wait_for_runtime(driver: &WebDriver) -> WebDriverResult<()> {
} }
async fn assert_text(driver: &WebDriver, text: &str) -> WebDriverResult<()> { async fn assert_text(driver: &WebDriver, text: &str) -> WebDriverResult<()> {
wait_for_text(driver, "body", text).await wait_for_text(driver, document_body_selector(), text).await
} }
async fn wait_for_text(driver: &WebDriver, selector: &str, text: &str) -> WebDriverResult<()> { async fn wait_for_text(driver: &WebDriver, selector: &str, text: &str) -> WebDriverResult<()> {
@@ -225,7 +186,12 @@ async fn wait_for_text(driver: &WebDriver, selector: &str, text: &str) -> WebDri
} }
} }
if Instant::now() >= deadline { if Instant::now() >= deadline {
let body = driver.find(By::Css("body")).await?.text().await.unwrap_or_default(); let body = driver
.find(By::Css(document_body_selector()))
.await?
.text()
.await
.unwrap_or_default();
panic!("timed out waiting for {text:?} in {selector:?}; body={body:?}"); panic!("timed out waiting for {text:?} in {selector:?}; body={body:?}");
} }
tokio::time::sleep(Duration::from_millis(50)).await; tokio::time::sleep(Duration::from_millis(50)).await;
+158 -149
View File
@@ -1,10 +1,14 @@
use scraper::{Html, Selector}; use scraper::{Html, Selector};
use slhx::{Effect, EffectBatch, Handle, Payload, EFFECT_BATCH_ABI_VERSION}; use slhx_techdemo::ui::control_center::{self as control, launch_work, reset_demo, simulate_push};
use slhx_axum::SLHX_HANDLE_FIELD; 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_techdemo::ui::BUILD_FINGERPRINT;
use slhx_techdemo::ui::control_center::handles; use slhx_test::{
use slhx_techdemo::ui::issue_card::handles as card_handles; class_descendant_selector, class_selector, handle_form_body, inspect_wire,
use slhx_techdemo::ui::issue_lane::handles as lane_handles; island_attribute_name, island_event_name, island_selector, island_snapshot_marker,
root_selector, sse_endpoint_marker, strong_text_selector, unknown_handle_form_body,
EffectInspector,
};
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::TcpStream; use std::net::TcpStream;
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
@@ -12,17 +16,6 @@ use std::time::{Duration, Instant};
const ADDR: &str = "127.0.0.1:3002"; const ADDR: &str = "127.0.0.1:3002";
fn handle_body<I>(handle: Handle<I>, fields: &[(&str, &str)]) -> String {
let mut body = format!("{SLHX_HANDLE_FIELD}={handle}");
for (name, value) in fields {
body.push('&');
body.push_str(name);
body.push('=');
body.push_str(value);
}
body
}
struct Server { struct Server {
child: Child, child: Child,
} }
@@ -63,29 +56,32 @@ fn product_is_e2e_working_over_http() {
assert_eq!(home.status, 200); assert_eq!(home.status, 200);
assert!(home.header("content-type").contains("text/html")); assert!(home.header("content-type").contains("text/html"));
let document = Html::parse_document(home.text()); let document = Html::parse_document(home.text());
assert_text(&document, "A Linear-class work system without a frontend framework"); assert_text(
&document,
"A Linear-class work system without a frontend framework",
);
assert_text(&document, "Compile checked handles"); assert_text(&document, "Compile checked handles");
assert_text(&document, "Stream typed presence"); assert_text(&document, "Stream typed presence");
assert_selector_count_at_least(&document, "[data-slhx-root=techdemo]", 1); assert_selector_count_at_least(&document, &root_selector("techdemo"), 1);
assert_selector_count_at_least(&document, "[data-hid]", 8); assert_work_card_count_at_least(&document, 3);
assert_selector_count_at_least(&document, "[data-sid]", 7); assert_selector_count_at_least(&document, &island_selector("orbit"), 1);
assert_selector_count_at_least(&document, ".work-card", 3);
assert_selector_count_at_least(&document, "[data-slhx-island=orbit]", 1);
assert_text(&document, "Opaque island bridge"); assert_text(&document, "Opaque island bridge");
assert!(home.text().contains("data-island-snapshot=")); assert!(home.text().contains(island_snapshot_marker()));
assert!(home.text().contains("data-slhx-sse=\"/events\"")); assert!(home.text().contains(&sse_endpoint_marker("/events")));
assert!(home.text().contains("/island.js")); assert!(home.text().contains("/island.js"));
let favicon = get("/favicon.ico");
assert_eq!(favicon.status, 204);
let runtime = get("/slhx.js"); let runtime = get("/slhx.js");
assert_eq!(runtime.status, 200); assert_eq!(runtime.status, 200);
assert!(runtime.header("content-type").contains("javascript")); assert!(runtime.header("content-type").contains("javascript"));
assert!(runtime.text().contains("const HID = \"data-hid\""));
let island = get("/island.js"); let island = get("/island.js");
assert_eq!(island.status, 200); assert_eq!(island.status, 200);
assert!(island.header("content-type").contains("javascript")); assert!(island.header("content-type").contains("javascript"));
assert!(island.text().contains("slhx:island-orbit")); assert!(island.text().contains(&island_event_name("orbit")));
assert!(island.text().contains("data-slhx-island")); assert!(island.text().contains(island_attribute_name()));
assert!(island.text().contains("MutationObserver")); assert!(island.text().contains("MutationObserver"));
assert!(island.text().contains("removeEventListener")); assert!(island.text().contains("removeEventListener"));
@@ -98,156 +94,183 @@ fn product_is_e2e_working_over_http() {
let launch = post( let launch = post(
"/", "/",
&handle_body( &handle_form_body(
handles::launch_work, launch_work,
&[ &[
("title", "Design+hero+moment"), ("title", "Design hero moment"),
("lane", "product"), ("lane", "product"),
("impact", "9"), ("impact", "9"),
], ],
), ),
); );
assert_effect_response(&launch); assert_effect_response(&launch);
let launch_batch = launch.batch(); let launch_batch = launch.effects();
assert_payload_contains(&launch_batch, "Design hero moment"); assert_payload_contains(&launch_batch, "Design hero moment");
assert_card(&launch_batch, "Design hero moment", "Product", "Draft", "width:99%"); assert_card(
&launch_batch,
"Design hero moment",
"Product",
"Draft",
"width:99%",
);
assert_payload_contains(&launch_batch, "Launch accepted"); assert_payload_contains(&launch_batch, "Launch accepted");
assert_payload_contains(&launch_batch, "Launched card #4"); assert_payload_contains(&launch_batch, "Launched card #4");
assert_emit(&launch_batch, "slhx:island-orbit", "activity rows"); assert_emit(&launch_batch, &island_event_name("orbit"), "activity rows");
assert!( assert!(
launch_batch.ops.len() >= 6, launch_batch.op_count() >= 6,
"launch should update generated targets and notify the island" "launch should update generated targets and notify the island"
); );
let default_impact = post( let default_impact = post(
"/", "/",
&handle_body( &handle_form_body(
handles::launch_work, launch_work,
&[("title", "Default+impact"), ("lane", "compiler")], &[("title", "Default impact"), ("lane", "compiler")],
), ),
); );
assert_effect_response(&default_impact); assert_effect_response(&default_impact);
let default_impact_batch = default_impact.batch(); let default_impact_batch = default_impact.effects();
assert_payload_contains(&default_impact_batch, "Default impact"); assert_payload_contains(&default_impact_batch, "Default impact");
assert_card(&default_impact_batch, "Default impact", "Compiler", "Draft", "width:55%"); assert_card(
&default_impact_batch,
"Default impact",
"Compiler",
"Draft",
"width:55%",
);
let low_impact = post( let low_impact = post(
"/", "/",
&handle_body( &handle_form_body(
handles::launch_work, launch_work,
&[ &[
("title", "Low+impact"), ("title", "Low impact"),
("lane", "runtime"), ("lane", "runtime"),
("impact", "0"), ("impact", "0"),
], ],
), ),
); );
assert_effect_response(&low_impact); assert_effect_response(&low_impact);
let low_impact_batch = low_impact.batch(); let low_impact_batch = low_impact.effects();
assert_card(&low_impact_batch, "Low impact", "Runtime", "Draft", "width:11%"); assert_card(
&low_impact_batch,
"Low impact",
"Runtime",
"Draft",
"width:11%",
);
let high_impact = post( let high_impact = post(
"/", "/",
&handle_body( &handle_form_body(
handles::launch_work, launch_work,
&[ &[
("title", "High+impact"), ("title", "High impact"),
("lane", "runtime"), ("lane", "runtime"),
("impact", "99"), ("impact", "99"),
], ],
), ),
); );
assert_effect_response(&high_impact); assert_effect_response(&high_impact);
let high_impact_batch = high_impact.batch(); let high_impact_batch = high_impact.effects();
assert_card(&high_impact_batch, "High impact", "Runtime", "Draft", "width:99%"); assert_card(
&high_impact_batch,
"High impact",
"Runtime",
"Draft",
"width:99%",
);
let missing_title = post( let missing_title = post(
"/", "/",
&handle_body(handles::launch_work, &[("lane", "runtime"), ("impact", "8")]), &handle_form_body(launch_work, &[("lane", "runtime"), ("impact", "8")]),
); );
assert_effect_response(&missing_title); assert_effect_response(&missing_title);
let missing_title_batch = missing_title.batch(); let missing_title_batch = missing_title.effects();
assert_payload_contains(&missing_title_batch, "Launch accepted"); assert_payload_contains(&missing_title_batch, "Launch accepted");
assert_payload_not_contains(&missing_title_batch, "data-key=\"8\""); assert!(missing_title_batch.payload_excludes_key(8));
assert_payload_not_contains(&missing_title_batch, "MUTATED"); assert_payload_not_contains(&missing_title_batch, "MUTATED");
let move_to_lane = post( let move_to_lane = post(
"/", "/",
&handle_body( &handle_form_body(
lane_handles::move_to_lane, move_to_lane_handle,
&[("work_id", "4"), ("lane", "runtime")], &[("work_id", "4"), ("lane", "runtime")],
), ),
); );
assert_effect_response(&move_to_lane); assert_effect_response(&move_to_lane);
let move_to_lane_batch = move_to_lane.batch(); let move_to_lane_batch = move_to_lane.effects();
assert_card(&move_to_lane_batch, "Design hero moment", "Runtime", "Active", "width:99%"); assert_card(
&move_to_lane_batch,
"Design hero moment",
"Runtime",
"Active",
"width:99%",
);
assert_payload_contains(&move_to_lane_batch, "Drag-and-drop move persisted"); assert_payload_contains(&move_to_lane_batch, "Drag-and-drop move persisted");
let inspect = post( let inspect = post("/", &handle_form_body(spotlight_work, &[("work_id", "4")]));
"/",
&handle_body(card_handles::spotlight_work, &[("work_id", "4")]),
);
assert_effect_response(&inspect); assert_effect_response(&inspect);
let inspect_batch = inspect.batch(); let inspect_batch = inspect.effects();
assert_payload_contains(&inspect_batch, "Design hero moment · lane=Runtime"); assert_payload_contains(&inspect_batch, "Design hero moment · lane=Runtime");
assert_payload_contains(&inspect_batch, "Inspector focused"); assert_payload_contains(&inspect_batch, "Inspector focused");
let advance = post( let advance = post("/", &handle_form_body(advance_work, &[("work_id", "4")]));
"/",
&handle_body(card_handles::advance_work, &[("work_id", "4")]),
);
assert_effect_response(&advance); assert_effect_response(&advance);
let advance_batch = advance.batch(); let advance_batch = advance.effects();
assert_payload_contains(&advance_batch, "Pipeline advanced"); assert_payload_contains(&advance_batch, "Pipeline advanced");
assert_payload_contains(&advance_batch, "<span class=\"pill\">Active</span>"); assert_payload_contains(&advance_batch, "<span class=\"pill\">Active</span>");
let advance_default = post( let advance_default = post("/", &handle_form_body(advance_work, &[("work_id", "5")]));
"/",
&handle_body(card_handles::advance_work, &[("work_id", "5")]),
);
assert_effect_response(&advance_default); assert_effect_response(&advance_default);
let advance_default_batch = advance_default.batch(); let advance_default_batch = advance_default.effects();
assert_payload_contains(&advance_default_batch, "Default impact"); assert_payload_contains(&advance_default_batch, "Default impact");
assert_card(&advance_default_batch, "Default impact", "Compiler", "Active", "width:55%"); assert_card(
&advance_default_batch,
let ship_default = post( "Default impact",
"/", "Compiler",
&handle_body(card_handles::advance_work, &[("work_id", "5")]), "Active",
"width:55%",
); );
assert_effect_response(&ship_default);
let ship_default_batch = ship_default.batch();
assert_card(&ship_default_batch, "Default impact", "Product", "Shipped", "width:55%");
let simulated_push = post("/", &handle_body(handles::simulate_push, &[])); let ship_default = post("/", &handle_form_body(advance_work, &[("work_id", "5")]));
assert_effect_response(&ship_default);
let ship_default_batch = ship_default.effects();
assert_card(
&ship_default_batch,
"Default impact",
"Product",
"Shipped",
"width:55%",
);
let simulated_push = post("/", &handle_form_body(simulate_push, &[]));
assert_effect_response(&simulated_push); assert_effect_response(&simulated_push);
let push_batch = simulated_push.batch(); let push_batch = simulated_push.effects();
assert_payload_contains(&push_batch, "SSE tick"); assert_payload_contains(&push_batch, "SSE tick");
assert_payload_contains(&push_batch, "Push simulated · no client app code"); assert_payload_contains(&push_batch, "Push simulated · no client app code");
assert_payload_contains(&push_batch, "Simulated push event produced the same generated update shape"); assert_payload_contains(
assert_emit(&push_batch, "slhx:island-orbit", "activity rows"); &push_batch,
"Simulated push event produced the same generated update shape",
let delete_missing = post(
"/",
&handle_body(card_handles::delete_work, &[("work_id", "999")]),
); );
assert_emit(&push_batch, &island_event_name("orbit"), "activity rows");
let delete_missing = post("/", &handle_form_body(delete_work, &[("work_id", "999")]));
assert_effect_response(&delete_missing); assert_effect_response(&delete_missing);
let delete_missing_batch = delete_missing.batch(); let delete_missing_batch = delete_missing.effects();
assert_payload_not_contains(&delete_missing_batch, "Deleted card #999"); assert_payload_not_contains(&delete_missing_batch, "Deleted card #999");
let delete = post( let delete = post("/", &handle_form_body(delete_work, &[("work_id", "4")]));
"/",
&handle_body(card_handles::delete_work, &[("work_id", "4")]),
);
assert_effect_response(&delete); assert_effect_response(&delete);
let delete_batch = delete.batch(); let delete_batch = delete.effects();
assert_payload_contains(&delete_batch, "Card removed"); assert_payload_contains(&delete_batch, "Card removed");
assert_payload_contains(&delete_batch, "Deleted card #4"); assert_payload_contains(&delete_batch, "Deleted card #4");
assert_payload_not_contains(&delete_batch, "data-key=\"4\""); assert!(delete_batch.payload_excludes_key(4));
assert_payload_contains(&delete_batch, "Default impact"); assert_payload_contains(&delete_batch, "Default impact");
let reset = post("/", &handle_body(handles::reset_demo, &[])); let reset = post("/", &handle_form_body(reset_demo, &[]));
assert_effect_response(&reset); assert_effect_response(&reset);
let reset_batch = reset.batch(); let reset_batch = reset.effects();
assert_payload_contains(&reset_batch, "Demo reset from Rust state"); assert_payload_contains(&reset_batch, "Demo reset from Rust state");
assert_payload_contains(&reset_batch, "Compile checked handles"); assert_payload_contains(&reset_batch, "Compile checked handles");
@@ -257,7 +280,7 @@ fn product_is_e2e_working_over_http() {
assert!(sse.text().contains("event: slhx")); assert!(sse.text().contains("event: slhx"));
assert!(sse.text().contains("data: ")); assert!(sse.text().contains("data: "));
let unknown = post("/", &format!("{SLHX_HANDLE_FIELD}=999999")); let unknown = post("/", &unknown_handle_form_body(999999));
assert_eq!(unknown.status, 404); assert_eq!(unknown.status, 404);
assert!(unknown.text().contains("unknown slhx handle id 999999")); assert!(unknown.text().contains("unknown slhx handle id 999999"));
} }
@@ -265,42 +288,37 @@ fn product_is_e2e_working_over_http() {
fn assert_effect_response(response: &Response) { fn assert_effect_response(response: &Response) {
assert_eq!(response.status, 200); assert_eq!(response.status, 200);
assert!(response.header("content-type").contains("application/slhx")); assert!(response.header("content-type").contains("application/slhx"));
assert_eq!(response.header("x-slhx-fingerprint"), BUILD_FINGERPRINT.0.to_string()); assert_eq!(
let batch = response.batch(); response.header("x-slhx-fingerprint"),
assert_eq!(batch.abi_version, EFFECT_BATCH_ABI_VERSION); BUILD_FINGERPRINT.0.to_string()
assert!(!batch.ops.is_empty()); );
assert!(!response.effects().is_empty());
} }
fn assert_payload_contains(batch: &EffectBatch, needle: &str) { fn assert_payload_contains(batch: &EffectInspector, needle: &str) {
assert!( assert!(
batch.ops.iter().any(|op| match op { batch.payload_contains(needle),
Effect::Put { payload, .. } | Effect::Insert { payload, .. } | Effect::Prepend { payload, .. } => payload_value(payload).contains(needle),
Effect::Emit { payload, .. } => payload.contains(needle),
Effect::Navigate { url, .. } => url.contains(needle),
Effect::Remove { .. } | Effect::Move { .. } | Effect::Focus { .. } => false,
}),
"missing payload {needle:?} in {batch:#?}" "missing payload {needle:?} in {batch:#?}"
); );
} }
fn assert_emit(batch: &EffectBatch, name: &str, needle: &str) { fn assert_emit(batch: &EffectInspector, name: &str, needle: &str) {
assert!( assert!(
batch.ops.iter().any(|op| match op { batch.emits_containing(name, needle),
Effect::Emit { name: actual, payload } => actual == name && payload.contains(needle),
_ => false,
}),
"missing emit {name:?} containing {needle:?} in {batch:#?}" "missing emit {name:?} containing {needle:?} in {batch:#?}"
); );
} }
fn assert_card(batch: &EffectBatch, title: &str, lane: &str, stage: &str, impact_style: &str) { fn assert_card(batch: &EffectInspector, title: &str, lane: &str, stage: &str, impact_style: &str) {
let board = board_html(batch); let board = batch
.target_html_containing(control::board, "class=\"lanes\"")
.expect("board html payload");
let document = Html::parse_fragment(&board); let document = Html::parse_fragment(&board);
let lane_selector = Selector::parse(".lane").unwrap(); let lane_selector = Selector::parse(&class_selector("lane")).unwrap();
let card_selector = Selector::parse(".work-card").unwrap(); let card_selector = Selector::parse(&class_selector("work-card")).unwrap();
let strong_selector = Selector::parse("strong").unwrap(); let strong_selector = Selector::parse(strong_text_selector()).unwrap();
let stage_selector = Selector::parse(".pill").unwrap(); let stage_selector = Selector::parse(&class_selector("pill")).unwrap();
let impact_selector = Selector::parse(".impact i").unwrap(); let impact_selector = Selector::parse(&class_descendant_selector("impact", "i")).unwrap();
for lane_node in document.select(&lane_selector) { for lane_node in document.select(&lane_selector) {
let lane_text = lane_node.text().collect::<Vec<_>>().join(" "); let lane_text = lane_node.text().collect::<Vec<_>>().join(" ");
@@ -327,7 +345,10 @@ fn assert_card(batch: &EffectBatch, title: &str, lane: &str, stage: &str, impact
.and_then(|node| node.value().attr("style")) .and_then(|node| node.value().attr("style"))
.unwrap_or(""); .unwrap_or("");
assert_eq!(card_stage, stage); assert_eq!(card_stage, stage);
assert!(style.contains(impact_style), "style {style:?} missing {impact_style:?}"); assert!(
style.contains(impact_style),
"style {style:?} missing {impact_style:?}"
);
return; return;
} }
} }
@@ -335,40 +356,22 @@ fn assert_card(batch: &EffectBatch, title: &str, lane: &str, stage: &str, impact
panic!("missing card title={title:?} lane={lane:?} in {board}"); panic!("missing card title={title:?} lane={lane:?} in {board}");
} }
fn board_html(batch: &EffectBatch) -> String { fn assert_payload_not_contains(batch: &EffectInspector, needle: &str) {
batch
.ops
.iter()
.find_map(|op| match op {
Effect::Put { payload: Payload::Html(value), .. } if value.contains("class=\"lanes\"") => Some(value.clone()),
_ => None,
})
.expect("board html payload")
}
fn assert_payload_not_contains(batch: &EffectBatch, needle: &str) {
assert!( assert!(
batch.ops.iter().all(|op| match op { batch.payload_excludes(needle),
Effect::Put { payload, .. } | Effect::Insert { payload, .. } | Effect::Prepend { payload, .. } => !payload_value(payload).contains(needle),
Effect::Emit { payload, .. } => !payload.contains(needle),
Effect::Navigate { url, .. } => !url.contains(needle),
Effect::Remove { .. } | Effect::Move { .. } | Effect::Focus { .. } => true,
}),
"unexpected payload {needle:?} in {batch:#?}" "unexpected payload {needle:?} in {batch:#?}"
); );
} }
fn payload_value(payload: &Payload) -> &str {
match payload {
Payload::Text(value) | Payload::Html(value) => value,
}
}
fn assert_text(document: &Html, text: &str) { fn assert_text(document: &Html, text: &str) {
let body = document.root_element().text().collect::<Vec<_>>().join(" "); let body = document.root_element().text().collect::<Vec<_>>().join(" ");
assert!(body.contains(text), "missing text {text:?} in {body:?}"); assert!(body.contains(text), "missing text {text:?} in {body:?}");
} }
fn assert_work_card_count_at_least(document: &Html, expected: usize) {
assert_selector_count_at_least(document, &class_selector("work-card"), expected);
}
fn assert_selector_count_at_least(document: &Html, selector: &str, expected: usize) { fn assert_selector_count_at_least(document: &Html, selector: &str, expected: usize) {
let selector = Selector::parse(selector).unwrap(); let selector = Selector::parse(selector).unwrap();
let count = document.select(&selector).count(); let count = document.select(&selector).count();
@@ -390,7 +393,9 @@ fn post(path: &str, body: &str) -> Response {
fn request(method: &str, path: &str, headers: &[(&str, &str)], body: &str) -> Response { fn request(method: &str, path: &str, headers: &[(&str, &str)], body: &str) -> Response {
let mut stream = TcpStream::connect(ADDR).expect("connect to server"); let mut stream = TcpStream::connect(ADDR).expect("connect to server");
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
write!( write!(
stream, stream,
"{method} {path} HTTP/1.1\r\nHost: {ADDR}\r\nConnection: close\r\nContent-Length: {}\r\n", "{method} {path} HTTP/1.1\r\nHost: {ADDR}\r\nConnection: close\r\nContent-Length: {}\r\n",
@@ -431,7 +436,11 @@ impl Response {
.filter_map(|line| line.split_once(':')) .filter_map(|line| line.split_once(':'))
.map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_string())) .map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_string()))
.collect(); .collect();
Self { status, headers, body } Self {
status,
headers,
body,
}
} }
fn header(&self, name: &str) -> &str { fn header(&self, name: &str) -> &str {
@@ -446,7 +455,7 @@ impl Response {
std::str::from_utf8(&self.body).unwrap() std::str::from_utf8(&self.body).unwrap()
} }
fn batch(&self) -> EffectBatch { fn effects(&self) -> EffectInspector {
EffectBatch::from_wire(&self.body).unwrap() inspect_wire(&self.body)
} }
} }
+43 -34
View File
@@ -5,7 +5,7 @@ pub mod ui {}
mod tests { mod tests {
use super::ui::{auth, counter, notifications, page_swap, todos, wizard}; use super::ui::{auth, counter, notifications, page_swap, todos, wizard};
use hemplate::Hemplate; use hemplate::Hemplate;
use slhx::{push, Effect, IntoEffect, NavigateMode, Payload}; use slhx::{push, IntoEffect};
use slhx_test::inspect; use slhx_test::inspect;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -36,9 +36,16 @@ mod tests {
#[derive(Hemplate)] #[derive(Hemplate)]
#[hemplate = "partials"] #[hemplate = "partials"]
struct TodoRow { struct TodoRow {
id: u64,
title: String, title: String,
} }
impl slhx::KeyedPartial for TodoRow {
fn slhx_key(&self) -> String {
self.id.to_string()
}
}
#[derive(Hemplate)] #[derive(Hemplate)]
#[hemplate = "partials"] #[hemplate = "partials"]
struct DocsContent { struct DocsContent {
@@ -49,13 +56,12 @@ mod tests {
#[test] #[test]
fn counter_updates_a_generated_slot() { fn counter_updates_a_generated_slot() {
fn increment(count: u64) -> impl IntoEffect { fn increment(count: u64) -> impl IntoEffect {
counter::targets::counter_value.text(count + 1) counter::counter_value.set(count + 1)
} }
let effect = inspect(increment(1)); let effect = inspect(increment(1));
assert!(effect.has_slot(counter::slots::counter_value)); assert!(effect.updates_text(counter::counter_value));
assert!(matches!(effect.ops(), [Effect::Put { .. }]));
} }
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003 // req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
@@ -63,27 +69,33 @@ mod tests {
fn form_handler_is_checked_against_hemplate_form() { fn form_handler_is_checked_against_hemplate_form() {
#[slhx::handler] #[slhx::handler]
fn add_todo(_form: slhx::Form<TodoInput>) -> impl IntoEffect { fn add_todo(_form: slhx::Form<TodoInput>) -> impl IntoEffect {
todos::slots::todo_list.text("queued") todos::todo_list.set("queued")
} }
let effect = inspect(add_todo(TodoInput::FORM)); let effect = inspect(add_todo(TodoInput::FORM));
assert!(effect.has_slot(todos::slots::todo_list)); assert!(effect.updates_text(todos::todo_list));
assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }]));
} }
// req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/002 // req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/002
#[test] #[test]
fn todos_append_keyed_rows_from_form_input() { fn todos_append_keyed_rows_from_form_input() {
fn add_todo(input: TodoInput) -> impl IntoEffect { fn add_todo(input: TodoInput) -> impl IntoEffect {
let todo = Todo { id: 7, title: input.title }; let todo = Todo {
todos::targets::todo_row.append(todo.id, &TodoRow { title: todo.title }) id: 7,
title: input.title,
};
todos::todo_row.append(TodoRow {
id: todo.id,
title: todo.title,
})
} }
let effect = inspect(add_todo(TodoInput { title: "Ship v0".into() })); let effect = inspect(add_todo(TodoInput {
title: "Ship v0".into(),
}));
assert!(effect.has_resource(todos::slots::todo_row.id())); assert!(effect.inserts_html_containing(todos::todo_row, 7, "Ship v0"));
assert!(matches!(effect.ops(), [Effect::Insert { key, payload, .. }] if key == "7" && matches!(payload, Payload::Html(_))));
} }
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003 // req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
@@ -91,26 +103,24 @@ mod tests {
fn wizard_form_handler_is_checked_against_hemplate_form() { fn wizard_form_handler_is_checked_against_hemplate_form() {
#[slhx::handler] #[slhx::handler]
fn next_step(_form: slhx::Form<WizardInput>) -> impl IntoEffect { fn next_step(_form: slhx::Form<WizardInput>) -> impl IntoEffect {
wizard::slots::wizard_step.text("queued") wizard::wizard_step.set("queued")
} }
let effect = inspect(next_step(WizardInput::FORM)); let effect = inspect(next_step(WizardInput::FORM));
assert!(effect.has_slot(wizard::slots::wizard_step)); assert!(effect.updates_text(wizard::wizard_step));
assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }]));
} }
// req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/004 // req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/004
#[test] #[test]
fn wizard_form_swaps_the_current_step() { fn wizard_form_swaps_the_current_step() {
fn next_step(input: WizardInput) -> impl IntoEffect { fn next_step(input: WizardInput) -> impl IntoEffect {
wizard::slots::wizard_step.text(format!("Step {}", input.step + 1)) wizard::wizard_step.set(format!("Step {}", input.step + 1))
} }
let effect = inspect(next_step(WizardInput { step: 1 })); let effect = inspect(next_step(WizardInput { step: 1 }));
assert!(effect.has_slot(wizard::slots::wizard_step)); assert!(effect.updates_text(wizard::wizard_step));
assert!(matches!(effect.ops(), [Effect::Put { .. }]));
} }
// req: examples/001 req: page_swap/002 req: page_swap/003 req: build/001 req: build/005 req: view/001 // req: examples/001 req: page_swap/002 req: page_swap/003 req: build/001 req: build/005 req: view/001
@@ -118,20 +128,19 @@ mod tests {
fn page_swap_updates_content_and_history() { fn page_swap_updates_content_and_history() {
fn load_docs() -> impl IntoEffect { fn load_docs() -> impl IntoEffect {
( (
page_swap::put(page_swap::slots::content, &DocsContent { page_swap::content.put(&DocsContent {
message: "This page was swapped.", message: "This page was swapped.",
}), }),
page_swap::slots::title.text("Docs"), page_swap::title.set("Docs"),
push("/docs"), push("/docs"),
) )
} }
let effect = inspect(load_docs()); let effect = inspect(load_docs());
assert!(effect.has_slot(page_swap::slots::content)); assert!(effect.updates_html_containing(page_swap::content, "This page was swapped."));
assert!(effect.has_slot(page_swap::slots::title)); assert!(effect.updates_text(page_swap::title));
assert!(matches!(effect.ops().first(), Some(Effect::Put { payload: Payload::Html(_), .. }))); assert!(effect.pushes_to("/docs"));
assert!(matches!(effect.ops().last(), Some(Effect::Navigate { url, mode: NavigateMode::Push, .. }) if url == "/docs"));
} }
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003 // req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
@@ -139,13 +148,12 @@ mod tests {
fn auth_form_handler_is_checked_against_hemplate_form() { fn auth_form_handler_is_checked_against_hemplate_form() {
#[slhx::handler] #[slhx::handler]
fn login(_form: slhx::Form<Credentials>) -> impl IntoEffect { fn login(_form: slhx::Form<Credentials>) -> impl IntoEffect {
auth::slots::login_status.text("queued") auth::login_status.set("queued")
} }
let effect = inspect(login(Credentials::FORM)); let effect = inspect(login(Credentials::FORM));
assert!(effect.has_slot(auth::slots::login_status)); assert!(effect.updates_text(auth::login_status));
assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }]));
} }
// req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/004 // req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/004
@@ -158,25 +166,26 @@ mod tests {
"Try again" "Try again"
}; };
auth::slots::login_status.text(status) auth::login_status.set(status)
} }
let effect = inspect(login(Credentials { email: "demo@example.com".into(), password: "secret".into() })); let effect = inspect(login(Credentials {
email: "demo@example.com".into(),
password: "secret".into(),
}));
assert!(effect.has_slot(auth::slots::login_status)); assert!(effect.updates_text(auth::login_status));
assert!(matches!(effect.ops(), [Effect::Put { .. }]));
} }
// req: examples/001 req: push/001 req: push/003 req: build/001 req: build/005 // req: examples/001 req: push/001 req: push/003 req: build/001 req: build/005
#[test] #[test]
fn sse_notifications_update_a_generated_slot() { fn sse_notifications_update_a_generated_slot() {
fn notification(message: &str) -> impl IntoEffect { fn notification(message: &str) -> impl IntoEffect {
notifications::slots::notifications.text(message) notifications::notifications.set(message)
} }
let effect = inspect(notification("Build finished")); let effect = inspect(notification("Build finished"));
assert!(effect.has_slot(notifications::slots::notifications)); assert!(effect.updates_text(notifications::notifications));
assert!(matches!(effect.ops(), [Effect::Put { .. }]));
} }
} }
+412 -102
View File
@@ -4,18 +4,12 @@ use axum::routing::get;
use axum::Router; use axum::Router;
use futures_util::{stream, StreamExt}; use futures_util::{stream, StreamExt};
use hemplate::Hemplate; use hemplate::Hemplate;
use slhx::{push, IntoEffect, SafeHtml}; use slhx::{Html, IntoEffect};
use slhx_axum::{ use slhx_axum::{
interactions, runtime_js, sse, DispatchRegistry, EffectResponse, InteractionRequest, PageRequest, interactions, runtime_js, sse, EffectResponse, Form, InteractionRequest, PageRequest, Registry,
}; };
use slhx_v0_examples::ui; use slhx_v0_examples::ui;
use slhx_v0_examples::ui::{auth, counter, notifications, page_swap, todos, wizard}; use slhx_v0_examples::ui::{auth, counter, notifications, page_swap, todo_row, todos, wizard};
use slhx_v0_examples::ui::auth::{handles as auth_handles, targets as auth_targets};
use slhx_v0_examples::ui::counter::{handles as counter_handles, targets as counter_targets};
use slhx_v0_examples::ui::notifications::targets as notification_targets;
use slhx_v0_examples::ui::page_swap::{handles as page_handles, targets as page_targets};
use slhx_v0_examples::ui::todos::{forms as todo_forms, handles as todo_handles, targets as todo_targets};
use slhx_v0_examples::ui::wizard::{handles as wizard_handles, targets as wizard_targets};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::convert::Infallible; use std::convert::Infallible;
use std::net::SocketAddr; use std::net::SocketAddr;
@@ -25,36 +19,101 @@ use std::time::Duration;
#[derive(Default)] #[derive(Default)]
struct ExampleState { struct ExampleState {
counter: Mutex<u64>, counter: Mutex<u64>,
todos: Mutex<Vec<Todo>>, todos: Mutex<Vec<TodoRecord>>,
wizard_step: Mutex<u8>, wizard_step: Mutex<u8>,
} }
#[derive(Clone)] #[derive(Clone)]
struct Todo { struct TodoRecord {
// Domain/SQL-shaped rows stay as boring Rust data; hemplate view structs are derived at render/update boundaries.
// req: examples/001 req: view/001
id: u64, id: u64,
title: String, title: String,
} }
#[slhx::form("new_todo")]
struct NewTodo {
title: String,
}
#[slhx::form("rename_todo")]
struct RenameTodo {
id: u64,
title: String,
}
#[slhx::form("delete_todo")]
struct DeleteTodo {
id: u64,
}
#[slhx::form("wizard_input")]
struct WizardInput {
step: String,
}
#[slhx::form("credentials")]
struct Credentials {
email: String,
password: String,
}
#[derive(Debug)]
struct TodoMutationError(String);
impl std::fmt::Display for TodoMutationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for TodoMutationError {}
#[derive(Hemplate)] #[derive(Hemplate)]
#[hemplate = "partials"] #[hemplate = "partials"]
struct TodoItems { struct TodoItems {
items: Vec<TodoItem>, items: Vec<TodoItem>,
} }
#[derive(Hemplate)]
#[hemplate = "partials"]
struct TodoRow {
id: u64,
title: String,
}
impl slhx::KeyedPartial for TodoRow {
fn slhx_key(&self) -> String {
self.id.to_string()
}
}
struct TodoItem { struct TodoItem {
id: u64, id: u64,
title: String, title: String,
} }
#[derive(Hemplate)]
struct Counter;
#[derive(Hemplate)]
struct Wizard;
#[derive(Hemplate)]
struct Auth;
#[derive(Hemplate)]
struct Notifications;
#[derive(Hemplate)] #[derive(Hemplate)]
struct PageSwap { struct PageSwap {
content: SafeHtml, content: Html,
title: &'static str, title: &'static str,
} }
#[derive(Hemplate)] #[derive(Hemplate)]
struct AppShell { struct AppShell {
body: SafeHtml, body: Html,
} }
#[derive(Hemplate)] #[derive(Hemplate)]
@@ -100,7 +159,7 @@ async fn interact(
State(state): State<Arc<ExampleState>>, State(state): State<Arc<ExampleState>>,
request: InteractionRequest, request: InteractionRequest,
) -> Result<EffectResponse, impl IntoResponse> { ) -> Result<EffectResponse, impl IntoResponse> {
request.dispatch(registry(state)) request.dispatch_async(registry(state)).await
} }
async fn runtime() -> impl IntoResponse { async fn runtime() -> impl IntoResponse {
@@ -110,96 +169,201 @@ async fn runtime() -> impl IntoResponse {
// req: push/001, req: push/003, req: examples/001 // req: push/001, req: push/003, req: examples/001
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse { async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
if params.contains_key("once") { if params.contains_key("once") {
let effect = notification_targets::notifications.text("Server event #1"); let effect = notifications::notifications.set("Server event #1");
return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed()); return sse(stream::iter([Ok::<_, Infallible>(
effect.into_batch(ui::BUILD_FINGERPRINT),
)])
.boxed());
} }
let batches = stream::unfold(1_u64, |count| async move { let batches = stream::unfold(1_u64, |count| async move {
tokio::time::sleep(Duration::from_secs(3)).await; tokio::time::sleep(Duration::from_secs(3)).await;
let effect = notification_targets::notifications.text(format!("Server event #{count}")); let effect = notifications::notifications.set(format!("Server event #{count}"));
Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1)) Some((
Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)),
count + 1,
))
}) })
.boxed(); .boxed();
sse(batches) sse(batches)
} }
fn registry(state: Arc<ExampleState>) -> impl DispatchRegistry { #[slhx::app(
counter_handlers,
todo_handlers,
todo_row_handlers,
wizard_handlers,
auth_handlers
)]
fn registry(state: Arc<ExampleState>) -> Registry {
interactions(ui::BUILD_FINGERPRINT) interactions(ui::BUILD_FINGERPRINT)
.on(counter_handles::increment, {
let state = state.clone();
move |_| {
// req: examples/001
let mut counter = state.counter.lock().unwrap();
*counter += 1;
counter_targets::counter_value.text(*counter)
}
})
.on(todo_handles::add_todo, {
let state = state.clone();
move |form| {
// req: examples/001
let title = form.value("title").unwrap_or("").trim();
let mut todos = state.todos.lock().unwrap();
if !title.is_empty() {
let id = todos.last().map_or(1, |todo| todo.id + 1);
todos.push(Todo { id, title: title.into() });
}
(
todo_targets::todo_list.put(&todos_view(&todos)),
todo_forms::new_todo.clear("title"),
)
}
})
.on(wizard_handles::next_step, {
let state = state.clone();
move |_| {
// req: examples/001
let mut step = state.wizard_step.lock().unwrap();
*step += 1;
wizard_targets::wizard_step.text(format!("Step {}", *step + 1))
}
})
.on(auth_handles::login, |form| {
// req: examples/001
let ok = form.value("email") == Some("demo@example.com")
&& form.value("password").is_some_and(|password| !password.is_empty());
auth_targets::login_status.text(if ok {
"Signed in as demo@example.com"
} else {
"Try demo@example.com with any password"
})
})
.on(page_handles::load_docs, |_| {
// req: page_swap/002, req: examples/001
(
page_targets::content.put(&DocsContent {
message: "This content came from a generated update response.",
}),
page_targets::title.text("Docs"),
push("/docs"),
)
})
} }
fn all_examples() -> SafeHtml { fn all_examples() -> Html {
// Static `.heml` fragments are lowered by generated code before they join rendered views.
// req: html_safety/001 req: html_safety/002 req: component/003 // req: html_safety/001 req: html_safety/002 req: component/003
SafeHtml::join([ Html::join([
counter::static_fragment(include_str!("../templates/counter.heml")), counter::render(&Counter),
todos::static_fragment(include_str!("../templates/todos.heml")), ui::render(&todos_view(&[])),
wizard::static_fragment(include_str!("../templates/wizard.heml")), wizard::render(&Wizard),
auth::static_fragment(include_str!("../templates/auth.heml")), auth::render(&Auth),
render_page_swap("Welcome", "Welcome"), render_page_swap("Welcome", "Welcome"),
notifications::static_fragment(include_str!("../templates/notifications.heml")), notifications::render(&Notifications),
]) ])
} }
fn shell(body: SafeHtml) -> SafeHtml { fn shell(body: Html) -> Html {
// req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003 // req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003
ui::render(&AppShell { body }) ui::render(&AppShell { body })
} }
fn todos_view(todos: &[Todo]) -> TodoItems { #[slhx::component("counter")]
mod counter_handlers {
use super::*;
#[slhx::handler]
async fn increment(State(state): State<Arc<ExampleState>>) -> impl IntoEffect {
// req: examples/001
let mut counter = state.counter.lock().unwrap();
*counter += 1;
counter::counter_value.set(*counter)
}
}
#[slhx::component("todos")]
mod todo_handlers {
use super::*;
#[slhx::handler]
async fn add_todo(
State(state): State<Arc<ExampleState>>,
Form(form): Form<NewTodo>,
) -> Result<impl IntoEffect, TodoMutationError> {
// req: examples/001 req: canonical_authoring/003
let mut todos = state.todos.lock().unwrap();
let id = todos.last().map_or(1, |todo| todo.id + 1);
let created = if form.title.is_empty() {
None
} else {
todos.push(TodoRecord {
id,
title: form.title.clone(),
});
Some(todos::todo_row.append(TodoRow {
id,
title: form.title,
}))
};
let summary = todo_summary(&todos);
Ok((
created,
todos::summary.set(summary),
todos::new_todo.clear(),
))
}
#[slhx::handler]
async fn rename_todo(
State(state): State<Arc<ExampleState>>,
Form(form): Form<RenameTodo>,
) -> impl IntoEffect {
rename_todo_effect(state, form)
}
#[slhx::handler]
async fn delete_todo(
State(state): State<Arc<ExampleState>>,
Form(form): Form<DeleteTodo>,
) -> impl IntoEffect {
delete_todo_effect(state, form)
}
}
#[slhx::component("todo_row")]
mod todo_row_handlers {
use super::*;
#[slhx::handler]
async fn rename_todo_row(
State(state): State<Arc<ExampleState>>,
Form(form): Form<RenameTodo>,
) -> impl IntoEffect {
rename_todo_effect(state, form)
}
#[slhx::handler]
async fn delete_todo_row(
State(state): State<Arc<ExampleState>>,
Form(form): Form<DeleteTodo>,
) -> impl IntoEffect {
delete_todo_effect(state, form)
}
}
#[slhx::component("wizard")]
mod wizard_handlers {
use super::*;
#[slhx::handler]
async fn next_step(
State(state): State<Arc<ExampleState>>,
Form(form): Form<WizardInput>,
) -> impl IntoEffect {
// req: examples/001
let _submitted_step = form.step;
let mut step = state.wizard_step.lock().unwrap();
*step += 1;
wizard::wizard_step.set(format!("Step {}", *step + 1))
}
}
#[slhx::component("auth")]
mod auth_handlers {
use super::*;
#[slhx::handler]
async fn login(
State(_state): State<Arc<ExampleState>>,
Form(credentials): Form<Credentials>,
) -> impl IntoEffect {
// req: examples/001
let ok = credentials.email == "demo@example.com" && !credentials.password.is_empty();
auth::login_status.set(if ok {
"Signed in as demo@example.com"
} else {
"Try demo@example.com with any password"
})
}
}
fn rename_todo_effect(state: Arc<ExampleState>, form: RenameTodo) -> impl IntoEffect {
// req: examples/001 req: canonical_authoring/003
let mut todos = state.todos.lock().unwrap();
todos
.iter_mut()
.find(|todo| todo.id == form.id)
.map(|todo| {
todo.title = form.title.clone();
todos::todo_row.replace(TodoRow {
id: form.id,
title: form.title,
})
})
}
fn delete_todo_effect(state: Arc<ExampleState>, form: DeleteTodo) -> impl IntoEffect {
// req: examples/001 req: canonical_authoring/003
let mut todos = state.todos.lock().unwrap();
let before = todos.len();
todos.retain(|todo| todo.id != form.id);
let deleted = todos.len() != before;
let summary = todo_summary(&todos);
(
deleted.then(|| todos::todo_row.remove(form.id)),
deleted.then(|| todos::summary.set(summary)),
)
}
fn todos_view(todos: &[TodoRecord]) -> TodoItems {
// req: html_safety/002 req: view/001 // req: html_safety/002 req: view/001
TodoItems { TodoItems {
items: todos items: todos
@@ -212,7 +376,15 @@ fn todos_view(todos: &[Todo]) -> TodoItems {
} }
} }
fn render_page_swap(title: &'static str, message: &'static str) -> SafeHtml { fn todo_summary(todos: &[TodoRecord]) -> String {
match todos.len() {
0 => "No todos".to_owned(),
1 => "1 todo".to_owned(),
count => format!("{count} todos"),
}
}
fn render_page_swap(title: &'static str, message: &'static str) -> Html {
// req: html_safety/002 req: view/001 req: component/003 // req: html_safety/002 req: view/001 req: component/003
page_swap::render(&PageSwap { page_swap::render(&PageSwap {
content: render_docs_content(message), content: render_docs_content(message),
@@ -220,7 +392,7 @@ fn render_page_swap(title: &'static str, message: &'static str) -> SafeHtml {
}) })
} }
fn render_docs_content(message: &'static str) -> SafeHtml { fn render_docs_content(message: &'static str) -> Html {
// req: html_safety/002 req: view/001 req: component/003 // req: html_safety/002 req: view/001 req: component/003
ui::render(&DocsContent { message }) ui::render(&DocsContent { message })
} }
@@ -229,11 +401,25 @@ fn render_docs_content(message: &'static str) -> SafeHtml {
mod tests { mod tests {
use super::*; use super::*;
use scraper::{Html, Selector}; use scraper::{Html, Selector};
use slhx_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,
};
fn selector(value: &str) -> Selector { fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses") 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(
handle,
fields
.iter()
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned())),
)
}
// req: html_safety/002 req: view/001 req: test/005 // req: html_safety/002 req: view/001 req: test/005
#[test] #[test]
fn shell_is_rendered_by_a_hemplate_view() { fn shell_is_rendered_by_a_hemplate_view() {
@@ -241,15 +427,28 @@ mod tests {
let document = Html::parse_document(html.as_str()); let document = Html::parse_document(html.as_str());
assert_eq!( assert_eq!(
document document
.select(&selector("title")) .select(&selector(document_title_selector()))
.next() .next()
.map(|title| title.text().collect::<String>()), .map(|title| title.text().collect::<String>()),
Some("slhx v0 examples".to_owned()) Some("slhx v0 examples".to_owned())
); );
assert_eq!(document.select(&selector("script[src=\"/slhx.js\"]")).count(), 1); assert_eq!(
assert_eq!(document.select(&selector("main[data-slhx-root=\"docs\"]")).count(), 1); document
.select(&selector(runtime_script_selector()))
.count(),
1
);
assert_eq!(
document
.select(&selector(&root_element_selector("main", "docs")))
.count(),
1
);
assert!( assert!(
document.root_element().text().all(|text| !text.contains("{+=")), document
.root_element()
.text()
.all(|text| !text.contains("{+=")),
"hemplate insertion markers must not leak into rendered text" "hemplate insertion markers must not leak into rendered text"
); );
} }
@@ -261,14 +460,14 @@ mod tests {
let document = Html::parse_fragment(html.as_str()); let document = Html::parse_fragment(html.as_str());
assert_eq!( assert_eq!(
document document
.select(&selector("h1")) .select(&selector(&heading_selector("", 1)))
.next() .next()
.map(|heading| heading.text().collect::<String>()), .map(|heading| heading.text().collect::<String>()),
Some("Docs".to_owned()) Some("Docs".to_owned())
); );
assert_eq!( assert_eq!(
document document
.select(&selector("p")) .select(&selector(&prose_selector("")))
.next() .next()
.map(|paragraph| paragraph.text().collect::<String>()), .map(|paragraph| paragraph.text().collect::<String>()),
Some("This content came from a generated update response.".to_owned()) Some("This content came from a generated update response.".to_owned())
@@ -280,11 +479,22 @@ mod tests {
fn docs_page_partial_is_rendered_by_a_hemplate_view() { fn docs_page_partial_is_rendered_by_a_hemplate_view() {
let html = render_page_swap("Docs", "This page was swapped without a full reload."); let html = render_page_swap("Docs", "This page was swapped without a full reload.");
let document = Html::parse_fragment(html.as_str()); let document = Html::parse_fragment(html.as_str());
assert_eq!(document.select(&selector("main[data-slhx-root=\"docs\"]")).count(), 1);
assert_eq!(document.select(&selector("article[data-sid]")).count(), 1);
assert_eq!( assert_eq!(
document document
.select(&selector("article h1")) .select(&selector(&root_element_selector("main", "docs")))
.count(),
1
);
assert_eq!(document.select(&selector(article_selector())).count(), 1);
assert_eq!(
document
.select(&selector(&page_nav_link_selector("/docs")))
.count(),
1
);
assert_eq!(
document
.select(&selector(&heading_selector("article", 1)))
.next() .next()
.map(|heading| heading.text().collect::<String>()), .map(|heading| heading.text().collect::<String>()),
Some("Docs".to_owned()) Some("Docs".to_owned())
@@ -294,15 +504,26 @@ mod tests {
// req: html_safety/002 req: view/001 req: test/005 // req: html_safety/002 req: view/001 req: test/005
#[test] #[test]
fn todos_payload_is_rendered_by_a_hemplate_view() { fn todos_payload_is_rendered_by_a_hemplate_view() {
let todos = vec![Todo { id: 7, title: "<b>Ship v0</b>".to_owned() }]; let todos = vec![TodoRecord {
id: 7,
title: "<b>Ship v0</b>".to_owned(),
}];
let html = ui::render(&todos_view(&todos)); let html = ui::render(&todos_view(&todos));
let document = Html::parse_fragment(html.as_str()); let document = Html::parse_fragment(html.as_str());
let rows = document.select(&selector("li[data-key]")).collect::<Vec<_>>(); let rows = document
.select(&selector(&keyed_items_selector("li")))
.collect::<Vec<_>>();
assert_eq!(rows.len(), 1); assert_eq!(rows.len(), 1);
assert_eq!(rows[0].value().attr("data-key"), Some("7")); let row = document
assert_eq!(rows[0].text().collect::<String>(), "<b>Ship v0</b>"); .select(&selector(&keyed_selector("li", 7)))
assert!(document.select(&selector("b")).next().is_none()); .next()
.expect("generated keyed row");
assert_eq!(row.text().collect::<String>(), "<b>Ship v0</b>");
assert!(document
.select(&selector(&escaped_markup_selector("b")))
.next()
.is_none());
} }
// req: html_safety/002 req: view/001 req: test/005 // req: html_safety/002 req: view/001 req: test/005
@@ -310,8 +531,97 @@ mod tests {
fn empty_todos_payload_is_rendered_by_a_hemplate_view() { fn empty_todos_payload_is_rendered_by_a_hemplate_view() {
let html = ui::render(&todos_view(&[])); let html = ui::render(&todos_view(&[]));
let document = Html::parse_fragment(html.as_str()); let document = Html::parse_fragment(html.as_str());
let rows = document.select(&selector("li")).collect::<Vec<_>>(); let rows = document
assert_eq!(rows.len(), 1); .select(&selector(&list_item_selector("")))
assert_eq!(rows[0].text().collect::<String>(), "No todos yet"); .collect::<Vec<_>>();
assert_eq!(rows.len(), 0);
}
// req: examples/001 req: page_swap/002 req: page_swap/003 req: component/005 req: public_api/003 req: test/005
#[tokio::test]
async fn registry_dispatches_generated_keyed_crud_effects() {
let state = Arc::new(ExampleState::default());
let counter = inspect_batch(
InteractionRequest::from(form(counter::increment, &[]))
.dispatch_async(registry(state.clone()))
.await
.unwrap()
.batch,
);
assert_eq!(*state.counter.lock().unwrap(), 1);
assert!(counter.updates_text(counter::counter_value));
assert!(counter.payload_contains("1"));
let add = inspect_batch(
InteractionRequest::from(form(todos::add_todo, &[("title", "Ship v0")]))
.dispatch_async(registry(state.clone()))
.await
.unwrap()
.batch,
);
assert_eq!(state.todos.lock().unwrap()[0].title, "Ship v0");
assert_eq!(add.op_count(), 3);
assert!(add.inserts_html_containing(todos::todo_row, "1", "Ship v0"));
assert!(add.updates_text(todos::summary));
assert!(add.resets_form(todos::new_todo));
let rename = inspect_batch(
InteractionRequest::from(form(
todo_row::rename_todo_row,
&[("id", "1"), ("title", "Ship 1.0")],
))
.dispatch_async(registry(state.clone()))
.await
.unwrap()
.batch,
);
assert_eq!(state.todos.lock().unwrap()[0].title, "Ship 1.0");
assert_eq!(rename.op_count(), 1);
assert!(rename.replaces_keyed_html_containing(todos::todo_row, "1", "Ship 1.0"));
let delete = inspect_batch(
InteractionRequest::from(form(todo_row::delete_todo_row, &[("id", "1")]))
.dispatch_async(registry(state.clone()))
.await
.unwrap()
.batch,
);
assert!(state.todos.lock().unwrap().is_empty());
assert_eq!(delete.op_count(), 2);
assert!(delete.removes_key(todos::todo_row, "1"));
assert!(delete.updates_text(todos::summary));
let missing_delete = inspect_batch(
InteractionRequest::from(form(todo_row::delete_todo_row, &[("id", "99")]))
.dispatch_async(registry(state.clone()))
.await
.unwrap()
.batch,
);
assert!(missing_delete.is_empty());
let wizard = inspect_batch(
InteractionRequest::from(form(wizard::next_step, &[("step", "1")]))
.dispatch_async(registry(state.clone()))
.await
.unwrap()
.batch,
);
assert!(wizard.updates_text(wizard::wizard_step));
assert!(wizard.payload_contains("Step 2"));
let auth = inspect_batch(
InteractionRequest::from(form(
auth::login,
&[("email", "demo@example.com"), ("password", "secret")],
))
.dispatch_async(registry(state))
.await
.unwrap()
.batch,
);
assert!(auth.updates_text(auth::login_status));
assert!(auth.payload_contains("Signed in as demo@example.com"));
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
<main data-slhx-root="docs"> <main data-slhx-root="docs">
<nav data-slhx-slot="nav"> <nav data-slhx-slot="nav">
<a href="/docs" data-slhx-nav="" data-slhx-handle="load_docs">Docs</a> <a href="/docs" data-slhx-nav="">Docs</a>
</nav> </nav>
<article data-slhx-slot="content">{+= self.content =+}</article> <article data-slhx-slot="content">{+= self.content =+}</article>
<title data-slhx-slot="title">{+ self.title +}</title> <title data-slhx-slot="title">{+ self.title +}</title>
@@ -1,6 +1,3 @@
<template h-if="self.items.is_empty()"> <template h-if="!self.items.is_empty()">
<li>No todos yet</li>
</template>
<template h-else>
<li h-for="todo in &self.items" +data-key="todo.id">{+ todo.title +}</li> <li h-for="todo in &self.items" +data-key="todo.id">{+ todo.title +}</li>
</template> </template>
+10 -1
View File
@@ -1 +1,10 @@
<li>{+ self.title +}</li> <li>
<span>{+ self.title +}</span>
<form data-slhx-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">
<button type="submit" name="id" +value="self.id">Delete</button>
</form>
</li>
+15 -3
View File
@@ -3,9 +3,21 @@
<input name="title" required="required"> <input name="title" required="required">
<button type="submit">Add</button> <button type="submit">Add</button>
</form> </form>
<ul data-slhx-slot="todo_list"> <p data-slhx-slot="summary">{+ self.summary +}</p>
<template h-for="todo in &self.todos" h-key="todo.id"> <div data-slhx-slot="todo_list">
<li data-slhx-slot="todo_row">{+ todo.title +}</li> <ul data-slhx-slot="todo_row">
<template h-for="todo in &self.items" h-key="todo.id">
<li data-slhx-slot="todo_row" +data-key="todo.id">
<span>{+ todo.title +}</span>
<form data-slhx-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">
<button type="submit" name="id" +value="todo.id">Delete</button>
</form>
</li>
</template> </template>
</ul> </ul>
</div>
</section> </section>
+647 -23
View File
@@ -1,13 +1,17 @@
use axum::async_trait; use axum::async_trait;
use axum::body::{to_bytes, Body}; use axum::body::{to_bytes, Body};
pub use axum::extract::State;
use axum::extract::{FromRequest, FromRequestParts, Multipart}; use axum::extract::{FromRequest, FromRequestParts, Multipart};
use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode}; use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode};
use axum::response::sse::{Event, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use futures_util::{Stream, StreamExt}; use futures_util::{Stream, StreamExt};
use slhx_core::{BuildFingerprint, EffectBatch, Handle, IntoEffect, SafeHtml}; use slhx_core::{BuildFingerprint, EffectBatch, FromForm, Handle, IntoEffect, SafeHtml};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::convert::Infallible; 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_PARTIAL_HEADER: &str = "x-slhx-partial";
pub const SLHX_FINGERPRINT_HEADER: &str = "x-slhx-fingerprint"; pub const SLHX_FINGERPRINT_HEADER: &str = "x-slhx-fingerprint";
@@ -54,7 +58,11 @@ impl PageRequest {
matches!(self.mode, PageMode::Partial) matches!(self.mode, PageMode::Partial)
} }
pub fn page(self, partial_html: impl Into<String>, shell: impl FnOnce(String) -> String) -> PageResponse { pub fn page(
self,
partial_html: impl Into<String>,
shell: impl FnOnce(String) -> String,
) -> PageResponse {
let partial_html = partial_html.into(); let partial_html = partial_html.into();
match self.mode { match self.mode {
PageMode::Full => PageResponse::full(shell(partial_html)), PageMode::Full => PageResponse::full(shell(partial_html)),
@@ -62,14 +70,14 @@ impl PageRequest {
} }
} }
pub fn page_html( pub fn page_html<P, S>(self, partial_html: P, shell: impl FnOnce(P) -> S) -> PageResponse
self, where
partial_html: SafeHtml, P: Into<SafeHtml>,
shell: impl FnOnce(SafeHtml) -> SafeHtml, S: Into<SafeHtml>,
) -> PageResponse { {
match self.mode { match self.mode {
PageMode::Full => PageResponse::full(shell(partial_html).into_string()), PageMode::Full => PageResponse::full(shell(partial_html).into().into_string()),
PageMode::Partial => PageResponse::partial(partial_html.into_string()), PageMode::Partial => PageResponse::partial(partial_html.into().into_string()),
} }
} }
} }
@@ -145,9 +153,73 @@ pub trait DispatchRegistry {
fn dispatch_form(self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection>; fn dispatch_form(self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection>;
} }
pub trait FromInteractionForm: Sized {
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Form<T>(pub T);
impl<T> Form<T> {
pub fn into_inner(self) -> T {
self.0
}
}
impl<T> std::ops::Deref for Form<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T> FromInteractionForm for T
where
T: FromForm,
{
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError> {
T::from_form_fields(form.fields())
.map_err(|error| FormDecodeError::new(error.message().to_owned()))
}
}
impl<T> FromInteractionForm for Form<T>
where
T: FromForm,
{
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError> {
T::from_form_fields(form.fields())
.map(Self)
.map_err(|error| FormDecodeError::new(error.message().to_owned()))
}
}
pub trait FromHandlerState<S>: Sized {
fn from_handler_state(state: S) -> Self;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FormDecodeError {
message: String,
}
type SyncHandler =
Box<dyn Fn(InteractionForm) -> Result<EffectBatch, DispatchRejection> + Send + Sync>;
type HandlerFuture = Pin<Box<dyn Future<Output = Result<EffectBatch, DispatchRejection>> + Send>>;
type AsyncHandler = Box<dyn Fn(InteractionForm) -> HandlerFuture + Send + Sync>;
pub struct HandlerRegistry { pub struct HandlerRegistry {
fingerprint: BuildFingerprint, fingerprint: BuildFingerprint,
handlers: BTreeMap<u32, Box<dyn Fn(InteractionForm) -> EffectBatch + Send + Sync>>, handlers: BTreeMap<u32, SyncHandler>,
async_handlers: BTreeMap<u32, AsyncHandler>,
}
pub type Registry = HandlerRegistry;
pub struct StateHandlerRegistry<S> {
registry: HandlerRegistry,
state: S,
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@@ -160,6 +232,8 @@ pub enum InteractionFormRejection {
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum DispatchRejection { pub enum DispatchRejection {
UnknownHandle(u32), UnknownHandle(u32),
InvalidForm { handle_id: u32, message: String },
HandlerError { handle_id: u32, message: String },
} }
impl EffectResponse { impl EffectResponse {
@@ -170,6 +244,30 @@ impl EffectResponse {
} }
} }
impl<S> FromHandlerState<S> for S {
fn from_handler_state(state: S) -> Self {
state
}
}
impl<S> FromHandlerState<S> for State<S> {
fn from_handler_state(state: S) -> Self {
State(state)
}
}
impl FormDecodeError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn message(&self) -> &str {
&self.message
}
}
impl InteractionForm { impl InteractionForm {
pub fn new(handle_id: u32, fields: impl IntoIterator<Item = (String, String)>) -> Self { pub fn new(handle_id: u32, fields: impl IntoIterator<Item = (String, String)>) -> Self {
Self { Self {
@@ -179,7 +277,10 @@ impl InteractionForm {
} }
} }
pub fn for_handle<I>(handle: Handle<I>, fields: impl IntoIterator<Item = (String, String)>) -> Self { pub fn for_handle<I>(
handle: Handle<I>,
fields: impl IntoIterator<Item = (String, String)>,
) -> Self {
Self::new(handle.id().id, fields) Self::new(handle.id().id, fields)
} }
@@ -188,7 +289,9 @@ impl InteractionForm {
} }
// req: multipart/001, req: multipart/002 // req: multipart/001, req: multipart/002
pub async fn parse_multipart(mut multipart: Multipart) -> Result<Self, InteractionFormRejection> { pub async fn parse_multipart(
mut multipart: Multipart,
) -> Result<Self, InteractionFormRejection> {
let mut fields = Vec::new(); let mut fields = Vec::new();
let mut files = Vec::new(); let mut files = Vec::new();
@@ -224,7 +327,10 @@ impl InteractionForm {
Self::from_parts(fields, files) Self::from_parts(fields, files)
} }
fn from_parts(fields: Vec<(String, String)>, files: Vec<InteractionFile>) -> Result<Self, InteractionFormRejection> { fn from_parts(
fields: Vec<(String, String)>,
files: Vec<InteractionFile>,
) -> Result<Self, InteractionFormRejection> {
let Some(handle) = fields let Some(handle) = fields
.iter() .iter()
.find_map(|(name, value)| (name == SLHX_HANDLE_FIELD).then_some(value)) .find_map(|(name, value)| (name == SLHX_HANDLE_FIELD).then_some(value))
@@ -234,7 +340,11 @@ impl InteractionForm {
let handle_id = handle let handle_id = handle
.parse::<u32>() .parse::<u32>()
.map_err(|_| InteractionFormRejection::InvalidHandle)?; .map_err(|_| InteractionFormRejection::InvalidHandle)?;
Ok(Self { handle_id, fields, files }) Ok(Self {
handle_id,
fields,
files,
})
} }
pub fn value(&self, name: &str) -> Option<&str> { pub fn value(&self, name: &str) -> Option<&str> {
@@ -267,6 +377,20 @@ impl InteractionForm {
pub fn file(&self, name: &str) -> Option<&InteractionFile> { pub fn file(&self, name: &str) -> Option<&InteractionFile> {
self.files.iter().find(|file| file.name == name) self.files.iter().find(|file| file.name == name)
} }
pub fn required(&self, name: &str) -> Result<&str, FormDecodeError> {
self.value(name)
.ok_or_else(|| FormDecodeError::new(format!("missing form field `{name}`")))
}
pub fn parse_required<T>(&self, name: &str) -> Result<T, FormDecodeError>
where
T: std::str::FromStr,
{
self.required(name)?
.parse()
.map_err(|_| FormDecodeError::new(format!("invalid form field `{name}`")))
}
} }
pub const fn handlers(fingerprint: BuildFingerprint) -> HandlerRegistry { pub const fn handlers(fingerprint: BuildFingerprint) -> HandlerRegistry {
@@ -285,6 +409,13 @@ impl InteractionRequest {
registry.dispatch_form(self.form) registry.dispatch_form(self.form)
} }
pub async fn dispatch_async(
self,
registry: HandlerRegistry,
) -> Result<EffectResponse, DispatchRejection> {
registry.dispatch_async(self.form).await
}
pub fn form(&self) -> &InteractionForm { pub fn form(&self) -> &InteractionForm {
&self.form &self.form
} }
@@ -301,6 +432,7 @@ impl HandlerRegistry {
Self { Self {
fingerprint, fingerprint,
handlers: BTreeMap::new(), handlers: BTreeMap::new(),
async_handlers: BTreeMap::new(),
} }
} }
@@ -315,7 +447,240 @@ impl HandlerRegistry {
let fingerprint = self.fingerprint; let fingerprint = self.fingerprint;
self.handlers.insert( self.handlers.insert(
handle_id, handle_id,
Box::new(move |form| handler(form).into_batch(fingerprint)), Box::new(move |form| Ok(handler(form).into_batch(fingerprint))),
);
self
}
pub fn register_typed<T, E>(
mut self,
handle_id: u32,
handler: impl Fn(T) -> E + Send + Sync + 'static,
) -> Self
where
T: FromInteractionForm,
E: IntoEffect,
{
let fingerprint = self.fingerprint;
self.handlers.insert(
handle_id,
Box::new(move |form| {
let handle_id = form.handle_id;
let input = T::from_interaction_form(&form).map_err(|error| {
DispatchRejection::InvalidForm {
handle_id,
message: error.message,
}
})?;
Ok(handler(input).into_batch(fingerprint))
}),
);
self
}
pub fn register_state<S, C, E>(
mut self,
handle_id: u32,
state: S,
handler: impl Fn(C) -> E + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
E: IntoEffect,
{
let fingerprint = self.fingerprint;
self.handlers.insert(
handle_id,
Box::new(move |_| {
Ok(handler(C::from_handler_state(state.clone())).into_batch(fingerprint))
}),
);
self
}
pub fn register_state_typed<S, C, T, E>(
mut self,
handle_id: u32,
state: S,
handler: impl Fn(C, T) -> E + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
T: FromInteractionForm,
E: IntoEffect,
{
let fingerprint = self.fingerprint;
self.handlers.insert(
handle_id,
Box::new(move |form| {
let handle_id = form.handle_id;
let input = T::from_interaction_form(&form).map_err(|error| {
DispatchRejection::InvalidForm {
handle_id,
message: error.message,
}
})?;
Ok(handler(C::from_handler_state(state.clone()), input).into_batch(fingerprint))
}),
);
self
}
pub fn register_async<E, F>(
mut self,
handle_id: u32,
handler: impl Fn(InteractionForm) -> F + Send + Sync + 'static,
) -> Self
where
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
let fingerprint = self.fingerprint;
self.async_handlers.insert(
handle_id,
Box::new(move |form| {
let future = handler(form);
Box::pin(async move { Ok(future.await.into_batch(fingerprint)) })
}),
);
self
}
pub fn register_typed_async<T, E, F>(
mut self,
handle_id: u32,
handler: impl Fn(T) -> F + Send + Sync + 'static,
) -> Self
where
T: FromInteractionForm,
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
let fingerprint = self.fingerprint;
self.async_handlers.insert(
handle_id,
Box::new(move |form| {
let handle_id = form.handle_id;
let input = match T::from_interaction_form(&form) {
Ok(input) => input,
Err(error) => {
return Box::pin(async move {
Err(DispatchRejection::InvalidForm {
handle_id,
message: error.message,
})
});
}
};
let future = handler(input);
Box::pin(async move { Ok(future.await.into_batch(fingerprint)) })
}),
);
self
}
pub fn register_state_async<S, C, E, F>(
mut self,
handle_id: u32,
state: S,
handler: impl Fn(C) -> F + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
let fingerprint = self.fingerprint;
self.async_handlers.insert(
handle_id,
Box::new(move |_| {
let future = handler(C::from_handler_state(state.clone()));
Box::pin(async move { Ok(future.await.into_batch(fingerprint)) })
}),
);
self
}
pub fn register_state_typed_async<S, C, T, E, F>(
mut self,
handle_id: u32,
state: S,
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
T: FromInteractionForm,
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
let fingerprint = self.fingerprint;
self.async_handlers.insert(
handle_id,
Box::new(move |form| {
let handle_id = form.handle_id;
let input = match T::from_interaction_form(&form) {
Ok(input) => input,
Err(error) => {
return Box::pin(async move {
Err(DispatchRejection::InvalidForm {
handle_id,
message: error.message,
})
});
}
};
let future = handler(C::from_handler_state(state.clone()), input);
Box::pin(async move { Ok(future.await.into_batch(fingerprint)) })
}),
);
self
}
pub fn register_state_typed_async_result<S, C, T, E, O, F>(
mut self,
handle_id: u32,
state: S,
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
T: FromInteractionForm,
F: Future<Output = Result<O, E>> + Send + 'static,
O: IntoEffect,
E: fmt::Display,
{
let fingerprint = self.fingerprint;
self.async_handlers.insert(
handle_id,
Box::new(move |form| {
let handle_id = form.handle_id;
let input = match T::from_interaction_form(&form) {
Ok(input) => input,
Err(error) => {
return Box::pin(async move {
Err(DispatchRejection::InvalidForm {
handle_id,
message: error.message,
})
});
}
};
let future = handler(C::from_handler_state(state.clone()), input);
Box::pin(async move {
future
.await
.map(|effects| effects.into_batch(fingerprint))
.map_err(|error| DispatchRejection::HandlerError {
handle_id,
message: error.to_string(),
})
})
}),
); );
self self
} }
@@ -331,6 +696,16 @@ impl HandlerRegistry {
self.register(handle.id().id, handler) self.register(handle.id().id, handler)
} }
pub fn with_state<S>(self, state: S) -> StateHandlerRegistry<S>
where
S: Clone + Send + Sync + 'static,
{
StateHandlerRegistry {
registry: self,
state,
}
}
pub fn on<I, E>( pub fn on<I, E>(
self, self,
handle: Handle<I>, handle: Handle<I>,
@@ -342,18 +717,243 @@ impl HandlerRegistry {
self.register_handle(handle, handler) self.register_handle(handle, handler)
} }
pub fn on_form<I, T, E>(
self,
handle: Handle<I>,
handler: impl Fn(T) -> E + Send + Sync + 'static,
) -> Self
where
T: FromInteractionForm,
E: IntoEffect,
{
self.register_typed(handle.id().id, handler)
}
pub fn on_state<I, S, C, E>(
self,
handle: Handle<I>,
state: S,
handler: impl Fn(C) -> E + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
E: IntoEffect,
{
self.register_state(handle.id().id, state, handler)
}
pub fn on_state_form<I, S, C, T, E>(
self,
handle: Handle<I>,
state: S,
handler: impl Fn(C, T) -> E + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
T: FromInteractionForm,
E: IntoEffect,
{
self.register_state_typed(handle.id().id, state, handler)
}
pub fn on_async<I, E, F>(
self,
handle: Handle<I>,
handler: impl Fn(InteractionForm) -> F + Send + Sync + 'static,
) -> Self
where
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
self.register_async(handle.id().id, handler)
}
pub fn on_form_async<I, T, E, F>(
self,
handle: Handle<I>,
handler: impl Fn(T) -> F + Send + Sync + 'static,
) -> Self
where
T: FromInteractionForm,
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
self.register_typed_async(handle.id().id, handler)
}
pub fn on_state_async<I, S, C, E, F>(
self,
handle: Handle<I>,
state: S,
handler: impl Fn(C) -> F + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
self.register_state_async(handle.id().id, state, handler)
}
pub fn on_state_form_async<I, S, C, T, E, F>(
self,
handle: Handle<I>,
state: S,
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
T: FromInteractionForm,
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
self.register_state_typed_async(handle.id().id, state, handler)
}
pub fn on_state_form_async_result<I, S, C, T, E, O, F>(
self,
handle: Handle<I>,
state: S,
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
T: FromInteractionForm,
F: Future<Output = Result<O, E>> + Send + 'static,
O: IntoEffect,
E: fmt::Display,
{
self.register_state_typed_async_result(handle.id().id, state, handler)
}
pub fn dispatch(&self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection> { pub fn dispatch(&self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection> {
let handle_id = form.handle_id; let handle_id = form.handle_id;
let Some(handler) = self.handlers.get(&handle_id) else { let Some(handler) = self.handlers.get(&handle_id) else {
return Err(DispatchRejection::UnknownHandle(handle_id)); return Err(DispatchRejection::UnknownHandle(handle_id));
}; };
Ok(EffectResponse { Ok(EffectResponse {
batch: handler(form), batch: handler(form)?,
}) })
} }
pub async fn dispatch_async(
&self,
form: InteractionForm,
) -> Result<EffectResponse, DispatchRejection> {
let handle_id = form.handle_id;
if let Some(handler) = self.async_handlers.get(&handle_id) {
return Ok(EffectResponse {
batch: handler(form).await?,
});
}
self.dispatch(form)
}
pub fn contains(&self, handle_id: u32) -> bool { pub fn contains(&self, handle_id: u32) -> bool {
self.handlers.contains_key(&handle_id) self.handlers.contains_key(&handle_id) || self.async_handlers.contains_key(&handle_id)
}
}
impl<S> StateHandlerRegistry<S>
where
S: Clone + Send + Sync + 'static,
{
pub fn on_state<I, C, E>(
mut self,
handle: Handle<I>,
handler: impl Fn(C) -> E + Send + Sync + 'static,
) -> Self
where
C: FromHandlerState<S>,
E: IntoEffect,
{
self.registry = self.registry.on_state(handle, self.state.clone(), handler);
self
}
pub fn on<I, C, T, E>(
mut self,
handle: Handle<I>,
handler: impl Fn(C, T) -> E + Send + Sync + 'static,
) -> Self
where
C: FromHandlerState<S>,
T: FromInteractionForm,
E: IntoEffect,
{
self.registry = self
.registry
.on_state_form(handle, self.state.clone(), handler);
self
}
pub fn on_state_async<I, C, E, F>(
mut self,
handle: Handle<I>,
handler: impl Fn(C) -> F + Send + Sync + 'static,
) -> Self
where
C: FromHandlerState<S>,
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
self.registry = self
.registry
.on_state_async(handle, self.state.clone(), handler);
self
}
pub fn on_async<I, C, T, E, F>(
mut self,
handle: Handle<I>,
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
) -> Self
where
C: FromHandlerState<S>,
T: FromInteractionForm,
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
self.registry = self
.registry
.on_state_form_async(handle, self.state.clone(), handler);
self
}
pub fn on_async_result<I, C, T, E, O, F>(
mut self,
handle: Handle<I>,
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
) -> Self
where
C: FromHandlerState<S>,
T: FromInteractionForm,
F: Future<Output = Result<O, E>> + Send + 'static,
O: IntoEffect,
E: fmt::Display,
{
self.registry =
self.registry
.on_state_form_async_result(handle, self.state.clone(), handler);
self
}
pub fn into_registry(self) -> HandlerRegistry {
self.registry
}
}
impl<S> DispatchRegistry for StateHandlerRegistry<S>
where
S: Clone + Send + Sync + 'static,
{
fn dispatch_form(self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection> {
self.registry.dispatch_form(form)
} }
} }
@@ -424,7 +1024,10 @@ fn is_multipart(headers: &HeaderMap) -> bool {
impl PageMode { impl PageMode {
pub fn from_headers(headers: &HeaderMap) -> Self { pub fn from_headers(headers: &HeaderMap) -> Self {
match headers.get(SLHX_PARTIAL_HEADER).and_then(|value| value.to_str().ok()) { match headers
.get(SLHX_PARTIAL_HEADER)
.and_then(|value| value.to_str().ok())
{
Some("1" | "true") => Self::Partial, Some("1" | "true") => Self::Partial,
_ => Self::Full, _ => Self::Full,
} }
@@ -452,7 +1055,10 @@ impl IntoResponse for PageResponse {
.headers_mut() .headers_mut()
.insert(SLHX_FINGERPRINT_HEADER, fingerprint); .insert(SLHX_FINGERPRINT_HEADER, fingerprint);
} }
if let Some(title) = self.title.and_then(|title| HeaderValue::from_str(&title).ok()) { 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(SLHX_TITLE_HEADER, title);
} }
response response
@@ -506,6 +1112,16 @@ impl IntoResponse for DispatchRejection {
format!("unknown slhx handle id {handle_id}"), format!("unknown slhx handle id {handle_id}"),
) )
.into_response(), .into_response(),
Self::InvalidForm { handle_id, message } => (
StatusCode::BAD_REQUEST,
format!("invalid slhx 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}"),
)
.into_response(),
} }
} }
} }
@@ -535,7 +1151,7 @@ where
S: Stream<Item = Result<EffectBatch, E>> + Send + 'static, S: Stream<Item = Result<EffectBatch, E>> + Send + 'static,
E: Into<axum::BoxError>, E: Into<axum::BoxError>,
{ {
Sse::new(batches.map(|batch| batch.map(sse_event))) Sse::new(batches.map(|batch| batch.map(sse_event))).keep_alive(KeepAlive::default())
} }
pub fn sse_event(batch: EffectBatch) -> Event { pub fn sse_event(batch: EffectBatch) -> Event {
@@ -630,8 +1246,16 @@ fn hex(byte: u8) -> Option<u8> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{encode_sse_batch, html_with_root_fingerprint, sse, BuildFingerprint, InteractionForm, SLHX_SSE_EVENT}; use super::{
use axum::{body::{to_bytes, Body}, extract::FromRequest, http::{header, Request}, response::IntoResponse}; encode_sse_batch, html_with_root_fingerprint, sse, BuildFingerprint, InteractionForm,
SLHX_SSE_EVENT,
};
use axum::{
body::{to_bytes, Body},
extract::FromRequest,
http::{header, Request},
response::IntoResponse,
};
use futures_util::stream; use futures_util::stream;
use scraper::{Html, Selector}; use scraper::{Html, Selector};
use slhx_core::{EffectBatch, EFFECT_BATCH_ABI_VERSION}; use slhx_core::{EffectBatch, EFFECT_BATCH_ABI_VERSION};
+231 -28
View File
@@ -1,18 +1,50 @@
use axum::extract::State;
use axum::http::{header, HeaderMap}; use axum::http::{header, HeaderMap};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use scraper::{Html, Selector}; use scraper::{Html, Selector};
use slhx_axum::{ use slhx_axum::{
interactions, runtime_js, DispatchRejection, EffectResponse, InteractionForm, interactions, runtime_js, DispatchRejection, EffectResponse, Form, InteractionForm,
InteractionFormRejection, InteractionRequest, PageMode, PageRequest, PageResponse, InteractionFormRejection, InteractionRequest, PageMode, PageRequest, PageResponse,
SLHX_CONTENT_TYPE, SLHX_FINGERPRINT_HEADER, SLHX_PARTIAL_HEADER, SLHX_RUNTIME_CONTENT_TYPE, SLHX_CONTENT_TYPE, SLHX_FINGERPRINT_HEADER, SLHX_PARTIAL_HEADER, SLHX_RUNTIME_CONTENT_TYPE,
SLHX_TITLE_HEADER, SLHX_TITLE_HEADER,
}; };
use slhx_core::{push, BuildFingerprint, Handle, SafeHtml, Slot}; use slhx_core::{push, BuildFingerprint, Handle, IntoEffect, SafeHtml, Slot};
fn selector(value: &str) -> Selector { fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses") Selector::parse(value).expect("test selector parses")
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct ProjectId(u32);
impl std::str::FromStr for ProjectId {
type Err = std::num::ParseIntError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
value.parse().map(ProjectId)
}
}
struct OpenProject {
project_id: ProjectId,
}
impl slhx_core::FromForm for OpenProject {
fn from_form_fields(fields: &[(String, String)]) -> Result<Self, slhx_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`"));
};
Ok(Self {
project_id: value
.parse()
.map_err(|_| slhx_core::FormError::new("invalid form field `project_id`"))?,
})
}
}
#[test] #[test]
fn page_mode_detects_partial_header() { fn page_mode_detects_partial_header() {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
@@ -25,27 +57,43 @@ fn page_mode_detects_partial_header() {
#[test] #[test]
fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() { fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() {
// req: test/005 // req: test/005
let full = PageRequest { mode: PageMode::Full }.page( let full = PageRequest {
"<main data-page=\"docs\">Docs</main>", mode: PageMode::Full,
|content| format!("<html><body data-shell=\"docs\">{content}</body></html>"), }
); .page("<main data-page=\"docs\">Docs</main>", |content| {
format!("<html><body data-shell=\"docs\">{content}</body></html>")
});
assert_eq!(full.mode, PageMode::Full); assert_eq!(full.mode, PageMode::Full);
let full_document = Html::parse_document(&full.html); let full_document = Html::parse_document(&full.html);
assert_eq!( assert_eq!(
full_document full_document
.select(&selector("body[data-shell=\"docs\"] main[data-page=\"docs\"]")) .select(&selector(
"body[data-shell=\"docs\"] main[data-page=\"docs\"]"
))
.count(), .count(),
1 1
); );
let partial = PageRequest { mode: PageMode::Partial }.page( let partial = PageRequest {
"<main data-page=\"docs\">Docs</main>", mode: PageMode::Partial,
|content| format!("<html><body data-shell=\"docs\">{content}</body></html>"), }
); .page("<main data-page=\"docs\">Docs</main>", |content| {
format!("<html><body data-shell=\"docs\">{content}</body></html>")
});
assert_eq!(partial.mode, PageMode::Partial); assert_eq!(partial.mode, PageMode::Partial);
let partial_fragment = Html::parse_fragment(&partial.html); let partial_fragment = Html::parse_fragment(&partial.html);
assert_eq!(partial_fragment.select(&selector("main[data-page=\"docs\"]")).count(), 1); assert_eq!(
assert_eq!(partial_fragment.select(&selector("body[data-shell=\"docs\"]")).count(), 0); partial_fragment
.select(&selector("main[data-page=\"docs\"]"))
.count(),
1
);
assert_eq!(
partial_fragment
.select(&selector("body[data-shell=\"docs\"]"))
.count(),
0
);
} }
#[test] #[test]
@@ -66,7 +114,9 @@ fn page_request_wraps_safe_html_full_pages_and_leaves_partials_unwrapped() {
let full_document = Html::parse_document(&full.html); let full_document = Html::parse_document(&full.html);
assert_eq!( assert_eq!(
full_document full_document
.select(&selector("body[data-shell=\"docs\"] main[data-page=\"docs\"]")) .select(&selector(
"body[data-shell=\"docs\"] main[data-page=\"docs\"]"
))
.count(), .count(),
1 1
); );
@@ -84,8 +134,18 @@ fn page_request_wraps_safe_html_full_pages_and_leaves_partials_unwrapped() {
); );
assert_eq!(partial.mode, PageMode::Partial); assert_eq!(partial.mode, PageMode::Partial);
let partial_fragment = Html::parse_fragment(&partial.html); let partial_fragment = Html::parse_fragment(&partial.html);
assert_eq!(partial_fragment.select(&selector("main[data-page=\"docs\"]")).count(), 1); assert_eq!(
assert_eq!(partial_fragment.select(&selector("body[data-shell=\"docs\"]")).count(), 0); partial_fragment
.select(&selector("main[data-page=\"docs\"]"))
.count(),
1
);
assert_eq!(
partial_fragment
.select(&selector("body[data-shell=\"docs\"]"))
.count(),
0
);
} }
#[test] #[test]
@@ -94,7 +154,10 @@ fn partial_page_response_sets_partial_and_title_headers() {
.title("Docs") .title("Docs")
.into_response(); .into_response();
assert_eq!(response.headers()[header::CONTENT_TYPE], "text/html; charset=utf-8"); assert_eq!(
response.headers()[header::CONTENT_TYPE],
"text/html; charset=utf-8"
);
assert_eq!(response.headers()[SLHX_PARTIAL_HEADER], "true"); assert_eq!(response.headers()[SLHX_PARTIAL_HEADER], "true");
assert_eq!(response.headers()[SLHX_TITLE_HEADER], "Docs"); assert_eq!(response.headers()[SLHX_TITLE_HEADER], "Docs");
} }
@@ -109,8 +172,8 @@ fn effect_response_is_wire_batch_with_fingerprint_header() {
#[test] #[test]
fn interaction_form_parses_handle_and_fields() { fn interaction_form_parses_handle_and_fields() {
let form = InteractionForm::parse_urlencoded(b"__h=42&title=Hello+World&tag=a&tag=b%2Fc") let form =
.unwrap(); InteractionForm::parse_urlencoded(b"__h=42&title=Hello+World&tag=a&tag=b%2Fc").unwrap();
assert_eq!(form.handle_id, 42); assert_eq!(form.handle_id, 42);
assert_eq!(form.value("title"), Some("Hello World")); assert_eq!(form.value("title"), Some("Hello World"));
@@ -120,8 +183,8 @@ fn interaction_form_parses_handle_and_fields() {
#[test] #[test]
fn interaction_form_parses_typed_values() { fn interaction_form_parses_typed_values() {
// req: form/004 req: dx/003 // req: form/004 req: dx/003
let form = InteractionForm::parse_urlencoded(b"__h=42&count=7&bad=nope") let form =
.expect("form should parse"); InteractionForm::parse_urlencoded(b"__h=42&count=7&bad=nope").expect("form should parse");
assert_eq!(form.parse::<u32>("count"), Some(7)); assert_eq!(form.parse::<u32>("count"), Some(7));
assert_eq!(form.parse::<u32>("bad"), None); assert_eq!(form.parse::<u32>("bad"), None);
@@ -143,8 +206,7 @@ fn interaction_form_requires_numeric_handle() {
#[test] #[test]
fn interaction_form_preserves_hidden_csrf_fields_for_extractors() { fn interaction_form_preserves_hidden_csrf_fields_for_extractors() {
// req: auth/004 // req: auth/004
let form = InteractionForm::parse_urlencoded(b"__h=42&csrf_token=abc123&title=Hello") let form = InteractionForm::parse_urlencoded(b"__h=42&csrf_token=abc123&title=Hello").unwrap();
.unwrap();
assert_eq!(form.handle_id, 42); assert_eq!(form.handle_id, 42);
assert_eq!(form.value("csrf_token"), Some("abc123")); assert_eq!(form.value("csrf_token"), Some("abc123"));
@@ -159,15 +221,151 @@ fn interaction_request_dispatches_with_concise_handlers_helper() {
Vec::new(), Vec::new(),
)); ));
let response = request let response = request
.dispatch(interactions(BuildFingerprint(4)).on(Handle::<()>::new(7), |_| { .dispatch(
Slot::<String>::new(3).text("ok") interactions(BuildFingerprint(4))
})) .on(Handle::<()>::new(7), |_| Slot::<String>::new(3).text("ok")),
)
.unwrap(); .unwrap();
assert_eq!(response.batch.fingerprint, BuildFingerprint(4)); assert_eq!(response.batch.fingerprint, BuildFingerprint(4));
assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text("ok")]); assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text("ok")]);
} }
#[test]
fn interaction_request_dispatches_typed_form_inputs() {
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
let request = InteractionRequest::from(InteractionForm::for_handle(
Handle::<()>::new(8),
vec![("project_id".to_owned(), "42".to_owned())],
));
let response = request
.dispatch(
interactions(BuildFingerprint(4))
.on_form(Handle::<()>::new(8), |input: OpenProject| {
Slot::<String>::new(3).text(input.project_id.0)
}),
)
.unwrap();
assert_eq!(response.batch.fingerprint, BuildFingerprint(4));
assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text(42)]);
}
#[test]
fn interaction_request_rejects_invalid_typed_form_inputs() {
// req: axum_integration/003 req: form/004 req: canonical_authoring/004
let request = InteractionRequest::from(InteractionForm::for_handle(
Handle::<()>::new(8),
vec![("project_id".to_owned(), "nope".to_owned())],
));
let rejection = request
.dispatch(
interactions(BuildFingerprint(4))
.on_form(Handle::<()>::new(8), |input: OpenProject| {
Slot::<String>::new(3).text(input.project_id.0)
}),
)
.unwrap_err();
assert!(matches!(
rejection,
DispatchRejection::InvalidForm { handle_id: 8, .. }
));
}
#[test]
fn interaction_request_dispatches_typed_state_handlers() {
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
fn open_project(multiplier: u32, input: OpenProject) -> impl IntoEffect {
Slot::<String>::new(3).text(input.project_id.0 * multiplier)
}
let request = InteractionRequest::from(InteractionForm::for_handle(
Handle::<()>::new(9),
vec![("project_id".to_owned(), "21".to_owned())],
));
let response = request
.dispatch(
interactions(BuildFingerprint(4))
.with_state(2_u32)
.on(Handle::<()>::new(9), open_project),
)
.unwrap();
assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text(42)]);
}
#[derive(Debug)]
struct HandlerBoom;
impl std::fmt::Display for HandlerBoom {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("database unavailable")
}
}
impl std::error::Error for HandlerBoom {}
#[tokio::test]
async fn interaction_request_dispatches_async_typed_state_extractors() {
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
async fn open_project(
State(multiplier): State<u32>,
Form(input): Form<OpenProject>,
) -> impl IntoEffect {
Slot::<String>::new(3).text(input.project_id.0 * multiplier)
}
let request = InteractionRequest::from(InteractionForm::for_handle(
Handle::<()>::new(10),
vec![("project_id".to_owned(), "21".to_owned())],
));
let response = request
.dispatch_async(
interactions(BuildFingerprint(4))
.with_state(2_u32)
.on_async(Handle::<()>::new(10), open_project)
.into_registry(),
)
.await
.unwrap();
assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text(42)]);
}
#[tokio::test]
async fn interaction_request_reports_async_result_handler_errors() {
// req: axum_integration/003 req: form/004 req: failure/003
async fn open_project(
State(_multiplier): State<u32>,
Form(_input): Form<OpenProject>,
) -> Result<impl IntoEffect, HandlerBoom> {
Err::<(), _>(HandlerBoom)
}
let request = InteractionRequest::from(InteractionForm::for_handle(
Handle::<()>::new(11),
vec![("project_id".to_owned(), "21".to_owned())],
));
let error = request
.dispatch_async(
interactions(BuildFingerprint(4))
.with_state(2_u32)
.on_async_result(Handle::<()>::new(11), open_project)
.into_registry(),
)
.await
.unwrap_err();
assert_eq!(
error,
DispatchRejection::HandlerError {
handle_id: 11,
message: "database unavailable".to_owned(),
}
);
}
#[test] #[test]
fn interactions_dispatch_by_checked_handle() { fn interactions_dispatch_by_checked_handle() {
// req: ceremony/004 req: public_api/001 // req: ceremony/004 req: public_api/001
@@ -193,7 +391,9 @@ fn interactions_reject_unknown_handle_ids() {
let request = InteractionRequest::from(InteractionForm::new(9, [])); let request = InteractionRequest::from(InteractionForm::new(9, []));
assert_eq!( assert_eq!(
request.dispatch(interactions(BuildFingerprint(123))).unwrap_err(), request
.dispatch(interactions(BuildFingerprint(123)))
.unwrap_err(),
DispatchRejection::UnknownHandle(9) DispatchRejection::UnknownHandle(9)
); );
} }
@@ -202,6 +402,9 @@ fn interactions_reject_unknown_handle_ids() {
fn runtime_js_response_serves_embedded_runtime() { fn runtime_js_response_serves_embedded_runtime() {
let response = runtime_js().into_response(); let response = runtime_js().into_response();
assert_eq!(response.headers()[header::CONTENT_TYPE], SLHX_RUNTIME_CONTENT_TYPE); assert_eq!(
response.headers()[header::CONTENT_TYPE],
SLHX_RUNTIME_CONTENT_TYPE
);
assert!(response.headers().contains_key(header::CACHE_CONTROL)); assert!(response.headers().contains_key(header::CACHE_CONTROL));
} }
+614 -155
View File
File diff suppressed because it is too large Load Diff
+168 -34
View File
@@ -68,6 +68,13 @@ impl ResourceId {
} }
} }
/// A generated UI target that can be inspected without exposing raw slots.
/// req: dx/006 req: test/001
pub trait GeneratedTarget {
#[doc(hidden)]
fn __slhx_resource_id(self) -> ResourceId;
}
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] #[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub enum ScopeKey { pub enum ScopeKey {
KeyValue(String), KeyValue(String),
@@ -281,6 +288,10 @@ impl EventName {
pub const fn as_str(self) -> &'static str { pub const fn as_str(self) -> &'static str {
self.name self.name
} }
pub fn emit(self, payload: impl Into<String>) -> Effect {
event(self, payload)
}
} }
impl AsRef<str> for EventName { impl AsRef<str> for EventName {
@@ -402,19 +413,42 @@ pub enum FormControlKind {
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum Effect { pub enum Effect {
Put { target: ResourceRef, payload: Payload }, Put {
Insert { target: ResourceRef, key: String, payload: Payload }, target: ResourceRef,
Prepend { target: ResourceRef, key: String, payload: Payload }, payload: Payload,
Remove { target: ResourceRef, key: Option<String> }, },
Move { target: ResourceRef, key: String, before: Option<String> }, Insert {
Focus { target: ResourceRef }, target: ResourceRef,
key: String,
payload: Payload,
},
Prepend {
target: ResourceRef,
key: String,
payload: Payload,
},
Remove {
target: ResourceRef,
key: Option<String>,
},
Move {
target: ResourceRef,
key: String,
before: Option<String>,
},
Focus {
target: ResourceRef,
},
Navigate { Navigate {
url: String, url: String,
mode: NavigateMode, mode: NavigateMode,
scroll: ScrollBehavior, scroll: ScrollBehavior,
title: Option<String>, title: Option<String>,
}, },
Emit { name: String, payload: String }, Emit {
name: String,
payload: String,
},
} }
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
@@ -476,13 +510,21 @@ fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
write_ref(target, out); write_ref(target, out);
write_payload(payload, out); write_payload(payload, out);
} }
Effect::Insert { target, key, payload } => { Effect::Insert {
target,
key,
payload,
} => {
write_u8(1, out); write_u8(1, out);
write_ref(target, out); write_ref(target, out);
write_str(key, out); write_str(key, out);
write_payload(payload, out); write_payload(payload, out);
} }
Effect::Prepend { target, key, payload } => { Effect::Prepend {
target,
key,
payload,
} => {
write_u8(2, out); write_u8(2, out);
write_ref(target, out); write_ref(target, out);
write_str(key, out); write_str(key, out);
@@ -493,7 +535,11 @@ fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
write_ref(target, out); write_ref(target, out);
write_option_str(key.as_deref(), out); write_option_str(key.as_deref(), out);
} }
Effect::Move { target, key, before } => { Effect::Move {
target,
key,
before,
} => {
write_u8(4, out); write_u8(4, out);
write_ref(target, out); write_ref(target, out);
write_str(key, out); write_str(key, out);
@@ -503,14 +549,22 @@ fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
write_u8(5, out); write_u8(5, out);
write_ref(target, out); write_ref(target, out);
} }
Effect::Navigate { url, mode, scroll, title } => { Effect::Navigate {
url,
mode,
scroll,
title,
} => {
write_u8(6, out); write_u8(6, out);
write_str(url, out); write_str(url, out);
write_u8(match mode { write_u8(
match mode {
NavigateMode::Push => 0, NavigateMode::Push => 0,
NavigateMode::Replace => 1, NavigateMode::Replace => 1,
NavigateMode::Redirect => 2, NavigateMode::Redirect => 2,
}, out); },
out,
);
write_scroll(scroll, out); write_scroll(scroll, out);
write_option_str(title.as_deref(), out); write_option_str(title.as_deref(), out);
} }
@@ -523,12 +577,15 @@ fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
} }
fn write_ref(reference: &ResourceRef, out: &mut Vec<u8>) { fn write_ref(reference: &ResourceRef, out: &mut Vec<u8>) {
write_u8(match reference.resource.kind { write_u8(
match reference.resource.kind {
ResourceKind::Slot => 0, ResourceKind::Slot => 0,
ResourceKind::Atom => 1, ResourceKind::Atom => 1,
ResourceKind::Handle => 2, ResourceKind::Handle => 2,
ResourceKind::Form => 3, ResourceKind::Form => 3,
}, out); },
out,
);
write_u32(reference.resource.id, out); write_u32(reference.resource.id, out);
match &reference.scope { match &reference.scope {
None => write_u8(0, out), None => write_u8(0, out),
@@ -661,17 +718,41 @@ fn read_batch(bytes: &[u8]) -> Result<EffectBatch, WireError> {
ops.push(read_effect(&mut reader)?); ops.push(read_effect(&mut reader)?);
} }
reader.finish()?; reader.finish()?;
Ok(EffectBatch { abi_version, fingerprint, ops }) Ok(EffectBatch {
abi_version,
fingerprint,
ops,
})
} }
fn read_effect(reader: &mut WireReader<'_>) -> Result<Effect, WireError> { fn read_effect(reader: &mut WireReader<'_>) -> Result<Effect, WireError> {
match reader.read_u8()? { match reader.read_u8()? {
0 => Ok(Effect::Put { target: read_ref(reader)?, payload: read_payload(reader)? }), 0 => Ok(Effect::Put {
1 => Ok(Effect::Insert { target: read_ref(reader)?, key: reader.read_str()?, payload: read_payload(reader)? }), target: read_ref(reader)?,
2 => Ok(Effect::Prepend { target: read_ref(reader)?, key: reader.read_str()?, payload: read_payload(reader)? }), payload: read_payload(reader)?,
3 => Ok(Effect::Remove { target: read_ref(reader)?, key: read_option_str(reader)? }), }),
4 => Ok(Effect::Move { target: read_ref(reader)?, key: reader.read_str()?, before: read_option_str(reader)? }), 1 => Ok(Effect::Insert {
5 => Ok(Effect::Focus { target: read_ref(reader)? }), target: read_ref(reader)?,
key: reader.read_str()?,
payload: read_payload(reader)?,
}),
2 => Ok(Effect::Prepend {
target: read_ref(reader)?,
key: reader.read_str()?,
payload: read_payload(reader)?,
}),
3 => Ok(Effect::Remove {
target: read_ref(reader)?,
key: read_option_str(reader)?,
}),
4 => Ok(Effect::Move {
target: read_ref(reader)?,
key: reader.read_str()?,
before: read_option_str(reader)?,
}),
5 => Ok(Effect::Focus {
target: read_ref(reader)?,
}),
6 => Ok(Effect::Navigate { 6 => Ok(Effect::Navigate {
url: reader.read_str()?, url: reader.read_str()?,
mode: match reader.read_u8()? { mode: match reader.read_u8()? {
@@ -683,7 +764,10 @@ fn read_effect(reader: &mut WireReader<'_>) -> Result<Effect, WireError> {
scroll: read_scroll(reader)?, scroll: read_scroll(reader)?,
title: read_option_str(reader)?, title: read_option_str(reader)?,
}), }),
7 => Ok(Effect::Emit { name: reader.read_str()?, payload: reader.read_str()? }), 7 => Ok(Effect::Emit {
name: reader.read_str()?,
payload: reader.read_str()?,
}),
_ => Err(WireError::UnknownTag), _ => Err(WireError::UnknownTag),
} }
} }
@@ -758,6 +842,14 @@ impl IntoEffect for () {
fn append_to(self, _ops: &mut Vec<Effect>) {} fn append_to(self, _ops: &mut Vec<Effect>) {}
} }
impl<T: IntoEffect> IntoEffect for Option<T> {
fn append_to(self, ops: &mut Vec<Effect>) {
if let Some(effect) = self {
effect.append_to(ops);
}
}
}
macro_rules! impl_tuple_into_effect { macro_rules! impl_tuple_into_effect {
($($name:ident $idx:tt),+) => { ($($name:ident $idx:tt),+) => {
impl<$($name),+> IntoEffect for ($($name,)+) impl<$($name),+> IntoEffect for ($($name,)+)
@@ -822,10 +914,10 @@ impl<T> Slot<T> {
} }
} }
pub fn html(self, value: SafeHtml) -> Effect { pub fn html(self, value: impl Into<SafeHtml>) -> Effect {
Effect::Put { Effect::Put {
target: ResourceRef::unscoped(self.id), target: ResourceRef::unscoped(self.id),
payload: Payload::html(value), payload: Payload::html(value.into()),
} }
} }
} }
@@ -901,30 +993,30 @@ where
} }
} }
pub fn append_html(self, key: K, value: SafeHtml) -> Effect { pub fn append_html(self, key: K, value: impl Into<SafeHtml>) -> Effect {
Effect::Insert { Effect::Insert {
target: ResourceRef::unscoped(self.id), target: ResourceRef::unscoped(self.id),
key: key.to_string(), key: key.to_string(),
payload: Payload::html(value), payload: Payload::html(value.into()),
} }
} }
pub fn prepend_html(self, key: K, value: SafeHtml) -> Effect { pub fn prepend_html(self, key: K, value: impl Into<SafeHtml>) -> Effect {
Effect::Prepend { Effect::Prepend {
target: ResourceRef::unscoped(self.id), target: ResourceRef::unscoped(self.id),
key: key.to_string(), key: key.to_string(),
payload: Payload::html(value), payload: Payload::html(value.into()),
} }
} }
pub fn replace_html(self, key: K, value: SafeHtml) -> Effect { pub fn replace_html(self, key: K, value: impl Into<SafeHtml>) -> Effect {
let key = key.to_string(); let key = key.to_string();
Effect::Put { Effect::Put {
target: ResourceRef { target: ResourceRef {
resource: self.id, resource: self.id,
scope: Some(ScopeKey::KeyValue(key)), scope: Some(ScopeKey::KeyValue(key)),
}, },
payload: Payload::html(value), payload: Payload::html(value.into()),
} }
} }
@@ -1009,9 +1101,47 @@ impl<I> core::fmt::Display for Handle<I> {
} }
} }
pub trait FormValue {} #[derive(Clone, Debug, Eq, PartialEq)]
pub struct FormError {
message: String,
}
impl<T> FormValue for T where T: std::str::FromStr {} impl FormError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn message(&self) -> &str {
&self.message
}
}
impl core::fmt::Display for FormError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for FormError {}
pub trait FormValue: Sized {
fn parse_form_value(value: &str) -> Result<Self, String>;
}
impl<T> FormValue for T
where
T: std::str::FromStr,
{
fn parse_form_value(value: &str) -> Result<Self, String> {
value.parse().map_err(|_| "invalid form value".to_owned())
}
}
pub trait FromForm: Sized {
fn from_form_fields(fields: &[(String, String)]) -> Result<Self, FormError>;
}
pub trait FormModel {} pub trait FormModel {}
@@ -1065,7 +1195,11 @@ impl<T> Form<T> {
} }
} }
pub fn clear(self, field: impl Into<String>) -> Effect { pub fn clear(self) -> Effect {
self.reset()
}
pub fn clear_field(self, field: impl Into<String>) -> Effect {
Effect::Put { Effect::Put {
target: self.field(field), target: self.field(field),
payload: Payload::text(""), payload: Payload::text(""),
+14 -1
View File
@@ -28,6 +28,16 @@ fn effect_batch_wire_round_trips() {
assert!(decoded.is_compatible()); assert!(decoded.is_compatible());
} }
#[test]
fn optional_effects_compose_into_batches() {
// req: component/005 req: public_api/003
let count = Slot::<u32>::new(1);
let batch = (Some(count.text(2)), Option::<Effect>::None).into_batch(BuildFingerprint(42));
assert_eq!(batch.ops.len(), 1);
assert_eq!(batch.ops[0], count.text(2));
}
#[test] #[test]
fn keyed_slot_replace_uses_scoped_resource_ref() { fn keyed_slot_replace_uses_scoped_resource_ref() {
let todos = KeyedSlot::<u64, String>::new(9); let todos = KeyedSlot::<u64, String>::new(9);
@@ -142,7 +152,10 @@ fn generated_css_classes_join_for_hemplate_dynamic_class_attrs() {
assert_eq!(classes.as_str(), "work-card is-selected"); assert_eq!(classes.as_str(), "work-card is-selected");
assert_eq!(classes.to_string(), "work-card is-selected"); assert_eq!(classes.to_string(), "work-card is-selected");
assert_eq!(CARD.with(SELECTED).as_str(), "work-card is-selected"); assert_eq!(CARD.with(SELECTED).as_str(), "work-card is-selected");
assert_eq!(CARD.with_if(true, SELECTED).as_str(), "work-card is-selected"); assert_eq!(
CARD.with_if(true, SELECTED).as_str(),
"work-card is-selected"
);
assert_eq!(CARD.with_if(false, SELECTED).as_str(), "work-card"); assert_eq!(CARD.with_if(false, SELECTED).as_str(), "work-card");
} }
+1
View File
@@ -8,5 +8,6 @@ proc-macro = true
path = "src/lib.rs" path = "src/lib.rs"
[dependencies] [dependencies]
proc-macro2 = "1"
quote = "1" quote = "1"
syn = { version = "2", features = ["full"] } syn = { version = "2", features = ["full"] }
+487 -74
View File
@@ -1,14 +1,16 @@
use proc_macro::TokenStream; use proc_macro::TokenStream;
use quote::quote; use quote::{format_ident, quote};
use std::path::PathBuf; use std::path::PathBuf;
use syn::parse::Parser;
use syn::punctuated::Punctuated;
use syn::{ use syn::{
parse_macro_input, parse_quote, Fields, FnArg, GenericArgument, Item, ItemFn, ItemMod, parse_macro_input, parse_quote, Fields, FnArg, GenericArgument, Item, ItemFn, ItemMod,
ItemStruct, LitStr, Pat, PathArguments, ReturnType, Type, ItemStruct, LitStr, Pat, Path, PathArguments, ReturnType, Token, Type,
}; };
#[proc_macro_attribute] #[proc_macro_attribute]
pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream { pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
let mut function = parse_macro_input!(item as ItemFn); let function = parse_macro_input!(item as ItemFn);
let name = function.sig.ident.to_string(); let name = function.sig.ident.to_string();
let Some(syms_path) = syms_path() else { let Some(syms_path) = syms_path() else {
@@ -49,7 +51,7 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
} }
if handle_requires_form(&syms_path, &name) && !has_form_param(&function) { if handle_requires_form(&syms_path, &name) && !has_form_param(&function) {
let message = format!( let message = format!(
"slhx handler `{name}` handles a generated form and must accept slhx::Form<_>" "slhx handler `{name}` handles a generated form and must accept a typed form argument"
); );
return quote!( return quote!(
#function #function
@@ -57,9 +59,6 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
) )
.into(); .into();
} }
if handle_requires_form(&syms_path, &name) {
add_form_model_bounds(&mut function);
}
let missing_params = missing_handle_params(&syms_path, &name, &function); let missing_params = missing_handle_params(&syms_path, &name, &function);
if !missing_params.is_empty() { if !missing_params.is_empty() {
let message = format!( let message = format!(
@@ -96,15 +95,29 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
let errors = form_contract_errors(&syms_path, &form_name, &form_struct); let errors = form_contract_errors(&syms_path, &form_name, &form_struct);
if errors.is_empty() { if errors.is_empty() {
let ident = &form_struct.ident; let ident = &form_struct.ident;
let resource_id = form_resource_id(&syms_path, &form_name).expect("checked form exists in slhx.syms"); let resource_id =
form_resource_id(&syms_path, &form_name).expect("checked form exists in slhx.syms");
let mut generics = form_struct.generics.clone(); let mut generics = form_struct.generics.clone();
for ty in form_parser_types(&form_struct) { for ty in form_parser_types(&form_struct) {
generics.make_where_clause().predicates.push(parse_quote!(#ty: ::slhx::FormValue)); generics
.make_where_clause()
.predicates
.push(parse_quote!(#ty: ::slhx::FormValue));
} }
let decode_fields = form_decode_fields(&syms_path, &form_name, &form_struct);
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
quote!( quote!(
#form_struct #form_struct
impl #impl_generics ::slhx::FormModel for #ident #ty_generics #where_clause {} impl #impl_generics ::slhx::FormModel for #ident #ty_generics #where_clause {}
impl #impl_generics ::slhx::FromForm for #ident #ty_generics #where_clause {
fn from_form_fields(
__slhx_fields: &[(String, String)],
) -> Result<Self, ::slhx::FormError> {
Ok(Self {
#(#decode_fields),*
})
}
}
impl #impl_generics #ident #ty_generics #where_clause { impl #impl_generics #ident #ty_generics #where_clause {
pub const FORM: ::slhx::Form<Self> = ::slhx::Form::new(#resource_id); pub const FORM: ::slhx::Form<Self> = ::slhx::Form::new(#resource_id);
} }
@@ -142,12 +155,9 @@ pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream {
return quote!(#module).into(); return quote!(#module).into();
} }
let component_filter = component_name.as_deref(); let component_filter = component_name.as_deref();
let missing = missing_component_handlers(&syms_path, component_filter, items); let errors = component_contract_errors(&syms_path, component_filter, items);
if !missing.is_empty() { if !errors.is_empty() {
let message = format!( let message = errors.join("; ");
"#[slhx::component] missing handler implementation(s): {}",
missing.join(", ")
);
return quote!( return quote!(
#module #module
compile_error!(#message); compile_error!(#message);
@@ -155,12 +165,25 @@ pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream {
.into(); .into();
} }
let module = match component_name.as_deref() {
Some(component) => add_component_register_helper(module, component),
None => module,
};
quote!(#module).into() quote!(#module).into()
} }
#[proc_macro_attribute] #[proc_macro_attribute]
pub fn app(_attr: TokenStream, item: TokenStream) -> TokenStream { pub fn app(attr: TokenStream, item: TokenStream) -> TokenStream {
item let components = match Punctuated::<Path, Token![,]>::parse_terminated.parse(attr) {
Ok(components) => components.into_iter().collect::<Vec<_>>(),
Err(error) => return error.to_compile_error().into(),
};
let function = parse_macro_input!(item as ItemFn);
match add_app_registry_helper(function, components) {
Ok(function) => quote!(#function).into(),
Err(message) => quote!(compile_error!(#message);).into(),
}
} }
fn inject_surface_include(item: TokenStream) -> TokenStream { fn inject_surface_include(item: TokenStream) -> TokenStream {
@@ -205,14 +228,7 @@ fn generated_path(file: &str) -> Option<PathBuf> {
} }
fn has_form_param(function: &ItemFn) -> bool { fn has_form_param(function: &ItemFn) -> bool {
function.sig.inputs.iter().any(|arg| match arg { handler_form_model_type(function).is_some()
FnArg::Typed(arg) => is_form_type(&arg.ty),
FnArg::Receiver(_) => false,
})
}
fn is_form_type(ty: &Type) -> bool {
form_model_type(ty).is_some()
} }
fn form_model_type(ty: &Type) -> Option<Type> { fn form_model_type(ty: &Type) -> Option<Type> {
@@ -221,7 +237,11 @@ fn form_model_type(ty: &Type) -> Option<Type> {
}; };
let mut segments = path.path.segments.iter(); let mut segments = path.path.segments.iter();
let first = segments.next()?; let first = segments.next()?;
let last = path.path.segments.last().expect("path has at least one segment"); let last = path
.path
.segments
.last()
.expect("path has at least one segment");
let path_is_form = if path.path.segments.len() == 1 { let path_is_form = if path.path.segments.len() == 1 {
first.ident == "Form" first.ident == "Form"
} else { } else {
@@ -239,29 +259,26 @@ fn form_model_type(ty: &Type) -> Option<Type> {
}) })
} }
fn form_model_types(function: &ItemFn) -> Vec<Type> { fn handler_form_model_type(function: &ItemFn) -> Option<Type> {
function function.sig.inputs.iter().rev().find_map(|arg| match arg {
.sig
.inputs
.iter()
.filter_map(|arg| match arg {
FnArg::Typed(arg) => form_model_type(&arg.ty), FnArg::Typed(arg) => form_model_type(&arg.ty),
FnArg::Receiver(_) => None, FnArg::Receiver(_) => None,
}) })
.collect()
}
fn add_form_model_bounds(function: &mut ItemFn) {
for ty in form_model_types(function) {
let where_clause = function.sig.generics.make_where_clause();
where_clause.predicates.push(parse_quote!(#ty: ::slhx::FormModel));
}
} }
fn has_non_unit_return(function: &ItemFn) -> bool { fn has_non_unit_return(function: &ItemFn) -> bool {
match &function.sig.output { match &function.sig.output {
ReturnType::Default => false, ReturnType::Default => false,
ReturnType::Type(_, ty) => !matches!(ty.as_ref(), Type::Tuple(tuple) if tuple.elems.is_empty()), ReturnType::Type(_, ty) => {
!matches!(ty.as_ref(), Type::Tuple(tuple) if tuple.elems.is_empty())
}
}
}
fn returns_result(output: &ReturnType) -> bool {
match output {
ReturnType::Type(_, ty) => is_type_named(ty, "Result"),
ReturnType::Default => false,
} }
} }
@@ -272,7 +289,9 @@ fn syms_contains_handle(path: &PathBuf, ident: &str) -> bool {
syms.lines().any(|line| { syms.lines().any(|line| {
let mut fields = line.split('\t'); let mut fields = line.split('\t');
matches!(fields.next(), Some("handle")) matches!(fields.next(), Some("handle"))
&& fields.nth(1).is_some_and(|handle_ident| handle_ident == ident) && fields
.nth(1)
.is_some_and(|handle_ident| handle_ident == ident)
}) })
} }
@@ -283,7 +302,9 @@ fn handle_requires_form(path: &PathBuf, ident: &str) -> bool {
syms.lines().any(|line| { syms.lines().any(|line| {
let mut fields = line.split('\t'); let mut fields = line.split('\t');
matches!(fields.next(), Some("handle_form")) matches!(fields.next(), Some("handle_form"))
&& fields.next().is_some_and(|handle_ident| handle_ident == ident) && fields
.next()
.is_some_and(|handle_ident| handle_ident == ident)
}) })
} }
@@ -295,7 +316,11 @@ struct GeneratedFormField {
multiple: bool, multiple: bool,
} }
fn form_contract_errors(syms_path: &PathBuf, form_name: &str, form_struct: &ItemStruct) -> Vec<String> { fn form_contract_errors(
syms_path: &PathBuf,
form_name: &str,
form_struct: &ItemStruct,
) -> Vec<String> {
if !syms_path.exists() { if !syms_path.exists() {
return vec![ return vec![
"#[slhx::form] could not find generated slhx symbols; add slhx_build::app().run()? to build.rs or check template generation" "#[slhx::form] could not find generated slhx symbols; add slhx_build::app().run()? to build.rs or check template generation"
@@ -316,7 +341,12 @@ fn form_contract_errors(syms_path: &PathBuf, form_name: &str, form_struct: &Item
let actual = fields let actual = fields
.named .named
.iter() .iter()
.filter_map(|field| field.ident.as_ref().map(|ident| (ident.to_string(), &field.ty))) .filter_map(|field| {
field
.ident
.as_ref()
.map(|ident| (ident.to_string(), &field.ty))
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut errors = Vec::new(); let mut errors = Vec::new();
for field in expected { for field in expected {
@@ -335,12 +365,6 @@ fn form_contract_errors(syms_path: &PathBuf, form_name: &str, form_struct: &Item
field.ident field.ident
)); ));
} }
if !field.required && !optional && !multiple {
errors.push(format!(
"slhx form `{form_name}` field `{}` is optional in HTML and must be Option<_>",
field.ident
));
}
if field.multiple && !multiple { if field.multiple && !multiple {
errors.push(format!( errors.push(format!(
"slhx form `{form_name}` field `{}` accepts multiple values and must be Vec<_>", "slhx form `{form_name}` field `{}` accepts multiple values and must be Vec<_>",
@@ -368,6 +392,74 @@ fn form_parser_types(form_struct: &ItemStruct) -> Vec<Type> {
.collect() .collect()
} }
fn form_decode_fields(
syms_path: &PathBuf,
form_name: &str,
form_struct: &ItemStruct,
) -> Vec<proc_macro2::TokenStream> {
let Fields::Named(fields) = &form_struct.fields else {
return Vec::new();
};
let actual = fields
.named
.iter()
.filter_map(|field| field.ident.as_ref().map(|ident| (ident, &field.ty)))
.collect::<Vec<_>>();
form_fields(syms_path, form_name)
.into_iter()
.filter_map(|field| {
let (ident, ty) = actual.iter().find(|(ident, _)| ident == &&field.ident)?;
let control_name = field.name;
let parser = parser_type(ty);
Some(if field.multiple {
quote! {
#ident: __slhx_fields
.iter()
.filter_map(|(__slhx_name, __slhx_value)|
(__slhx_name == #control_name).then_some(__slhx_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)))
})
.collect::<Result<Vec<_>, _>>()?
}
} else if is_type_named(ty, "Option") {
quote! {
#ident: match __slhx_fields
.iter()
.find_map(|(__slhx_name, __slhx_value)|
(__slhx_name == #control_name).then_some(__slhx_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)))?
),
None => None,
}
}
} else {
quote! {
#ident: {
let Some(__slhx_value) = __slhx_fields
.iter()
.find_map(|(__slhx_name, __slhx_value)|
(__slhx_name == #control_name).then_some(__slhx_value.as_str())
)
else {
return Err(::slhx::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)))?
}
}
})
})
.collect()
}
fn parser_type(ty: &Type) -> &Type { fn parser_type(ty: &Type) -> &Type {
generic_inner_type(ty, "Option") generic_inner_type(ty, "Option")
.or_else(|| generic_inner_type(ty, "Vec")) .or_else(|| generic_inner_type(ty, "Vec"))
@@ -431,7 +523,11 @@ fn form_fields(path: &PathBuf, form_name: &str) -> Vec<GeneratedFormField> {
fn is_type_named(ty: &Type, name: &str) -> bool { fn is_type_named(ty: &Type, name: &str) -> bool {
match ty { match ty {
Type::Path(path) => path.path.segments.last().is_some_and(|segment| segment.ident == name), Type::Path(path) => path
.path
.segments
.last()
.is_some_and(|segment| segment.ident == name),
_ => false, _ => false,
} }
} }
@@ -497,19 +593,235 @@ fn handler_arg_names(function: &ItemFn) -> Vec<String> {
.collect() .collect()
} }
fn missing_component_handlers(path: &PathBuf, component: Option<&str>, items: &[Item]) -> 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());
}
let Some(state) = function.sig.inputs.iter().find_map(|arg| match arg {
FnArg::Typed(arg) => match arg.pat.as_ref() {
Pat::Ident(ident) => Some(ident.ident.clone()),
_ => None,
},
FnArg::Receiver(_) => None,
}) else {
return Err(
"#[slhx::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 __slhx_registry = #components::register_with_state(
__slhx_registry,
::slhx_axum::State(#state.clone()),
);
)*
__slhx_registry
}))
.expect("generated app registry helper parses");
Ok(function)
}
struct ComponentHandler {
ident: syn::Ident,
is_async: bool,
returns_result: bool,
typed_arg_count: usize,
}
fn add_component_register_helper(mut module: ItemMod, component: &str) -> ItemMod {
let Some((_, items)) = &mut module.content else {
return module;
};
if items.iter().any(|item| match item {
Item::Fn(function) => {
function.sig.ident == "register" || function.sig.ident == "register_with_state"
}
_ => false,
}) {
return module;
}
let component_ident = format_ident!("{}", component);
let handlers = component_handler_idents(items);
let Some(state_ty) = component_state_type(items) else {
return module;
};
if handlers.is_empty() {
return module;
}
let calls = handlers.iter().map(|handler| {
let ident = &handler.ident;
if handler.typed_arg_count == 1 && handler.is_async {
quote!(.on_state_async(super::#component_ident::#ident, #ident))
} else if handler.typed_arg_count == 1 {
quote!(.on_state(super::#component_ident::#ident, #ident))
} else if handler.is_async && handler.returns_result {
quote!(.on_async_result(super::#component_ident::#ident, #ident))
} else if handler.is_async {
quote!(.on_async(super::#component_ident::#ident, #ident))
} else {
quote!(.on(super::#component_ident::#ident, #ident))
}
});
let register: Item = syn::parse2(quote! {
pub fn register(
registry: ::slhx_axum::StateHandlerRegistry<#state_ty>,
) -> ::slhx_axum::StateHandlerRegistry<#state_ty>
where
#state_ty: Clone + Send + Sync + 'static,
{
registry #(#calls)*
}
})
.expect("generated component register helper parses");
let calls = handlers.iter().map(|handler| {
let ident = &handler.ident;
if handler.typed_arg_count == 1 && handler.is_async {
quote!(.on_state_async(super::#component_ident::#ident, #ident))
} else if handler.typed_arg_count == 1 {
quote!(.on_state(super::#component_ident::#ident, #ident))
} else if handler.is_async && handler.returns_result {
quote!(.on_async_result(super::#component_ident::#ident, #ident))
} else if handler.is_async {
quote!(.on_async(super::#component_ident::#ident, #ident))
} else {
quote!(.on(super::#component_ident::#ident, #ident))
}
});
let register_with_state: Item = syn::parse2(quote! {
pub fn register_with_state(
registry: ::slhx_axum::HandlerRegistry,
state: #state_ty,
) -> ::slhx_axum::HandlerRegistry
where
#state_ty: Clone + Send + Sync + 'static,
{
registry
.with_state(state)
#(#calls)*
.into_registry()
}
})
.expect("generated component state register helper parses");
items.push(register);
items.push(register_with_state);
module
}
fn component_contract_errors(
path: &PathBuf,
component: Option<&str>,
items: &[Item],
) -> Vec<String> {
let generated = syms_handles(path, component);
let implemented = component_handler_names(items); let implemented = component_handler_names(items);
syms_handles(path, component) let mut errors = Vec::new();
.into_iter()
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",
component.unwrap()
));
}
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",
ambiguous.join(", ")
));
}
let missing = generated
.iter()
.filter(|handle| !implemented.contains(handle)) .filter(|handle| !implemented.contains(handle))
.cloned()
.collect::<Vec<_>>();
if !missing.is_empty() {
errors.push(format!(
"#[slhx::component] missing handler implementation(s): {}",
missing.join(", ")
));
}
let extras = implemented
.iter()
.filter(|handler| !generated.contains(handler))
.cloned()
.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",
extras.join(", ")
));
}
errors
}
#[cfg(test)]
fn missing_component_handlers(
path: &PathBuf,
component: Option<&str>,
items: &[Item],
) -> Vec<String> {
component_contract_errors(path, component, items)
.into_iter()
.find_map(|error| {
error
.strip_prefix("#[slhx::component] missing handler implementation(s): ")
.map(|missing| missing.split(", ").map(ToOwned::to_owned).collect())
})
.unwrap_or_default()
}
fn duplicate_names(names: &[String]) -> Vec<String> {
let mut counts = std::collections::BTreeMap::<&str, usize>::new();
for name in names {
*counts.entry(name.as_str()).or_default() += 1;
}
counts
.into_iter()
.filter_map(|(name, count)| (count > 1).then(|| name.to_owned()))
.collect() .collect()
} }
fn component_state_type(items: &[Item]) -> Option<Type> {
items.iter().find_map(|item| match item {
Item::Fn(function) if has_handler_attr(function) => {
function.sig.inputs.iter().find_map(|arg| match arg {
FnArg::Typed(arg) => Some((*arg.ty).clone()),
FnArg::Receiver(_) => None,
})
}
_ => None,
})
}
fn component_handler_names(items: &[Item]) -> Vec<String> { fn component_handler_names(items: &[Item]) -> Vec<String> {
component_handler_idents(items)
.into_iter()
.map(|handler| handler.ident.to_string())
.collect()
}
fn component_handler_idents(items: &[Item]) -> Vec<ComponentHandler> {
items items
.iter() .iter()
.filter_map(|item| match item { .filter_map(|item| match item {
Item::Fn(function) if has_handler_attr(function) => Some(function.sig.ident.to_string()), Item::Fn(function) if has_handler_attr(function) => Some(ComponentHandler {
ident: function.sig.ident.clone(),
is_async: function.sig.asyncness.is_some(),
returns_result: returns_result(&function.sig.output),
typed_arg_count: function
.sig
.inputs
.iter()
.filter(|arg| matches!(arg, FnArg::Typed(_)))
.count(),
}),
_ => None, _ => None,
}) })
.collect() .collect()
@@ -560,7 +872,11 @@ fn compile_error(message: &str) -> TokenStream {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{component_handler_names, handle_params, handle_requires_form, has_form_param, has_non_unit_return, is_form_type, missing_component_handlers, missing_handle_params, parser_type, syms_contains_handle}; use super::{
add_app_registry_helper, add_component_register_helper, component_handler_names,
form_model_type, handle_params, handle_requires_form, has_form_param, has_non_unit_return,
missing_component_handlers, missing_handle_params, parser_type, syms_contains_handle,
};
use quote::quote; use quote::quote;
use syn::{parse_quote, ItemFn, Type}; use syn::{parse_quote, ItemFn, Type};
@@ -586,9 +902,17 @@ mod tests {
#[test] #[test]
fn handler_shape_accepts_form_or_effect_return() { fn handler_shape_accepts_form_or_effect_return() {
// req: derive_handler/001 // req: derive_handler/001
let with_form = parse_quote!(fn save(form: slhx::Form<String>) {}); let with_form = parse_quote!(
let with_return = parse_quote!(fn ping() -> impl slhx::IntoEffect { slhx::EffectBatch::default() }); fn save(form: slhx::Form<String>) {}
let empty = parse_quote!(fn noop() {}); );
let with_return = parse_quote!(
fn ping() -> impl slhx::IntoEffect {
slhx::advanced::EffectBatch::default()
}
);
let empty = parse_quote!(
fn noop() {}
);
assert!(has_form_param(&with_form)); assert!(has_form_param(&with_form));
assert!(has_non_unit_return(&with_return)); assert!(has_non_unit_return(&with_return));
@@ -607,9 +931,18 @@ mod tests {
let optional_parser = parser_type(&optional); let optional_parser = parser_type(&optional);
let multiple_parser = parser_type(&multiple); let multiple_parser = parser_type(&multiple);
assert_eq!(quote!(#required_parser).to_string(), quote!(#required).to_string()); assert_eq!(
assert_eq!(quote!(#optional_parser).to_string(), quote!(#required).to_string()); quote!(#required_parser).to_string(),
assert_eq!(quote!(#multiple_parser).to_string(), quote!(#required).to_string()); quote!(#required).to_string()
);
assert_eq!(
quote!(#optional_parser).to_string(),
quote!(#required).to_string()
);
assert_eq!(
quote!(#multiple_parser).to_string(),
quote!(#required).to_string()
);
} }
#[test] #[test]
@@ -621,20 +954,31 @@ mod tests {
let nongeneric_impostor: Type = parse_quote!(Form); let nongeneric_impostor: Type = parse_quote!(Form);
let foreign_form: Type = parse_quote!(other::Form<CreateTodo>); let foreign_form: Type = parse_quote!(other::Form<CreateTodo>);
assert!(is_form_type(&qualified_form)); assert!(form_model_type(&qualified_form).is_some());
assert!(is_form_type(&imported_form)); assert!(form_model_type(&imported_form).is_some());
assert!(!is_form_type(&name_suffix_impostor)); assert!(form_model_type(&name_suffix_impostor).is_none());
assert!(!is_form_type(&nongeneric_impostor)); assert!(form_model_type(&nongeneric_impostor).is_none());
assert!(!is_form_type(&foreign_form)); assert!(form_model_type(&foreign_form).is_none());
} }
#[test] #[test]
fn handler_params_match_generated_param_names() { 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("slhx-derive-param-test.syms");
std::fs::write(&path, "slhx-syms-v1\nhandle_param\tshow\ttodo_id\nhandle_param\tshow\tmode\n") std::fs::write(
&path,
"slhx-syms-v1\nhandle_param\tshow\ttodo_id\nhandle_param\tshow\tmode\n",
)
.unwrap(); .unwrap();
let complete: ItemFn = parse_quote!(fn show(todo_id: String, mode: String) -> impl slhx::IntoEffect { slhx::EffectBatch::default() }); let complete: ItemFn = parse_quote!(
let missing: ItemFn = parse_quote!(fn show(todo_id: String) -> impl slhx::IntoEffect { slhx::EffectBatch::default() }); fn show(todo_id: String, mode: String) -> impl slhx::IntoEffect {
slhx::advanced::EffectBatch::default()
}
);
let missing: ItemFn = parse_quote!(
fn show(todo_id: String) -> impl slhx::IntoEffect {
slhx::advanced::EffectBatch::default()
}
);
assert!(missing_handle_params(&path, "show", &complete).is_empty()); assert!(missing_handle_params(&path, "show", &complete).is_empty());
assert_eq!(missing_handle_params(&path, "show", &missing), vec!["mode"]); assert_eq!(missing_handle_params(&path, "show", &missing), vec!["mode"]);
@@ -653,15 +997,84 @@ mod tests {
let module: syn::ItemMod = parse_quote! { let module: syn::ItemMod = parse_quote! {
mod component { mod component {
#[slhx::handler] #[slhx::handler]
fn create() -> impl slhx::IntoEffect { slhx::EffectBatch::default() } fn create() -> impl slhx::IntoEffect { slhx::advanced::EffectBatch::default() }
} }
}; };
let (_, items) = module.content.expect("inline module"); let (_, items) = module.content.expect("inline module");
assert_eq!(component_handler_names(&items), vec!["create"]); assert_eq!(component_handler_names(&items), vec!["create"]);
assert_eq!(missing_component_handlers(&path, None, &items), vec!["delete", "archive"]); assert_eq!(
assert_eq!(missing_component_handlers(&path, Some("a"), &items), vec!["delete"]); missing_component_handlers(&path, None, &items),
vec!["delete", "archive"]
);
assert_eq!(
missing_component_handlers(&path, Some("a"), &items),
vec!["delete"]
);
let _ = std::fs::remove_file(path); let _ = std::fs::remove_file(path);
} }
#[test]
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)
}
};
let function = add_app_registry_helper(
function,
vec![parse_quote!(counter_handlers), parse_quote!(todo_handlers)],
)
.unwrap();
let generated = quote!(#function).to_string();
assert!(
generated.contains("counter_handlers :: register_with_state"),
"{generated}"
);
assert!(
generated.contains("todo_handlers :: register_with_state"),
"{generated}"
);
assert!(generated.contains("slhx_axum :: State"), "{generated}");
assert!(generated.contains("state . clone"), "{generated}");
}
#[test]
fn component_macro_generates_registration_helpers() {
// 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("")
}
#[slhx::handler]
async fn increment(app: super::App) -> impl slhx::IntoEffect {
slhx::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(""))
}
}
};
let module = add_component_register_helper(module, "todos");
let generated = quote!(#module).to_string();
assert!(generated.contains("register_with_state"), "{generated}");
assert!(generated.contains("StateHandlerRegistry"), "{generated}");
assert!(
generated.contains("super :: todos :: create"),
"{generated}"
);
assert!(generated.contains(". on"), "{generated}");
assert!(generated.contains(". on_state_async"), "{generated}");
assert!(generated.contains(". on_async_result"), "{generated}");
}
} }
+238 -173
View File
@@ -40,7 +40,7 @@ slhx = {{ path = {:?} }}
mod todos { mod todos {
#[slhx::handler] #[slhx::handler]
fn create() -> impl slhx::IntoEffect { fn create() -> impl slhx::IntoEffect {
slhx::EffectBatch::default() slhx::advanced::EffectBatch::default()
} }
} }
"#, "#,
@@ -95,7 +95,7 @@ slhx = {{ path = {:?} }}
mod todos { mod todos {
#[slhx::handler] #[slhx::handler]
fn create() -> impl slhx::IntoEffect { fn create() -> impl slhx::IntoEffect {
slhx::event("created", "") slhx::EventName::new("created").emit("")
} }
} }
"#, "#,
@@ -110,6 +110,176 @@ 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");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-component-extra-handler-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
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",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[slhx::component("todos")]
mod handlers {
#[slhx::handler]
fn create() -> impl slhx::IntoEffect {
slhx::EventName::new("created").emit("")
}
#[slhx::handler]
fn delete() -> impl slhx::IntoEffect {
slhx::EventName::new("deleted").emit("")
}
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("handler(s) not declared by this component's generated handles: delete"),
"missing scoped extra-handler diagnostic in stderr:\n{stderr}"
);
}
#[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");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-component-unknown-scope-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
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",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[slhx::component("todos")]
mod handlers {
#[slhx::handler]
fn create() -> impl slhx::IntoEffect {
slhx::EventName::new("created").emit("")
}
}
"#,
);
let output = check_fixture(&fixture);
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"),
"missing unknown component diagnostic in stderr:\n{stderr}"
);
}
#[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");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-component-ambiguous-handle-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
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",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[slhx::component("todos")]
mod handlers {
#[slhx::handler]
fn save() -> impl slhx::IntoEffect {
slhx::EventName::new("saved").emit("")
}
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("ambiguous generated handle name(s): save"),
"missing ambiguous-handle diagnostic in stderr:\n{stderr}"
);
}
#[test] #[test]
fn handler_macro_reports_unknown_handle_and_bad_shape() { fn handler_macro_reports_unknown_handle_and_bad_shape() {
// req: derive_handler/001 req: test/003 // req: derive_handler/001 req: test/003
@@ -147,7 +317,7 @@ slhx = {{ path = {:?} }}
"src/lib.rs", "src/lib.rs",
r#"#[slhx::handler] r#"#[slhx::handler]
fn missing() -> impl slhx::IntoEffect { fn missing() -> impl slhx::IntoEffect {
slhx::EffectBatch::default() slhx::advanced::EffectBatch::default()
} }
#[slhx::handler] #[slhx::handler]
@@ -201,7 +371,7 @@ slhx = {{ path = {:?} }}
"src/lib.rs", "src/lib.rs",
r#"#[slhx::handler] r#"#[slhx::handler]
fn create() -> impl slhx::IntoEffect { fn create() -> impl slhx::IntoEffect {
slhx::EffectBatch::default() slhx::advanced::EffectBatch::default()
} }
"#, "#,
); );
@@ -261,7 +431,7 @@ slhx = {{ path = {:?} }}
"src/lib.rs", "src/lib.rs",
r#"#[slhx::handler] r#"#[slhx::handler]
fn show(todo_id: String) -> impl slhx::IntoEffect { fn show(todo_id: String) -> impl slhx::IntoEffect {
slhx::EffectBatch::default() slhx::advanced::EffectBatch::default()
} }
"#, "#,
); );
@@ -479,7 +649,7 @@ struct CreateTodo {
#[slhx::handler] #[slhx::handler]
fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect { fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
slhx::event("created", "") slhx::EventName::new("created").emit("")
} }
fn smoke() { fn smoke() {
@@ -497,62 +667,6 @@ fn smoke() {
); );
} }
#[test]
fn form_handle_requires_checked_form_model() {
// req: form/001 req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-handler-unchecked-model-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-handler-unchecked-model-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
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",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"struct CreateTodo {
title: String,
}
#[slhx::handler]
fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
slhx::event("created", "")
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("CreateTodo: FormModel") || stderr.contains("CreateTodo: slhx::FormModel"),
"missing checked form model diagnostic in stderr:\n{stderr}"
);
}
#[test] #[test]
fn form_handle_requires_form_parameter() { fn form_handle_requires_form_parameter() {
// req: form/004 req: form/006 req: test/003 // req: form/004 req: form/006 req: test/003
@@ -590,7 +704,7 @@ slhx = {{ path = {:?} }}
"src/lib.rs", "src/lib.rs",
r#"#[slhx::handler] r#"#[slhx::handler]
fn create() -> impl slhx::IntoEffect { fn create() -> impl slhx::IntoEffect {
slhx::EffectBatch::default() slhx::advanced::EffectBatch::default()
} }
"#, "#,
); );
@@ -600,11 +714,70 @@ fn create() -> impl slhx::IntoEffect {
assert!(!output.status.success(), "fixture unexpectedly compiled"); assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
assert!( assert!(
stderr.contains("slhx handler `create` handles a generated form and must accept slhx::Form<_>"), stderr.contains(
"slhx handler `create` handles a generated form and must accept a typed form argument"
),
"missing form-handler diagnostic in stderr:\n{stderr}" "missing form-handler diagnostic in stderr:\n{stderr}"
); );
} }
#[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");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-handler-state-only-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
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",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"struct App;
struct State<T>(T);
#[slhx::handler]
fn create(_state: State<App>) -> impl slhx::IntoEffect {
slhx::advanced::EffectBatch::default()
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
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"
),
"missing state-only form-handler diagnostic in stderr:\n{stderr}"
);
}
#[test] #[test]
fn form_handle_still_requires_generated_param_arguments() { fn form_handle_still_requires_generated_param_arguments() {
// req: form/006 req: derive_handler/003 req: test/003 // req: form/006 req: derive_handler/003 req: test/003
@@ -644,7 +817,7 @@ slhx = {{ path = {:?} }}
#[slhx::handler] #[slhx::handler]
fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect { fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
slhx::EffectBatch::default() slhx::advanced::EffectBatch::default()
} }
"#, "#,
); );
@@ -659,114 +832,6 @@ fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
); );
} }
#[test]
fn form_handle_rejects_nongeneric_form_impostor() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-handler-nongeneric-impostor-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-handler-nongeneric-impostor-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
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",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"struct Form;
#[slhx::handler]
fn create(_form: Form) -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("slhx handler `create` handles a generated form and must accept slhx::Form<_>"),
"missing nongeneric form diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn form_handle_rejects_form_name_suffix_impostor() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-impostor-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-impostor-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
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",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"struct CreateForm;
#[slhx::handler]
fn create(_form: CreateForm) -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("slhx handler `create` handles a generated form and must accept slhx::Form<_>"),
"missing form-impostor diagnostic in stderr:\n{stderr}"
);
}
#[test] #[test]
fn surface_macro_reports_missing_generated_include() { fn surface_macro_reports_missing_generated_include() {
// req: build/004 req: test/003 // req: build/004 req: test/003
+22 -13
View File
@@ -134,9 +134,10 @@
} }
} }
function formDataFor(el, eventName) { function formDataFor(el, eventName, source = el) {
const form = formOwner(el); const form = formOwner(el);
const data = form ? new FormData(form) : new FormData(); const data = form ? new FormData(form) : new FormData();
if (form && source && source !== form && source.name && !source.disabled) data.append(source.name, source.value);
const id = handleId(el) || formHandleId(form); const id = handleId(el) || formHandleId(form);
if (id && !data.has("__h")) data.set("__h", id); if (id && !data.has("__h")) data.set("__h", id);
const dragKey = eventName === "drop" && dragKeys.get(rootOf(el)); const dragKey = eventName === "drop" && dragKeys.get(rootOf(el));
@@ -168,9 +169,9 @@
return url.href; return url.href;
} }
async function send(el, eventName) { async function send(el, eventName, source = el) {
if (el.getAttribute("data-slhx-confirm") && !confirm(el.getAttribute("data-slhx-confirm"))) return; if (el.getAttribute("data-slhx-confirm") && !confirm(el.getAttribute("data-slhx-confirm"))) return;
const { form, data, multipart } = formDataFor(el, eventName); const { form, data, multipart } = formDataFor(el, eventName, source);
const target = form || el; const target = form || el;
const policy = requestPolicy(target, eventName); const policy = requestPolicy(target, eventName);
const active = pending.get(target); const active = pending.get(target);
@@ -182,7 +183,7 @@
if (active && policy === "queue") { if (active && policy === "queue") {
const base = queues.get(target) || active.done; const base = queues.get(target) || active.done;
let queued; let queued;
const next = base.then(() => send(el, eventName)); const next = base.then(() => send(el, eventName, source));
queued = next.catch(() => {}).finally(() => { queued = next.catch(() => {}).finally(() => {
if (queues.get(target) === queued) queues.delete(target); if (queues.get(target) === queued) queues.delete(target);
}); });
@@ -309,11 +310,12 @@
} }
const target = targetFor(scope, op.target); const target = targetFor(scope, op.target);
if (!target) return missing(scope, op.target); if (!target) return missing(scope, op.target);
putPayload(target, op.payload); if (op.target.scope && op.target.scope.kind === "key" && op.payload.kind === "html") replacePayload(target, op.payload, op.target.scope.value, op.target.resource.id);
else putPayload(target, op.payload);
} else if (op.kind === "insert" || op.kind === "prepend") { } else if (op.kind === "insert" || op.kind === "prepend") {
const target = targetFor(scope, op.target); const target = targetFor(scope, op.target);
if (!target) return missing(scope, op.target); if (!target) return missing(scope, op.target);
const nodes = fragmentNodes(op.payload, op.key); const nodes = fragmentNodes(op.payload, op.key, op.target.resource.id);
target[op.kind === "prepend" ? "prepend" : "append"](...nodes); target[op.kind === "prepend" ? "prepend" : "append"](...nodes);
} else if (op.kind === "remove") { } else if (op.kind === "remove") {
const target = op.key ? keyedTarget(scope, op.target.resource.id, op.key) : targetFor(scope, op.target); const target = op.key ? keyedTarget(scope, op.target.resource.id, op.key) : targetFor(scope, op.target);
@@ -448,13 +450,20 @@
else target.textContent = payload.value; else target.textContent = payload.value;
} }
function fragmentNodes(payload, key) { function replacePayload(target, payload, key, resourceId) {
const nodes = fragmentNodes(payload, key, resourceId);
if (nodes.length) target.replaceWith(...nodes);
else target.innerHTML = "";
}
function fragmentNodes(payload, key, resourceId) {
const template = document.createElement("template"); const template = document.createElement("template");
if (payload.kind === "html") template.innerHTML = payload.value; if (payload.kind === "html") template.innerHTML = payload.value;
else template.textContent = payload.value; else template.textContent = payload.value;
const nodes = Array.from(template.content.childNodes); const nodes = Array.from(template.content.childNodes);
const firstElement = nodes.find((node) => node.nodeType === 1); const firstElement = nodes.find((node) => node.nodeType === 1);
if (firstElement && key != null && !firstElement.hasAttribute("data-key")) firstElement.setAttribute("data-key", key); if (firstElement && key != null && !firstElement.hasAttribute("data-key")) firstElement.setAttribute("data-key", key);
if (firstElement && resourceId != null && !firstElement.hasAttribute("data-sid")) firstElement.setAttribute("data-sid", resourceId);
return nodes; return nodes;
} }
@@ -589,7 +598,7 @@
if (form && root.contains(form) && formHandleId(form)) { if (form && root.contains(form) && formHandleId(form)) {
if (form.reportValidity && !form.reportValidity()) return; if (form.reportValidity && !form.reportValidity()) return;
event.preventDefault(); event.preventDefault();
schedule(form, "submit"); schedule(form, "submit", submitter);
return; return;
} }
} }
@@ -600,23 +609,23 @@
if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && formHandleId(event.target)) el = event.target; if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && formHandleId(event.target)) el = event.target;
if (!el || defaultEvent(el) !== name) return; if (!el || defaultEvent(el) !== name) return;
event.preventDefault(); event.preventDefault();
schedule(el, name); schedule(el, name, event.submitter || el);
}); });
}); });
bindPolling(root); bindPolling(root);
} }
function schedule(el, eventName) { function schedule(el, eventName, source = el) {
const debounce = duration(el.getAttribute("data-slhx-debounce")); const debounce = duration(el.getAttribute("data-slhx-debounce"));
const throttle = duration(el.getAttribute("data-slhx-throttle")); const throttle = duration(el.getAttribute("data-slhx-throttle"));
if (debounce) { if (debounce) {
clearTimeout(timers.get(el)); clearTimeout(timers.get(el));
timers.set(el, setTimeout(() => send(el, eventName), debounce)); timers.set(el, setTimeout(() => send(el, eventName, source), debounce));
} else if (throttle) { } else if (throttle) {
if (timers.get(el)) return; if (timers.get(el)) return;
send(el, eventName).finally(() => setTimeout(() => timers.delete(el), throttle)); send(el, eventName, source).finally(() => setTimeout(() => timers.delete(el), throttle));
} else { } else {
send(el, eventName); send(el, eventName, source);
} }
} }
+50 -15
View File
@@ -14,7 +14,9 @@ fn runtime_preserves_multipart_file_upload_fallback_shape() {
// req: multipart/003 // req: multipart/003
let source = slhx_js::RUNTIME_JS; let source = slhx_js::RUNTIME_JS;
assert!(source.contains("multipart: form && String(form.enctype).toLowerCase() === \"multipart/form-data\"")); assert!(source.contains(
"multipart: form && String(form.enctype).toLowerCase() === \"multipart/form-data\""
));
assert!(source.contains("if (value instanceof File) continue")); assert!(source.contains("if (value instanceof File) continue"));
assert!(source.contains("return multipart ? data : urlEncoded(data)")); assert!(source.contains("return multipart ? data : urlEncoded(data)"));
assert!(source.contains("if (body instanceof URLSearchParams) headers[\"Content-Type\"] = \"application/x-www-form-urlencoded;charset=UTF-8\"")); assert!(source.contains("if (body instanceof URLSearchParams) headers[\"Content-Type\"] = \"application/x-www-form-urlencoded;charset=UTF-8\""));
@@ -46,6 +48,13 @@ fn runtime_targets_generated_resources_not_response_selectors() {
assert!(source.contains("function firstElement(scope, predicate)")); assert!(source.contains("function firstElement(scope, predicate)"));
assert!(source.contains("function generatedResource(el, id)")); assert!(source.contains("function generatedResource(el, id)"));
assert!(source.contains("return firstElement(scope, (el) => attrEquals(el, \"data-key\", key) && withinGeneratedResource(el, scope, id))")); assert!(source.contains("return firstElement(scope, (el) => attrEquals(el, \"data-key\", key) && withinGeneratedResource(el, scope, id))"));
assert!(
source.contains("const nodes = fragmentNodes(op.payload, op.key, op.target.resource.id)")
);
assert!(source.contains("if (op.target.scope && op.target.scope.kind === \"key\" && op.payload.kind === \"html\") replacePayload(target, op.payload, op.target.scope.value, op.target.resource.id)"));
assert!(source.contains("function replacePayload(target, payload, key, resourceId)"));
assert!(source.contains("target.replaceWith(...nodes)"));
assert!(source.contains("firstElement.setAttribute(\"data-sid\", resourceId)"));
assert!(source.contains("const target = generatedTarget(scope, id)")); assert!(source.contains("const target = generatedTarget(scope, id)"));
assert!(source.contains("if (!target) return missing(scope, op.target)")); assert!(source.contains("if (!target) return missing(scope, op.target)"));
assert!(!source.contains("data-slhx-target")); assert!(!source.contains("data-slhx-target"));
@@ -60,8 +69,12 @@ fn runtime_interval_dispatch_avoids_duplicate_timers() {
assert!(source.contains("const everyTimers = new WeakMap()")); assert!(source.contains("const everyTimers = new WeakMap()"));
assert!(source.contains("forEachElement(root, (el) =>")); assert!(source.contains("forEachElement(root, (el) =>"));
assert!(source.contains("!el.hasAttribute(\"data-slhx-every\") || everyTimers.has(el)")); assert!(source.contains("!el.hasAttribute(\"data-slhx-every\") || everyTimers.has(el)"));
assert!(source.contains("if (!el.hasAttribute(\"data-slhx-every\") || everyTimers.has(el)) return")); assert!(
assert!(source.contains("setInterval(() => document.contains(el) ? send(el, \"every\") : stopPolling(el), ms)")); source.contains("if (!el.hasAttribute(\"data-slhx-every\") || everyTimers.has(el)) return")
);
assert!(source.contains(
"setInterval(() => document.contains(el) ? send(el, \"every\") : stopPolling(el), ms)"
));
assert!(source.contains("function stopPolling(el)")); assert!(source.contains("function stopPolling(el)"));
assert!(source.contains("clearInterval(everyTimers.get(el))")); assert!(source.contains("clearInterval(everyTimers.get(el))"));
assert!(source.contains("everyTimers.delete(el)")); assert!(source.contains("everyTimers.delete(el)"));
@@ -84,10 +97,13 @@ fn runtime_toggles_pending_conventions_around_requests() {
assert!(source.contains("indicator.hidden = state.hidden")); assert!(source.contains("indicator.hidden = state.hidden"));
assert!(source.contains("el.hasAttribute(\"data-slhx-disable-while-pending\")")); assert!(source.contains("el.hasAttribute(\"data-slhx-disable-while-pending\")"));
assert!(source.contains("if (isDisableControl(el)) controls.push(el)")); assert!(source.contains("if (isDisableControl(el)) controls.push(el)"));
assert!(source.contains("forEachElement(el, (child) => { if (isDisableControl(child)) controls.push(child); })")); assert!(source.contains(
"forEachElement(el, (child) => { if (isDisableControl(child)) controls.push(child); })"
));
assert!(source.contains("const disabledStates = new WeakMap()")); assert!(source.contains("const disabledStates = new WeakMap()"));
assert!(source.contains("function toggleDisabled(control, on)")); assert!(source.contains("function toggleDisabled(control, on)"));
assert!(source.contains("else disabledStates.set(control, { count: 1, disabled: control.disabled })")); assert!(source
.contains("else disabledStates.set(control, { count: 1, disabled: control.disabled })"));
assert!(source.contains("control.disabled = state.disabled")); assert!(source.contains("control.disabled = state.disabled"));
assert!(source.contains("controls.forEach((c) => toggleDisabled(c, on))")); assert!(source.contains("controls.forEach((c) => toggleDisabled(c, on))"));
assert!(source.contains("showPending(target, true)")); assert!(source.contains("showPending(target, true)"));
@@ -128,13 +144,20 @@ fn runtime_clicking_submitter_schedules_form_submit() {
assert!(source.contains("function formOwner(el)")); assert!(source.contains("function formOwner(el)"));
assert!(source.contains("function formHandleId(form)")); assert!(source.contains("function formHandleId(form)"));
assert!(source.contains("function elementById(scope, id)")); assert!(source.contains("function elementById(scope, id)"));
assert!(source.contains("return elementById(rootOf(el) || document, el.getAttribute(\"form\"))")); assert!(
source.contains("return elementById(rootOf(el) || document, el.getAttribute(\"form\"))")
);
assert!(!source.contains("document.getElementById(el.getAttribute(\"form\"))")); assert!(!source.contains("document.getElementById(el.getAttribute(\"form\"))"));
assert!(source.contains("const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID))")); assert!(source.contains(
"const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID))"
));
assert!(source.contains("const submitter = closestInRoot(event.target, root, (el) =>")); assert!(source.contains("const submitter = closestInRoot(event.target, root, (el) =>"));
assert!(source.contains("el.tagName === \"BUTTON\" && (!el.hasAttribute(\"type\") || el.getAttribute(\"type\") === \"submit\")")); assert!(source.contains("el.tagName === \"BUTTON\" && (!el.hasAttribute(\"type\") || el.getAttribute(\"type\") === \"submit\")"));
assert!(source.contains("form.reportValidity && !form.reportValidity()")); assert!(source.contains("form.reportValidity && !form.reportValidity()"));
assert!(source.contains("schedule(form, \"submit\")")); assert!(source.contains("schedule(form, \"submit\", submitter)"));
assert!(source.contains("function formDataFor(el, eventName, source = el)"));
assert!(source.contains("data.append(source.name, source.value)"));
assert!(source.contains("schedule(el, name, event.submitter || el)"));
} }
#[test] #[test]
@@ -156,7 +179,9 @@ fn runtime_supports_queued_request_policy() {
assert!(source.contains("function normalizedPolicy(value)")); assert!(source.contains("function normalizedPolicy(value)"));
assert!(source.contains("value === \"latest\" || value === \"queue\" || value === \"drop\" || value === \"parallel\"")); assert!(source.contains("value === \"latest\" || value === \"queue\" || value === \"drop\" || value === \"parallel\""));
assert!(source.contains("const policy = normalizedPolicy(el.getAttribute(\"data-slhx-policy\"))")); assert!(
source.contains("const policy = normalizedPolicy(el.getAttribute(\"data-slhx-policy\"))")
);
assert!(source.contains("const queues = new WeakMap()")); assert!(source.contains("const queues = new WeakMap()"));
assert!(source.contains("policy === \"queue\"")); assert!(source.contains("policy === \"queue\""));
assert!(source.contains("const base = queues.get(target) || active.done")); assert!(source.contains("const base = queues.get(target) || active.done"));
@@ -176,7 +201,9 @@ fn runtime_parallel_request_policy_releases_each_pending_state() {
// req: convention/006 req: convention/007 req: convention/008 // req: convention/006 req: convention/007 req: convention/008
let source = slhx_js::RUNTIME_JS; let source = slhx_js::RUNTIME_JS;
assert!(source.contains("else if (policy === \"parallel\") {\n showPending(target, false);\n }")); assert!(source.contains(
"else if (policy === \"parallel\") {\n showPending(target, false);\n }"
));
assert!(source.contains("const pendingClassStates = new WeakMap()")); assert!(source.contains("const pendingClassStates = new WeakMap()"));
assert!(source.contains("const indicatorStates = new WeakMap()")); assert!(source.contains("const indicatorStates = new WeakMap()"));
assert!(source.contains("const disabledStates = new WeakMap()")); assert!(source.contains("const disabledStates = new WeakMap()"));
@@ -256,8 +283,10 @@ fn runtime_applies_sse_effect_batches_inside_roots() {
assert!(source.contains("if (href.origin !== location.origin)")); assert!(source.contains("if (href.origin !== location.origin)"));
assert!(source.contains("emit(root, \"slhx:sse-error\", url)")); assert!(source.contains("emit(root, \"slhx:sse-error\", url)"));
assert!(source.contains("new EventSource(href.href)")); assert!(source.contains("new EventSource(href.href)"));
assert!(source.contains("source.addEventListener(\"slhx\", (event) => applySseMessage(root, event))")); assert!(source
assert!(source.contains("source.addEventListener(\"message\", (event) => applySseMessage(root, event))")); .contains("source.addEventListener(\"slhx\", (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("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, \"slhx:sse-error\", url)"));
} }
@@ -278,16 +307,22 @@ fn runtime_keeps_form_field_targets_separate_from_error_targets() {
let source = slhx_js::RUNTIME_JS; let source = slhx_js::RUNTIME_JS;
assert!(source.contains("function fieldTarget(scope, id, field)")); assert!(source.contains("function fieldTarget(scope, id, field)"));
assert!(source.contains("attrEquals(el, \"name\", field) && withinGeneratedForm(el, scope, id)")); assert!(
source.contains("attrEquals(el, \"name\", field) && withinGeneratedForm(el, scope, id)")
);
assert!(source.contains("function formErrorTarget(scope, id, field)")); assert!(source.contains("function formErrorTarget(scope, id, field)"));
assert!(source.contains("attrEquals(el, \"data-slhx-error-for\", field) && withinGeneratedForm(el, scope, id)")); assert!(source.contains(
"attrEquals(el, \"data-slhx-error-for\", field) && withinGeneratedForm(el, scope, id)"
));
} }
#[test] #[test]
fn runtime_preflights_batches_before_applying_ops() { fn runtime_preflights_batches_before_applying_ops() {
let source = slhx_js::RUNTIME_JS; let source = slhx_js::RUNTIME_JS;
assert!(source.contains("const missingTarget = batch.ops.map((op) => canApplyOp(scope, op)).find(Boolean)")); assert!(source.contains(
"const missingTarget = batch.ops.map((op) => canApplyOp(scope, op)).find(Boolean)"
));
assert!(source.contains("function canApplyOp(scope, op)")); assert!(source.contains("function canApplyOp(scope, op)"));
assert!(source.contains("for (const op of batch.ops) applyOp(scope, op)")); assert!(source.contains("for (const op of batch.ops) applyOp(scope, op)"));
} }
+621 -3
View File
@@ -1,4 +1,7 @@
use slhx_core::{Atom, BuildFingerprint, Effect, EffectBatch, Form, IntoEffect, KeyedSlot, ResourceId, ResourceRef, Slot}; use slhx_core::{
Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, IntoEffect, KeyedSlot,
NavigateMode, Payload, ResourceId, ResourceKind, ResourceRef, ScopeKey, Slot,
};
pub fn run<I, F, R>(handler: F, input: I) -> EffectInspector pub fn run<I, F, R>(handler: F, input: I) -> EffectInspector
where where
@@ -9,9 +12,382 @@ where
} }
pub fn inspect(effect: impl IntoEffect) -> EffectInspector { pub fn inspect(effect: impl IntoEffect) -> EffectInspector {
EffectInspector { inspect_batch(effect.into_batch(BuildFingerprint(0)))
batch: effect.into_batch(BuildFingerprint(0)), }
/// Inspect an already-dispatched batch without matching raw effect variants in tests.
/// req: test/001 req: dx/006
pub fn inspect_batch(batch: EffectBatch) -> EffectInspector {
EffectInspector { batch }
}
/// 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"))
}
/// 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()
}
/// Return the unscoped resource reference behind a generated target for low-level test assertions.
/// req: test/001 req: dx/006
pub fn target_ref(target: impl GeneratedTarget) -> ResourceRef {
ResourceRef::unscoped(target_resource(target))
}
/// 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 {
let mut body = form_pair("__h", &handle.to_string());
for (name, value) in fields {
body.push('&');
body.push_str(&form_pair(name, value));
} }
body
}
/// Build a request body for invalid-handle tests without exposing the wire field name.
/// req: test/001 req: dx/006
pub fn unknown_handle_form_body(id: u32) -> String {
form_pair("__h", &id.to_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 {
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 {
format!("button{}", handle_selector(handle))
}
/// Build a selector for a heading in a semantic container without spelling document structure in examples.
/// req: test/001 req: dx/006
pub fn heading_selector(scope_selector: &str, level: u8) -> String {
assert!((1..=6).contains(&level), "heading level must be 1..=6");
if scope_selector.is_empty() {
format!("h{level}")
} else {
format!("{scope_selector} h{level}")
}
}
/// Build a selector for article content without spelling document structure in examples.
/// req: test/001 req: dx/006
pub fn article_selector() -> &'static str {
"article"
}
/// Build a selector for emphasized/card-title text without spelling document structure.
/// req: test/001 req: dx/006
pub fn strong_text_selector() -> &'static str {
"strong"
}
/// Build a selector for secondary/help text without spelling document structure.
/// req: test/001 req: dx/006
pub fn small_text_selector() -> &'static str {
"small"
}
/// Build a selector for an HTML tag that must be absent when user text is escaped.
/// req: test/001 req: dx/006
pub fn escaped_markup_selector(tag: &str) -> String {
assert!(
tag.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-'),
"tag selector must be a simple tag name"
);
tag.to_owned()
}
/// Build a selector for list items without spelling document structure in examples.
/// req: test/001 req: dx/006
pub fn list_item_selector(scope_selector: &str) -> String {
if scope_selector.is_empty() {
"li".to_owned()
} else {
format!("{scope_selector} li")
}
}
/// Build a selector for prose text in a semantic container without spelling document structure.
/// req: test/001 req: dx/006
pub fn prose_selector(scope_selector: &str) -> String {
if scope_selector.is_empty() {
"p".to_owned()
} else {
format!("{scope_selector} p")
}
}
/// Build a selector for a form in a semantic container without spelling form structure in examples.
/// req: test/001 req: dx/006
pub fn form_selector(scope_selector: &str) -> String {
format!("{scope_selector} form")
}
/// Build a selector for a form select's options from the authoring field name.
/// req: test/001 req: dx/006
pub fn select_options_selector(field: &str) -> String {
format!("select{} > option", attr_selector("name", field))
}
/// Build a selector for an app-owned semantic class.
/// req: test/001 req: dx/006
pub fn class_selector(class: &str) -> String {
assert_simple_selector_part(class, "class");
format!(".{class}")
}
/// Build a selector for an element carrying an app-owned semantic class.
/// req: test/001 req: dx/006
pub fn element_class_selector(element: &str, class: &str) -> String {
assert_simple_selector_part(element, "element");
assert_simple_selector_part(class, "class");
format!("{element}.{class}")
}
/// Build a selector for classed children inside an app-owned semantic container.
/// req: test/001 req: dx/006
pub fn class_child_selector(parent_class: &str, element: &str, class: &str) -> String {
assert_simple_selector_part(parent_class, "parent class");
assert_simple_selector_part(element, "element");
assert_simple_selector_part(class, "class");
format!(".{parent_class} > {element}.{class}")
}
/// Build a selector for an element inside an app-owned semantic class.
/// req: test/001 req: dx/006
pub fn class_descendant_selector(parent_class: &str, element: &str) -> String {
assert_simple_selector_part(parent_class, "parent class");
assert_simple_selector_part(element, "element");
format!(".{parent_class} {element}")
}
/// Build a selector for disabled action buttons without spelling CSS selector state in examples.
/// req: test/001 req: dx/006
pub fn disabled_button_selector() -> &'static str {
"button[disabled]"
}
/// Build a selector for progressive-enhancement navigation links.
/// req: test/001 req: dx/006
pub fn nav_link_selector(href: &str) -> String {
format!("a{}", attr_selector("href", href))
}
/// Build a selector for page-enhanced navigation links that do not use handler dispatch.
/// req: test/001 req: dx/006
pub fn page_nav_link_selector(href: &str) -> String {
format!(
"{}[data-slhx-nav]:not([data-slhx-handle])",
nav_link_selector(href)
)
}
/// 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 attr = match resource.kind {
ResourceKind::Slot => "data-sid",
ResourceKind::Atom => "data-aid",
ResourceKind::Handle => "data-hid",
ResourceKind::Form => "data-fid",
};
attr_selector(attr, &resource.id.to_string())
}
/// Build a selector for an slhx root from its authoring name.
/// req: test/001 req: dx/006
pub fn root_selector(name: &str) -> String {
attr_selector("data-slhx-root", name)
}
/// Build a selector for a specific root element from its authoring name.
/// req: test/001 req: dx/006
pub fn root_element_selector(element: &str, name: &str) -> String {
format!("{}{}", element, root_selector(name))
}
/// Build a selector for the document body without spelling raw document structure in examples.
/// req: test/001 req: dx/006
pub fn document_body_selector() -> &'static str {
"body"
}
/// Build a selector for the document title without spelling raw document structure in examples.
/// req: test/001 req: dx/006
pub fn document_title_selector() -> &'static str {
"title"
}
/// Build a selector for the slhx 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\"]"
}
/// Build a selector for any slhx root without spelling the attribute in tests.
/// req: test/001 req: dx/006
pub fn any_root_selector() -> &'static str {
"[data-slhx-root]"
}
/// Build a selector for a keyed generated row without spelling runtime key metadata.
/// req: test/001 req: dx/006
pub fn keyed_selector(base_selector: &str, key: impl ToString) -> String {
format!(
"{}{}",
base_selector,
attr_selector("data-key", &key.to_string())
)
}
/// Build a selector for all generated keyed rows under a semantic base selector.
/// req: test/001 req: dx/006
pub fn keyed_items_selector(base_selector: &str) -> String {
format!("{base_selector}[data-key]")
}
/// 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)
}
/// 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"
}
/// 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}")
}
/// 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}\"")
}
/// Return the island snapshot marker without spelling island metadata in tests.
/// req: test/001 req: dx/006
pub fn island_snapshot_marker() -> &'static str {
"data-island-snapshot="
}
/// Build a selector for island readouts without spelling island metadata in tests.
/// req: test/001 req: dx/006
pub fn island_readout_selector() -> &'static str {
"[data-island-readout]"
}
/// Build browser-driver JavaScript for injecting a synthetic island probe.
///
/// This lets product tests exercise the island bridge without spelling slhx island
/// metadata attributes in the test body. req: test/001 req: dx/006
pub fn island_probe_script(
element_id: &str,
island_name: &str,
snapshot: &str,
event_detail: &str,
) -> String {
format!(
r#"
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-island-snapshot', {snapshot});
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 16;
island.appendChild(canvas);
const readout = document.createElement('p');
readout.setAttribute('data-island-readout', '');
readout.textContent = 'waiting';
island.appendChild(readout);
root.appendChild(island);
setTimeout(() => {{
root.dispatchEvent(new CustomEvent('slhx:island-' + islandName, {{ bubbles: true, detail: {event_detail} }}));
}}, 25);
return true;
"#,
element_id = js_string(element_id),
island_name = js_string(island_name),
snapshot = js_string(snapshot),
event_detail = js_string(event_detail),
)
}
/// Build a scoped selector for island readouts without spelling island metadata in tests.
/// req: test/001 req: dx/006
pub fn scoped_island_readout_selector(scope_selector: &str) -> String {
format!("{scope_selector} {}", island_readout_selector())
}
fn assert_simple_selector_part(value: &str, label: &str) {
assert!(
value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-'),
"{label} selector part must contain only ascii alphanumerics or '-'"
);
}
fn attr_selector(name: &str, value: &str) -> String {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!(r#"[{name}="{escaped}"]"#)
}
fn js_string(value: &str) -> String {
let mut escaped = String::from("\"");
for ch in value.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
ch => escaped.push(ch),
}
}
escaped.push('"');
escaped
}
fn form_pair(name: &str, value: &str) -> String {
format!("{}={}", form_encode(name), form_encode(value))
}
fn form_encode(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(byte as char)
}
b' ' => encoded.push('+'),
_ => encoded.push_str(&format!("%{byte:02X}")),
}
}
encoded
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -32,6 +408,14 @@ impl EffectInspector {
self.batch.ops.contains(op) self.batch.ops.contains(op)
} }
pub fn op_count(&self) -> usize {
self.batch.ops.len()
}
pub fn is_empty(&self) -> bool {
self.batch.ops.is_empty()
}
pub fn has_resource(&self, resource: ResourceId) -> bool { pub fn has_resource(&self, resource: ResourceId) -> bool {
self.batch self.batch
.ops .ops
@@ -39,6 +423,210 @@ impl EffectInspector {
.any(|op| op_targets_resource(op, resource)) .any(|op| op_targets_resource(op, resource))
} }
/// 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())
}
/// 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();
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Put {
target,
payload: Payload::Text(_),
} if target.resource == resource
)
})
}
/// 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();
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Put {
target,
payload: Payload::Html(_),
} if target.resource == resource
)
})
}
/// 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();
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Put {
target,
payload: Payload::Html(html),
} if target.resource == resource && html.contains(needle)
)
})
}
/// Assert that a keyed generated target is replaced with HTML containing text.
/// req: test/001 req: dx/006
pub fn replaces_keyed_html_containing(
&self,
target: impl GeneratedTarget,
key: impl ToString,
needle: &str,
) -> bool {
let resource = target.__slhx_resource_id();
let scope = Some(ScopeKey::KeyValue(key.to_string()));
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Put {
target,
payload: Payload::Html(html),
} if target.resource == resource && target.scope == scope && html.contains(needle)
)
})
}
/// Assert that a keyed generated target appends HTML containing text.
/// req: test/001 req: dx/006
pub fn inserts_html_containing(
&self,
target: impl GeneratedTarget,
key: impl ToString,
needle: &str,
) -> bool {
let resource = target.__slhx_resource_id();
let key = key.to_string();
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Insert {
target,
key: actual_key,
payload: Payload::Html(html),
} if target.resource == resource && actual_key == &key && html.contains(needle)
)
})
}
/// 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 key = key.to_string();
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Remove {
target,
key: Some(actual_key),
} if target.resource == resource && actual_key == &key
)
})
}
/// Assert that the batch requests a push navigation to a URL.
/// req: test/001 req: dx/006
pub fn pushes_to(&self, url: &str) -> bool {
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Navigate {
url: actual_url,
mode: NavigateMode::Push,
..
} if actual_url == url
)
})
}
/// Assert that any payload or URL contains text, without matching raw effects.
/// req: test/001 req: dx/006
pub fn payload_contains(&self, needle: &str) -> bool {
self.batch
.ops
.iter()
.any(|op| effect_payload_contains(op, needle))
}
/// Assert that no payload or URL contains text, without matching raw effects.
/// req: test/001 req: dx/006
pub fn payload_excludes(&self, needle: &str) -> bool {
self.batch
.ops
.iter()
.all(|op| !effect_payload_contains(op, needle))
}
/// Assert that generated keyed-row metadata for a key is absent from payloads.
/// req: test/001 req: dx/006
pub fn payload_excludes_key(&self, key: impl ToString) -> bool {
self.payload_excludes(&format!("data-key=\"{}\"", key.to_string()))
}
/// Return HTML for a generated target containing text, without exposing raw payloads.
/// req: test/001 req: dx/006
pub fn target_html_containing(
&self,
target: impl GeneratedTarget,
needle: &str,
) -> Option<&str> {
let resource = target.__slhx_resource_id();
self.batch.ops.iter().find_map(|op| match op {
Effect::Put {
target,
payload: Payload::Html(html),
} if target.resource == resource && html.contains(needle) => Some(html.as_str()),
Effect::Insert {
target,
payload: Payload::Html(html),
..
} if target.resource == resource && html.contains(needle) => Some(html.as_str()),
Effect::Prepend {
target,
payload: Payload::Html(html),
..
} if target.resource == resource && html.contains(needle) => Some(html.as_str()),
_ => None,
})
}
/// Assert that a named generated event is emitted with the exact payload.
/// req: test/001 req: dx/006
pub fn emits(&self, name: &str, payload: &str) -> bool {
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Emit {
name: actual_name,
payload: actual_payload,
} if actual_name == name && actual_payload == payload
)
})
}
/// Assert that a named generated event payload contains text.
/// req: test/001 req: dx/006
pub fn emits_containing(&self, name: &str, needle: &str) -> bool {
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Emit {
name: actual_name,
payload,
} if actual_name == name && payload.contains(needle)
)
})
}
pub fn has_ref(&self, target: &ResourceRef) -> bool { pub fn has_ref(&self, target: &ResourceRef) -> bool {
self.batch.ops.iter().any(|op| op_targets_ref(op, target)) self.batch.ops.iter().any(|op| op_targets_ref(op, target))
} }
@@ -61,6 +649,36 @@ impl EffectInspector {
pub fn has_form<T>(&self, form: Form<T>) -> bool { pub fn has_form<T>(&self, form: Form<T>) -> bool {
self.has_resource(form.id()) self.has_resource(form.id())
} }
/// Assert that a generated form is reset/cleared without matching raw events in tests.
/// req: test/001 req: dx/006
pub fn resets_form<T>(&self, form: Form<T>) -> bool {
let form_id = form.id().id.to_string();
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Emit { name, payload }
if name == "slhx:form-reset" && payload == &form_id
)
})
}
}
fn effect_payload_contains(op: &Effect, needle: &str) -> bool {
match op {
Effect::Put { payload, .. }
| Effect::Insert { payload, .. }
| Effect::Prepend { payload, .. } => payload_value(payload).contains(needle),
Effect::Emit { payload, .. } => payload.contains(needle),
Effect::Navigate { url, .. } => url.contains(needle),
Effect::Remove { .. } | Effect::Move { .. } | Effect::Focus { .. } => false,
}
}
fn payload_value(payload: &Payload) -> &str {
match payload {
Payload::Text(value) | Payload::Html(value) => value,
}
} }
fn op_targets_resource(op: &Effect, resource: ResourceId) -> bool { fn op_targets_resource(op: &Effect, resource: ResourceId) -> bool {
+103 -2
View File
@@ -66,6 +66,18 @@ fn canonical_examples_do_not_author_low_level_resource_plumbing() {
"HandlerRegistry", "HandlerRegistry",
"InteractionForm", "InteractionForm",
"register_handle(", "register_handle(",
"SafeHtml",
"slhx::advanced",
"advanced::slots",
"KeyedSlot",
"Slot<",
"Effect::",
"Payload::",
"NavigateMode",
"ScopeKey",
"opcode",
"wire format",
"manual generated",
"RenderSlotExt", "RenderSlotExt",
"RenderKeyedSlotExt", "RenderKeyedSlotExt",
".render(&", ".render(&",
@@ -111,6 +123,41 @@ fn canonical_examples_do_not_author_low_level_resource_plumbing() {
assert!(failures.is_empty(), "{}", failures.join("\n")); assert!(failures.is_empty(), "{}", failures.join("\n"));
} }
#[test]
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 prelude = facade
.split("pub mod prelude {")
.nth(1)
.and_then(|tail| tail.split("\n}").next())
.expect("slhx facade exposes prelude module");
let exported = prelude
.split(|c: char| !(c == '_' || c.is_ascii_alphanumeric()))
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
for token in [
"SafeHtml",
"Effect",
"Slot",
"KeyedSlot",
"ResourceId",
"EffectBatch",
"BuildFingerprint",
] {
assert!(
!exported.contains(&token),
"slhx::prelude must not export low-level `{token}`"
);
}
assert!(prelude.contains("Html"), "slhx::prelude should export Html");
assert!(
prelude.contains("IntoEffect"),
"slhx::prelude should export IntoEffect"
);
}
#[test] #[test]
fn canonical_example_docs_do_not_teach_low_level_plumbing() { fn canonical_example_docs_do_not_teach_low_level_plumbing() {
// req: dx/002 req: dx/003 req: examples/003 // req: dx/002 req: dx/003 req: examples/003
@@ -130,13 +177,63 @@ fn canonical_example_docs_do_not_teach_low_level_plumbing() {
"register_handle(", "register_handle(",
"lower_html(", "lower_html(",
"render_html(", "render_html(",
"SafeHtml::trusted", "SafeHtml",
"KeyedSlot",
"Slot<",
"Effect::",
"opcode",
"wire format",
"manual generated",
"Effect::batch",
"Effect::class",
"Effect::move",
"Effect::set",
"Effect::broadcast",
"Effect::ack",
"SyncEffect::",
"postcard DOM ops",
"data-hid",
"data-sid",
".render(&", ".render(&",
"addEventListener(", "addEventListener(",
"querySelector", "querySelector",
"querySelectorAll", "querySelectorAll",
]; ];
let mut failures = Vec::new(); let mut failures = Vec::new();
scan_examples(&examples, &mut |path, text| {
if path.extension().and_then(|ext| ext.to_str()) != Some("md")
|| is_advanced_boundary_doc(text)
{
return;
}
for (line_no, line) in text.lines().enumerate() {
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
failures.push(format!(
"{}:{}: example docs must teach generated ergonomic APIs, not `{token}`",
path.display(),
line_no + 1
));
}
}
});
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn example_docs_do_not_show_runtime_metadata_as_authoring_contract() {
// req: dx/006 req: misc/006
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let examples = root.join("examples");
let forbidden = [
"data-hid",
"data-sid",
"data-aid",
"data-fid",
"[data-hid",
"[data-sid",
];
let mut failures = Vec::new();
scan_examples(&examples, &mut |path, text| { scan_examples(&examples, &mut |path, text| {
if path.extension().and_then(|ext| ext.to_str()) != Some("md") { if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
return; return;
@@ -144,7 +241,7 @@ fn canonical_example_docs_do_not_teach_low_level_plumbing() {
for (line_no, line) in text.lines().enumerate() { for (line_no, line) in text.lines().enumerate() {
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) { if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
failures.push(format!( failures.push(format!(
"{}:{}: example docs must teach generated ergonomic APIs, not `{token}`", "{}:{}: example docs must describe generated authoring APIs, not runtime metadata `{token}`",
path.display(), path.display(),
line_no + 1 line_no + 1
)); ));
@@ -188,6 +285,10 @@ fn scan_examples(dir: &Path, visit: &mut impl FnMut(&Path, &str)) {
} }
} }
fn is_advanced_boundary_doc(text: &str) -> bool {
text.contains("advanced/low-level north-star boundary sketch")
}
fn is_example_source(path: &PathBuf) -> bool { fn is_example_source(path: &PathBuf) -> bool {
matches!( matches!(
path.extension().and_then(|ext| ext.to_str()), path.extension().and_then(|ext| ext.to_str()),
+117 -5
View File
@@ -1,14 +1,13 @@
use slhx_core::{Atom, Effect, KeyedSlot, Payload, ResourceRef, Slot}; use slhx_core::{
Atom, Effect, GeneratedTarget, KeyedSlot, Payload, ResourceId, ResourceKind, ResourceRef, Slot,
};
#[test] #[test]
fn inspects_tuple_effects() { fn inspects_tuple_effects() {
let count = Slot::<u32>::new(1); let count = Slot::<u32>::new(1);
let user = Atom::<String>::new(2); let user = Atom::<String>::new(2);
let inspected = slhx_test::run( let inspected = slhx_test::run(|value| (count.text(value), user.set("alice")), 42);
|value| (count.text(value), user.set("alice")),
42,
);
assert!(inspected.has_slot(count)); assert!(inspected.has_slot(count));
assert!(inspected.has_atom(user)); assert!(inspected.has_atom(user));
@@ -26,3 +25,116 @@ fn finds_keyed_slot_targets() {
assert!(inspected.has_keyed_slot(rows)); assert!(inspected.has_keyed_slot(rows));
assert_eq!(inspected.ops().len(), 1); 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)
}
}
+91 -17
View File
@@ -3,18 +3,89 @@
//! Most application code should depend on this crate, use the proc-macros from //! 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 `#[slhx::surface]`.
pub use slhx_core::*; use slhx_core::{Effect, KeyedSlot, SafeHtml, Slot};
pub use slhx_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 slhx_derive::{app, component, form, handler, surface};
pub fn render(view: &impl hemplate::Hemplate) -> SafeHtml { /// Advanced/raw slhx primitives used by generated code, integrations, and tests.
let mut html = String::new(); ///
view.render_into(&mut html) /// Beginner-facing application code should prefer generated targets, generated
.expect("hemplate view renders into slhx effect payload"); /// handles/forms/classes, `Html`, `IntoEffect`, and tuple composition. req: dx/001 req: public_api/005
SafeHtml::trusted(html) pub mod advanced {
pub use slhx_core::*;
}
/// Rendered, checked HTML produced by hemplate/slhx 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
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub struct Html(SafeHtml);
impl Html {
pub fn as_str(&self) -> &str {
self.0.as_str()
}
pub fn into_string(self) -> String {
self.0.into_string()
}
pub fn join(fragments: impl IntoIterator<Item = Html>) -> Self {
Self(SafeHtml::join(fragments.into_iter().map(Into::into)))
}
}
impl From<Html> for SafeHtml {
fn from(value: Html) -> Self {
value.0
}
}
impl AsRef<str> for Html {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl core::fmt::Display for Html {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.as_str())
}
} }
#[doc(hidden)] #[doc(hidden)]
pub fn render_html(view: &impl hemplate::Hemplate) -> SafeHtml { pub mod __private {
use super::{Html, SafeHtml};
pub fn html_trusted(value: impl Into<String>) -> Html {
Html(SafeHtml::trusted(value))
}
}
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");
__private::html_trusted(html)
}
/// A hemplate partial that carries the stable key for a generated keyed target.
///
/// Generated keyed target helpers use this to keep ordinary handler code at the
/// 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;
}
#[doc(hidden)]
pub fn render_html(view: &impl hemplate::Hemplate) -> Html {
render(view) render(view)
} }
@@ -42,7 +113,7 @@ impl<T> RenderSlotExt for Slot<T> {
/// Compatibility shim for raw keyed slot rendering. /// Compatibility shim for raw keyed slot rendering.
/// ///
/// Prefer generated target objects such as `targets::row.append(key, &view)` so /// Prefer generated target objects such as `targets::row.append(&view)` so
/// generated resource lowering stays attached to the view boundary. req: dx/006 /// generated resource lowering stays attached to the view boundary. req: dx/006
#[doc(hidden)] #[doc(hidden)]
pub trait RenderKeyedSlotExt<K> { pub trait RenderKeyedSlotExt<K> {
@@ -90,12 +161,12 @@ where
} }
pub mod prelude { pub mod prelude {
pub use slhx_core::{
navigate, push, redirect, replace, Atom, BuildFingerprint, ComponentRef, CssClass,
CssClasses, Effect, EventName, Form, FormModel, FormValue, Handle, IntoEffect, KeyedSlot,
ParamName, SafeHtml, Slot,
};
pub use crate::render; pub use crate::render;
pub use crate::Html;
pub use slhx_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 slhx_derive::{app, component, form, handler, surface};
} }
@@ -114,15 +185,18 @@ mod tests {
fn render_is_the_short_safe_html_helper() { fn render_is_the_short_safe_html_helper() {
// req: dx/006 req: html_safety/002 // req: dx/006 req: html_safety/002
assert_eq!(crate::render(&InlineView).as_str(), "<strong>ok</strong>"); assert_eq!(crate::render(&InlineView).as_str(), "<strong>ok</strong>");
assert_eq!(crate::render_html(&InlineView).as_str(), "<strong>ok</strong>"); assert_eq!(
crate::render_html(&InlineView).as_str(),
"<strong>ok</strong>"
);
} }
#[test] #[test]
fn prelude_exports_safe_html_for_page_composition() { fn prelude_exports_html_for_page_composition() {
// req: dx/006 req: html_safety/002 // req: dx/006 req: public_api/005 req: html_safety/002
use crate::prelude::*; use crate::prelude::*;
let html = SafeHtml::join([render(&InlineView)]); let html = Html::join([render(&InlineView)]);
assert_eq!(html.as_str(), "<strong>ok</strong>"); assert_eq!(html.as_str(), "<strong>ok</strong>");
} }