chore(checkpoint): save current v0 build state
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
/target/
|
||||||
@@ -1,280 +1,45 @@
|
|||||||
# — AGENTS.md
|
# slhx — AGENTS.md
|
||||||
|
|
||||||
> Auto-generated from REQUIREMENTS.md. Do not edit directly.
|
## Purpose
|
||||||
> Edit REQUIREMENTS.md and run: redgate agents > AGENTS.md
|
|
||||||
|
|
||||||
## Requirements
|
This file tells coding agents how to work in this repository. It is hand-edited project context, not a generated requirements dump.
|
||||||
|
|
||||||
### req:_boundary/001
|
## Agent workflow
|
||||||
|
|
||||||
- **001** hemplate does not expose a slhx API. It exposes a stable, generic Template Surface IR. slhx is one consumer; a11y tools, test generators, and documentation generators are others.
|
- Start with requirements before implementation details.
|
||||||
|
- Read `REQUIREMENTS.md` before changing behavior.
|
||||||
|
- If behavior changes, update `REQUIREMENTS.md` in the same change.
|
||||||
|
- Cite relevant requirements in code, tests, or docs as `req: component/001`.
|
||||||
|
- Run `redgate list`, `redgate refs`, and `redgate health` when requirements change.
|
||||||
|
|
||||||
### req:_boundary/002
|
## Requirements-first TDD
|
||||||
|
|
||||||
- **002** hemplate never interprets `data-slhx-*`, `slhx-*`, or any other tool-prefixed attribute. It records them as raw `name: value` pairs in the Surface.
|
- Write requirements as intent, not as a dump of current behavior.
|
||||||
|
- Break requirements into large error classes first: what can go wrong, and what outcome should hold.
|
||||||
|
- Use tests to pin those error classes before changing code.
|
||||||
|
- Add adversarial tests for malformed, hostile, ambiguous, missing, duplicated, and boundary inputs.
|
||||||
|
- Avoid over-codifying existing behavior while the direction is still unclear.
|
||||||
|
- Add narrower, concrete cases only after requirements converge into a clear design.
|
||||||
|
|
||||||
### req:_boundary/003
|
## Redgate CLI
|
||||||
|
|
||||||
- **003** slhx never parses `.heml` directly. It consumes `hemplate.surface.postcard` emitted by `hemplate_build`. slhx interprets tool-specific conventions (`data-slhx-handle`, `data-slhx-slot`, etc.) from the generic Surface.
|
- `redgate list` — show requirements as TSV.
|
||||||
|
- `redgate refs` — show `req:` citations found in the repo.
|
||||||
|
- `redgate health` — show uncited requirements, duplicate IDs, and stale citations.
|
||||||
|
- `redgate health --strict` — fail on hard errors: duplicate IDs or stale citations.
|
||||||
|
- `redgate agents` — print this starter template; review and edit before committing.
|
||||||
|
|
||||||
### req:_build/001
|
## Project commands
|
||||||
|
|
||||||
- **001** Build order: `.heml` → `hemplate_build` → `hemplate.surface.postcard` → `slhx_build` → `slhx.syms` + generated Rust constants.
|
- `cargo check --workspace`
|
||||||
|
- `cargo test --workspace`
|
||||||
|
- `redgate health --strict`
|
||||||
|
|
||||||
### req:_build/002
|
## Project conventions
|
||||||
|
|
||||||
- **002** `slhx-derive` (`#[slhx::handler]`) reads `slhx.syms` at expansion time to validate handle names, slot names, and form signatures.
|
- Keep `AGENTS.md` concise; do not paste the requirements catalog into it.
|
||||||
|
- Requirement IDs use the current form `req: component/001`, not the legacy `req:_component/001` form.
|
||||||
### req:_build/003
|
- slhx core stays small: effects, typed ids, registries, and wire schema only.
|
||||||
|
- Routing, auth, sessions, transport, transitions, sync, and storage belong in integration/user crates.
|
||||||
- **003** A `build.rs` failure (missing Surface, version mismatch, stale hash) is a hard error before proc-macro expansion.
|
- Public examples and beginner APIs should use generated resources and `IntoEffect`, not raw ids or runtime opcodes.
|
||||||
|
- JS runtime changes must preserve root-scoped lookup and avoid selectors, VDOM, expressions, and per-node listeners.
|
||||||
### req:_build/004
|
|
||||||
|
|
||||||
- **004** Id allocation is deterministic from canonical symbol paths. Stable across builds unless the symbol path changes.
|
|
||||||
|
|
||||||
### req:_check/001
|
|
||||||
|
|
||||||
- **001** All cross-file references verified at `cargo check`. Unknown handle → hard error. Unknown slot → hard error. Type mismatch between slot and atom → hard error.
|
|
||||||
|
|
||||||
### req:_check/002
|
|
||||||
|
|
||||||
- **002** Dead handle warning: `#[slhx::handler]` never referenced by any template. Dead slot warning: template node never targeted by any handler.
|
|
||||||
|
|
||||||
### req:_check/003
|
|
||||||
|
|
||||||
- **003** Renaming a slot or handle breaks `cargo check` immediately with a span pointing to the Rust handler or template source.
|
|
||||||
|
|
||||||
### req:_check/004
|
|
||||||
|
|
||||||
- **004** Page-scoped slot lookup: JS runtime resolves `data-sid` only within the current Page root element.
|
|
||||||
|
|
||||||
### req:_derive_app/001
|
|
||||||
|
|
||||||
- **001** `#[slhx::app]` marks the root application struct containing all global atoms. It is the registry entry point for `slhx_build`. Zero- or single-instance per process.
|
|
||||||
|
|
||||||
### req:_effect/001
|
|
||||||
|
|
||||||
- **001** Effects are a typed command stream describing *what*, *where*, and *how* of a DOM mutation. Declarative: Rust builds the stream; JS applies it.
|
|
||||||
|
|
||||||
### req:_effect/002
|
|
||||||
|
|
||||||
- **002** The public API is `IntoEffect` (a trait) for Rust ergonomics and zero-allocation encoding. The wire API is a canonical postcard opcode schema (`Op::ReplaceHtml`, `Op::SetText`, `Op::PatchAtom`, `Op::Navigate`, `Op::Focus`, `Op::AddClass`, `Op::RemoveClass`, `Op::RemoveKeyed`, `Op::CustomOp`). IntoEffect writes opcodes directly; advanced users may implement the trait to stream custom opcodes.
|
|
||||||
|
|
||||||
### req:_effect/003
|
|
||||||
|
|
||||||
- **003** Core effect helpers: `replace(slot, html)`, `patch(slot, atom)`, `text(slot, value)`, `remove_keyed(slot, key)`, `append_keyed(slot, key, html)`, `move_keyed(slot, key, target_slot, target_key, position)`, `class_keyed(slot, key, class, active)`, `navigate(Nav { url, mode, title, scroll })`, `focus(slot)`, `add_class(slot, class)`, `remove_class(slot, class)`, `batch((...))`.
|
|
||||||
|
|
||||||
### req:_effect/004
|
|
||||||
|
|
||||||
- **004** `batch` composes effects in declared order. No implicit ordering, no priority weights.
|
|
||||||
|
|
||||||
### req:_effect/005
|
|
||||||
|
|
||||||
- **005** `Effect::navigate` carries `NavMode::Push | Replace | Redirect`, optional `title`, and `ScrollMode`. No separate router framework required for basic cases.
|
|
||||||
|
|
||||||
### req:_effect/006
|
|
||||||
|
|
||||||
- **006** Core effects may carry an opaque transition token, but core never interprets or implements transitions. Transitions live in `slhx-transition`.
|
|
||||||
|
|
||||||
### req:_effect/007
|
|
||||||
|
|
||||||
- **007** Zero runtime parsing of selectors. The JS runtime looks up elements by numeric `data-sid` or `data-hid` attributes. Slot ids are allocated deterministically.
|
|
||||||
|
|
||||||
### req:_form/001
|
|
||||||
|
|
||||||
- **001** Forms are first-class. hemplate exports `FormSurface` with raw `ControlKind` facts. slhx generates/validates Rust form structs from those facts.
|
|
||||||
|
|
||||||
### req:_form/002
|
|
||||||
|
|
||||||
- **002** `Form<T>` is generated by slhx-build from the Surface. `T` derives from HTML control names and kinds, mapped to Rust types by slhx rules (e.g. `type="number"` + `required` → `u64`; same without `required` → `Option<u64>`).
|
|
||||||
|
|
||||||
### req:_form/003
|
|
||||||
|
|
||||||
- **003** Handler signature mismatch between generated `Form<T>` and the handler parameter is a `cargo check` error.
|
|
||||||
|
|
||||||
### req:_form/004
|
|
||||||
|
|
||||||
- **004** Progressive enhancement: if JS fails, `<form data-slhx-handle>` degrades to normal submission via hidden `__h` field. Server reads `__h` and dispatches by numeric handle id.
|
|
||||||
|
|
||||||
### req:_invariant/001
|
|
||||||
|
|
||||||
- **001** User-authored references are symbolic at author time and numeric at runtime.
|
|
||||||
|
|
||||||
### req:_invariant/002
|
|
||||||
|
|
||||||
- **002** The JS runtime never parses CSS selectors, expressions, or handler names.
|
|
||||||
|
|
||||||
### req:_invariant/003
|
|
||||||
|
|
||||||
- **003** Rust handlers return effects; they do not imperatively mutate DOM.
|
|
||||||
|
|
||||||
### req:_invariant/004
|
|
||||||
|
|
||||||
- **004** Cross-file references fail at `cargo check` with a precise span.
|
|
||||||
|
|
||||||
### req:_invariant/005
|
|
||||||
|
|
||||||
- **005** slhx core owns effects, typed ids, and registries only. Routing, auth, sessions, transport, transitions, and sync are integration concerns.
|
|
||||||
|
|
||||||
### req:_js/001
|
|
||||||
|
|
||||||
- **001** The JS runtime is a single file under 3 kB minified+gzipped. It reads `data-hid` and `data-sid`, delegates events on `document`, and applies effects by direct DOM mutation.
|
|
||||||
|
|
||||||
### req:_js/002
|
|
||||||
|
|
||||||
- **002** No build step, no virtual DOM, no diffing, no scheduler. Receiving an effect = apply ops immediately in declared order.
|
|
||||||
|
|
||||||
### req:_js/003
|
|
||||||
|
|
||||||
- **003** The runtime consists of an Op interpreter (`ReplaceHtml`, `SetText`, `PatchAtom`, `Navigate`, `Focus`, `AddClass`, `RemoveClass`, `RemoveKeyed`, `CustomOp`) reading from a postcard byte stream.
|
|
||||||
|
|
||||||
### req:_list/001
|
|
||||||
|
|
||||||
- **001** Any `data-slhx-slot` or `data-slhx-handle` inside `@for` requires an explicit `key` expression. Syntax: `@for item in items key item.id { ... }`. Without `key`, slhx-addressable nodes inside the loop are rejected at build time. Keyed identity is `(SlotId, KeyValue)`.
|
|
||||||
|
|
||||||
### req:_list/002
|
|
||||||
|
|
||||||
- **002** Slots inside a keyed loop receive a composite identity: `(SlotId, KeyValue)`, not a flat id. hemplate records `key_expr` in the Surface; slhx implements keyed slot lookups.
|
|
||||||
|
|
||||||
### req:_list/003
|
|
||||||
|
|
||||||
- **003** Effects on keyed slots: `replace_keyed(slot, key, value)`, `remove_keyed(slot, key)`, `append_keyed(slot, key, value)`. Mismatch between key type and slot key type is compile-time error.
|
|
||||||
|
|
||||||
### req:_misc/001
|
|
||||||
|
|
||||||
- **001** Workspace layout: `slhx-core` (types + postcard schema, no_std), `slhx-derive` (proc-macros), `slhx-build` (surface consumer + code generation), `slhx-axum` (integration), `slhx-js` (runtime single file), `slhx-transition` (optional), `slhx-sync` (optional), `slhx-wasm` (optional). No kitchen-sink crate.
|
|
||||||
|
|
||||||
### req:_misc/002
|
|
||||||
|
|
||||||
- **002** All crates compile on stable Rust. MSRV 1.80. `slhx-core` has zero proc-macro dependencies.
|
|
||||||
|
|
||||||
### req:_misc/003
|
|
||||||
|
|
||||||
- **003** No auth, no routing, no session storage inside slhx core. `slhx-axum` provides typed route mounting; actual routing is axum/tower.
|
|
||||||
|
|
||||||
### req:_misc/004
|
|
||||||
|
|
||||||
- **004** Three execution modes supported: server-first (request/response), client-local WASM (requestAnimationFrame, no round-trip), and hybrid sync (local + remote via `slhx-sync`). Modes are opt-in per handler, not global.
|
|
||||||
|
|
||||||
### req:_misc/005
|
|
||||||
|
|
||||||
- **005** The only user-facing proc-macro is `#[slhx::handler]`. No `!` call-syntax macros. Attribute macros only.
|
|
||||||
|
|
||||||
### req:_ms/001
|
|
||||||
|
|
||||||
- **001** **Milestone app: Local-first Multiplayer Kanban.** A board with drag-and-drop cards, 60fps pointer-follow, optimistic updates, offline queue, conflict reconciliation, live presence, and SSR-first rendering — all without React/Vue/VDOM, in a single typed Rust codebase. This acts as the north-star integration test for slhx + hemplate + slhx-sync. [north_star]
|
|
||||||
|
|
||||||
### req:_pitch/001
|
|
||||||
|
|
||||||
- **001** slhx is checked hypermedia for Rust. Authors write HTML templates and Rust handlers. The compiler lowers every cross-file reference to a stable numeric id. The browser runtime only sees ids and effect bytes. [north_star]
|
|
||||||
|
|
||||||
### req:_pitch/002
|
|
||||||
|
|
||||||
- **002** No CSS selectors. No hx-* strings. No virtual DOM. No client framework. No hidden global proxy magic. No hydration. [north_star]
|
|
||||||
|
|
||||||
### req:_pitch/003
|
|
||||||
|
|
||||||
- **003** slhx replaces React/Vue not with a UI framework, but with a compiler contract: hemplate knows the surface, Rust knows the types, slhx knows the effects, the browser only executes commands. [north_star]
|
|
||||||
|
|
||||||
### req:_resource/001
|
|
||||||
|
|
||||||
- **001** Async remote data lives in `Resource<T>` / `Query<K, T>` / `Mutation<I, O>`. These are optional, not core primitives. They provide loading/error/refresh semantics without client-side data libraries.
|
|
||||||
|
|
||||||
### req:_resource/002
|
|
||||||
|
|
||||||
- **002** `Effect::resource(res).reload()` triggers a re-fetch and re-render. The server sends a new EffectBatch when data is ready.
|
|
||||||
|
|
||||||
### req:_state/001
|
|
||||||
|
|
||||||
- **001** `Atom<T>` is a typed, stable-id handle to a piece of state. Atoms are the only state primitive in core.
|
|
||||||
|
|
||||||
### req:_state/002
|
|
||||||
|
|
||||||
- **002** State shape is flat. Nesting is an anti-pattern; compose via multiple atoms.
|
|
||||||
|
|
||||||
### req:_state/003
|
|
||||||
|
|
||||||
- **003** No proxy magic. State access is explicit: `store.get(atom)` returns `Option<&T>`. Mutations return `impl IntoEffect`, not side-effects.
|
|
||||||
|
|
||||||
### req:_state/004
|
|
||||||
|
|
||||||
- **004** Page-local transient state lives in JS as `Map<AtomId, unknown>`. Not reactive-by-default.
|
|
||||||
|
|
||||||
### req:_state/005
|
|
||||||
|
|
||||||
- **005** Global long-lived state is stored server-side in a session-compatible way. On re-render the server injects a `postcard`-encoded blob in `<script type="application/slhx-state">`; JS hydrates it so client-side handlers (WASM-compiled Rust) can read it without round-trips.
|
|
||||||
|
|
||||||
### req:_state/006
|
|
||||||
|
|
||||||
- **006** The atom model is isomorphic to ECS. Atoms are components, Pages are worlds, Slots are entities. Scales to game-like WASM applications.
|
|
||||||
|
|
||||||
### req:_surface/001
|
|
||||||
|
|
||||||
- **001** `hemplate_build` scans `.heml` files and emits `$OUT_DIR/hemplate.surface.postcard` (postcard-encoded, deterministic, versioned).
|
|
||||||
|
|
||||||
### req:_surface/002
|
|
||||||
|
|
||||||
- **002** The Surface contains: nodes (NodeId, parent, scope, element, attrs, source span), scopes (ScopeKind: Root | If | For { binding, key_expr }), forms (form controls with raw HTML types), and component uses.
|
|
||||||
|
|
||||||
### req:_surface/003
|
|
||||||
|
|
||||||
- **003** Node identity is `NodeId` in a parent/scope graph. No `css_path` is used as a primary identifier. An optional `debug_path` string may exist for diagnostics only.
|
|
||||||
|
|
||||||
### req:_surface/004
|
|
||||||
|
|
||||||
- **004** Form controls in the Surface carry raw HTML facts: `ControlKind::Text`, `ControlKind::Number { min, max, step }`, `ControlKind::Checkbox`, `ControlKind::Select { multiple, options }`, etc. No Rust type mapping lives in hemplate.
|
|
||||||
|
|
||||||
### req:_surface/005
|
|
||||||
|
|
||||||
- **005** Loop scopes expose the binding name and an optional `key_expr` (e.g. `todo.id`). hemplate does not enforce key usage; it only records it for consumers.
|
|
||||||
|
|
||||||
### req:_surface/006
|
|
||||||
|
|
||||||
- **006** Surface schema is versioned (`schema_version: u32`). Postcard encoding, no JSON. `no_std`-compatible schema definition so any tool can read it without heavy dependencies.
|
|
||||||
|
|
||||||
### req:_surface/007
|
|
||||||
|
|
||||||
- **007** `hemplate-derive` does not write Surface files. Surface generation is a `build.rs` / `hemplate_build` concern, proc-macro side-effect free.
|
|
||||||
|
|
||||||
### req:_sync/001
|
|
||||||
|
|
||||||
- **001** `slhx-sync` is an optional crate for collaborative / multiplayer state. Provides presence tracking, patch reconciliation, conflict resolution (server-authoritative), and offline queueing. Not part of core.
|
|
||||||
|
|
||||||
### req:_sync/002
|
|
||||||
|
|
||||||
- **002** `SyncEffect::send_patch(atom, patch)` queues a state diff for server sync. If offline, the patch is stored in a local queue and sent when connection resumes. If online, it is sent immediately via WebSocket/SSE.
|
|
||||||
|
|
||||||
### req:_sync/003
|
|
||||||
|
|
||||||
- **003** `Effect::ack(atom)` acknowledges a successful server-side mutation, allowing the client to clear its local optimistic queue for that atom.
|
|
||||||
|
|
||||||
### req:_sync/004
|
|
||||||
|
|
||||||
- **004** `Effect::broadcast(channel, effect_batch)` sends an `EffectBatch` to all subscribers of a named channel. Used for presence updates and live collaboration. The server framework manages the transport (WS/SSE).
|
|
||||||
|
|
||||||
### req:_sync/005
|
|
||||||
|
|
||||||
- **005** `#[slhx_sync::presence]` is an attribute macro on functions that return `impl IntoEffect` when a user joins or leaves a shared session. Emits `Effect::broadcast` over a presence channel scoped to the session.
|
|
||||||
|
|
||||||
### req:_sync/006
|
|
||||||
|
|
||||||
- **006** `slhx-sync` uses a flat patch model per atom, not CRDT by default. Server is authoritative; clients apply server-canonical state on conflict. Optional CRDT backend may be provided by a future `slhx-crdt` crate.
|
|
||||||
|
|
||||||
### req:_wire/001
|
|
||||||
|
|
||||||
- **001** HTML wire format is standard HTML with `data-hid` and `data-sid` attributes only. No custom markup, no hx-* attributes.
|
|
||||||
|
|
||||||
### req:_wire/002
|
|
||||||
|
|
||||||
- **002** POST bodies carry `application/x-www-form-urlencoded` with distinguished field `__h` (handle id). Server routes by numeric id, not by URL path.
|
|
||||||
|
|
||||||
### req:_wire/003
|
|
||||||
|
|
||||||
- **003** Responses are `text/html` fragments (or `application/slhx` for push streams). Fragments may contain `<template data-slhx>` elements whose text content is a base64url-encoded postcard `EffectBatch`. JS decodes and applies.
|
|
||||||
|
|
||||||
### req:_wire/004
|
|
||||||
|
|
||||||
- **004** Server push is supported orthogonally: `Effect::push(stream, effect)` sends a pre-serialized effect batch over an SSE or WebSocket connection. Connection management is a server-framework concern.
|
|
||||||
|
|
||||||
## Coverage: 0/68 (100.0% uncited)
|
|
||||||
|
|||||||
Generated
+528
@@ -0,0 +1,528 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aho-corasick"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-trait"
|
||||||
|
version = "0.1.89"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum"
|
||||||
|
version = "0.7.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"itoa",
|
||||||
|
"matchit",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustversion",
|
||||||
|
"serde",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-core"
|
||||||
|
version = "0.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustversion",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.2.62"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cobs"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "embedded-io"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "embedded-io"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-core"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-task"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-util"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-task",
|
||||||
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hemplate-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"hemplate-parser",
|
||||||
|
"thiserror",
|
||||||
|
"tree-sitter",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hemplate-parser"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"tree-sitter",
|
||||||
|
"tree-sitter-hemplate",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body-util"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchit"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime"
|
||||||
|
version = "0.3.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "percent-encoding"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "postcard"
|
||||||
|
version = "1.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
||||||
|
dependencies = [
|
||||||
|
"cobs",
|
||||||
|
"embedded-io 0.4.0",
|
||||||
|
"embedded-io 0.6.1",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-automata"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-syntax"
|
||||||
|
version = "0.8.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.149"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slab"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slhx"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"slhx-core",
|
||||||
|
"slhx-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slhx-axum"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"slhx-core",
|
||||||
|
"slhx-js",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slhx-build"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"hemplate-core",
|
||||||
|
"slhx-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slhx-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"postcard",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slhx-derive"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slhx-js"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slhx-test"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"slhx-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.117"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sync_wrapper"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-layer"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-service"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tree-sitter"
|
||||||
|
version = "0.25.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"regex",
|
||||||
|
"regex-syntax",
|
||||||
|
"serde_json",
|
||||||
|
"streaming-iterator",
|
||||||
|
"tree-sitter-language",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tree-sitter-hemplate"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"tree-sitter-language",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tree-sitter-language"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["slhx-core", "slhx-derive", "slhx-js", "slhx-axum"]
|
members = ["slhx", "slhx-core", "slhx-derive", "slhx-js", "slhx-axum", "slhx-build", "slhx-test"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
+528
-239
@@ -7,13 +7,32 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## laws
|
||||||
|
|
||||||
|
### req: law/001
|
||||||
|
001 A feature belongs in core only if it can be expressed as typed resources plus EffectBatch ops.
|
||||||
|
|
||||||
|
### req: law/002
|
||||||
|
002 A feature belongs in an integration crate if it depends on transport, framework, auth, storage, browser capability, or deployment policy.
|
||||||
|
|
||||||
|
### req: law/003
|
||||||
|
003 A feature belongs in generated API if it improves author ergonomics without adding runtime semantics.
|
||||||
|
|
||||||
|
### req: law/004
|
||||||
|
004 A feature belongs in user code if it is business logic, domain validation, routing policy, authorization policy, persistence, or layout choice.
|
||||||
|
|
||||||
|
### req: law/005
|
||||||
|
005 Add one primitive only if it removes at least five special cases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 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 `#[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]
|
||||||
|
|
||||||
### 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 hydration. No user-authored JavaScript for standard forms, lists, navigation, or server push. [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]
|
||||||
|
|
||||||
### req: pitch/003
|
### req: pitch/003
|
||||||
003 slhx replaces React/Vue not with a UI framework, but with a compiler contract: hemplate knows the surface, Rust knows the types, slhx knows the effects, the browser only executes commands. [north_star]
|
003 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]
|
||||||
@@ -23,6 +42,22 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## modes
|
||||||
|
|
||||||
|
### req: mode/001
|
||||||
|
001 slhx has two happy paths: Page Enhancer and Interaction Handler.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### req: mode/003
|
||||||
|
003 Interaction Handler mode handles forms, buttons, typed params, and targeted updates through `#[slhx::handler]`.
|
||||||
|
|
||||||
|
### req: mode/004
|
||||||
|
004 Beginner docs must teach Page Enhancer first, Interaction Handler second, Atoms third, client-local/WASM fourth, sync last.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## dx
|
## dx
|
||||||
|
|
||||||
### req: dx/001
|
### req: dx/001
|
||||||
@@ -38,10 +73,10 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
|
|||||||
004 Common handlers must fit in a small function. Advanced contexts (`EffectWriter`, raw ops, custom encoders) exist but are not part of the beginner path.
|
004 Common handlers must fit in a small function. Advanced contexts (`EffectWriter`, raw ops, custom encoders) exist but are not part of the beginner path.
|
||||||
|
|
||||||
### req: dx/005
|
### req: dx/005
|
||||||
005 Error messages must explain fixes in author language, not internal language. Say “add `key todo.id` to this loop”, 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 resource methods are the preferred authoring API: `slots::todo_list.render(view)`, `slots::card.replace(key, view)`, `slots::count.text(42)`, `atoms::user.set(user)`. These return `impl IntoEffect`. Raw `Effect::render`, `Effect::set`, and `EffectWriter` remain low-level. [north_star]
|
006 Generated resource methods are the preferred authoring API: `slots::todo_list.render(view)`, `slots::card.replace(key, view)`, `slots::count.text(42)`, `atoms::user.set(user)`. These return `impl IntoEffect`. Raw `Effect` constructors, opcodes, and `EffectWriter` remain low-level. [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.
|
||||||
@@ -83,6 +118,44 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## page_swap
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### req: page_swap/003
|
||||||
|
003 Page swapping uses generated slots, not CSS selectors. The default content target is the slot named `content`, not `#content`.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### req: page_swap/006
|
||||||
|
006 If a page lacks the expected `content` slot, slhx-axum falls back to normal browser navigation in production and emits a diagnostic in development.
|
||||||
|
|
||||||
|
### req: page_swap/007
|
||||||
|
007 `data-slhx-boost` progressively enhances descendant same-origin anchors and forms. Links behave like `data-slhx-nav`; forms behave like slhx form submissions. External links, downloads, new-tab links, and modified-clicks preserve native browser behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## htmx_equivalents
|
||||||
|
|
||||||
|
### req: htmx/001
|
||||||
|
001 slhx replaces common HTMX use-cases through typed equivalents, not HTMX syntax.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## component
|
## component
|
||||||
|
|
||||||
### req: component/001
|
### req: component/001
|
||||||
@@ -103,60 +176,7 @@ Global exports are opt-in only.
|
|||||||
005 An optional `#[slhx::component]` macro may validate that every handle declared in the template Surface has a corresponding `#[slhx::handler]` within the annotated module. This is the only macro with cross-handler visibility inside a single module; it remains strictly local. Missing handlers without `#[slhx::component]` are caught at app mount or test time, not `cargo check`.
|
005 An optional `#[slhx::component]` macro may validate that every handle declared in the template Surface has a corresponding `#[slhx::handler]` within the annotated module. This is the only macro with cross-handler visibility inside a single module; it remains strictly local. Missing handlers without `#[slhx::component]` are caught at app mount or test time, not `cargo check`.
|
||||||
|
|
||||||
### req: component/006
|
### req: component/006
|
||||||
006 `#[derive(Hemplate)]` structs are natural component boundaries. slhx_build
|
006 `#[derive(Hemplate)]` structs are natural component boundaries. slhx_build discovers them automatically; no additional configuration is required for most apps.
|
||||||
generates APIs per component/template namespace and may merge component
|
|
||||||
Surfaces only at explicit app/root boundaries.
|
|
||||||
|
|
||||||
---
|
|
||||||
## codegen
|
|
||||||
|
|
||||||
### req: codegen/001
|
|
||||||
001 `slhx_build` generates three artifacts from the generic Surface IR: (a) `slhx.generated.rs` containing ergonomic resource modules (`slots`, `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
|
|
||||||
002 Generated module `slots` exposes ergonomic methods: `Slot<T>::render(value)`, `Slot<T>::text(value)`, `KeyedSlot<K,T>::append(key, value)`, `KeyedSlot<K,T>::replace(key, value)`, `KeyedSlot<K,T>::remove(key)`. Methods return `impl IntoEffect`.
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
### req: codegen/004
|
|
||||||
004 Generated module `forms` exports `FormContract` metadata (field names, HTML control kinds, required). `#[slhx::handler]` compares the `Form<T>` type against the contract. Domain types (`Email`, `TodoId`) remain user-authored; no auto-generated structs.
|
|
||||||
|
|
||||||
### req: codegen/005
|
|
||||||
005 Generated module `atoms` exports `Atom<T>` for values that must be addressable, hydrated, or synced. Ordinary Rust fields on app/components are not automatically atoms.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## invariant
|
|
||||||
|
|
||||||
### req: invariant/001
|
|
||||||
001 User-authored references are symbolic at author time and numeric at runtime.
|
|
||||||
|
|
||||||
### req: invariant/002
|
|
||||||
002 The JS runtime never parses CSS selectors, expressions, or handler names.
|
|
||||||
|
|
||||||
### req: invariant/003
|
|
||||||
003 Rust handlers return effects; they do not imperatively mutate DOM.
|
|
||||||
|
|
||||||
### req: invariant/004
|
|
||||||
004 Cross-file references fail at `cargo check` with a precise span.
|
|
||||||
|
|
||||||
### req: invariant/005
|
|
||||||
005 slhx core owns effects, typed ids, and registries only. Routing, auth, sessions, transport, transitions, and sync are integration concerns.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## boundary
|
|
||||||
|
|
||||||
### req: boundary/001
|
|
||||||
001 hemplate does not expose a slhx API. It exposes a stable, generic Template Surface IR. slhx is one consumer; a11y tools, test generators, and documentation generators are others.
|
|
||||||
|
|
||||||
### req: boundary/002
|
|
||||||
002 hemplate never interprets `data-slhx-*` or any other tool-prefixed
|
|
||||||
attribute. It records them as raw `name: value` pairs in the Surface.
|
|
||||||
|
|
||||||
### req: boundary/003
|
|
||||||
003 slhx never parses `.heml` directly. It consumes `hemplate.surface.postcard` emitted by `hemplate_build`. slhx interprets tool-specific conventions (`data-slhx-handle`, `data-slhx-slot`, etc.) from the generic Surface.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -166,7 +186,7 @@ attribute. It records them as raw `name: value` pairs in the Surface.
|
|||||||
001 `hemplate_build` scans `.heml` files and emits `$OUT_DIR/hemplate.surface.postcard` (postcard-encoded, deterministic, versioned).
|
001 `hemplate_build` scans `.heml` files and emits `$OUT_DIR/hemplate.surface.postcard` (postcard-encoded, deterministic, versioned).
|
||||||
|
|
||||||
### req: surface/002
|
### req: surface/002
|
||||||
002 The Surface contains: nodes (NodeId, parent, scope, element, attrs, source span), scopes (ScopeKind: Root | If | For { binding, key_expr }), forms (form controls with raw HTML types), and component uses.
|
002 The Surface contains: nodes (NodeId, parent, scope, element, attrs, source span), scopes (ScopeKind: Root | If | Match | Case | For { binding, key_expr }), forms (form controls with raw HTML types), and component uses.
|
||||||
|
|
||||||
### req: surface/003
|
### req: surface/003
|
||||||
003 Node identity is `NodeId` in a parent/scope graph. No `css_path` is used as a primary identifier. An optional `debug_path` string may exist for diagnostics only.
|
003 Node identity is `NodeId` in a parent/scope graph. No `css_path` is used as a primary identifier. An optional `debug_path` string may exist for diagnostics only.
|
||||||
@@ -175,7 +195,7 @@ attribute. It records them as raw `name: value` pairs in the Surface.
|
|||||||
004 Form controls in the Surface carry raw HTML facts: `ControlKind::Text`, `ControlKind::Number { min, max, step }`, `ControlKind::Checkbox`, `ControlKind::Select { multiple, options }`, etc. No Rust type mapping lives in hemplate.
|
004 Form controls in the Surface carry raw HTML facts: `ControlKind::Text`, `ControlKind::Number { min, max, step }`, `ControlKind::Checkbox`, `ControlKind::Select { multiple, options }`, etc. No Rust type mapping lives in hemplate.
|
||||||
|
|
||||||
### req: surface/005
|
### req: surface/005
|
||||||
005 Loop scopes expose the binding name and an optional `key_expr` (e.g. `todo.id`). hemplate does not enforce key usage; it only records it for consumers.
|
005 Loop scopes expose the binding name and an optional `key_expr` (e.g. `todo.id`). hemplate does not enforce key usage; it only records it for consumers. slhx_build enforces key presence only when a slhx-addressable node appears inside the loop.
|
||||||
|
|
||||||
### req: surface/006
|
### req: surface/006
|
||||||
006 Surface schema is versioned (`schema_version: u32`). Postcard encoding, no JSON. `no_std`-compatible schema definition so any tool can read it without heavy dependencies.
|
006 Surface schema is versioned (`schema_version: u32`). Postcard encoding, no JSON. `no_std`-compatible schema definition so any tool can read it without heavy dependencies.
|
||||||
@@ -185,93 +205,114 @@ attribute. It records them as raw `name: value` pairs in the Surface.
|
|||||||
|
|
||||||
### req: surface/008
|
### req: surface/008
|
||||||
008 The Surface records hemplate structural directives as first-class facts:
|
008 The Surface records hemplate structural directives as first-class facts:
|
||||||
`h-for`, `h-key`, `h-if`, `h-else-if`, `h-else`, dynamic `+attr` bindings,
|
`h-for`, `h-key`, `h-if`, `h-else-if`, `h-else`, `h-match`, `h-case`,
|
||||||
and interpolated attr/text expressions. slhx consumes these facts; it never
|
dynamic `+attr` bindings, and interpolated attr/text expressions. slhx consumes
|
||||||
parses `.heml` source directly.
|
these facts; it never parses `.heml` source directly.
|
||||||
|
|
||||||
### req: surface/009
|
### req: surface/009
|
||||||
009 Raw/pre-rendered HTML insertions are opaque Surface holes. The parent
|
009 Raw/pre-rendered HTML insertions are opaque Surface holes. The parent
|
||||||
Surface records the insertion point and source span, but nodes inside inserted
|
element is present; slhx_build emits the appropriate rendering call.
|
||||||
HTML belong to the child component Surface or remain invisible to tools.
|
|
||||||
|
|
||||||
### req: surface/010
|
### req: surface/010
|
||||||
010 Attribute values preserve their origin: static literal, dynamic `+attr`
|
010 Attribute values preserve their origin: static literal, dynamic `+attr`
|
||||||
expression, or interpolated template string. slhx param inference consumes
|
binding, or interpolated expression. slhx-build uses this to determine whether
|
||||||
these from Surface and never reparses `.heml`.
|
a `data-*` handle param is statically known or runtime-extracted.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## build
|
## codegen
|
||||||
|
|
||||||
### req: build/001
|
### req: codegen/001
|
||||||
001 Build order: `.heml` → `hemplate_build` → `hemplate.surface.postcard` → `slhx_build` → `slhx.generated.rs` + `slhx.syms` + diagnostics.
|
001 `slhx_build` generates three artifacts from the generic Surface IR: (a) `slhx.generated.rs` containing ergonomic resource modules (`slots`, `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: build/002
|
### req: codegen/002
|
||||||
002 Proc-macros (`#[slhx::handler]`, `#[slhx::surface]`) are side-effect free. They read generated artifacts (`slhx.syms`, `slhx.generated.rs`) but never parse `.heml`, never process generic Surface IR, and never write files. Global codegen lives only in `build.rs` invoked by `slhx_build`. [north_star]
|
002 Generated module `slots` exposes ergonomic methods: `Slot<T>::render(value)`, `Slot<T>::text(value)`, `KeyedSlot<K,T>::append(key, value)`, `KeyedSlot<K,T>::prepend(key, value)`, `KeyedSlot<K,T>::replace(key, value)`, `KeyedSlot<K,T>::remove(key)`. Methods return `impl IntoEffect`.
|
||||||
|
|
||||||
### req: build/003
|
### req: codegen/003
|
||||||
003 `slhx-derive` (`#[slhx::handler]`) reads `slhx.syms` at expansion time to validate handle names, slot names, and form signatures. It generates only local glue (static fn-table entry) plus compile errors.
|
003 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.
|
||||||
|
|
||||||
### req: build/004
|
### req: codegen/004
|
||||||
004 `#[slhx::surface]` reads `slhx.generated.rs` from `$OUT_DIR` and expands it into the annotated module. It is a pure include/bridge macro with no semantic analysis of its own.
|
004 Generated module `forms` exports `FormContract` metadata (field names, HTML control kinds, required). `#[slhx::handler]` compares the `Form<T>` type against the contract. Domain types (`Email`, `TodoId`) remain user-authored; no auto-generated structs.
|
||||||
|
|
||||||
### req: build/005
|
### req: codegen/005
|
||||||
005 A `build.rs` failure (missing Surface, version mismatch, stale hash) is a hard error before proc-macro expansion.
|
005 Generated module `atoms` exports `Atom<T>` for values that must be addressable, bootstrapped, or synced. Ordinary Rust fields on app/components are not automatically atoms.
|
||||||
|
|
||||||
### req: build/006
|
|
||||||
006 Proc-macros are considered local: they have knowledge of the item they annotate, plus pre-generated symbol tables. They do not have global knowledge of all handlers across the crate. Global checks (e.g. every declared handle has an implementation) are either deferred to app-mount tests or enabled by an optional `#[slhx::component]` macro.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## effect
|
## public_api
|
||||||
|
|
||||||
### req: effect/001
|
### req: public_api/001
|
||||||
001 Rust handlers return `impl IntoEffect`, not a concrete `Vec<Effect>`. `IntoEffect::encode(self, &mut EffectWriter)` may write directly into a response buffer, a test collector, or an event stream. Zero-allocation encoding is possible; allocation is not required.
|
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.
|
||||||
|
|
||||||
### req: effect/002
|
### req: public_api/002
|
||||||
002 `EffectWriter` has a fixed canonical op set: `Set`, `Patch`, `Insert`, `Remove`, `Move`, `Focus`, `Navigate`, `Emit`. The DOM is one backend; core does not hardcode DOM operations. Wire format is canonical postcard opcodes; Rust API is flexible.
|
002 `Effect`, `EffectWriter`, `ResourceId`, `ResourceRef`, and raw opcodes are advanced APIs. They must not appear in beginner docs, generated examples, or common diagnostics.
|
||||||
|
|
||||||
### req: effect/003
|
### req: public_api/003
|
||||||
003 Multiple effects are combined with tuple composition or a method chain on `EffectWriter`. No `!` call-syntax macros.
|
003 Every generated command returns `impl IntoEffect` and composes through tuple composition.
|
||||||
|
|
||||||
### req: effect/004
|
### req: public_api/004
|
||||||
004 `Effect::render(slot, value)` is sugar for `Effect::set` on a Slot resource. `Effect::text(slot, value)` is sugar for `Effect::set` with a text payload.
|
004 If a common UI operation requires raw `EffectWriter`, the public API is considered incomplete.
|
||||||
|
|
||||||
### req: effect/005
|
|
||||||
005 effects may carry an opaque transition token, but core never interprets or implements transitions. `slhx-transition` provides transition semantics as an orthogonal integration.
|
|
||||||
|
|
||||||
### req: effect/006
|
|
||||||
006 `Effect::event(name, payload)` dispatches a native `CustomEvent` on the root element. Core interprets the payload as opaque bytes. Web Components, charts, editors, or legacy JS may listen without slhx knowing about them. [north_star]
|
|
||||||
|
|
||||||
### req: effect/007
|
|
||||||
007 `Effect::event` is the raw escape hatch. Common UI events such as toast, dialog close, focus, and clipboard may have typed helper wrappers (`toast("Saved")`, `nav::push("/dashboard")`) in optional crates. Helpers compile to `Effect::event` or canonical ops.
|
|
||||||
|
|
||||||
### req: effect/008
|
|
||||||
008 Small semantic helper types may implement `IntoEffect`: `Toast`, `Nav`, `FormError`, generated slot commands, generated atom commands. Users compose them by returning tuples.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## state
|
## effect_algebra
|
||||||
|
|
||||||
### req: state/001
|
### req: effect_algebra/001
|
||||||
001 Typed atoms with `Atom<T>` are explicit addressable state resources.
|
001 The canonical op set is minimal and closed: `Put`, `Insert`, `Remove`, `Move`, `Focus`, `Navigate`, `Emit`.
|
||||||
Read via `atom.get()`, mutate via `atom.set()` / `atom.update()`, publish to
|
|
||||||
the runtime via generated atom commands or `Effect::set(atom, value)`.
|
|
||||||
No hidden global proxy / reactive graph. Atoms are explicit values in `struct App`.
|
|
||||||
|
|
||||||
### req: state/002
|
### req: effect_algebra/002
|
||||||
002 Atoms are not reactive by default. Updating an atom does not re-render
|
002 `Put` replaces the payload of a resource. For a Slot, this means replacing its rendered contents. For an Atom, this means replacing its stored value.
|
||||||
anything unless the handler returns an effect targeting consumers.
|
|
||||||
Subscriptions are explicit runtime/store APIs. No automatic component re-render graph.
|
|
||||||
|
|
||||||
### req: state/003
|
### req: effect_algebra/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 `Insert`, `Remove`, and `Move` operate on keyed collection resources. They require a key type checked by generated `KeyedSlot<K, T>` wrappers.
|
||||||
|
|
||||||
### req: state/004
|
### req: effect_algebra/004
|
||||||
004 SSR pages carry a `data-slhx-st` base64url-encoded postcard blob on the document root. 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 `Navigate` changes browser history or represents a server redirect. Route matching remains outside slhx core.
|
||||||
|
|
||||||
### req: state/005
|
### req: effect_algebra/005
|
||||||
005 Not all state is an Atom. Ordinary Rust fields are preferred unless the value must be independently addressed, bootstrapped, synced, or subscribed. Atoms are explicit resources, not the default state container.
|
005 `Emit` dispatches a native `CustomEvent` and is the only raw JS interop primitive in core.
|
||||||
|
|
||||||
|
### req: effect_algebra/006
|
||||||
|
006 DOM-specific operations such as `innerHTML`, `textContent`, class toggles, or keyed node lookup are runtime lowering details, not separate author-facing concepts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## typed_id
|
||||||
|
|
||||||
|
### req: typed_id/001
|
||||||
|
001 All public cross-page identifiers (`Slot`, `Atom`, `Handle`, `Form`) share a single internal primitive `ResourceId { kind: ResourceKind, id: u32 }`. A concrete runtime target is a `ResourceRef { resource: ResourceId, scope: Option<ScopeKey> }`. Typed wrappers (`Slot<T>`, `KeyedSlot<K, T>`, `Atom<T>`, `Handle<I>`, `Form<T>`) enforce kind safety at compile time. No special-case opcodes per resource kind; effects address resources uniformly. [north_star]
|
||||||
|
|
||||||
|
### req: typed_id/002
|
||||||
|
002 `ResourceKind` is an internal closed enum (Slot, Atom, Handle, Form).
|
||||||
|
Navigation is represented by `Navigate` effects, not by route resources.
|
||||||
|
External crates may not add variants. Extensibility comes via `Effect::event`
|
||||||
|
or custom `IntoEffect` implementations, never via new `ResourceKind` variants
|
||||||
|
in core. `Effect::event` lowers to the canonical `Emit` opcode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## scope
|
||||||
|
|
||||||
|
### req: scope/001
|
||||||
|
001 `Scope` is a first-class primitive. Keyed loops (`h-for`) create keyed
|
||||||
|
dynamic scopes and require `h-key` for slhx-addressable nodes. Conditional
|
||||||
|
branches (`h-if`, `h-else-if`, `h-else`, `h-match`, `h-case`) create optional
|
||||||
|
presence scopes. Component instances, modals, tabs, and nested forms are scoped
|
||||||
|
resources. Concrete runtime targets are addressed through `ResourceRef`
|
||||||
|
`{ resource: ResourceId, scope: Option<ScopeKey> }`. [north_star]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## list
|
||||||
|
|
||||||
|
### req: list/001
|
||||||
|
001 Any `data-slhx-slot` or `data-slhx-handle` inside a hemplate `h-for` scope requires a stable key. Preferred syntax: `<template h-for="item in &self.items" h-key="item.id"> ... </template>`. Without a key, slhx-addressable nodes inside the loop are rejected at build time. Keyed identity is `ResourceRef { resource: ResourceId, scope: Some(ScopeKey::KeyValue(...)) }`.
|
||||||
|
|
||||||
|
### req: list/002
|
||||||
|
002 Slots inside a keyed loop receive a composite identity. hemplate records `key_expr` in the Surface; slhx implements keyed slot lookups.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -286,66 +327,42 @@ Subscriptions are explicit runtime/store APIs. No automatic component re-render
|
|||||||
### req: form/003
|
### req: form/003
|
||||||
003 Handler receives `form: Form<CreateTodo>`. Validation errors target `(FormId, field_name)` or generated control ids. The runtime maps them to originating form controls via control ids derived from Surface `NodeId`, not via slot ids.
|
003 Handler receives `form: Form<CreateTodo>`. Validation errors target `(FormId, field_name)` or generated control ids. The runtime maps them to originating form controls via control ids derived from Surface `NodeId`, not via slot ids.
|
||||||
|
|
||||||
---
|
### req: form/004
|
||||||
|
004 Form compatibility checks validate field presence, optionality, multiplicity, and parser availability. Domain validation remains Rust logic (`TryFrom`, custom validators, or handler code).
|
||||||
|
|
||||||
## list
|
### req: form/005
|
||||||
|
005 HTML control facts are lower bounds, not complete domain semantics.
|
||||||
|
`type="email"` may require a Rust `Email` parser, but hemplate never defines
|
||||||
|
what a valid business email is.
|
||||||
|
|
||||||
### req: list/001
|
### req: form/006
|
||||||
001 Any `data-slhx-slot` or `data-slhx-handle` inside a hemplate `h-for` scope requires a stable key. Preferred syntax: `<template h-for="item in &self.items" h-key="item.id"> ... </template>`. Without a key, slhx-addressable nodes inside the loop are rejected at build time. Keyed identity is `(SlotId, KeyValue)`.
|
006 Generated diagnostics distinguish structure errors from validation errors: missing field / wrong optionality are compile-time issues; invalid submitted values are runtime form errors.
|
||||||
|
|
||||||
### req: list/002
|
|
||||||
002 Slots inside a keyed loop receive a composite identity: `(SlotId, KeyValue)`, not a flat id. hemplate records `key_expr` in the Surface; slhx implements keyed slot lookups.
|
|
||||||
|
|
||||||
### req: list/003
|
|
||||||
003 Effects on keyed slots: `replace_keyed(slot, key, value)`, `remove_keyed(slot, key)`, `append_keyed(slot, key, value)`. Mismatch between key type and slot key type is compile-time error.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## typed_id
|
## form_effects
|
||||||
|
|
||||||
### req: typed_id/001
|
### req: form_effects/001
|
||||||
001 All public cross-page identifiers (`Slot`, `Atom`, `Handle`, `Form`) share a single internal primitive `ResourceId { kind: ResourceKind, id: u32 }`. A concrete runtime target is a `ResourceRef { resource: ResourceId, scope: Option<ScopeKey> }`. Typed wrappers (`Slot<T>`, `KeyedSlot<K, T>`, `Atom<T>`, `Handle<I>`, `Form<T>`) enforce kind safety at compile time. No special-case opcodes per resource kind; effects address resources uniformly. [north_star]
|
001 Generated form APIs provide common commands: `reset()`, `clear(field)`, `error(field, message)`, `focus(field)`, and `disable_while_pending()`.
|
||||||
|
|
||||||
### req: typed_id/002
|
### req: form_effects/002
|
||||||
002 `ResourceKind` is an internal closed enum (Slot, Atom, Handle, Form, Route). External crates may not add variants. Extensibility comes via `Effect::event`, `Effect::emit`, or custom `IntoEffect` implementations, never via new `ResourceKind` variants in core.
|
002 Form effects target generated form/control ids, not CSS selectors.
|
||||||
|
|
||||||
---
|
### req: form_effects/003
|
||||||
|
003 Templates may declare error display targets with `data-slhx-error-for="field"`. Generated form error effects render into those targets when present and fall back to control validity APIs otherwise.
|
||||||
## async_data
|
|
||||||
|
|
||||||
### req: async_data/001
|
|
||||||
001 Async remote data lives in `Resource<T>` / `Query<K, T>` / `Mutation<I, O>`. These are optional, not core primitives. They provide loading/error/refresh semantics without client-side data libraries.
|
|
||||||
|
|
||||||
### req: async_data/002
|
|
||||||
002 `QueryEffect::reload(res)` triggers a re-fetch and re-render. The server sends a new `EffectBatch` when data is ready. Query/Resource effects live in a separate API surface to keep core small.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## scope
|
|
||||||
|
|
||||||
### req: scope/001
|
|
||||||
001 `Scope` is a first-class primitive. Keyed loops (`h-for`), conditional
|
|
||||||
branches (`h-if`), component instances, modals, tabs, nested forms — all are
|
|
||||||
scopes. A slhx-addressable node inside any dynamic scope must carry a stable
|
|
||||||
`ScopeKey`. Concrete runtime targets are addressed through `ResourceRef`
|
|
||||||
`{ resource: ResourceId, scope: Option<ScopeKey> }`. [north_star]
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## wire
|
## wire
|
||||||
|
|
||||||
### req: wire/001
|
### req: wire/001
|
||||||
001 Authoring HTML uses symbolic `data-slhx-*` attributes. Rendered runtime
|
001 Authoring HTML uses symbolic `data-slhx-*` attributes. Rendered runtime HTML lowers these to compact numeric metadata: `data-hid`, `data-sid`, optional `data-key`, optional atom ids, optional form/control ids, and `data-slhx-st` for state bootstrap. The browser never sees handler or slot names. `data-slhx-root` marks a scoped root boundary.
|
||||||
HTML lowers these to compact numeric metadata: `data-hid`, `data-sid`,
|
|
||||||
optional `data-key`, optional atom ids, optional form/control ids, and
|
|
||||||
`data-slhx-st` for state bootstrap. The browser never sees handler or slot
|
|
||||||
names. `data-slhx-root` marks a scoped root boundary.
|
|
||||||
|
|
||||||
### req: wire/002
|
### req: wire/002
|
||||||
002 POST bodies carry `application/x-www-form-urlencoded` with distinguished field `__h` (handle id). Server routes by numeric id, not by URL path.
|
002 POST bodies carry `application/x-www-form-urlencoded` with distinguished field `__h` (handle id). Server routes by numeric id, not by URL path.
|
||||||
|
|
||||||
### req: wire/003
|
### req: wire/003
|
||||||
003 Responses are `text/html` fragments (or `application/slhx` for push streams). Fragments may contain `<template data-slhx>` elements whose text content is a base64url-encoded postcard `EffectBatch`. JS decodes and applies.
|
003 HTTP interaction responses may be `text/html` fragments containing `<template data-slhx>...`. Push streams use `application/slhx` or transport-specific event frames carrying serialized `EffectBatch`.
|
||||||
|
|
||||||
### req: wire/004
|
### req: wire/004
|
||||||
004 Server push is orthogonal: integration crates stream postcard `EffectBatch` over SSE or WebSocket connections. slhx core owns the effect bytes; transport and connection management are integration concerns.
|
004 Server push is orthogonal: integration crates stream postcard `EffectBatch` over SSE or WebSocket connections. slhx core owns the effect bytes; transport and connection management are integration concerns.
|
||||||
@@ -355,6 +372,25 @@ names. `data-slhx-root` marks a scoped root boundary.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## abi
|
||||||
|
|
||||||
|
### req: abi/001
|
||||||
|
001 Surface IR, slhx symbols, generated Rust API, EffectBatch wire schema, and JS runtime each carry explicit schema/ABI versions.
|
||||||
|
|
||||||
|
### req: abi/002
|
||||||
|
002 slhx_build emits a build fingerprint derived from Surface schema version, resource id allocation, EffectBatch ABI version, and runtime ABI version.
|
||||||
|
|
||||||
|
### req: abi/003
|
||||||
|
003 The server includes the slhx build fingerprint in initial roots. The runtime compares it with its own fingerprint before applying EffectBatches.
|
||||||
|
|
||||||
|
### req: abi/004
|
||||||
|
004 On fingerprint mismatch, the runtime refuses partial updates and falls back to full page navigation or reload. Silent mismatch is forbidden.
|
||||||
|
|
||||||
|
### req: abi/005
|
||||||
|
005 Resource ids are deterministic within a build and stable across builds when canonical symbol paths do not change. Stability is best-effort across refactors, not a persistence guarantee.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## runtime
|
## runtime
|
||||||
|
|
||||||
### req: runtime/001
|
### req: runtime/001
|
||||||
@@ -367,39 +403,83 @@ names. `data-slhx-root` marks a scoped root boundary.
|
|||||||
003 The core JS runtime target is under 5KB minified+gzipped. It remains a tiny op interpreter (no selectors, no VDOM, no scheduler, no expressions). It reads postcard `EffectBatch` bytes and applies them as DOM operations. Optional sync/transition/WASM helpers are separate files.
|
003 The core JS runtime target is under 5KB minified+gzipped. It remains a tiny op interpreter (no selectors, no VDOM, no scheduler, no expressions). It reads postcard `EffectBatch` bytes and applies them as DOM operations. Optional sync/transition/WASM helpers are separate files.
|
||||||
|
|
||||||
### req: runtime/004
|
### req: runtime/004
|
||||||
004 `RuntimeCaps { binary, wasm, sync, transitions }` is exchanged once at init. Handlers may query capabilities and degrade gracefully (e.g. fall back to server request if WASM unavailable). Core does not know about these features; caps are opaque to the wire protocol and only consulted by integrations.
|
004 Core runtime exposes a minimal version/fingerprint handshake only. Capability negotiation belongs to integration crates such as `slhx-wasm`, `slhx-sync`, and `slhx-transition`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## js
|
## failure
|
||||||
|
|
||||||
### req: js/001
|
### req: failure/001
|
||||||
001 Runtime reads attributes `data-hid` and `data-sid`, delegates events on each `data-slhx-root`, and applies effects by direct DOM mutation scoped to that root.
|
001 Missing runtime targets are non-panicking. In development, the runtime emits a diagnostic event and logs the missing ResourceRef. In production, missing optional targets no-op; missing required targets fail the batch and report a recoverable error.
|
||||||
|
|
||||||
### req: js/002
|
### req: failure/002
|
||||||
002 No build step, no virtual DOM, no diffing, no scheduler. Receiving an effect = apply ops immediately in declared order.
|
002 EffectBatch application is ordered and transactional per root where possible. If an op fails, later ops in the same batch are skipped unless the op is marked best-effort.
|
||||||
|
|
||||||
### req: js/003
|
### req: failure/003
|
||||||
003 The runtime implements the canonical `EffectBatch` opcode schema from
|
003 Form parse errors do not call the handler. They produce typed form errors targeting generated control ids.
|
||||||
`slhx-core`. DOM-specific operations such as `ReplaceHtml`, `SetText`,
|
|
||||||
`AddClass`, or `RemoveKeyed` are lowering details generated from canonical
|
### req: failure/004
|
||||||
resource ops.
|
004 Handler errors may map to HTTP responses, form errors, navigation effects, toast/events, or app-defined error effects. Core does not prescribe UI policy.
|
||||||
|
|
||||||
|
### req: failure/005
|
||||||
|
005 Wire/schema version mismatch is a hard failure. The runtime refuses to apply unknown incompatible EffectBatch versions and falls back to full page reload when possible.
|
||||||
|
|
||||||
|
### req: failure/006
|
||||||
|
006 Progressive enhancement failures preserve native browser behavior for forms and links whenever valid HTML fallback exists.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## check
|
## axum_integration
|
||||||
|
|
||||||
### req: check/001
|
### req: axum/001
|
||||||
001 All cross-file references verified at `cargo check`. Unknown handle → hard error. Unknown slot → hard error. Type mismatch between slot and atom → hard error.
|
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.
|
||||||
|
|
||||||
### req: check/002
|
### req: axum/002
|
||||||
002 Dead/missing handle diagnostics are best-effort by default. With `#[slhx::component]`, dead/missing handlers inside the component are checked at `cargo check`. Without it, missing implementations are caught at app mount or in generated registry tests.
|
002 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: check/003
|
### req: axum/003
|
||||||
003 Renaming a slot or handle breaks `cargo check` immediately with a span pointing to the Rust handler or template source.
|
003 Interactive fragments that would traditionally be implemented as `/demo/...` HTMX endpoints should be expressible as `#[slhx::handler]` functions returning generated slot commands.
|
||||||
|
|
||||||
### req: check/004
|
### req: axum/004
|
||||||
004 Page-scoped slot lookup: JS runtime resolves `data-sid` only within the current Page root element.
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## auth
|
||||||
|
|
||||||
|
### req: auth/001
|
||||||
|
001 Auth is not part of slhx core. Authentication, authorization, sessions, cookies, CSRF, and permissions are handled by axum/tower extractors and middleware. slhx handlers may accept typed auth/context extractors.
|
||||||
|
|
||||||
|
### req: auth/002
|
||||||
|
002 slhx-axum preserves normal HTTP auth semantics. Unauthorized handlers may return normal HTTP 401/403, a navigation effect, or an application-defined auth failure effect.
|
||||||
|
|
||||||
|
### req: auth/003
|
||||||
|
003 Progressive enhancement is preserved: login/logout forms remain valid HTML forms. With JS disabled, the server performs normal redirects; with slhx enabled, handlers may return EffectBatch responses.
|
||||||
|
|
||||||
|
### req: auth/004
|
||||||
|
004 CSRF is integration-level. slhx-axum must allow normal hidden form fields, cookies, and extractor-based CSRF validation. slhx core does not define CSRF policy.
|
||||||
|
|
||||||
|
### req: auth/005
|
||||||
|
005 slhx requests preserve standard HTTP credentials semantics. Cookies, SameSite policy, Authorization headers, and session middleware remain framework/browser concerns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## push
|
||||||
|
|
||||||
|
### req: push/001
|
||||||
|
001 Server push streams canonical postcard EffectBatch over SSE or WebSocket. slhx core owns the EffectBatch schema, not the transport.
|
||||||
|
|
||||||
|
### req: push/002
|
||||||
|
002 SSE/WebSocket connections are authenticated by the server framework before stream creation. slhx does not define auth semantics for streams.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### req: push/004
|
||||||
|
004 Out-of-band updates are ordinary multi-target EffectBatches.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -426,6 +506,9 @@ resource ops.
|
|||||||
### req: sync/007
|
### req: sync/007
|
||||||
007 `slhx-sync` uses a flat patch model per atom, not CRDT by default. Server is authoritative; clients apply server-canonical state on conflict. Optional CRDT backend may be provided by a future `slhx-crdt` crate.
|
007 `slhx-sync` uses a flat patch model per atom, not CRDT by default. Server is authoritative; clients apply server-canonical state on conflict. Optional CRDT backend may be provided by a future `slhx-crdt` crate.
|
||||||
|
|
||||||
|
### req: sync/008
|
||||||
|
008 Sync is bidirectional state reconciliation built on top of push/transport. It is not required for server-sent dashboards, notifications, or live status updates.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## interop
|
## interop
|
||||||
@@ -442,49 +525,44 @@ resource ops.
|
|||||||
### 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
|
||||||
|
005 HTMX-style response triggers are represented by `Effect::event` or generated event helpers. Events are native `CustomEvent`s scoped to the slhx root.
|
||||||
## test
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## navigation
|
## nav
|
||||||
|
|
||||||
### req: nav/001
|
### req: nav/001
|
||||||
001 Navigation is an effect, not a router framework: `Effect::navigate(url, mode, scroll, title)`. Core supports `Push`, `Replace`, `Redirect`. Actual route matching, guards, loaders, and nested routes are outside core.
|
001 Navigation is an effect, not a router framework: `Effect::navigate(url, mode, scroll, title)`. Core supports `Push`, `Replace`, `Redirect`. Actual route matching, guards, loaders, and nested routes are outside core.
|
||||||
|
|
||||||
### req: nav/002
|
### req: nav/002
|
||||||
002 Navigation modes: `Push` (history.pushState), `Replace` (replaceState), `Redirect` (server-side 302). Scroll behaviour: `Preserve`, `Top`, `Element(ResourceId)`. Title is optional.
|
002 Navigation modes: `Push` (history.pushState), `Replace` (replaceState), `Redirect` (server-side 302). Scroll behaviour: `Preserve`, `Top`, `Element(ResourceRef)`. Title is optional.
|
||||||
|
|
||||||
### req: nav/003
|
### req: nav/003
|
||||||
003 Navigation enhancement preserves real anchors. Links keep valid `href`. slhx may intercept enhanced links through `data-slhx-handle` or `data-slhx-nav`, but without JS the browser performs normal navigation.
|
003 Navigation enhancement preserves real anchors. Links keep valid `href`. slhx may intercept enhanced links through `data-slhx-handle` or `data-slhx-nav`, but without JS the browser performs normal navigation.
|
||||||
|
|
||||||
---
|
### req: nav/004
|
||||||
|
004 slhx supports both normal HTTP redirects and navigation effects. HTTP redirects are preferred for full-page/non-enhanced flows; navigation effects are preferred for enhanced interaction responses.
|
||||||
|
|
||||||
## client_local
|
### req: nav/005
|
||||||
|
005 Page swap preserves browser history semantics: push, replace, popstate, scroll behavior, and normal modified-click behavior. Back/forward may re-fetch partial content or restore from a bounded cache; correctness must not depend on the cache.
|
||||||
### req: client_local/001
|
|
||||||
001 Client-local handlers use the same function shape as server handlers. Adding `client` changes the execution backend, not the authoring model.
|
|
||||||
|
|
||||||
### req: client_local/002
|
|
||||||
002 Local UI state may live as ordinary fields on the app/component state. `Atom<T>` is required only when the value must be addressed by effects, synced, hydrated, or subscribed.
|
|
||||||
|
|
||||||
### req: client_local/003
|
|
||||||
003 High-frequency UI handlers (drag, pointermove, animation tick) must not require server round-trips or handwritten JS.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## escape_hatch
|
## html_safety
|
||||||
|
|
||||||
### req: escape_hatch/001
|
### req: html/001
|
||||||
001 Three sanctioned escape hatches exist: (1) Web Components as opaque leaf nodes, (2) `Effect::event` for imperative JS interop, (3) WASM islands for CPU-intensive client logic. All three are leaves in the slhx tree, never the app foundation.
|
001 Raw HTML insertion requires an explicit safe HTML type (`SafeHtml` or equivalent). Plain `String` renders as escaped text unless explicitly wrapped.
|
||||||
|
|
||||||
|
### req: html/002
|
||||||
|
002 Hemplate-rendered output may be converted to `SafeHtml` by trusted render APIs. User input is never `SafeHtml` by default.
|
||||||
|
|
||||||
|
### req: html/003
|
||||||
|
003 Slot render commands distinguish text payloads from HTML payloads at the type level.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## view_model
|
## view
|
||||||
|
|
||||||
### req: view/001
|
### req: view/001
|
||||||
001 Slots render view types, not necessarily domain types. Domain-to-view conversion is explicit Rust (`From`, `Into`, or constructor). slhx never assumes a domain object is its own view.
|
001 Slots render view types, not necessarily domain types. Domain-to-view conversion is explicit Rust (`From`, `Into`, or constructor). slhx never assumes a domain object is its own view.
|
||||||
@@ -507,30 +585,138 @@ resource ops.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## derive_handler
|
## test
|
||||||
|
|
||||||
### req: derive_handler/001
|
### req: test/001
|
||||||
001 `#[slhx::handler]` validates: handle name exists in symbol table, params match form surface or `data-*` attributes, return type implements `IntoEffect`. Generate code registers the function in a static lookup table keyed by numeric handle id.
|
001 `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.
|
||||||
|
|
||||||
### req: derive_handler/002
|
### req: test/002
|
||||||
002 Handler param inference from `data-*` attributes: when a template declares `data-card-id="{card.id}"` on a node with `data-slhx-handle`, the handler may declare `card_id: CardId` as a parameter. slhx-build checks attribute → param name and type mapping.
|
002 `#[slhx::component]` and `#[slhx::surface]` proc-macros compile successfully even when no Surface files are present, for incremental development and testing.
|
||||||
|
|
||||||
### req: derive_handler/003
|
### req: test/003
|
||||||
003 Handler parameters are inferred from four sources: `Form<T>`, `data-*` attributes on the triggering node, route params supplied by integration crates, and explicit app/context parameters. Missing or incompatible params are compile-time errors with source spans.
|
003 Generated registries are validated by compile-time tests: missing handler implementations produce test failures with actionable messages.
|
||||||
|
|
||||||
### req: derive_handler/004
|
|
||||||
004 The common handler signature forms are:
|
|
||||||
`fn h(form: Form<T>, app: &mut App) -> impl IntoEffect`
|
|
||||||
`fn h(id: Id, app: &mut App) -> impl IntoEffect`
|
|
||||||
`fn h(event: Event<T>, app: &mut App) -> impl IntoEffect`
|
|
||||||
`fn h(ctx: Ctx, ...) -> Result<impl IntoEffect, Error>`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## derive_app
|
## check
|
||||||
|
|
||||||
### req: derive_app/001
|
### req: check/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 All symbolic cross-file references visible to build/proc-macro validation are verified at `cargo check`. Unknown handle → hard error. Unknown slot → hard error. Type mismatch between slot and atom → hard error.
|
||||||
|
|
||||||
|
### req: check/002
|
||||||
|
002 Dead/missing handle diagnostics are best-effort by default. With `#[slhx::component]`, dead/missing handlers inside the component are checked at `cargo check`. Without it, missing implementations are caught at app mount or in generated registry tests.
|
||||||
|
|
||||||
|
### req: check/003
|
||||||
|
003 Renaming a slot or handle breaks `cargo check` immediately with a span pointing to the Rust handler or template source.
|
||||||
|
|
||||||
|
### req: check/004
|
||||||
|
004 Root-scoped slot lookup: JS runtime resolves `data-sid` only within the nearest/current `data-slhx-root`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## state
|
||||||
|
|
||||||
|
### req: state/001
|
||||||
|
001 Typed atoms with `Atom<T>` are explicit addressable state resources.
|
||||||
|
|
||||||
|
### req: state/002
|
||||||
|
002 Atoms are not reactive by default. Updating an atom does not re-render anything until a handler returns an effect referencing it.
|
||||||
|
|
||||||
|
### req: state/003
|
||||||
|
003 The JS runtime 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`.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### req: state/005
|
||||||
|
005 Not all state is an Atom. Ordinary Rust fields are preferred unless the value must be independently addressed, bootstrapped, synced, or subscribed. Atoms are explicit resources, not the default state container.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## client_local
|
||||||
|
|
||||||
|
### req: client_local/001
|
||||||
|
001 Client-local handlers use the same function shape as server handlers. Opting into a client-local backend changes where the handler executes, not the authoring model.
|
||||||
|
|
||||||
|
### req: client_local/002
|
||||||
|
002 Local UI state may live as ordinary fields on the app/component state. `Atom<T>` is required only when the value must be addressed by effects, bootstrapped, synced, or subscribed.
|
||||||
|
|
||||||
|
### req: client_local/003
|
||||||
|
003 High-frequency UI handlers (drag, pointermove, animation tick) must not require server round-trips or handwritten JS.
|
||||||
|
|
||||||
|
### req: client_local/004
|
||||||
|
004 The exact opt-in syntax for client-local handlers is not part of slhx-core v0. `#[slhx::handler(client)]` is illustrative; final syntax belongs to `slhx-wasm` integration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## async_data
|
||||||
|
|
||||||
|
### req: async_data/001
|
||||||
|
001 If introduced, async remote data helpers live in optional `Resource<T>` / `Query<K, T>` / `Mutation<I, O>` integrations, not in v0 core. v0 server-first data loading is ordinary Rust/Axum code.
|
||||||
|
|
||||||
|
### req: async_data/002
|
||||||
|
002 `QueryEffect::reload(res)` triggers a re-fetch and re-render. The server sends a new `EffectBatch` when data is ready. Query/Resource effects live in a separate API surface to keep core small.
|
||||||
|
|
||||||
|
### req: async_data/003
|
||||||
|
003 If introduced, query helpers must compile to ordinary handlers and effects; they must not introduce a client-side data framework or cache as a core dependency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## invariant
|
||||||
|
|
||||||
|
### req: invariant/001
|
||||||
|
001 User-authored references are symbolic at author time and numeric at runtime.
|
||||||
|
|
||||||
|
### req: invariant/002
|
||||||
|
002 The JS runtime never parses CSS selectors, expressions, or handler names.
|
||||||
|
|
||||||
|
### req: invariant/003
|
||||||
|
003 Rust handlers return effects; they do not imperatively mutate DOM.
|
||||||
|
|
||||||
|
### req: invariant/004
|
||||||
|
004 Cross-file references that are visible to build/proc-macro validation fail at `cargo check` with a precise span. Global completeness checks, such as missing handler implementations across a crate, are `cargo check` errors only inside `#[slhx::component]`; otherwise they are caught at app mount or generated registry tests.
|
||||||
|
|
||||||
|
### req: invariant/005
|
||||||
|
005 slhx core owns effects, typed ids, and registries only. Routing, auth, sessions, transport, transitions, and sync are integration concerns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0_scope
|
||||||
|
|
||||||
|
### req: v0/001
|
||||||
|
001 v0 stable release includes: Surface consumption, generated slots/forms/handles, `#[slhx::handler]`, tuple `IntoEffect`, form dispatch, typed data-* params, keyed slots, page swap, root-scoped runtime, EffectBatch wire schema, diagnostics, tests, and `slhx-axum` integration.
|
||||||
|
|
||||||
|
### req: v0/002
|
||||||
|
002 v0 excludes: sync, wasm/client-local handlers, transitions, query/cache helpers, CRDT, custom component lifecycle, built-in auth, built-in router, and HTMX compatibility mode.
|
||||||
|
|
||||||
|
### req: v0/003
|
||||||
|
003 v0 proof apps are: counter, docs-site HTMX replacement, form wizard, auth action, SSE notification stream, and keyed todo list.
|
||||||
|
|
||||||
|
### req: v0/004
|
||||||
|
004 The local-first kanban remains the north-star milestone, not a v0 blocker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## examples
|
||||||
|
|
||||||
|
### req: examples/001
|
||||||
|
001 The repository must contain canonical examples that act as API tests. v0 examples are counter, todo CRUD, form wizard, docs-site page swap, auth action, SSE notifications, and keyed todo list; local-first kanban is a north-star milestone example.
|
||||||
|
|
||||||
|
### req: examples/002
|
||||||
|
002 Each example must have a maximum ceremony budget. The counter example must fit in under 50 lines of user-authored Rust plus one template. Todo CRUD must fit in under 150 lines excluding model definitions.
|
||||||
|
|
||||||
|
### req: examples/003
|
||||||
|
003 If an example requires raw EffectWriter, manual ids, manual JS, or manual registry setup, the API is considered too complex.
|
||||||
|
|
||||||
|
### req: examples/004
|
||||||
|
004 Canonical examples are compile-tested golden API contracts. Changing generated API shape requires updating the examples deliberately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## milestone
|
||||||
|
|
||||||
|
### req: ms/001
|
||||||
|
001 **Milestone app: Local-first Multiplayer Kanban.** A board with drag-and-drop cards, 60fps pointer-follow, optimistic updates, offline queue, conflict reconciliation, live presence, and SSR-first rendering — all without React/Vue/VDOM, in a single typed Rust codebase. This acts as the north-star integration test for slhx + hemplate + slhx-sync. [north_star]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -541,23 +727,25 @@ resource ops.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## examples
|
## build
|
||||||
|
|
||||||
### req: examples/001
|
### req: build/001
|
||||||
001 The repository must contain canonical examples that act as API tests: counter, todo CRUD, form wizard, realtime dashboard, local-first kanban.
|
001 Build order: `.heml` → `hemplate_build` → `hemplate.surface.postcard` → `slhx_build` → `slhx.generated.rs` + `slhx.syms` + diagnostics.
|
||||||
|
|
||||||
### req: examples/002
|
### req: build/002
|
||||||
002 Each example must have a maximum ceremony budget. The counter example must fit in under 50 lines of user-authored Rust plus one template. Todo CRUD must fit in under 150 lines excluding model definitions.
|
002 Proc-macros (`#[slhx::handler]`, `#[slhx::surface]`) are side-effect free. They read generated artifacts (`slhx.syms`, `slhx.generated.rs`) but never parse `.heml`, never process generic Surface IR, and never write files. Global codegen lives only in `build.rs` invoked by `slhx_build`. [north_star]
|
||||||
|
|
||||||
### req: examples/003
|
### req: build/003
|
||||||
003 If an example requires raw EffectWriter, manual ids, manual JS, or manual registry setup, the API is considered too complex.
|
003 `slhx-derive` (`#[slhx::handler]`) reads `slhx.syms` at expansion time to validate handle names, slot names, and form signatures. It generates only local glue (static fn-table entry) plus compile errors.
|
||||||
|
|
||||||
---
|
### req: build/004
|
||||||
|
004 `#[slhx::surface]` reads `slhx.generated.rs` from `$OUT_DIR` and expands it into the annotated module. It is a pure include/bridge macro with no semantic analysis of its own.
|
||||||
|
|
||||||
## milestone
|
### req: build/005
|
||||||
|
005 A `build.rs` failure (missing Surface, version mismatch, stale hash) is a hard error before proc-macro expansion.
|
||||||
|
|
||||||
### req: ms/001
|
### req: build/006
|
||||||
001 **Milestone app: Local-first Multiplayer Kanban.** A board with drag-and-drop cards, 60fps pointer-follow, optimistic updates, offline queue, conflict reconciliation, live presence, and SSR-first rendering — all without React/Vue/VDOM, in a single typed Rust codebase. This acts as the north-star integration test for slhx + hemplate + slhx-sync. [north_star]
|
006 Proc-macros are considered local: they have knowledge of the item they annotate, plus pre-generated symbol tables. They do not have global knowledge of all handlers across the crate. Global checks (e.g. every declared handle has an implementation) are either deferred to app-mount tests or enabled by an optional `#[slhx::component]` macro.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -570,25 +758,126 @@ resource ops.
|
|||||||
002 All crates compile on stable Rust. MSRV 1.80. `slhx-core` has zero proc-macro dependencies.
|
002 All crates compile on stable Rust. MSRV 1.80. `slhx-core` has zero proc-macro dependencies.
|
||||||
|
|
||||||
### req: misc/003
|
### req: misc/003
|
||||||
003 No auth, no routing, no session storage inside slhx core. `slhx-axum` provides typed route mounting; actual routing is axum/tower.
|
003 Three execution modes supported: server-first (request/response), client-local WASM (requestAnimationFrame, no round-trip), and hybrid sync (local + remote via `slhx-sync`). Modes are opt-in per handler, not global.
|
||||||
|
|
||||||
### req: misc/004
|
### req: misc/004
|
||||||
004 Three execution modes supported: server-first (request/response), client-local WASM (requestAnimationFrame, no round-trip), and hybrid sync (local + remote via `slhx-sync`). Modes are opt-in per handler, not global.
|
004 The only required user-facing proc-macro in slhx core is `#[slhx::handler]`.
|
||||||
|
|
||||||
### req: misc/005
|
|
||||||
005 The only required user-facing proc-macro in slhx core is `#[slhx::handler]`.
|
|
||||||
Optional ergonomic macros may exist: `#[slhx::surface]`, `#[slhx::component]`,
|
Optional ergonomic macros may exist: `#[slhx::surface]`, `#[slhx::component]`,
|
||||||
`#[slhx::app]`, and integration-crate macros such as `#[slhx::island]` or
|
`#[slhx::app]`, and integration-crate macros such as `#[slhx::island]` or
|
||||||
`#[slhx_sync::presence]`. No `!` call-syntax macros.
|
`#[slhx_sync::presence]`. No `!` call-syntax macros.
|
||||||
|
|
||||||
|
### req: misc/005
|
||||||
|
005 Source spans are present on every Surface node, attribute, and scope. Error messages cite file, line, and column. This is non-negotiable for DX.
|
||||||
|
|
||||||
### req: misc/006
|
### req: misc/006
|
||||||
006 Source spans are present on every Surface node, attribute, and scope. Error messages cite file, line, and column. This is non-negotiable for DX.
|
006 Authoring slhx attributes use the `data-slhx-*` prefix. Runtime lowering may emit compact `data-hid`, `data-sid`, `data-key`, atom ids, and control ids. No unprefixed custom attributes.
|
||||||
|
|
||||||
### req: misc/007
|
### req: misc/007
|
||||||
007 All slhx HTML attributes use `data-` prefix (`data-slhx-handle`, `data-slhx-slot`, `data-slhx-atom`, `data-slhx-root`). No unprefixed custom attributes. Valid HTML, tool-friendly, zero custom syntax.
|
007 Id allocation is deterministic from canonical symbol paths. Ids are stable across builds unless the symbol path changes. Deploy mismatch between server and client is caught by a build-schema version check, not silent failure.
|
||||||
|
|
||||||
### req: misc/008
|
### req: misc/008
|
||||||
008 Id allocation is deterministic from canonical symbol paths. Ids are stable across builds unless the symbol path changes. Deploy mismatch between server and client is caught by a build-schema version check, not silent failure.
|
008 Core design rule: add one primitive only if it deletes five special cases. `ScopeKey` deletes: loop keying, component scoping, modal instances, nested forms, portal boundaries. `ResourceId` deletes: special opcodes per kind, separate registries, separate wire formats, separate test APIs. `Effect::event` deletes: plugin API, custom JS bridges, chart adapters, map SDK wrappers.
|
||||||
|
|
||||||
### req: misc/009
|
---
|
||||||
009 Core design rule: add one primitive only if it deletes five special cases. `ScopeKey` deletes: loop keying, component scoping, modal instances, nested forms, portal boundaries. `ResourceId` deletes: special opcodes per kind, separate registries, separate wire formats, separate test APIs. `Effect::event` deletes: plugin API, custom JS bridges, chart adapters, map SDK wrappers.
|
|
||||||
|
## boundary
|
||||||
|
|
||||||
|
### req: boundary/001
|
||||||
|
001 hemplate does not expose a slhx API. It exposes a stable, generic Template Surface IR. slhx is one consumer; a11y tools, test generators, and documentation generators are others.
|
||||||
|
|
||||||
|
### req: boundary/002
|
||||||
|
002 hemplate never interprets `data-slhx-*` or any other tool-prefixed attributes. It records them faithfully as generic raw attributes in the Surface.
|
||||||
|
|
||||||
|
### req: boundary/003
|
||||||
|
003 slhx never parses `.heml` directly. It consumes `hemplate.surface.postcard` emitted by `hemplate_build`. slhx interprets tool-specific conventions (`data-slhx-handle`, `data-slhx-slot`, etc.) from the generic Surface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## derive_handler
|
||||||
|
|
||||||
|
### req: derive_handler/001
|
||||||
|
001 `#[slhx::handler]` validates: handle name exists in symbol table, params match form surface or `data-*` attributes, return type implements `IntoEffect`. Generate code registers the function in a static lookup table keyed by numeric handle id.
|
||||||
|
|
||||||
|
### req: derive_handler/002
|
||||||
|
002 Handler param inference from `data-*` attributes: when a template declares `data-card-id="{card.id}"` on a node with `data-slhx-handle`, the handler may declare `card_id: CardId` as a parameter. slhx-build checks attribute → param name and type mapping.
|
||||||
|
|
||||||
|
### req: derive_handler/003
|
||||||
|
003 Handler parameters are inferred from four sources: `Form<T>`, `data-*` attributes on the triggering node, route params supplied by integration crates, and explicit app/context parameters. Missing or incompatible params are compile-time errors with source spans. slhx does not implement selector-based `hx-include`; shared params are represented by forms, hidden inputs, scoped context, or explicit `data-*` attributes.
|
||||||
|
|
||||||
|
### req: derive_handler/004
|
||||||
|
004 The common handler signature forms are:
|
||||||
|
```rust
|
||||||
|
fn my_handler() -> impl IntoEffect
|
||||||
|
fn my_handler(form: Form<CreateTodo>) -> impl IntoEffect
|
||||||
|
fn my_handler(app: &mut AppState) -> impl IntoEffect
|
||||||
|
fn my_handler(card_id: CardId, app: &mut AppState) -> impl IntoEffect
|
||||||
|
```
|
||||||
|
All forms support returning `impl IntoEffect` and compose through tuples.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## derive_app
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## locality
|
||||||
|
|
||||||
|
### req: locality/001
|
||||||
|
001 slhx does not support selector targets such as `closest`, `find`, or `this` in core. The equivalent pattern is a named/generated slot on the local component or keyed row.
|
||||||
|
|
||||||
|
### req: locality/002
|
||||||
|
002 Diagnostics should suggest adding `data-slhx-slot` to the local element when users attempt a self/row update pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## convention
|
||||||
|
|
||||||
|
### req: convention/001
|
||||||
|
001 slhx-axum and the JS runtime support common UX conventions as attributes, not core effects: `data-slhx-pending-class`, `data-slhx-indicator`, `data-slhx-confirm`, `data-slhx-debounce`, `data-slhx-throttle`, `data-slhx-every`, `data-slhx-disable-while-pending`, `data-slhx-policy`, and `data-slhx-on`. These are orthogonal to the core effect algebra.
|
||||||
|
|
||||||
|
### req: convention/002
|
||||||
|
002 Default event triggers: `submit` for forms, `click` for buttons and links. `data-slhx-on` overrides the default.
|
||||||
|
|
||||||
|
### req: convention/003
|
||||||
|
003 `data-slhx-debounce` and `data-slhx-throttle` support simple millisecond values. No trigger mini-language in core.
|
||||||
|
|
||||||
|
### req: convention/004
|
||||||
|
004 `data-slhx-confirm` dispatches a native `confirm()` before handler dispatch. Custom confirm UI belongs to integration crates.
|
||||||
|
|
||||||
|
### req: convention/005
|
||||||
|
005 `data-slhx-every` dispatches a handle at a fixed interval while the element remains in the document. Duplicate timers per root are avoided.
|
||||||
|
|
||||||
|
### req: convention/006
|
||||||
|
006 Request concurrency policy (`latest`, `queue`, `drop`, `parallel`) may be declared per handle with `data-slhx-policy`. Default for debounced/input handlers is `latest`; default for form submit is `drop` while pending. Stale EffectBatches from superseded requests must not be applied.
|
||||||
|
|
||||||
|
### req: convention/007
|
||||||
|
007 Pending indicators are cosmetic only. The runtime toggles pending classes, indicator visibility, and disabled controls around request/effect execution; handler semantics are unchanged.
|
||||||
|
|
||||||
|
### req: convention/008
|
||||||
|
008 `data-slhx-disable-while-pending` disables the triggering form controls or button while the request is active and restores them afterward.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## multipart
|
||||||
|
|
||||||
|
### req: multipart/001
|
||||||
|
001 slhx-axum supports `multipart/form-data` as an integration boundary for file uploads.
|
||||||
|
|
||||||
|
### req: multipart/002
|
||||||
|
002 Multipart parsing belongs to slhx-axum/axum extractors, not slhx-core.
|
||||||
|
|
||||||
|
### req: multipart/003
|
||||||
|
003 File upload forms preserve native browser fallback behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## target_policy
|
||||||
|
|
||||||
|
### req: target/001
|
||||||
|
001 slhx does not support response-side selector retargeting. Handlers choose targets by returning effects for generated resources.
|
||||||
|
|
||||||
|
### req: target/002
|
||||||
|
002 slhx does not implement response-side CSS fragment selection in core. Servers return explicit component fragments or EffectBatches.
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "slhx-axum"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.7", default-features = false }
|
||||||
|
slhx-core = { path = "../slhx-core" }
|
||||||
|
slhx-js = { path = "../slhx-js" }
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
use axum::async_trait;
|
||||||
|
use axum::body::{to_bytes, Body};
|
||||||
|
use axum::extract::{FromRequest, FromRequestParts};
|
||||||
|
use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use slhx_core::{BuildFingerprint, EffectBatch, IntoEffect};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::convert::Infallible;
|
||||||
|
|
||||||
|
pub const SLHX_PARTIAL_HEADER: &str = "x-slhx-partial";
|
||||||
|
pub const SLHX_FINGERPRINT_HEADER: &str = "x-slhx-fingerprint";
|
||||||
|
pub const SLHX_TITLE_HEADER: &str = "x-slhx-title";
|
||||||
|
pub const SLHX_CONTENT_TYPE: &str = "application/slhx";
|
||||||
|
pub const SLHX_HANDLE_FIELD: &str = "__h";
|
||||||
|
pub const SLHX_RUNTIME_CONTENT_TYPE: &str = "application/javascript; charset=utf-8";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct RuntimeJs;
|
||||||
|
|
||||||
|
pub const fn runtime_js() -> RuntimeJs {
|
||||||
|
RuntimeJs
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PageMode {
|
||||||
|
Full,
|
||||||
|
Partial,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PageRequest {
|
||||||
|
pub mode: PageMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PageResponse {
|
||||||
|
pub mode: PageMode,
|
||||||
|
pub html: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub fingerprint: Option<BuildFingerprint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PageRequest {
|
||||||
|
pub fn from_headers(headers: &HeaderMap) -> Self {
|
||||||
|
Self {
|
||||||
|
mode: PageMode::from_headers(headers),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn is_partial(self) -> bool {
|
||||||
|
matches!(self.mode, PageMode::Partial)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn page(self, partial_html: impl Into<String>, shell: impl FnOnce(String) -> String) -> PageResponse {
|
||||||
|
let partial_html = partial_html.into();
|
||||||
|
match self.mode {
|
||||||
|
PageMode::Full => PageResponse::full(shell(partial_html)),
|
||||||
|
PageMode::Partial => PageResponse::partial(partial_html),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<S> FromRequestParts<S> for PageRequest
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = Infallible;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
Ok(Self::from_headers(&parts.headers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PageResponse {
|
||||||
|
pub fn full(html: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
mode: PageMode::Full,
|
||||||
|
html: html.into(),
|
||||||
|
title: None,
|
||||||
|
fingerprint: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn partial(html: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
mode: PageMode::Partial,
|
||||||
|
html: html.into(),
|
||||||
|
title: None,
|
||||||
|
fingerprint: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title(mut self, title: impl Into<String>) -> Self {
|
||||||
|
self.title = Some(title.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fingerprint(mut self, fingerprint: BuildFingerprint) -> Self {
|
||||||
|
self.fingerprint = Some(fingerprint);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct EffectResponse {
|
||||||
|
pub batch: EffectBatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct InteractionForm {
|
||||||
|
pub handle_id: u32,
|
||||||
|
fields: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HandlerRegistry {
|
||||||
|
fingerprint: BuildFingerprint,
|
||||||
|
handlers: BTreeMap<u32, Box<dyn Fn(InteractionForm) -> EffectBatch + Send + Sync>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum InteractionFormRejection {
|
||||||
|
InvalidBody,
|
||||||
|
MissingHandle,
|
||||||
|
InvalidHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum DispatchRejection {
|
||||||
|
UnknownHandle(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EffectResponse {
|
||||||
|
pub fn new(effects: impl IntoEffect, fingerprint: BuildFingerprint) -> Self {
|
||||||
|
Self {
|
||||||
|
batch: effects.into_batch(fingerprint),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InteractionForm {
|
||||||
|
pub fn new(handle_id: u32, fields: impl IntoIterator<Item = (String, String)>) -> Self {
|
||||||
|
Self {
|
||||||
|
handle_id,
|
||||||
|
fields: fields.into_iter().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_urlencoded(body: &[u8]) -> Result<Self, InteractionFormRejection> {
|
||||||
|
let fields = parse_urlencoded_pairs(body)?;
|
||||||
|
let Some(handle) = fields
|
||||||
|
.iter()
|
||||||
|
.find_map(|(name, value)| (name == SLHX_HANDLE_FIELD).then_some(value))
|
||||||
|
else {
|
||||||
|
return Err(InteractionFormRejection::MissingHandle);
|
||||||
|
};
|
||||||
|
let handle_id = handle
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|_| InteractionFormRejection::InvalidHandle)?;
|
||||||
|
Ok(Self { handle_id, fields })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value(&self, name: &str) -> Option<&str> {
|
||||||
|
self.fields
|
||||||
|
.iter()
|
||||||
|
.find_map(|(field, value)| (field == name).then_some(value.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a str> + 'a {
|
||||||
|
self.fields
|
||||||
|
.iter()
|
||||||
|
.filter_map(move |(field, value)| (field == name).then_some(value.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fields(&self) -> &[(String, String)] {
|
||||||
|
&self.fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HandlerRegistry {
|
||||||
|
pub const fn new(fingerprint: BuildFingerprint) -> Self {
|
||||||
|
Self {
|
||||||
|
fingerprint,
|
||||||
|
handlers: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register<E>(
|
||||||
|
mut self,
|
||||||
|
handle_id: u32,
|
||||||
|
handler: impl Fn(InteractionForm) -> E + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
let fingerprint = self.fingerprint;
|
||||||
|
self.handlers.insert(
|
||||||
|
handle_id,
|
||||||
|
Box::new(move |form| handler(form).into_batch(fingerprint)),
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dispatch(&self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection> {
|
||||||
|
let handle_id = form.handle_id;
|
||||||
|
let Some(handler) = self.handlers.get(&handle_id) else {
|
||||||
|
return Err(DispatchRejection::UnknownHandle(handle_id));
|
||||||
|
};
|
||||||
|
Ok(EffectResponse {
|
||||||
|
batch: handler(form),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contains(&self, handle_id: u32) -> bool {
|
||||||
|
self.handlers.contains_key(&handle_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for InteractionFormRejection {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
let (status, message) = match self {
|
||||||
|
Self::InvalidBody => (StatusCode::BAD_REQUEST, "invalid urlencoded slhx form body"),
|
||||||
|
Self::MissingHandle => (StatusCode::BAD_REQUEST, "missing __h slhx handle field"),
|
||||||
|
Self::InvalidHandle => (StatusCode::BAD_REQUEST, "invalid __h slhx handle field"),
|
||||||
|
};
|
||||||
|
(status, message).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<S> FromRequest<S> for InteractionForm
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = InteractionFormRejection;
|
||||||
|
|
||||||
|
async fn from_request(req: Request<Body>, _state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
let bytes = to_bytes(req.into_body(), 1024 * 1024)
|
||||||
|
.await
|
||||||
|
.map_err(|_| InteractionFormRejection::InvalidBody)?;
|
||||||
|
Self::parse_urlencoded(&bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PageMode {
|
||||||
|
pub fn from_headers(headers: &HeaderMap) -> Self {
|
||||||
|
match headers.get(SLHX_PARTIAL_HEADER).and_then(|value| value.to_str().ok()) {
|
||||||
|
Some("1" | "true") => Self::Partial,
|
||||||
|
_ => Self::Full,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for PageResponse {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
let html = match self.fingerprint {
|
||||||
|
Some(fingerprint) => html_with_root_fingerprint(self.html, fingerprint),
|
||||||
|
None => self.html,
|
||||||
|
};
|
||||||
|
let mut response = Response::new(Body::from(html));
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||||
|
);
|
||||||
|
if self.mode == PageMode::Partial {
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(SLHX_PARTIAL_HEADER, HeaderValue::from_static("true"));
|
||||||
|
}
|
||||||
|
if let Some(fingerprint) = self.fingerprint.and_then(fingerprint_header) {
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(SLHX_FINGERPRINT_HEADER, fingerprint);
|
||||||
|
}
|
||||||
|
if let Some(title) = self.title.and_then(|title| HeaderValue::from_str(&title).ok()) {
|
||||||
|
response.headers_mut().insert(SLHX_TITLE_HEADER, title);
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html_with_root_fingerprint(mut html: String, fingerprint: BuildFingerprint) -> String {
|
||||||
|
if html.contains("data-slhx-fp=") {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
let Some(root_attr) = html.find("data-slhx-root") else {
|
||||||
|
return html;
|
||||||
|
};
|
||||||
|
let Some(tag_start) = html[..root_attr].rfind('<') else {
|
||||||
|
return html;
|
||||||
|
};
|
||||||
|
let Some(tag_end) = html[tag_start..].find('>') else {
|
||||||
|
return html;
|
||||||
|
};
|
||||||
|
let insert_at = tag_start + tag_end;
|
||||||
|
html.insert_str(insert_at, &format!(" data-slhx-fp=\"{}\"", fingerprint.0));
|
||||||
|
html
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fingerprint_header(fingerprint: BuildFingerprint) -> Option<HeaderValue> {
|
||||||
|
HeaderValue::from_str(&fingerprint.0.to_string()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for EffectResponse {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
let bytes = self.batch.to_wire();
|
||||||
|
let mut response = Response::new(Body::from(bytes));
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static(SLHX_CONTENT_TYPE),
|
||||||
|
);
|
||||||
|
if let Some(fingerprint) = fingerprint_header(self.batch.fingerprint) {
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(SLHX_FINGERPRINT_HEADER, fingerprint);
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for DispatchRejection {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
match self {
|
||||||
|
Self::UnknownHandle(handle_id) => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
format!("unknown slhx handle id {handle_id}"),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for RuntimeJs {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
let mut response = Response::new(Body::from(slhx_js::RUNTIME_JS));
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static(SLHX_RUNTIME_CONTENT_TYPE),
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn runtime_js_source() -> &'static str {
|
||||||
|
slhx_js::RUNTIME_JS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_urlencoded_pairs(body: &[u8]) -> Result<Vec<(String, String)>, InteractionFormRejection> {
|
||||||
|
if body.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
body.split(|byte| *byte == b'&')
|
||||||
|
.map(|pair| {
|
||||||
|
let equals = pair.iter().position(|byte| *byte == b'=');
|
||||||
|
let (name, value) = match equals {
|
||||||
|
Some(index) => (&pair[..index], &pair[index + 1..]),
|
||||||
|
None => (pair, &[][..]),
|
||||||
|
};
|
||||||
|
Ok((percent_decode(name)?, percent_decode(value)?))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_decode(input: &[u8]) -> Result<String, InteractionFormRejection> {
|
||||||
|
let mut out = Vec::with_capacity(input.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < input.len() {
|
||||||
|
match input[i] {
|
||||||
|
b'+' => {
|
||||||
|
out.push(b' ');
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
b'%' if i + 2 < input.len() => {
|
||||||
|
let high = hex(input[i + 1]).ok_or(InteractionFormRejection::InvalidBody)?;
|
||||||
|
let low = hex(input[i + 2]).ok_or(InteractionFormRejection::InvalidBody)?;
|
||||||
|
out.push((high << 4) | low);
|
||||||
|
i += 3;
|
||||||
|
}
|
||||||
|
b'%' => return Err(InteractionFormRejection::InvalidBody),
|
||||||
|
byte => {
|
||||||
|
out.push(byte);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8(out).map_err(|_| InteractionFormRejection::InvalidBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(byte: u8) -> Option<u8> {
|
||||||
|
match byte {
|
||||||
|
b'0'..=b'9' => Some(byte - b'0'),
|
||||||
|
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||||
|
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{html_with_root_fingerprint, BuildFingerprint};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_fingerprint_is_added_to_initial_root() {
|
||||||
|
let html = html_with_root_fingerprint(
|
||||||
|
"<html><body><main data-slhx-root>Docs</main></body></html>".into(),
|
||||||
|
BuildFingerprint(99),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
html,
|
||||||
|
"<html><body><main data-slhx-root data-slhx-fp=\"99\">Docs</main></body></html>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn existing_root_fingerprint_is_preserved() {
|
||||||
|
let html = html_with_root_fingerprint(
|
||||||
|
"<main data-slhx-root data-slhx-fp=\"1\">Docs</main>".into(),
|
||||||
|
BuildFingerprint(99),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(html, "<main data-slhx-root data-slhx-fp=\"1\">Docs</main>");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
use axum::http::{header, HeaderMap};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use slhx_axum::{
|
||||||
|
runtime_js, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm,
|
||||||
|
InteractionFormRejection, PageMode, PageRequest, PageResponse, SLHX_CONTENT_TYPE,
|
||||||
|
SLHX_FINGERPRINT_HEADER, SLHX_PARTIAL_HEADER, SLHX_RUNTIME_CONTENT_TYPE, SLHX_TITLE_HEADER,
|
||||||
|
};
|
||||||
|
use slhx_core::{push, BuildFingerprint, Slot};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_mode_detects_partial_header() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
assert_eq!(PageMode::from_headers(&headers), PageMode::Full);
|
||||||
|
|
||||||
|
headers.insert(SLHX_PARTIAL_HEADER, "true".parse().unwrap());
|
||||||
|
assert_eq!(PageMode::from_headers(&headers), PageMode::Partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() {
|
||||||
|
let full = PageRequest { mode: PageMode::Full }.page("<main>Docs</main>", |content| {
|
||||||
|
format!("<html>{content}</html>")
|
||||||
|
});
|
||||||
|
assert_eq!(full.mode, PageMode::Full);
|
||||||
|
assert_eq!(full.html, "<html><main>Docs</main></html>");
|
||||||
|
|
||||||
|
let partial = PageRequest { mode: PageMode::Partial }.page("<main>Docs</main>", |content| {
|
||||||
|
format!("<html>{content}</html>")
|
||||||
|
});
|
||||||
|
assert_eq!(partial.mode, PageMode::Partial);
|
||||||
|
assert_eq!(partial.html, "<main>Docs</main>");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_page_response_sets_partial_and_title_headers() {
|
||||||
|
let response = PageResponse::partial("<main>Docs</main>")
|
||||||
|
.title("Docs")
|
||||||
|
.into_response();
|
||||||
|
|
||||||
|
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_TITLE_HEADER], "Docs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effect_response_is_wire_batch_with_fingerprint_header() {
|
||||||
|
let response = EffectResponse::new(push("/docs"), BuildFingerprint(99)).into_response();
|
||||||
|
|
||||||
|
assert_eq!(response.headers()[header::CONTENT_TYPE], SLHX_CONTENT_TYPE);
|
||||||
|
assert_eq!(response.headers()[SLHX_FINGERPRINT_HEADER], "99");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interaction_form_parses_handle_and_fields() {
|
||||||
|
let form = InteractionForm::parse_urlencoded(b"__h=42&title=Hello+World&tag=a&tag=b%2Fc")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(form.handle_id, 42);
|
||||||
|
assert_eq!(form.value("title"), Some("Hello World"));
|
||||||
|
assert_eq!(form.values("tag").collect::<Vec<_>>(), ["a", "b/c"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interaction_form_requires_numeric_handle() {
|
||||||
|
assert_eq!(
|
||||||
|
InteractionForm::parse_urlencoded(b"title=Hello").unwrap_err(),
|
||||||
|
InteractionFormRejection::MissingHandle
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
InteractionForm::parse_urlencoded(b"__h=nope").unwrap_err(),
|
||||||
|
InteractionFormRejection::InvalidHandle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handler_registry_dispatches_by_numeric_handle_id() {
|
||||||
|
let title = Slot::<String>::new(7);
|
||||||
|
let registry = HandlerRegistry::new(BuildFingerprint(123)).register(42, move |form| {
|
||||||
|
title.text(form.value("title").unwrap_or(""))
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = registry
|
||||||
|
.dispatch(InteractionForm::new(42, [("title".into(), "Hello".into())]))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.batch.fingerprint, BuildFingerprint(123));
|
||||||
|
assert_eq!(response.batch.ops, vec![title.text("Hello")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handler_registry_rejects_unknown_handle_ids() {
|
||||||
|
let registry = HandlerRegistry::new(BuildFingerprint(123));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
registry.dispatch(InteractionForm::new(9, [])).unwrap_err(),
|
||||||
|
DispatchRejection::UnknownHandle(9)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_js_response_serves_embedded_runtime() {
|
||||||
|
let response = runtime_js().into_response();
|
||||||
|
|
||||||
|
assert_eq!(response.headers()[header::CONTENT_TYPE], SLHX_RUNTIME_CONTENT_TYPE);
|
||||||
|
assert!(response.headers().contains_key(header::CACHE_CONTROL));
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "slhx-build"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
hemplate-core = { path = "../../hemplate/hemplate-core", features = ["surface"] }
|
||||||
|
slhx-core = { path = "../slhx-core" }
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
use hemplate_core::ast::build_ast;
|
||||||
|
use hemplate_core::surface::{
|
||||||
|
extract_surface, AttributeOrigin, ControlKind, ScopeId, ScopeKind, SurfaceAttribute,
|
||||||
|
SurfaceDocument, SurfaceNodeKind,
|
||||||
|
};
|
||||||
|
use slhx_core::{EFFECT_BATCH_ABI_VERSION, RUNTIME_ABI_VERSION, SURFACE_SCHEMA_VERSION};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AppBuilder {
|
||||||
|
out_dir: Option<PathBuf>,
|
||||||
|
template_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppBuilder {
|
||||||
|
pub fn out_dir(mut self, out_dir: impl Into<PathBuf>) -> Self {
|
||||||
|
self.out_dir = Some(out_dir.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn template_dir(mut self, template_dir: impl Into<PathBuf>) -> Self {
|
||||||
|
self.template_dir = template_dir.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(self) -> io::Result<()> {
|
||||||
|
println!("cargo:rerun-if-changed={}", self.template_dir.display());
|
||||||
|
|
||||||
|
let out_dir = self
|
||||||
|
.out_dir
|
||||||
|
.or_else(|| std::env::var_os("OUT_DIR").map(PathBuf::from));
|
||||||
|
|
||||||
|
let Some(out_dir) = out_dir else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&out_dir)?;
|
||||||
|
|
||||||
|
let mut resources = Resources::default();
|
||||||
|
for path in collect_heml(&self.template_dir)? {
|
||||||
|
let source = std::fs::read_to_string(&path)?;
|
||||||
|
let doc = build_ast(Arc::new(source)).map_err(|err| parse_error(&path, err))?;
|
||||||
|
let Some(doc) = doc else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let surface = extract_surface(&doc);
|
||||||
|
resources.add_surface(&self.template_dir, &path, &surface)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::write(out_dir.join("slhx.generated.rs"), resources.generated_rs())?;
|
||||||
|
std::fs::write(out_dir.join("slhx.syms"), resources.syms())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn app() -> AppBuilder {
|
||||||
|
AppBuilder {
|
||||||
|
out_dir: None,
|
||||||
|
template_dir: PathBuf::from("templates"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Resources {
|
||||||
|
slots: BTreeMap<String, Resource>,
|
||||||
|
handles: BTreeMap<String, Resource>,
|
||||||
|
forms: BTreeMap<String, FormResource>,
|
||||||
|
atoms: BTreeMap<String, Resource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct Resource {
|
||||||
|
symbol: String,
|
||||||
|
ident: String,
|
||||||
|
keyed: bool,
|
||||||
|
id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct FormResource {
|
||||||
|
resource: Resource,
|
||||||
|
controls: Vec<GeneratedControl>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct GeneratedControl {
|
||||||
|
name: String,
|
||||||
|
kind: ControlKind,
|
||||||
|
required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Resources {
|
||||||
|
fn add_surface(&mut self, root: &Path, path: &Path, surface: &SurfaceDocument) -> io::Result<()> {
|
||||||
|
for node in &surface.nodes {
|
||||||
|
let SurfaceNodeKind::Element { tag } = &node.kind else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(name) = static_attr(&node.attrs, "data-slhx-slot") {
|
||||||
|
reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?;
|
||||||
|
let keyed = is_inside_keyed_for(surface, node.scope);
|
||||||
|
let canonical = canonical_symbol(root, path, &name);
|
||||||
|
self.insert_slot(canonical, name, keyed)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = static_attr(&node.attrs, "data-slhx-atom") {
|
||||||
|
reject_unkeyed_loop(surface, node.scope, path, "atom", &name)?;
|
||||||
|
let canonical = canonical_symbol(root, path, &name);
|
||||||
|
self.insert_atom(canonical, name)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = static_attr(&node.attrs, "data-slhx-handle") {
|
||||||
|
reject_unkeyed_loop(surface, node.scope, path, "handle", &name)?;
|
||||||
|
let canonical = canonical_symbol(root, path, &name);
|
||||||
|
self.insert_handle(canonical, name.clone())?;
|
||||||
|
|
||||||
|
if tag == "form" {
|
||||||
|
let controls = surface
|
||||||
|
.forms
|
||||||
|
.iter()
|
||||||
|
.find(|form| form.node == node.id)
|
||||||
|
.map(|form| {
|
||||||
|
form.controls
|
||||||
|
.iter()
|
||||||
|
.map(|control| GeneratedControl {
|
||||||
|
name: control.name.clone(),
|
||||||
|
kind: control.kind.clone(),
|
||||||
|
required: control.required,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.insert_form(canonical_symbol(root, path, &name), name, controls)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_slot(&mut self, symbol: String, name: String, keyed: bool) -> io::Result<()> {
|
||||||
|
insert_resource(&mut self.slots, "slot", symbol, name, keyed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_handle(&mut self, symbol: String, name: String) -> io::Result<()> {
|
||||||
|
insert_resource(&mut self.handles, "handle", symbol, name, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_atom(&mut self, symbol: String, name: String) -> io::Result<()> {
|
||||||
|
insert_resource(&mut self.atoms, "atom", symbol, name, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_form(
|
||||||
|
&mut self,
|
||||||
|
symbol: String,
|
||||||
|
name: String,
|
||||||
|
controls: Vec<GeneratedControl>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let resource = make_resource("form", symbol, name, false)?;
|
||||||
|
match self.forms.get(&resource.ident) {
|
||||||
|
Some(existing) if existing.resource.symbol != resource.symbol => Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
format!(
|
||||||
|
"duplicate generated identifier `{}` for `{}` and `{}`",
|
||||||
|
resource.ident, existing.resource.symbol, resource.symbol
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
Some(_) => Ok(()),
|
||||||
|
None => {
|
||||||
|
self.forms.insert(resource.ident.clone(), FormResource { resource, controls });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generated_rs(&self) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str("// @generated by slhx-build. Do not edit.\n");
|
||||||
|
out.push_str(&format!(
|
||||||
|
"pub const BUILD_FINGERPRINT: ::slhx::BuildFingerprint = ::slhx::BuildFingerprint::from_parts(&[{}]);\n\n",
|
||||||
|
self.fingerprint_parts()
|
||||||
|
.into_iter()
|
||||||
|
.map(|part| part.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
));
|
||||||
|
|
||||||
|
out.push_str("pub mod slots {\n");
|
||||||
|
for res in self.slots.values() {
|
||||||
|
if res.keyed {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" pub const {}: ::slhx::KeyedSlot<::std::string::String, ::std::string::String> = ::slhx::KeyedSlot::new({});\n",
|
||||||
|
res.ident, res.id
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" pub const {}: ::slhx::Slot<::std::string::String> = ::slhx::Slot::new({});\n",
|
||||||
|
res.ident, res.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str("}\n\n");
|
||||||
|
|
||||||
|
out.push_str("pub mod handles {\n");
|
||||||
|
for res in self.handles.values() {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" pub const {}: ::slhx::Handle<()> = ::slhx::Handle::new({});\n",
|
||||||
|
res.ident, res.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str("}\n\n");
|
||||||
|
|
||||||
|
out.push_str("pub mod atoms {\n");
|
||||||
|
for res in self.atoms.values() {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" pub const {}: ::slhx::Atom<::std::string::String> = ::slhx::Atom::new({});\n",
|
||||||
|
res.ident, res.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str("}\n\n");
|
||||||
|
|
||||||
|
out.push_str("pub mod forms {\n");
|
||||||
|
for form in self.forms.values() {
|
||||||
|
let res = &form.resource;
|
||||||
|
out.push_str(&format!(
|
||||||
|
" pub const {}: ::slhx::Form<::std::string::String> = ::slhx::Form::new({});\n",
|
||||||
|
res.ident, res.id
|
||||||
|
));
|
||||||
|
out.push_str(&format!(
|
||||||
|
" pub const {}_CONTRACT: ::slhx::FormContract = ::slhx::FormContract {{ fields: &{}_FIELDS }};\n",
|
||||||
|
res.ident.to_ascii_uppercase(), res.ident.to_ascii_uppercase()
|
||||||
|
));
|
||||||
|
out.push_str(&format!(
|
||||||
|
" pub const {}_FIELDS: &[::slhx::FormField] = &[\n",
|
||||||
|
res.ident.to_ascii_uppercase()
|
||||||
|
));
|
||||||
|
for control in &form.controls {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" ::slhx::FormField {{ name: {}, kind: {}, required: {} }},\n",
|
||||||
|
rust_str(&control.name),
|
||||||
|
form_control_kind_expr(&control.kind),
|
||||||
|
control.required
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str(" ];\n");
|
||||||
|
}
|
||||||
|
out.push_str("}\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn syms(&self) -> String {
|
||||||
|
let mut out = String::from("slhx-syms-v1\n");
|
||||||
|
for res in self.slots.values() {
|
||||||
|
out.push_str(&format!("slot\t{}\t{}\t{}\n", res.symbol, res.ident, res.id));
|
||||||
|
}
|
||||||
|
for res in self.handles.values() {
|
||||||
|
out.push_str(&format!("handle\t{}\t{}\t{}\n", res.symbol, res.ident, res.id));
|
||||||
|
}
|
||||||
|
for form in self.forms.values() {
|
||||||
|
let res = &form.resource;
|
||||||
|
out.push_str(&format!("form\t{}\t{}\t{}\n", res.symbol, res.ident, res.id));
|
||||||
|
}
|
||||||
|
for res in self.atoms.values() {
|
||||||
|
out.push_str(&format!("atom\t{}\t{}\t{}\n", res.symbol, res.ident, res.id));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fingerprint_parts(&self) -> Vec<u32> {
|
||||||
|
let mut parts = vec![
|
||||||
|
SURFACE_SCHEMA_VERSION,
|
||||||
|
EFFECT_BATCH_ABI_VERSION,
|
||||||
|
RUNTIME_ABI_VERSION,
|
||||||
|
];
|
||||||
|
|
||||||
|
for res in self.slots.values() {
|
||||||
|
parts.push(0);
|
||||||
|
parts.push(res.id);
|
||||||
|
}
|
||||||
|
for res in self.handles.values() {
|
||||||
|
parts.push(1);
|
||||||
|
parts.push(res.id);
|
||||||
|
}
|
||||||
|
for res in self.atoms.values() {
|
||||||
|
parts.push(3);
|
||||||
|
parts.push(res.id);
|
||||||
|
}
|
||||||
|
for form in self.forms.values() {
|
||||||
|
parts.push(2);
|
||||||
|
parts.push(form.resource.id);
|
||||||
|
parts.push(form.controls.len() as u32);
|
||||||
|
for control in &form.controls {
|
||||||
|
parts.push(stable_id("form-field", &control.name));
|
||||||
|
parts.push(control.required as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_resource(
|
||||||
|
map: &mut BTreeMap<String, Resource>,
|
||||||
|
kind: &str,
|
||||||
|
symbol: String,
|
||||||
|
name: String,
|
||||||
|
keyed: bool,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let resource = make_resource(kind, symbol, name, keyed)?;
|
||||||
|
match map.get(&resource.ident) {
|
||||||
|
Some(existing) if existing.symbol != resource.symbol => Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
format!(
|
||||||
|
"duplicate generated identifier `{}` for `{}` and `{}`",
|
||||||
|
resource.ident, existing.symbol, resource.symbol
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
Some(_) => Ok(()),
|
||||||
|
None => {
|
||||||
|
map.insert(resource.ident.clone(), resource);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_resource(kind: &str, symbol: String, name: String, keyed: bool) -> io::Result<Resource> {
|
||||||
|
let ident = rust_ident(&name).ok_or_else(|| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
format!("invalid slhx {kind} name `{name}`; expected a Rust identifier"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let id = stable_id(kind, &symbol);
|
||||||
|
Ok(Resource {
|
||||||
|
symbol,
|
||||||
|
ident,
|
||||||
|
keyed,
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_heml(root: &Path) -> io::Result<Vec<PathBuf>> {
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
if !root.exists() {
|
||||||
|
return Ok(paths);
|
||||||
|
}
|
||||||
|
collect_heml_into(root, &mut paths)?;
|
||||||
|
paths.sort();
|
||||||
|
Ok(paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_heml_into(dir: &Path, paths: &mut Vec<PathBuf>) -> io::Result<()> {
|
||||||
|
for entry in std::fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_heml_into(&path, paths)?;
|
||||||
|
} else if path.extension().and_then(|ext| ext.to_str()) == Some("heml") {
|
||||||
|
paths.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn static_attr(attrs: &[SurfaceAttribute], name: &str) -> Option<String> {
|
||||||
|
attrs
|
||||||
|
.iter()
|
||||||
|
.find(|attr| attr.origin == AttributeOrigin::Static && attr.name == name)
|
||||||
|
.and_then(|attr| attr.value.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_inside_keyed_for(surface: &SurfaceDocument, mut scope: ScopeId) -> bool {
|
||||||
|
loop {
|
||||||
|
let Some(current) = surface.scopes.get(scope.0 as usize) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if matches!(current.kind, ScopeKind::For { key_expr: Some(_), .. }) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Some(parent) = current.parent else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
scope = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reject_unkeyed_loop(
|
||||||
|
surface: &SurfaceDocument,
|
||||||
|
mut scope: ScopeId,
|
||||||
|
path: &Path,
|
||||||
|
kind: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
loop {
|
||||||
|
let Some(current) = surface.scopes.get(scope.0 as usize) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if matches!(current.kind, ScopeKind::For { key_expr: None, .. }) {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
format!(
|
||||||
|
"{}: data-slhx-{kind}=\"{name}\" is inside an h-for without h-key; add h-key=\"item.id\" to the loop",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Some(parent) = current.parent else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
scope = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_symbol(root: &Path, path: &Path, name: &str) -> String {
|
||||||
|
let rel = path.strip_prefix(root).unwrap_or(path);
|
||||||
|
format!("{}::{name}", rel.to_string_lossy().replace('\\', "/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rust_ident(name: &str) -> Option<String> {
|
||||||
|
let mut chars = name.chars();
|
||||||
|
let first = chars.next()?;
|
||||||
|
if !(first == '_' || first.is_ascii_alphabetic()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if chars.clone().any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric())) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stable_id(kind: &str, symbol: &str) -> u32 {
|
||||||
|
let mut hash = 0x811c9dc5u32;
|
||||||
|
for byte in kind.bytes().chain([b':']).chain(symbol.bytes()) {
|
||||||
|
hash ^= byte as u32;
|
||||||
|
hash = hash.wrapping_mul(0x01000193);
|
||||||
|
}
|
||||||
|
hash
|
||||||
|
}
|
||||||
|
|
||||||
|
fn form_control_kind_expr(kind: &ControlKind) -> String {
|
||||||
|
match kind {
|
||||||
|
ControlKind::Text => "::slhx::FormControlKind::Text".to_string(),
|
||||||
|
ControlKind::Number { min, max, step } => format!(
|
||||||
|
"::slhx::FormControlKind::Number {{ min: {}, max: {}, step: {} }}",
|
||||||
|
rust_str_opt(min.as_deref()),
|
||||||
|
rust_str_opt(max.as_deref()),
|
||||||
|
rust_str_opt(step.as_deref())
|
||||||
|
),
|
||||||
|
ControlKind::Checkbox => "::slhx::FormControlKind::Checkbox".to_string(),
|
||||||
|
ControlKind::Radio => "::slhx::FormControlKind::Radio".to_string(),
|
||||||
|
ControlKind::Select { multiple } => {
|
||||||
|
format!("::slhx::FormControlKind::Select {{ multiple: {multiple} }}")
|
||||||
|
}
|
||||||
|
ControlKind::TextArea => "::slhx::FormControlKind::TextArea".to_string(),
|
||||||
|
ControlKind::File => "::slhx::FormControlKind::File".to_string(),
|
||||||
|
ControlKind::Hidden => "::slhx::FormControlKind::Hidden".to_string(),
|
||||||
|
ControlKind::Submit => "::slhx::FormControlKind::Submit".to_string(),
|
||||||
|
ControlKind::Other { tag, input_type } => format!(
|
||||||
|
"::slhx::FormControlKind::Other {{ tag: {}, input_type: {} }}",
|
||||||
|
rust_str(tag),
|
||||||
|
rust_str_opt(input_type.as_deref())
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rust_str(value: &str) -> String {
|
||||||
|
format!("{value:?}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rust_str_opt(value: Option<&str>) -> String {
|
||||||
|
match value {
|
||||||
|
Some(value) => format!("Some({})", rust_str(value)),
|
||||||
|
None => "None".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_error(path: &Path, err: impl std::fmt::Display) -> io::Error {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
format!("failed to parse {}: {err}", path.display()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emits_generated_resources_from_heml() {
|
||||||
|
let base = test_dir("slhx-build-test");
|
||||||
|
let templates = base.join("templates");
|
||||||
|
let out = base.join("out");
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
std::fs::create_dir_all(&templates).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
templates.join("todo.heml"),
|
||||||
|
r#"<form data-slhx-handle="create"><input name="title"></form><ul data-slhx-slot="todos"></ul><section data-slhx-atom="filter"></section>"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
app().template_dir(&templates).out_dir(&out).run().unwrap();
|
||||||
|
|
||||||
|
let generated = std::fs::read_to_string(out.join("slhx.generated.rs")).unwrap();
|
||||||
|
assert!(generated.contains("pub mod slots"));
|
||||||
|
assert!(generated.contains("pub const todos"));
|
||||||
|
assert!(generated.contains("pub mod handles"));
|
||||||
|
assert!(generated.contains("pub const create"));
|
||||||
|
assert!(generated.contains("pub mod forms"));
|
||||||
|
assert!(generated.contains("pub mod atoms"));
|
||||||
|
assert!(generated.contains("pub const filter"));
|
||||||
|
|
||||||
|
let syms = std::fs::read_to_string(out.join("slhx.syms")).unwrap();
|
||||||
|
assert!(syms.contains("atom\t"));
|
||||||
|
assert!(syms.contains("\tfilter\t"));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_slhx_resources_inside_unkeyed_for() {
|
||||||
|
let base = test_dir("slhx-build-unkeyed-for-test");
|
||||||
|
let templates = base.join("templates");
|
||||||
|
let out = base.join("out");
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
std::fs::create_dir_all(&templates).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
templates.join("todo.heml"),
|
||||||
|
r#"<template h-for="todo in &self.todos"><li data-slhx-slot="todo_row">{+ todo.title +}</li></template>"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let err = app().template_dir(&templates).out_dir(&out).run().unwrap_err();
|
||||||
|
assert!(err.to_string().contains("inside an h-for without h-key"));
|
||||||
|
assert!(err.to_string().contains("data-slhx-slot=\"todo_row\""));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_dir(prefix: &str) -> PathBuf {
|
||||||
|
std::env::temp_dir().join(format!("{prefix}-{}", std::process::id()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "slhx-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["std"]
|
||||||
|
std = ["serde/std"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
postcard = { version = "1", default-features = false, features = ["alloc"] }
|
||||||
|
serde = { version = "1", default-features = false, features = ["alloc", "derive"] }
|
||||||
@@ -0,0 +1,869 @@
|
|||||||
|
#![cfg_attr(not(feature = "std"), no_std)]
|
||||||
|
|
||||||
|
extern crate alloc;
|
||||||
|
|
||||||
|
use alloc::string::{String, ToString};
|
||||||
|
use alloc::vec::Vec;
|
||||||
|
use core::marker::PhantomData;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const SURFACE_SCHEMA_VERSION: u32 = 1;
|
||||||
|
pub const EFFECT_BATCH_ABI_VERSION: u32 = 1;
|
||||||
|
pub const RUNTIME_ABI_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct BuildFingerprint(pub u64);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct AtomSnapshot {
|
||||||
|
pub id: u32,
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct AtomState {
|
||||||
|
pub atoms: Vec<AtomSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AtomState {
|
||||||
|
pub fn to_postcard(&self) -> Result<Vec<u8>, postcard::Error> {
|
||||||
|
postcard::to_allocvec(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_postcard(bytes: &[u8]) -> Result<Self, postcard::Error> {
|
||||||
|
postcard::from_bytes(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BuildFingerprint {
|
||||||
|
pub const fn from_parts(parts: &[u32]) -> Self {
|
||||||
|
let mut hash = 0xcbf29ce484222325u64;
|
||||||
|
let mut i = 0;
|
||||||
|
while i < parts.len() {
|
||||||
|
hash ^= parts[i] as u64;
|
||||||
|
hash = hash.wrapping_mul(0x100000001b3);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
Self(hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum ResourceKind {
|
||||||
|
Slot,
|
||||||
|
Atom,
|
||||||
|
Handle,
|
||||||
|
Form,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct ResourceId {
|
||||||
|
pub kind: ResourceKind,
|
||||||
|
pub id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResourceId {
|
||||||
|
pub const fn new(kind: ResourceKind, id: u32) -> Self {
|
||||||
|
Self { kind, id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum ScopeKey {
|
||||||
|
KeyValue(String),
|
||||||
|
Field(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct ResourceRef {
|
||||||
|
pub resource: ResourceId,
|
||||||
|
pub scope: Option<ScopeKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResourceRef {
|
||||||
|
pub const fn unscoped(resource: ResourceId) -> Self {
|
||||||
|
Self {
|
||||||
|
resource,
|
||||||
|
scope: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scoped(resource: ResourceId, scope: ScopeKey) -> Self {
|
||||||
|
Self {
|
||||||
|
resource,
|
||||||
|
scope: Some(scope),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum NavigateMode {
|
||||||
|
Push,
|
||||||
|
Replace,
|
||||||
|
Redirect,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum ScrollBehavior {
|
||||||
|
Preserve,
|
||||||
|
Top,
|
||||||
|
Element(ResourceRef),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum Payload {
|
||||||
|
Text(String),
|
||||||
|
Html(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Payload {
|
||||||
|
pub fn text(value: impl ToString) -> Self {
|
||||||
|
Self::Text(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn html(value: SafeHtml) -> Self {
|
||||||
|
Self::Html(value.into_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct SafeHtml(String);
|
||||||
|
|
||||||
|
impl SafeHtml {
|
||||||
|
pub fn trusted(value: impl Into<String>) -> Self {
|
||||||
|
Self(value.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_string(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||||
|
pub struct FormContract {
|
||||||
|
pub fields: &'static [FormField],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||||
|
pub struct FormField {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub kind: FormControlKind,
|
||||||
|
pub required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||||
|
pub enum FormControlKind {
|
||||||
|
Text,
|
||||||
|
Number {
|
||||||
|
min: Option<&'static str>,
|
||||||
|
max: Option<&'static str>,
|
||||||
|
step: Option<&'static str>,
|
||||||
|
},
|
||||||
|
Checkbox,
|
||||||
|
Radio,
|
||||||
|
Select {
|
||||||
|
multiple: bool,
|
||||||
|
},
|
||||||
|
TextArea,
|
||||||
|
File,
|
||||||
|
Hidden,
|
||||||
|
Submit,
|
||||||
|
Other {
|
||||||
|
tag: &'static str,
|
||||||
|
input_type: Option<&'static str>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum Effect {
|
||||||
|
Put { target: ResourceRef, payload: Payload },
|
||||||
|
Insert { 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 {
|
||||||
|
url: String,
|
||||||
|
mode: NavigateMode,
|
||||||
|
scroll: ScrollBehavior,
|
||||||
|
title: Option<String>,
|
||||||
|
},
|
||||||
|
Emit { name: String, payload: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct EffectBatch {
|
||||||
|
pub abi_version: u32,
|
||||||
|
pub fingerprint: BuildFingerprint,
|
||||||
|
pub ops: Vec<Effect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EffectBatch {
|
||||||
|
pub fn to_wire(&self) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
write_batch(self, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_wire(bytes: &[u8]) -> Result<Self, WireError> {
|
||||||
|
read_batch(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_postcard(&self) -> Result<Vec<u8>, postcard::Error> {
|
||||||
|
postcard::to_allocvec(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_postcard(bytes: &[u8]) -> Result<Self, postcard::Error> {
|
||||||
|
postcard::from_bytes(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn is_compatible(&self) -> bool {
|
||||||
|
self.abi_version == EFFECT_BATCH_ABI_VERSION
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum WireError {
|
||||||
|
BadMagic,
|
||||||
|
Truncated,
|
||||||
|
InvalidUtf8,
|
||||||
|
UnknownTag,
|
||||||
|
TrailingBytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
const WIRE_MAGIC: &[u8; 4] = b"SLHX";
|
||||||
|
|
||||||
|
fn write_batch(batch: &EffectBatch, out: &mut Vec<u8>) {
|
||||||
|
out.extend_from_slice(WIRE_MAGIC);
|
||||||
|
write_u32(batch.abi_version, out);
|
||||||
|
write_u64(batch.fingerprint.0, out);
|
||||||
|
write_u32(batch.ops.len() as u32, out);
|
||||||
|
for op in &batch.ops {
|
||||||
|
write_effect(op, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
|
||||||
|
match effect {
|
||||||
|
Effect::Put { target, payload } => {
|
||||||
|
write_u8(0, out);
|
||||||
|
write_ref(target, out);
|
||||||
|
write_payload(payload, out);
|
||||||
|
}
|
||||||
|
Effect::Insert { target, key, payload } => {
|
||||||
|
write_u8(1, out);
|
||||||
|
write_ref(target, out);
|
||||||
|
write_str(key, out);
|
||||||
|
write_payload(payload, out);
|
||||||
|
}
|
||||||
|
Effect::Prepend { target, key, payload } => {
|
||||||
|
write_u8(2, out);
|
||||||
|
write_ref(target, out);
|
||||||
|
write_str(key, out);
|
||||||
|
write_payload(payload, out);
|
||||||
|
}
|
||||||
|
Effect::Remove { target, key } => {
|
||||||
|
write_u8(3, out);
|
||||||
|
write_ref(target, out);
|
||||||
|
write_option_str(key.as_deref(), out);
|
||||||
|
}
|
||||||
|
Effect::Move { target, key, before } => {
|
||||||
|
write_u8(4, out);
|
||||||
|
write_ref(target, out);
|
||||||
|
write_str(key, out);
|
||||||
|
write_option_str(before.as_deref(), out);
|
||||||
|
}
|
||||||
|
Effect::Focus { target } => {
|
||||||
|
write_u8(5, out);
|
||||||
|
write_ref(target, out);
|
||||||
|
}
|
||||||
|
Effect::Navigate { url, mode, scroll, title } => {
|
||||||
|
write_u8(6, out);
|
||||||
|
write_str(url, out);
|
||||||
|
write_u8(match mode {
|
||||||
|
NavigateMode::Push => 0,
|
||||||
|
NavigateMode::Replace => 1,
|
||||||
|
NavigateMode::Redirect => 2,
|
||||||
|
}, out);
|
||||||
|
write_scroll(scroll, out);
|
||||||
|
write_option_str(title.as_deref(), out);
|
||||||
|
}
|
||||||
|
Effect::Emit { name, payload } => {
|
||||||
|
write_u8(7, out);
|
||||||
|
write_str(name, out);
|
||||||
|
write_str(payload, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_ref(reference: &ResourceRef, out: &mut Vec<u8>) {
|
||||||
|
write_u8(match reference.resource.kind {
|
||||||
|
ResourceKind::Slot => 0,
|
||||||
|
ResourceKind::Atom => 1,
|
||||||
|
ResourceKind::Handle => 2,
|
||||||
|
ResourceKind::Form => 3,
|
||||||
|
}, out);
|
||||||
|
write_u32(reference.resource.id, out);
|
||||||
|
match &reference.scope {
|
||||||
|
None => write_u8(0, out),
|
||||||
|
Some(ScopeKey::KeyValue(value)) => {
|
||||||
|
write_u8(1, out);
|
||||||
|
write_str(value, out);
|
||||||
|
}
|
||||||
|
Some(ScopeKey::Field(value)) => {
|
||||||
|
write_u8(2, out);
|
||||||
|
write_str(value, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_payload(payload: &Payload, out: &mut Vec<u8>) {
|
||||||
|
match payload {
|
||||||
|
Payload::Text(value) => {
|
||||||
|
write_u8(0, out);
|
||||||
|
write_str(value, out);
|
||||||
|
}
|
||||||
|
Payload::Html(value) => {
|
||||||
|
write_u8(1, out);
|
||||||
|
write_str(value, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_scroll(scroll: &ScrollBehavior, out: &mut Vec<u8>) {
|
||||||
|
match scroll {
|
||||||
|
ScrollBehavior::Preserve => write_u8(0, out),
|
||||||
|
ScrollBehavior::Top => write_u8(1, out),
|
||||||
|
ScrollBehavior::Element(target) => {
|
||||||
|
write_u8(2, out);
|
||||||
|
write_ref(target, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_option_str(value: Option<&str>, out: &mut Vec<u8>) {
|
||||||
|
match value {
|
||||||
|
None => write_u8(0, out),
|
||||||
|
Some(value) => {
|
||||||
|
write_u8(1, out);
|
||||||
|
write_str(value, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_str(value: &str, out: &mut Vec<u8>) {
|
||||||
|
write_u32(value.len() as u32, out);
|
||||||
|
out.extend_from_slice(value.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_u8(value: u8, out: &mut Vec<u8>) {
|
||||||
|
out.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_u32(value: u32, out: &mut Vec<u8>) {
|
||||||
|
out.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_u64(value: u64, out: &mut Vec<u8>) {
|
||||||
|
out.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WireReader<'a> {
|
||||||
|
bytes: &'a [u8],
|
||||||
|
offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> WireReader<'a> {
|
||||||
|
fn new(bytes: &'a [u8]) -> Self {
|
||||||
|
Self { bytes, offset: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&self) -> Result<(), WireError> {
|
||||||
|
if self.offset == self.bytes.len() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(WireError::TrailingBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u8(&mut self) -> Result<u8, WireError> {
|
||||||
|
let bytes = self.read_exact(1)?;
|
||||||
|
Ok(bytes[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(&mut self) -> Result<u32, WireError> {
|
||||||
|
let bytes = self.read_exact(4)?;
|
||||||
|
Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u64(&mut self) -> Result<u64, WireError> {
|
||||||
|
let bytes = self.read_exact(8)?;
|
||||||
|
Ok(u64::from_le_bytes([
|
||||||
|
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_str(&mut self) -> Result<String, WireError> {
|
||||||
|
let len = self.read_u32()? as usize;
|
||||||
|
let bytes = self.read_exact(len)?;
|
||||||
|
core::str::from_utf8(bytes)
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.map_err(|_| WireError::InvalidUtf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_exact(&mut self, len: usize) -> Result<&'a [u8], WireError> {
|
||||||
|
let end = self.offset.checked_add(len).ok_or(WireError::Truncated)?;
|
||||||
|
if end > self.bytes.len() {
|
||||||
|
return Err(WireError::Truncated);
|
||||||
|
}
|
||||||
|
let bytes = &self.bytes[self.offset..end];
|
||||||
|
self.offset = end;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_batch(bytes: &[u8]) -> Result<EffectBatch, WireError> {
|
||||||
|
let mut reader = WireReader::new(bytes);
|
||||||
|
if reader.read_exact(WIRE_MAGIC.len())? != WIRE_MAGIC {
|
||||||
|
return Err(WireError::BadMagic);
|
||||||
|
}
|
||||||
|
let abi_version = reader.read_u32()?;
|
||||||
|
let fingerprint = BuildFingerprint(reader.read_u64()?);
|
||||||
|
let ops_len = reader.read_u32()?;
|
||||||
|
let mut ops = Vec::new();
|
||||||
|
for _ in 0..ops_len {
|
||||||
|
ops.push(read_effect(&mut reader)?);
|
||||||
|
}
|
||||||
|
reader.finish()?;
|
||||||
|
Ok(EffectBatch { abi_version, fingerprint, ops })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_effect(reader: &mut WireReader<'_>) -> Result<Effect, WireError> {
|
||||||
|
match reader.read_u8()? {
|
||||||
|
0 => Ok(Effect::Put { target: read_ref(reader)?, payload: read_payload(reader)? }),
|
||||||
|
1 => Ok(Effect::Insert { 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 {
|
||||||
|
url: reader.read_str()?,
|
||||||
|
mode: match reader.read_u8()? {
|
||||||
|
0 => NavigateMode::Push,
|
||||||
|
1 => NavigateMode::Replace,
|
||||||
|
2 => NavigateMode::Redirect,
|
||||||
|
_ => return Err(WireError::UnknownTag),
|
||||||
|
},
|
||||||
|
scroll: read_scroll(reader)?,
|
||||||
|
title: read_option_str(reader)?,
|
||||||
|
}),
|
||||||
|
7 => Ok(Effect::Emit { name: reader.read_str()?, payload: reader.read_str()? }),
|
||||||
|
_ => Err(WireError::UnknownTag),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_ref(reader: &mut WireReader<'_>) -> Result<ResourceRef, WireError> {
|
||||||
|
let kind = match reader.read_u8()? {
|
||||||
|
0 => ResourceKind::Slot,
|
||||||
|
1 => ResourceKind::Atom,
|
||||||
|
2 => ResourceKind::Handle,
|
||||||
|
3 => ResourceKind::Form,
|
||||||
|
_ => return Err(WireError::UnknownTag),
|
||||||
|
};
|
||||||
|
let resource = ResourceId::new(kind, reader.read_u32()?);
|
||||||
|
let scope = match reader.read_u8()? {
|
||||||
|
0 => None,
|
||||||
|
1 => Some(ScopeKey::KeyValue(reader.read_str()?)),
|
||||||
|
2 => Some(ScopeKey::Field(reader.read_str()?)),
|
||||||
|
_ => return Err(WireError::UnknownTag),
|
||||||
|
};
|
||||||
|
Ok(ResourceRef { resource, scope })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_payload(reader: &mut WireReader<'_>) -> Result<Payload, WireError> {
|
||||||
|
match reader.read_u8()? {
|
||||||
|
0 => Ok(Payload::Text(reader.read_str()?)),
|
||||||
|
1 => Ok(Payload::Html(reader.read_str()?)),
|
||||||
|
_ => Err(WireError::UnknownTag),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_scroll(reader: &mut WireReader<'_>) -> Result<ScrollBehavior, WireError> {
|
||||||
|
match reader.read_u8()? {
|
||||||
|
0 => Ok(ScrollBehavior::Preserve),
|
||||||
|
1 => Ok(ScrollBehavior::Top),
|
||||||
|
2 => Ok(ScrollBehavior::Element(read_ref(reader)?)),
|
||||||
|
_ => Err(WireError::UnknownTag),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_option_str(reader: &mut WireReader<'_>) -> Result<Option<String>, WireError> {
|
||||||
|
match reader.read_u8()? {
|
||||||
|
0 => Ok(None),
|
||||||
|
1 => Ok(Some(reader.read_str()?)),
|
||||||
|
_ => Err(WireError::UnknownTag),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IntoEffect {
|
||||||
|
fn append_to(self, ops: &mut Vec<Effect>);
|
||||||
|
|
||||||
|
fn into_batch(self, fingerprint: BuildFingerprint) -> EffectBatch
|
||||||
|
where
|
||||||
|
Self: Sized,
|
||||||
|
{
|
||||||
|
let mut ops = Vec::new();
|
||||||
|
self.append_to(&mut ops);
|
||||||
|
EffectBatch {
|
||||||
|
abi_version: EFFECT_BATCH_ABI_VERSION,
|
||||||
|
fingerprint,
|
||||||
|
ops,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoEffect for Effect {
|
||||||
|
fn append_to(self, ops: &mut Vec<Effect>) {
|
||||||
|
ops.push(self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoEffect for () {
|
||||||
|
fn append_to(self, _ops: &mut Vec<Effect>) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! impl_tuple_into_effect {
|
||||||
|
($($name:ident $idx:tt),+) => {
|
||||||
|
impl<$($name),+> IntoEffect for ($($name,)+)
|
||||||
|
where
|
||||||
|
$($name: IntoEffect),+
|
||||||
|
{
|
||||||
|
fn append_to(self, ops: &mut Vec<Effect>) {
|
||||||
|
$(self.$idx.append_to(ops);)+
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_tuple_into_effect!(A 0, B 1);
|
||||||
|
impl_tuple_into_effect!(A 0, B 1, C 2);
|
||||||
|
impl_tuple_into_effect!(A 0, B 1, C 2, D 3);
|
||||||
|
impl_tuple_into_effect!(A 0, B 1, C 2, D 3, E 4);
|
||||||
|
impl_tuple_into_effect!(A 0, B 1, C 2, D 3, E 4, F 5);
|
||||||
|
impl_tuple_into_effect!(A 0, B 1, C 2, D 3, E 4, F 5, G 6);
|
||||||
|
impl_tuple_into_effect!(A 0, B 1, C 2, D 3, E 4, F 5, G 6, H 7);
|
||||||
|
impl_tuple_into_effect!(A 0, B 1, C 2, D 3, E 4, F 5, G 6, H 7, I 8);
|
||||||
|
impl_tuple_into_effect!(A 0, B 1, C 2, D 3, E 4, F 5, G 6, H 7, I 8, J 9);
|
||||||
|
impl_tuple_into_effect!(A 0, B 1, C 2, D 3, E 4, F 5, G 6, H 7, I 8, J 9, K 10);
|
||||||
|
impl_tuple_into_effect!(A 0, B 1, C 2, D 3, E 4, F 5, G 6, H 7, I 8, J 9, K 10, L 11);
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Hash)]
|
||||||
|
pub struct Slot<T> {
|
||||||
|
id: ResourceId,
|
||||||
|
_marker: PhantomData<fn() -> T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Clone for Slot<T> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
*self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Copy for Slot<T> {}
|
||||||
|
|
||||||
|
impl<T> Slot<T> {
|
||||||
|
pub const fn new(id: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
id: ResourceId::new(ResourceKind::Slot, id),
|
||||||
|
_marker: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn id(self) -> ResourceId {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(self, value: impl ToString) -> Effect {
|
||||||
|
self.text(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn text(self, value: impl ToString) -> Effect {
|
||||||
|
Effect::Put {
|
||||||
|
target: ResourceRef::unscoped(self.id),
|
||||||
|
payload: Payload::text(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn html(self, value: SafeHtml) -> Effect {
|
||||||
|
Effect::Put {
|
||||||
|
target: ResourceRef::unscoped(self.id),
|
||||||
|
payload: Payload::html(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Hash)]
|
||||||
|
pub struct KeyedSlot<K, T> {
|
||||||
|
id: ResourceId,
|
||||||
|
_marker: PhantomData<fn(K) -> T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<K, T> Clone for KeyedSlot<K, T> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
*self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<K, T> Copy for KeyedSlot<K, T> {}
|
||||||
|
|
||||||
|
impl<K, T> KeyedSlot<K, T>
|
||||||
|
where
|
||||||
|
K: ToString,
|
||||||
|
{
|
||||||
|
pub const fn new(id: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
id: ResourceId::new(ResourceKind::Slot, id),
|
||||||
|
_marker: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn id(self) -> ResourceId {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append(self, key: K, value: impl ToString) -> Effect {
|
||||||
|
Effect::Insert {
|
||||||
|
target: ResourceRef::unscoped(self.id),
|
||||||
|
key: key.to_string(),
|
||||||
|
payload: Payload::text(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prepend(self, key: K, value: impl ToString) -> Effect {
|
||||||
|
Effect::Prepend {
|
||||||
|
target: ResourceRef::unscoped(self.id),
|
||||||
|
key: key.to_string(),
|
||||||
|
payload: Payload::text(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace(self, key: K, value: impl ToString) -> Effect {
|
||||||
|
let key = key.to_string();
|
||||||
|
Effect::Put {
|
||||||
|
target: ResourceRef {
|
||||||
|
resource: self.id,
|
||||||
|
scope: Some(ScopeKey::KeyValue(key)),
|
||||||
|
},
|
||||||
|
payload: Payload::text(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append_html(self, key: K, value: SafeHtml) -> Effect {
|
||||||
|
Effect::Insert {
|
||||||
|
target: ResourceRef::unscoped(self.id),
|
||||||
|
key: key.to_string(),
|
||||||
|
payload: Payload::html(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prepend_html(self, key: K, value: SafeHtml) -> Effect {
|
||||||
|
Effect::Prepend {
|
||||||
|
target: ResourceRef::unscoped(self.id),
|
||||||
|
key: key.to_string(),
|
||||||
|
payload: Payload::html(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace_html(self, key: K, value: SafeHtml) -> Effect {
|
||||||
|
let key = key.to_string();
|
||||||
|
Effect::Put {
|
||||||
|
target: ResourceRef {
|
||||||
|
resource: self.id,
|
||||||
|
scope: Some(ScopeKey::KeyValue(key)),
|
||||||
|
},
|
||||||
|
payload: Payload::html(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(self, key: K) -> Effect {
|
||||||
|
Effect::Remove {
|
||||||
|
target: ResourceRef::unscoped(self.id),
|
||||||
|
key: Some(key.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Hash)]
|
||||||
|
pub struct Atom<T> {
|
||||||
|
id: ResourceId,
|
||||||
|
_marker: PhantomData<fn() -> T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Clone for Atom<T> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
*self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Copy for Atom<T> {}
|
||||||
|
|
||||||
|
impl<T> Atom<T> {
|
||||||
|
pub const fn new(id: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
id: ResourceId::new(ResourceKind::Atom, id),
|
||||||
|
_marker: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn id(self) -> ResourceId {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(self, value: impl ToString) -> Effect {
|
||||||
|
Effect::Put {
|
||||||
|
target: ResourceRef::unscoped(self.id),
|
||||||
|
payload: Payload::text(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Hash)]
|
||||||
|
pub struct Handle<I> {
|
||||||
|
id: ResourceId,
|
||||||
|
_marker: PhantomData<fn(I)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<I> Clone for Handle<I> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
*self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<I> Copy for Handle<I> {}
|
||||||
|
|
||||||
|
impl<I> Handle<I> {
|
||||||
|
pub const fn new(id: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
id: ResourceId::new(ResourceKind::Handle, id),
|
||||||
|
_marker: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn id(self) -> ResourceId {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Hash)]
|
||||||
|
pub struct Form<T> {
|
||||||
|
id: ResourceId,
|
||||||
|
_marker: PhantomData<fn() -> T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Clone for Form<T> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
*self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Copy for Form<T> {}
|
||||||
|
|
||||||
|
impl<T> Form<T> {
|
||||||
|
pub const fn new(id: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
id: ResourceId::new(ResourceKind::Form, id),
|
||||||
|
_marker: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn id(self) -> ResourceId {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn field(self, name: impl Into<String>) -> ResourceRef {
|
||||||
|
ResourceRef::scoped(self.id, ScopeKey::Field(name.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(self) -> Effect {
|
||||||
|
Effect::Emit {
|
||||||
|
name: String::from("slhx:form-reset"),
|
||||||
|
payload: self.id.id.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(self, field: impl Into<String>) -> Effect {
|
||||||
|
Effect::Put {
|
||||||
|
target: self.field(field),
|
||||||
|
payload: Payload::text(""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error(self, field: impl Into<String>, message: impl ToString) -> Effect {
|
||||||
|
let field = field.into();
|
||||||
|
let message = message.to_string();
|
||||||
|
let mut payload = self.id.id.to_string();
|
||||||
|
payload.push('\u{1f}');
|
||||||
|
payload.push_str(&field);
|
||||||
|
payload.push('\u{1f}');
|
||||||
|
payload.push_str(&message);
|
||||||
|
Effect::Emit {
|
||||||
|
name: String::from("slhx:form-error"),
|
||||||
|
payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn focus(self, field: impl Into<String>) -> Effect {
|
||||||
|
Effect::Focus {
|
||||||
|
target: self.field(field),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn disable_while_pending(self) -> Effect {
|
||||||
|
Effect::Emit {
|
||||||
|
name: String::from("slhx:form-disable-while-pending"),
|
||||||
|
payload: self.id.id.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn navigate(url: impl Into<String>) -> Effect {
|
||||||
|
push(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(url: impl Into<String>) -> Effect {
|
||||||
|
Effect::Navigate {
|
||||||
|
url: url.into(),
|
||||||
|
mode: NavigateMode::Push,
|
||||||
|
scroll: ScrollBehavior::Top,
|
||||||
|
title: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace(url: impl Into<String>) -> Effect {
|
||||||
|
Effect::Navigate {
|
||||||
|
url: url.into(),
|
||||||
|
mode: NavigateMode::Replace,
|
||||||
|
scroll: ScrollBehavior::Top,
|
||||||
|
title: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn redirect(url: impl Into<String>) -> Effect {
|
||||||
|
Effect::Navigate {
|
||||||
|
url: url.into(),
|
||||||
|
mode: NavigateMode::Redirect,
|
||||||
|
scroll: ScrollBehavior::Top,
|
||||||
|
title: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn event(name: impl Into<String>, payload: impl Into<String>) -> Effect {
|
||||||
|
Effect::Emit {
|
||||||
|
name: name.into(),
|
||||||
|
payload: payload.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
use slhx_core::{event, navigate, redirect, replace, Atom, AtomSnapshot, AtomState, BuildFingerprint, Effect, EffectBatch, Form, IntoEffect, KeyedSlot, NavigateMode, Payload, ResourceKind, SafeHtml, ScopeKey, Slot};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effect_batch_wire_round_trips() {
|
||||||
|
let count = Slot::<u32>::new(1);
|
||||||
|
let todos = KeyedSlot::<u64, String>::new(2);
|
||||||
|
let user = Atom::<String>::new(3);
|
||||||
|
|
||||||
|
let batch = (
|
||||||
|
count.text(2),
|
||||||
|
todos.append(7, "Buy milk"),
|
||||||
|
todos.replace(7, "Buy oat milk"),
|
||||||
|
user.set("Ada"),
|
||||||
|
navigate("/todos"),
|
||||||
|
event("toast", "Saved"),
|
||||||
|
)
|
||||||
|
.into_batch(BuildFingerprint(42));
|
||||||
|
|
||||||
|
let bytes = batch.to_wire();
|
||||||
|
assert_eq!(&bytes[..4], b"SLHX");
|
||||||
|
let decoded = EffectBatch::from_wire(&bytes).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decoded, batch);
|
||||||
|
assert!(decoded.is_compatible());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keyed_slot_replace_uses_scoped_resource_ref() {
|
||||||
|
let todos = KeyedSlot::<u64, String>::new(9);
|
||||||
|
let effect = todos.replace(12, "done");
|
||||||
|
|
||||||
|
let Effect::Put { target, payload } = effect else {
|
||||||
|
panic!("expected Put");
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(target.resource.kind, ResourceKind::Slot);
|
||||||
|
assert_eq!(target.resource.id, 9);
|
||||||
|
assert_eq!(target.scope, Some(ScopeKey::KeyValue(String::from("12"))));
|
||||||
|
assert_eq!(payload, Payload::Text(String::from("done")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tuple_composition_supports_arity_twelve() {
|
||||||
|
let slot = Slot::<u8>::new(1);
|
||||||
|
let batch = (
|
||||||
|
slot.text(1),
|
||||||
|
slot.text(2),
|
||||||
|
slot.text(3),
|
||||||
|
slot.text(4),
|
||||||
|
slot.text(5),
|
||||||
|
slot.text(6),
|
||||||
|
slot.text(7),
|
||||||
|
slot.text(8),
|
||||||
|
slot.text(9),
|
||||||
|
slot.text(10),
|
||||||
|
slot.text(11),
|
||||||
|
slot.text(12),
|
||||||
|
)
|
||||||
|
.into_batch(BuildFingerprint(1));
|
||||||
|
|
||||||
|
assert_eq!(batch.ops.len(), 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_form_helpers_target_form_fields() {
|
||||||
|
let signup = Form::<()>::new(4);
|
||||||
|
|
||||||
|
let Effect::Emit { name, payload } = signup.error("email", "Use your work email") else {
|
||||||
|
panic!("expected Emit");
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(name, "slhx:form-error");
|
||||||
|
assert_eq!(payload, "4\u{1f}email\u{1f}Use your work email");
|
||||||
|
|
||||||
|
let Effect::Focus { target } = signup.focus("email") else {
|
||||||
|
panic!("expected Focus");
|
||||||
|
};
|
||||||
|
assert_eq!(target.scope, Some(ScopeKey::Field(String::from("email"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slot_html_requires_explicit_safe_html() {
|
||||||
|
let content = Slot::<String>::new(10);
|
||||||
|
let Effect::Put { payload, .. } = content.html(SafeHtml::trusted("<strong>ok</strong>")) else {
|
||||||
|
panic!("expected Put");
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(payload, Payload::Html(String::from("<strong>ok</strong>")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn atom_state_bootstrap_is_postcard_round_trippable() {
|
||||||
|
let state = AtomState {
|
||||||
|
atoms: vec![AtomSnapshot {
|
||||||
|
id: 7,
|
||||||
|
bytes: vec![1, 2, 3],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = state.to_postcard().unwrap();
|
||||||
|
assert_eq!(AtomState::from_postcard(&bytes).unwrap(), state);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn navigation_helpers_choose_explicit_modes() {
|
||||||
|
let Effect::Navigate { mode, .. } = navigate("/docs") else {
|
||||||
|
panic!("expected Navigate");
|
||||||
|
};
|
||||||
|
assert_eq!(mode, NavigateMode::Push);
|
||||||
|
|
||||||
|
let Effect::Navigate { mode, .. } = replace("/docs") else {
|
||||||
|
panic!("expected Navigate");
|
||||||
|
};
|
||||||
|
assert_eq!(mode, NavigateMode::Replace);
|
||||||
|
|
||||||
|
let Effect::Navigate { mode, .. } = redirect("/login") else {
|
||||||
|
panic!("expected Navigate");
|
||||||
|
};
|
||||||
|
assert_eq!(mode, NavigateMode::Redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_fingerprint_is_deterministic_from_abi_parts() {
|
||||||
|
let a = BuildFingerprint::from_parts(&[1, 2, 3, 4]);
|
||||||
|
let b = BuildFingerprint::from_parts(&[1, 2, 3, 4]);
|
||||||
|
let c = BuildFingerprint::from_parts(&[1, 2, 3, 5]);
|
||||||
|
|
||||||
|
assert_eq!(a, b);
|
||||||
|
assert_ne!(a, c);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "slhx-derive"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
proc-macro = true
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
quote = "1"
|
||||||
|
syn = { version = "2", features = ["full"] }
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
use proc_macro::TokenStream;
|
||||||
|
use quote::quote;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use syn::{parse_macro_input, ItemFn};
|
||||||
|
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
let function = parse_macro_input!(item as ItemFn);
|
||||||
|
let name = function.sig.ident.to_string();
|
||||||
|
|
||||||
|
if let Some(syms_path) = syms_path() {
|
||||||
|
if syms_path.exists() && !syms_contains_handle(&syms_path, &name) {
|
||||||
|
let message = format!(
|
||||||
|
"unknown slhx handle `{name}`; add `data-slhx-handle=\"{name}\"` to a template or rename this handler"
|
||||||
|
);
|
||||||
|
return quote!(
|
||||||
|
#function
|
||||||
|
compile_error!(#message);
|
||||||
|
)
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
quote!(#function).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn surface(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
inject_surface_include(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn component(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
item
|
||||||
|
}
|
||||||
|
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn app(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
item
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inject_surface_include(item: TokenStream) -> TokenStream {
|
||||||
|
let item = item.to_string();
|
||||||
|
let Some(insert_at) = item.rfind('}') else {
|
||||||
|
return compile_error("#[slhx::surface] must be used on an inline module");
|
||||||
|
};
|
||||||
|
|
||||||
|
let include = surface_include();
|
||||||
|
let expanded = format!("{}{}{}", &item[..insert_at], include, &item[insert_at..]);
|
||||||
|
expanded
|
||||||
|
.parse()
|
||||||
|
.unwrap_or_else(|_| compile_error("#[slhx::surface] could not expand this module"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn surface_include() -> String {
|
||||||
|
let Some(path) = generated_path("slhx.generated.rs") else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
format!(" include!({:?}); ", path.display().to_string())
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn syms_path() -> Option<PathBuf> {
|
||||||
|
generated_path("slhx.syms")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generated_path(file: &str) -> Option<PathBuf> {
|
||||||
|
std::env::var_os("OUT_DIR").map(|out_dir| PathBuf::from(out_dir).join(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn syms_contains_handle(path: &PathBuf, ident: &str) -> bool {
|
||||||
|
let Ok(syms) = std::fs::read_to_string(path) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
syms.lines().any(|line| {
|
||||||
|
let mut fields = line.split('\t');
|
||||||
|
matches!(fields.next(), Some("handle"))
|
||||||
|
&& fields.nth(1).is_some_and(|handle_ident| handle_ident == ident)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_error(message: &str) -> TokenStream {
|
||||||
|
format!("compile_error!({message:?});")
|
||||||
|
.parse()
|
||||||
|
.expect("compile_error expansion is valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::syms_contains_handle;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn syms_lookup_matches_handle_ident() {
|
||||||
|
let path = std::env::temp_dir().join("slhx-derive-syms-test.syms");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
"slhx-syms-v1\nslot\ttemplates/a.heml::count\tcount\t1\nhandle\ttemplates/a.heml::create\tcreate\t2\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(syms_contains_handle(&path, "create"));
|
||||||
|
assert!(!syms_contains_handle(&path, "missing"));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
use slhx_derive::surface;
|
||||||
|
|
||||||
|
#[surface]
|
||||||
|
mod ui {
|
||||||
|
pub const EXISTING: u8 = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn surface_macro_preserves_inline_module_without_generated_file() {
|
||||||
|
assert_eq!(ui::EXISTING, 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[package]
|
||||||
|
name = "slhx-js"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
(() => {
|
||||||
|
const ROOT = "data-slhx-root";
|
||||||
|
const HID = "data-hid";
|
||||||
|
const SID = "data-sid";
|
||||||
|
const runtimeAbiVersion = 1;
|
||||||
|
const FINGERPRINT = "data-slhx-fp";
|
||||||
|
const STATE = "data-slhx-st";
|
||||||
|
const pending = new WeakMap();
|
||||||
|
const queues = new WeakMap();
|
||||||
|
const timers = new WeakMap();
|
||||||
|
const atomStores = new WeakMap();
|
||||||
|
|
||||||
|
function roots() {
|
||||||
|
return Array.from(document.querySelectorAll(`[${ROOT}]`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rootOf(node) {
|
||||||
|
return node && node.closest ? node.closest(`[${ROOT}]`) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closestInRoot(start, root, selector) {
|
||||||
|
for (let node = start; node && node !== root.parentNode; node = node.parentNode) {
|
||||||
|
if (node.matches && node.matches(selector)) return node;
|
||||||
|
if (node === root) break;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleId(el) {
|
||||||
|
const raw = el && el.getAttribute(HID);
|
||||||
|
return raw && /^\d+$/.test(raw) ? raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestPolicy(el, eventName) {
|
||||||
|
const policy = el.getAttribute("data-slhx-policy");
|
||||||
|
if (policy) return policy;
|
||||||
|
if (el.hasAttribute("data-slhx-debounce") || eventName === "input") return "latest";
|
||||||
|
if (el.tagName === "FORM") return "drop";
|
||||||
|
return "parallel";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPending(el, on) {
|
||||||
|
const klass = el.getAttribute("data-slhx-pending-class");
|
||||||
|
if (klass) el.classList.toggle(klass, on);
|
||||||
|
const root = rootOf(el) || document;
|
||||||
|
root.querySelectorAll("[data-slhx-indicator]").forEach((i) => { i.hidden = !on; });
|
||||||
|
if (el.hasAttribute("data-slhx-disable-while-pending")) {
|
||||||
|
const controls = el.matches("button,input,select,textarea") ? [el] : el.querySelectorAll("button,input,select,textarea");
|
||||||
|
controls.forEach((c) => { c.disabled = on; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formDataFor(el) {
|
||||||
|
const form = el.tagName === "FORM" ? el : el.closest("form");
|
||||||
|
const data = form ? new FormData(form) : new FormData();
|
||||||
|
const id = handleId(el) || (form && handleId(form));
|
||||||
|
if (id && !data.has("__h")) data.set("__h", id);
|
||||||
|
for (const { name, value } of Array.from(el.attributes || [])) {
|
||||||
|
if (name.startsWith("data-") && !name.startsWith("data-slhx-") && name !== HID && name !== SID) {
|
||||||
|
data.set(name.slice(5).replace(/-/g, "_"), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { form, data, multipart: form && String(form.enctype).toLowerCase() === "multipart/form-data" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlEncoded(data) {
|
||||||
|
const encoded = new URLSearchParams();
|
||||||
|
for (const [name, value] of data.entries()) {
|
||||||
|
if (value instanceof File) continue;
|
||||||
|
encoded.append(name, value);
|
||||||
|
}
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBody(data, multipart) {
|
||||||
|
return multipart ? data : urlEncoded(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestUrl(form, data, method) {
|
||||||
|
const url = new URL((form && form.action) || location.href, location.href);
|
||||||
|
if (method === "GET") url.search = urlEncoded(data).toString();
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(el, eventName) {
|
||||||
|
if (el.getAttribute("data-slhx-confirm") && !confirm(el.getAttribute("data-slhx-confirm"))) return;
|
||||||
|
const { form, data, multipart } = formDataFor(el);
|
||||||
|
const target = form || el;
|
||||||
|
const policy = requestPolicy(target, eventName);
|
||||||
|
const active = pending.get(target);
|
||||||
|
if (active && policy === "drop") return;
|
||||||
|
if (active && policy === "latest") active.abort.abort();
|
||||||
|
if (active && policy === "queue") {
|
||||||
|
const base = queues.get(target) || active.done;
|
||||||
|
let queued;
|
||||||
|
const next = base.then(() => send(el, eventName));
|
||||||
|
queued = next.catch(() => {}).finally(() => {
|
||||||
|
if (queues.get(target) === queued) queues.delete(target);
|
||||||
|
});
|
||||||
|
queues.set(target, queued);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abort = new AbortController();
|
||||||
|
let finish;
|
||||||
|
const done = new Promise((resolve) => { finish = resolve; });
|
||||||
|
const method = String((form && form.method) || "POST").toUpperCase();
|
||||||
|
const body = method === "GET" || method === "HEAD" ? undefined : requestBody(data, multipart);
|
||||||
|
const headers = { "X-SLHX-Partial": "1", "Accept": "application/slhx, text/html" };
|
||||||
|
if (body instanceof URLSearchParams) headers["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8";
|
||||||
|
pending.set(target, { abort, done });
|
||||||
|
showPending(target, true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(requestUrl(form, data, method), {
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
signal: abort.signal,
|
||||||
|
});
|
||||||
|
if (pending.get(target)?.abort !== abort && policy === "latest") return;
|
||||||
|
await applyResponse(response, rootOf(target));
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name !== "AbortError") emit(rootOf(target), "slhx:error", String(error));
|
||||||
|
} finally {
|
||||||
|
if (pending.get(target)?.abort === abort) {
|
||||||
|
pending.delete(target);
|
||||||
|
showPending(target, false);
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigate(anchor, mode = "push") {
|
||||||
|
const href = anchor.href;
|
||||||
|
const root = rootOf(anchor);
|
||||||
|
showPending(anchor, true);
|
||||||
|
try {
|
||||||
|
await navigateUrl(href, root, mode);
|
||||||
|
} finally {
|
||||||
|
showPending(anchor, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateUrl(href, root, mode = "replace") {
|
||||||
|
const response = await fetch(href, { headers: { "X-SLHX-Partial": "1", "Accept": "text/html" } });
|
||||||
|
await applyResponse(response, root);
|
||||||
|
if (mode === "push") history.pushState({ slhx: true }, "", href);
|
||||||
|
else if (mode === "replace") history.replaceState({ slhx: true }, "", href);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyResponse(response, root) {
|
||||||
|
if (response.redirected) {
|
||||||
|
location.href = response.url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!compatibleFingerprint(response, root)) {
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const type = response.headers.get("content-type") || "";
|
||||||
|
if (type.includes("text/html")) applyHtml(await response.text(), root, response.headers.get("x-slhx-title"));
|
||||||
|
else if (type.includes("application/slhx")) applyBatch(await response.arrayBuffer(), root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compatibleFingerprint(response, root) {
|
||||||
|
const received = response.headers.get("x-slhx-fingerprint");
|
||||||
|
const expected = root && root.getAttribute(FINGERPRINT);
|
||||||
|
return !received || !expected || received === expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBatch(buffer, root) {
|
||||||
|
const batch = decodeBatch(buffer);
|
||||||
|
if (batch.abiVersion !== runtimeAbiVersion) {
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const expected = root && root.getAttribute(FINGERPRINT);
|
||||||
|
if (expected && String(batch.fingerprint) !== expected) {
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scope = root || document;
|
||||||
|
const missingTarget = batch.ops.map((op) => canApplyOp(scope, op)).find(Boolean);
|
||||||
|
if (missingTarget) {
|
||||||
|
missing(scope, missingTarget);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const op of batch.ops) applyOp(scope, op);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canApplyOp(scope, op) {
|
||||||
|
if (op.kind === "put" && isAtom(op.target)) return null;
|
||||||
|
if (op.kind === "put" || op.kind === "focus") return targetFor(scope, op.target) ? null : op.target;
|
||||||
|
if (op.kind === "insert" || op.kind === "prepend") return targetFor(scope, op.target) ? null : op.target;
|
||||||
|
if (op.kind === "remove") return (op.key ? keyedTarget(scope, op.target.resource.id, op.key) : targetFor(scope, op.target)) ? null : op.target;
|
||||||
|
if (op.kind === "move") return targetFor(scope, op.target) && keyedTarget(scope, op.target.resource.id, op.key) ? null : op.target;
|
||||||
|
if (op.kind === "navigate" && op.scroll && op.scroll.kind === "element") return targetFor(scope, op.scroll.target) ? null : op.scroll.target;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOp(scope, op) {
|
||||||
|
if (op.kind === "put") {
|
||||||
|
if (isAtom(op.target)) {
|
||||||
|
atomStore(scope).set(String(op.target.resource.id), op.payload.value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const target = targetFor(scope, op.target);
|
||||||
|
if (!target) return missing(scope, op.target);
|
||||||
|
putPayload(target, op.payload);
|
||||||
|
} else if (op.kind === "insert" || op.kind === "prepend") {
|
||||||
|
const target = targetFor(scope, op.target);
|
||||||
|
if (!target) return missing(scope, op.target);
|
||||||
|
const nodes = fragmentNodes(op.payload, op.key);
|
||||||
|
target[op.kind === "prepend" ? "prepend" : "append"](...nodes);
|
||||||
|
} else if (op.kind === "remove") {
|
||||||
|
const target = op.key ? keyedTarget(scope, op.target.resource.id, op.key) : targetFor(scope, op.target);
|
||||||
|
if (!target) return missing(scope, op.target);
|
||||||
|
target.remove();
|
||||||
|
} else if (op.kind === "move") {
|
||||||
|
const target = targetFor(scope, op.target);
|
||||||
|
const item = keyedTarget(scope, op.target.resource.id, op.key);
|
||||||
|
if (!target || !item) return missing(scope, op.target);
|
||||||
|
const before = op.before && keyedTarget(scope, op.target.resource.id, op.before);
|
||||||
|
target.insertBefore(item, before || null);
|
||||||
|
} else if (op.kind === "focus") {
|
||||||
|
const target = targetFor(scope, op.target);
|
||||||
|
if (target && target.focus) target.focus();
|
||||||
|
else return missing(scope, op.target);
|
||||||
|
} else if (op.kind === "navigate") {
|
||||||
|
if (op.mode === "redirect") location.href = op.url;
|
||||||
|
else {
|
||||||
|
history[op.mode === "replace" ? "replaceState" : "pushState"]({ slhx: true }, "", op.url);
|
||||||
|
if (op.scroll === "top") scrollTo(0, 0);
|
||||||
|
else if (op.scroll && op.scroll.kind === "element") {
|
||||||
|
const target = targetFor(scope, op.scroll.target);
|
||||||
|
if (target) target.scrollIntoView();
|
||||||
|
}
|
||||||
|
if (op.title) document.title = op.title;
|
||||||
|
}
|
||||||
|
} else if (op.kind === "emit") {
|
||||||
|
handleRuntimeEvent(scope, op.name, op.payload);
|
||||||
|
emit(scope, op.name, op.payload);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetFor(scope, ref) {
|
||||||
|
if (isAtom(ref)) return null;
|
||||||
|
if (ref.scope && ref.scope.kind === "key") return keyedTarget(scope, ref.resource.id, ref.scope.value);
|
||||||
|
if (ref.scope && ref.scope.kind === "field") return fieldTarget(scope, ref.resource.id, ref.scope.value);
|
||||||
|
return scope.querySelector(`[data-sid="${ref.resource.id}"], [data-slot-id="${ref.resource.id}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAtom(ref) {
|
||||||
|
return ref && ref.resource && ref.resource.kind === "atom";
|
||||||
|
}
|
||||||
|
|
||||||
|
function atomStore(root) {
|
||||||
|
const owner = root && root.nodeType === 1 ? root : document.documentElement;
|
||||||
|
let store = atomStores.get(owner);
|
||||||
|
if (!store) {
|
||||||
|
store = new Map();
|
||||||
|
atomStores.set(owner, store);
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
function atomValue(root, id) {
|
||||||
|
return atomStore(rootOf(root) || root || roots()[0]).get(String(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyedTarget(scope, id, key) {
|
||||||
|
const escapedKey = cssEscape(key);
|
||||||
|
return scope.querySelector(`[data-sid="${id}"][data-key="${escapedKey}"], [data-sid="${id}"] [data-key="${escapedKey}"], [data-slot-id="${id}"][data-key="${escapedKey}"], [data-slot-id="${id}"] [data-key="${escapedKey}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldTarget(scope, id, field) {
|
||||||
|
const escapedField = cssEscape(field);
|
||||||
|
return scope.querySelector(`[data-fid="${id}"] [name="${escapedField}"], [data-form-id="${id}"] [name="${escapedField}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formErrorTarget(scope, id, field) {
|
||||||
|
const escapedField = cssEscape(field);
|
||||||
|
return scope.querySelector(`[data-fid="${id}"] [data-slhx-error-for="${escapedField}"], [data-form-id="${id}"] [data-slhx-error-for="${escapedField}"]`) ||
|
||||||
|
scope.querySelector(`[data-fid="${id}"] [name="${escapedField}"], [data-form-id="${id}"] [name="${escapedField}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function putFormError(target, message) {
|
||||||
|
if (target.matches && target.matches("input,textarea,select")) target.setCustomValidity(message);
|
||||||
|
else target.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function putPayload(target, payload) {
|
||||||
|
if (payload.kind === "html") target.innerHTML = payload.value;
|
||||||
|
else if (target.matches && target.matches("input,textarea,select")) target.value = payload.value;
|
||||||
|
else target.textContent = payload.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fragmentNodes(payload, key) {
|
||||||
|
const template = document.createElement("template");
|
||||||
|
if (payload.kind === "html") template.innerHTML = payload.value;
|
||||||
|
else template.textContent = payload.value;
|
||||||
|
const nodes = Array.from(template.content.childNodes);
|
||||||
|
const firstElement = nodes.find((node) => node.nodeType === 1);
|
||||||
|
if (firstElement && key != null && !firstElement.hasAttribute("data-key")) firstElement.setAttribute("data-key", key);
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRuntimeEvent(scope, name, payload) {
|
||||||
|
if (name === "slhx:form-reset") {
|
||||||
|
const form = scope.querySelector(`[data-fid="${payload}"], [data-form-id="${payload}"]`);
|
||||||
|
if (form && form.reset) form.reset();
|
||||||
|
} else if (name === "slhx:form-error") {
|
||||||
|
const [id, field, message] = String(payload).split("\u001f");
|
||||||
|
const target = formErrorTarget(scope, id, field);
|
||||||
|
if (target) putFormError(target, message || "");
|
||||||
|
} else if (name === "slhx:form-disable-while-pending") {
|
||||||
|
const form = scope.querySelector(`[data-fid="${payload}"], [data-form-id="${payload}"]`);
|
||||||
|
if (form) form.setAttribute("data-slhx-disable-while-pending", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function missing(root, target) {
|
||||||
|
emit(root, "slhx:missing-target", target);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssEscape(value) {
|
||||||
|
return String(value).replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHtml(html, root, title) {
|
||||||
|
const scope = root || document;
|
||||||
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
const template = doc.querySelector("template[data-slhx]");
|
||||||
|
if (!replaceSlot(scope, doc, "content", template ? template.innerHTML : html)) {
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replaceSlot(scope, doc, "nav");
|
||||||
|
const nextTitle = title || (doc.querySelector("title") && doc.querySelector("title").textContent);
|
||||||
|
if (nextTitle) document.title = nextTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceSlot(scope, doc, name, fallback) {
|
||||||
|
const target = scope.querySelector(`[data-slhx-slot="${name}"], [data-slot="${name}"]`);
|
||||||
|
if (!target) return false;
|
||||||
|
const source = doc.querySelector(`[data-slhx-slot="${name}"], [data-slot="${name}"]`);
|
||||||
|
if (!source && fallback === undefined) return false;
|
||||||
|
target.innerHTML = source ? source.innerHTML : fallback;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(root, name, detail) {
|
||||||
|
(root || document).dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultEvent(el) {
|
||||||
|
if (el.getAttribute("data-slhx-on")) return el.getAttribute("data-slhx-on");
|
||||||
|
if (el.tagName === "FORM") return "submit";
|
||||||
|
return "click";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindRoot(root) {
|
||||||
|
["click", "submit", "input", "change"].forEach((name) => {
|
||||||
|
root.addEventListener(name, (event) => {
|
||||||
|
const nav = closestInRoot(event.target, root, "a[data-slhx-nav], [data-slhx-boost] a[href]");
|
||||||
|
if (name === "click" && nav && sameOriginNav(event, nav)) {
|
||||||
|
event.preventDefault();
|
||||||
|
navigate(nav);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = closestInRoot(event.target, root, `[${HID}], [data-slhx-boost] form`);
|
||||||
|
if (!el || defaultEvent(el) !== name) return;
|
||||||
|
event.preventDefault();
|
||||||
|
schedule(el, name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
bindPolling(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule(el, eventName) {
|
||||||
|
const debounce = duration(el.getAttribute("data-slhx-debounce"));
|
||||||
|
const throttle = duration(el.getAttribute("data-slhx-throttle"));
|
||||||
|
if (debounce) {
|
||||||
|
clearTimeout(timers.get(el));
|
||||||
|
timers.set(el, setTimeout(() => send(el, eventName), debounce));
|
||||||
|
} else if (throttle) {
|
||||||
|
if (timers.get(el)) return;
|
||||||
|
send(el, eventName).finally(() => setTimeout(() => timers.delete(el), throttle));
|
||||||
|
} else {
|
||||||
|
send(el, eventName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindPolling(root) {
|
||||||
|
root.querySelectorAll("[data-slhx-every]").forEach((el) => {
|
||||||
|
if (timers.has(el)) return;
|
||||||
|
const ms = duration(el.getAttribute("data-slhx-every"));
|
||||||
|
if (!ms) return;
|
||||||
|
timers.set(el, setInterval(() => document.contains(el) ? send(el, "every") : clearInterval(timers.get(el)), ms));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function duration(value) {
|
||||||
|
if (!value) return 0;
|
||||||
|
const match = String(value).trim().match(/^(\d+)(ms|s)?$/);
|
||||||
|
if (!match) return 0;
|
||||||
|
return Number(match[1]) * (match[2] === "s" ? 1000 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bootstrapState(root) {
|
||||||
|
const encoded = root.getAttribute(STATE);
|
||||||
|
if (!encoded) return;
|
||||||
|
const store = atomStore(root);
|
||||||
|
for (const atom of decodeAtomState(encoded)) store.set(String(atom.id), atom.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeAtomState(encoded) {
|
||||||
|
const bytes = base64UrlBytes(encoded);
|
||||||
|
const d = postcardDecoder(bytes);
|
||||||
|
const atoms = d.vec(() => ({ id: d.varint(), bytes: d.bytes() }));
|
||||||
|
if (!d.done()) throw new Error("trailing slhx state bytes");
|
||||||
|
return atoms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlBytes(encoded) {
|
||||||
|
const normalized = String(encoded).replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
|
||||||
|
return Uint8Array.from(atob(padded), (ch) => ch.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function postcardDecoder(bytes) {
|
||||||
|
let offset = 0;
|
||||||
|
const need = (len) => {
|
||||||
|
const end = offset + len;
|
||||||
|
if (end > bytes.length) throw new Error("truncated slhx state");
|
||||||
|
const slice = bytes.subarray(offset, end);
|
||||||
|
offset = end;
|
||||||
|
return slice;
|
||||||
|
};
|
||||||
|
const varint = () => {
|
||||||
|
let shift = 0;
|
||||||
|
let value = 0;
|
||||||
|
for (;;) {
|
||||||
|
const byte = need(1)[0];
|
||||||
|
value |= (byte & 0x7f) << shift;
|
||||||
|
if ((byte & 0x80) === 0) return value >>> 0;
|
||||||
|
shift += 7;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const bytesField = () => need(varint());
|
||||||
|
const vec = (read) => Array.from({ length: varint() }, read);
|
||||||
|
return { varint, bytes: bytesField, vec, done: () => offset === bytes.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decoder(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let offset = 0;
|
||||||
|
const need = (len) => {
|
||||||
|
const end = offset + len;
|
||||||
|
if (end > bytes.length) throw new Error("truncated slhx batch");
|
||||||
|
const slice = bytes.subarray(offset, end);
|
||||||
|
offset = end;
|
||||||
|
return slice;
|
||||||
|
};
|
||||||
|
const u8 = () => need(1)[0];
|
||||||
|
const u32 = () => {
|
||||||
|
const b = need(4);
|
||||||
|
return (b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24)) >>> 0;
|
||||||
|
};
|
||||||
|
const u64 = () => {
|
||||||
|
const lo = BigInt(u32());
|
||||||
|
const hi = BigInt(u32());
|
||||||
|
return lo | (hi << 32n);
|
||||||
|
};
|
||||||
|
const str = () => new TextDecoder().decode(need(u32()));
|
||||||
|
const option = (read) => u8() === 0 ? null : read();
|
||||||
|
const resource = () => ({ kind: ["slot", "atom", "handle", "form"][u8()], id: u32() });
|
||||||
|
const scope = () => {
|
||||||
|
const kind = u8();
|
||||||
|
if (kind === 0) return null;
|
||||||
|
return { kind: kind === 1 ? "key" : "field", value: str() };
|
||||||
|
};
|
||||||
|
const ref = () => ({ resource: resource(), scope: scope() });
|
||||||
|
const payload = () => ({ kind: u8() === 0 ? "text" : "html", value: str() });
|
||||||
|
const scroll = () => {
|
||||||
|
const kind = u8();
|
||||||
|
if (kind === 0) return "preserve";
|
||||||
|
if (kind === 1) return "top";
|
||||||
|
return { kind: "element", target: ref() };
|
||||||
|
};
|
||||||
|
const effect = () => {
|
||||||
|
const kind = u8();
|
||||||
|
if (kind === 0) return { kind: "put", target: ref(), payload: payload() };
|
||||||
|
if (kind === 1) return { kind: "insert", target: ref(), key: str(), payload: payload() };
|
||||||
|
if (kind === 2) return { kind: "prepend", target: ref(), key: str(), payload: payload() };
|
||||||
|
if (kind === 3) return { kind: "remove", target: ref(), key: option(str) };
|
||||||
|
if (kind === 4) return { kind: "move", target: ref(), key: str(), before: option(str) };
|
||||||
|
if (kind === 5) return { kind: "focus", target: ref() };
|
||||||
|
if (kind === 6) return { kind: "navigate", url: str(), mode: ["push", "replace", "redirect"][u8()], scroll: scroll(), title: option(str) };
|
||||||
|
if (kind === 7) return { kind: "emit", name: str(), payload: str() };
|
||||||
|
throw new Error(`unknown slhx effect ${kind}`);
|
||||||
|
};
|
||||||
|
const vec = (read) => Array.from({ length: u32() }, read);
|
||||||
|
return { u8, u32, u64, vec, effect, done: () => offset === bytes.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBatch(buffer) {
|
||||||
|
const d = decoder(buffer);
|
||||||
|
if (String.fromCharCode(d.u8(), d.u8(), d.u8(), d.u8()) !== "SLHX") throw new Error("bad slhx batch magic");
|
||||||
|
const batch = { abiVersion: d.u32(), fingerprint: d.u64(), ops: d.vec(d.effect) };
|
||||||
|
if (!d.done()) throw new Error("trailing slhx batch bytes");
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameOriginNav(event, anchor) {
|
||||||
|
return !event.defaultPrevented && event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey &&
|
||||||
|
anchor.origin === location.origin && !anchor.download && anchor.target !== "_blank";
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
roots().forEach((root) => {
|
||||||
|
bootstrapState(root);
|
||||||
|
bindRoot(root);
|
||||||
|
});
|
||||||
|
history.replaceState(history.state || { slhx: true }, "", location.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener("popstate", () => {
|
||||||
|
const root = roots()[0];
|
||||||
|
if (root) navigateUrl(location.href, root, "none").catch((error) => emit(root, "slhx:error", String(error)));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
|
||||||
|
else start();
|
||||||
|
|
||||||
|
window.slhx = Object.freeze({ runtimeAbiVersion, roots, rootOf, applyHtml, applyBatch, decodeBatch, atomValue, decodeAtomState });
|
||||||
|
})();
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub const RUNTIME_ABI_VERSION: u32 = 1;
|
||||||
|
pub const RUNTIME_JS: &str = include_str!("../runtime/slhx.js");
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#[test]
|
||||||
|
fn runtime_posts_urlencoded_forms_by_default() {
|
||||||
|
let source = slhx_js::RUNTIME_JS;
|
||||||
|
|
||||||
|
assert!(source.contains("new URLSearchParams()"));
|
||||||
|
assert!(source.contains("multipart/form-data"));
|
||||||
|
assert!(source.contains("const body = method === \"GET\" || method === \"HEAD\" ? undefined : requestBody(data, multipart)"));
|
||||||
|
assert!(source.contains("application/x-www-form-urlencoded;charset=UTF-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_handles_get_forms_without_request_body() {
|
||||||
|
let source = slhx_js::RUNTIME_JS;
|
||||||
|
|
||||||
|
assert!(source.contains("function requestUrl(form, data, method)"));
|
||||||
|
assert!(source.contains("method === \"GET\" || method === \"HEAD\" ? undefined : requestBody"));
|
||||||
|
assert!(source.contains("if (method === \"GET\") url.search = urlEncoded(data).toString()"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_supports_queued_request_policy() {
|
||||||
|
let source = slhx_js::RUNTIME_JS;
|
||||||
|
|
||||||
|
assert!(source.contains("const queues = new WeakMap()"));
|
||||||
|
assert!(source.contains("policy === \"queue\""));
|
||||||
|
assert!(source.contains("const base = queues.get(target) || active.done"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_exposes_page_swap_hooks() {
|
||||||
|
let source = slhx_js::RUNTIME_JS;
|
||||||
|
|
||||||
|
assert!(source.contains("data-slhx-nav"));
|
||||||
|
assert!(source.contains("data-slhx-boost"));
|
||||||
|
assert!(source.contains("history.pushState"));
|
||||||
|
assert!(source.contains("popstate"));
|
||||||
|
assert!(source.contains("x-slhx-title"));
|
||||||
|
assert!(source.contains("x-slhx-fingerprint"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_keeps_form_field_targets_separate_from_error_targets() {
|
||||||
|
let source = slhx_js::RUNTIME_JS;
|
||||||
|
|
||||||
|
assert!(source.contains("function fieldTarget(scope, id, field)"));
|
||||||
|
assert!(source.contains("[data-fid=\"${id}\"] [name=\"${escapedField}\"]"));
|
||||||
|
assert!(source.contains("function formErrorTarget(scope, id, field)"));
|
||||||
|
assert!(source.contains("[data-slhx-error-for=\"${escapedField}\"]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_preflights_batches_before_applying_ops() {
|
||||||
|
let source = slhx_js::RUNTIME_JS;
|
||||||
|
|
||||||
|
assert!(source.contains("const missingTarget = batch.ops.map((op) => canApplyOp(scope, op)).find(Boolean)"));
|
||||||
|
assert!(source.contains("function canApplyOp(scope, op)"));
|
||||||
|
assert!(source.contains("for (const op of batch.ops) applyOp(scope, op)"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[package]
|
||||||
|
name = "slhx-test"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
slhx-core = { path = "../slhx-core" }
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
use slhx_core::{Atom, BuildFingerprint, Effect, EffectBatch, Form, IntoEffect, KeyedSlot, ResourceId, ResourceRef, Slot};
|
||||||
|
|
||||||
|
pub fn run<I, F, R>(handler: F, input: I) -> EffectInspector
|
||||||
|
where
|
||||||
|
F: FnOnce(I) -> R,
|
||||||
|
R: IntoEffect,
|
||||||
|
{
|
||||||
|
inspect(handler(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inspect(effect: impl IntoEffect) -> EffectInspector {
|
||||||
|
EffectInspector {
|
||||||
|
batch: effect.into_batch(BuildFingerprint(0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct EffectInspector {
|
||||||
|
batch: EffectBatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EffectInspector {
|
||||||
|
pub fn batch(&self) -> &EffectBatch {
|
||||||
|
&self.batch
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ops(&self) -> &[Effect] {
|
||||||
|
&self.batch.ops
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contains(&self, op: &Effect) -> bool {
|
||||||
|
self.batch.ops.contains(op)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_resource(&self, resource: ResourceId) -> bool {
|
||||||
|
self.batch
|
||||||
|
.ops
|
||||||
|
.iter()
|
||||||
|
.any(|op| op_targets_resource(op, resource))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_ref(&self, target: &ResourceRef) -> bool {
|
||||||
|
self.batch.ops.iter().any(|op| op_targets_ref(op, target))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_slot<T>(&self, slot: Slot<T>) -> bool {
|
||||||
|
self.has_resource(slot.id())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_keyed_slot<K, T>(&self, slot: KeyedSlot<K, T>) -> bool
|
||||||
|
where
|
||||||
|
K: ToString,
|
||||||
|
{
|
||||||
|
self.has_resource(slot.id())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_atom<T>(&self, atom: Atom<T>) -> bool {
|
||||||
|
self.has_resource(atom.id())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_form<T>(&self, form: Form<T>) -> bool {
|
||||||
|
self.has_resource(form.id())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn op_targets_resource(op: &Effect, resource: ResourceId) -> bool {
|
||||||
|
match op {
|
||||||
|
Effect::Put { target, .. }
|
||||||
|
| Effect::Insert { target, .. }
|
||||||
|
| Effect::Prepend { target, .. }
|
||||||
|
| Effect::Remove { target, .. }
|
||||||
|
| Effect::Move { target, .. }
|
||||||
|
| Effect::Focus { target } => target.resource == resource,
|
||||||
|
Effect::Navigate { .. } | Effect::Emit { .. } => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn op_targets_ref(op: &Effect, wanted: &ResourceRef) -> bool {
|
||||||
|
match op {
|
||||||
|
Effect::Put { target, .. }
|
||||||
|
| Effect::Insert { target, .. }
|
||||||
|
| Effect::Prepend { target, .. }
|
||||||
|
| Effect::Remove { target, .. }
|
||||||
|
| Effect::Move { target, .. }
|
||||||
|
| Effect::Focus { target } => target == wanted,
|
||||||
|
Effect::Navigate { .. } | Effect::Emit { .. } => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
use slhx_core::{Atom, Effect, KeyedSlot, Payload, ResourceRef, Slot};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inspects_tuple_effects() {
|
||||||
|
let count = Slot::<u32>::new(1);
|
||||||
|
let user = Atom::<String>::new(2);
|
||||||
|
|
||||||
|
let inspected = slhx_test::run(
|
||||||
|
|value| (count.text(value), user.set("alice")),
|
||||||
|
42,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(inspected.has_slot(count));
|
||||||
|
assert!(inspected.has_atom(user));
|
||||||
|
assert!(inspected.contains(&Effect::Put {
|
||||||
|
target: ResourceRef::unscoped(count.id()),
|
||||||
|
payload: Payload::text(42),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finds_keyed_slot_targets() {
|
||||||
|
let rows = KeyedSlot::<u32, String>::new(9);
|
||||||
|
let inspected = slhx_test::inspect(rows.append(7, "row"));
|
||||||
|
|
||||||
|
assert!(inspected.has_keyed_slot(rows));
|
||||||
|
assert_eq!(inspected.ops().len(), 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "slhx"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
slhx-core = { path = "../slhx-core" }
|
||||||
|
slhx-derive = { path = "../slhx-derive" }
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! Public authoring facade for slhx applications.
|
||||||
|
//!
|
||||||
|
//! Most application code should depend on this crate, use the proc-macros from
|
||||||
|
//! here, and import generated resources through `#[slhx::surface]`.
|
||||||
|
|
||||||
|
pub use slhx_core::*;
|
||||||
|
pub use slhx_derive::{app, component, handler, surface};
|
||||||
|
|
||||||
|
pub mod prelude {
|
||||||
|
pub use slhx_core::{
|
||||||
|
navigate, push, redirect, replace, Atom, BuildFingerprint, Effect, Form, Handle,
|
||||||
|
IntoEffect, KeyedSlot, Slot,
|
||||||
|
};
|
||||||
|
pub use slhx_derive::{app, component, handler, surface};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user