366 lines
18 KiB
Markdown
366 lines
18 KiB
Markdown
# slhx — Semantic, Laterally HX
|
|
|
|
slhx does not compete with React by becoming a better frontend framework.
|
|
slhx competes with React by making frontend frameworks unnecessary for most apps.
|
|
|
|
> **hemplate owns syntax. hemplate emits surface. slhx consumes surface. slhx owns semantics. JS executes bytecode.**
|
|
|
|
---
|
|
|
|
## pitch
|
|
|
|
### 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]
|
|
|
|
---
|
|
|
|
## 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-*`, `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.
|
|
|
|
---
|
|
|
|
## surface
|
|
|
|
### 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.
|
|
|
|
---
|
|
|
|
## build
|
|
|
|
### req: build/001
|
|
001 Build order: `.heml` → `hemplate_build` → `hemplate.surface.postcard` → `slhx_build` → `slhx.syms` + generated Rust constants.
|
|
|
|
### req: build/002
|
|
002 `slhx-derive` (`#[slhx::handler]`) reads `slhx.syms` at expansion time to validate handle names, slot names, and form signatures.
|
|
|
|
### req: build/003
|
|
003 A `build.rs` failure (missing Surface, version mismatch, stale hash) is a hard error before proc-macro expansion.
|
|
|
|
---
|
|
|
|
## effect
|
|
|
|
### req: effect/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.
|
|
|
|
### req: effect/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.
|
|
|
|
### req: effect/003
|
|
003 Multiple effects are combined with `Effect::batch((...))` or a method chain on `EffectWriter`. No `!` call-syntax macros.
|
|
|
|
### req: effect/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.
|
|
|
|
### 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]
|
|
|
|
---
|
|
|
|
## state
|
|
|
|
### req: state/001
|
|
001 Typed atoms with `Atom<T>`: read via `atom.get()`, subscribe via `Effect::set`. No hidden global proxy / reactive graph. Atoms are explicit values in `struct App`.
|
|
|
|
### req: state/002
|
|
002 Subscription is explicit: `Effect::set(atoms::FOO, 42)` pushes the new value to all consumers. No automatic component re-render graph.
|
|
|
|
### req: state/003
|
|
003 JS runtime maintains a client-side atom store with identical API to the server: `get<T>(atom)`, `set<T>(atom, value)`, `subscribe<T>(atom, callback)`. Type erased at runtime with `TypeId`.
|
|
|
|
### req: state/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.
|
|
|
|
---
|
|
|
|
## form
|
|
|
|
### req: form/001
|
|
001 Forms are source of truth in HTML. hemplate Surface exports form shape (controls, names, required, types). slhx checks compatibility with the Rust handler's `Form<T>` type. No auto-generated structs; domain types (e.g. `Email`) are first-class. The Surface describes; Rust owns; slhx checks.
|
|
|
|
### req: form/002
|
|
002 The handle id is carried as `__h` in POST `application/x-www-form-urlencoded`. A JSON body is allowed at the integration boundary (`application/json`) only if the handler accepts it; core uses form encoding.
|
|
|
|
### req: form/003
|
|
003 handler receives `form: Form<CreateTodo>`. Validation errors are returned as `Effect::form_error(field, message)`, which the JS runtime maps back to the originating input via `data-sid`.
|
|
|
|
---
|
|
|
|
## list
|
|
|
|
### 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.
|
|
|
|
---
|
|
|
|
## 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, key: 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, 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.
|
|
|
|
---
|
|
|
|
## 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 `Effect::resource(res).reload()` triggers a re-fetch and re-render. The server sends a new EffectBatch when data is ready.
|
|
|
|
---
|
|
|
|
## scope
|
|
|
|
### req: scope/001
|
|
001 `Scope` is a first-class primitive. Keyed loops (`@for`), conditional branches (`@if`), component instances, modals, tabs, nested forms — all are scopes. A slhx-addressable node inside any dynamic scope must carry a stable `ScopeKey`. Composite identity is `(ResourceId, ScopeKey)`. [north_star]
|
|
|
|
---
|
|
|
|
## wire
|
|
|
|
### 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.
|
|
|
|
### req: wire/005
|
|
005 No JSON anywhere in slhx-internal artifacts. Public request/response envelopes use `application/x-www-form-urlencoded`; effects and symbols use postcard. `application/json` is acceptable only at integration boundaries.
|
|
|
|
---
|
|
|
|
## runtime
|
|
|
|
### req: runtime/001
|
|
001 Every slhx tree must declare a root boundary via `data-slhx-root` on an ancestor element. The JS runtime resolves lookups within that root only. Multiple independent slhx apps/widgets/modals may coexist on the same document without ID collision. [north_star]
|
|
|
|
### req: runtime/002
|
|
002 JS runtime attaches a single delegated listener per event type on the root. No per-node listeners. Dispatch resolves target via `data-hid` / `data-sid` attributes on the event path.
|
|
|
|
### req: runtime/003
|
|
003 The JS runtime is a tiny op interpreter (~2KB, no selectors, no VDOM, no scheduler, no expressions). It reads postcard `EffectBatch` bytes and applies them as DOM operations.
|
|
|
|
### 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.
|
|
|
|
---
|
|
|
|
## js
|
|
|
|
### req: js/001
|
|
001 Runtime reads attributes `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.
|
|
|
|
---
|
|
|
|
## check
|
|
|
|
### 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.
|
|
|
|
---
|
|
|
|
## sync
|
|
|
|
### req: sync/001
|
|
001 `slhx-sync` is an optional crate for collaborative / multiplayer state. Provides presence tracking, patch reconciliation, conflict resolution (server-authoritative), and offline queueing. Not part of core.
|
|
|
|
### 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.
|
|
|
|
---
|
|
|
|
## interop
|
|
|
|
### req: interop/001
|
|
001 `Effect::event` (see req:effect/006) is the single bridge between slhx and third-party JS. External widgets, charts, and maps listen via native `CustomEvent`. slhx core does not inspect or manage them. [north_star]
|
|
|
|
### req: interop/002
|
|
002 Web Components and custom elements are valid opaque leaf nodes. slhx does not inspect shadow DOM. Escape hatches are leaves, never app foundations.
|
|
|
|
### req: interop/003
|
|
003 WASM islands (`#[slhx::island]`) compile handler code to WASM for client-local execution. The island is a leaf in the DOM; slhx core is unaware of WASM except via the same `EffectBatch` contract.
|
|
|
|
---
|
|
|
|
## 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
|
|
|
|
### 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.
|
|
|
|
### req: nav/002
|
|
002 Navigation modes: `Push` (history.pushState), `Replace` (replaceState), `Redirect` (server-side 302). Scroll behaviour: `Preserve`, `Top`, `Element(ResourceId)`. Title is optional.
|
|
|
|
---
|
|
|
|
## escape_hatch
|
|
|
|
### req: escape_hatch/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.
|
|
|
|
---
|
|
|
|
## 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.
|
|
|
|
---
|
|
|
|
## derive_app
|
|
|
|
### 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.
|
|
|
|
---
|
|
|
|
## ts
|
|
|
|
### req: ts/001
|
|
001 TypeScript definitions for `slhx-js` runtime are shipped as a single `.d.ts` file. Types mirror the postcard `EffectBatch` schema for advanced consumers. Tooling must not depend on these types for core functionality; they are developer convenience only.
|
|
|
|
---
|
|
|
|
## 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]
|
|
|
|
---
|
|
|
|
## misc
|
|
|
|
### req: misc/001
|
|
001 Workspace layout: `slhx-core` (types + postcard schema, no_std), `slhx-derive` (proc-macros), `slhx-build` (surface consumer + code generation), `slhx-axum` (integration), `slhx-js` (runtime single file), `slhx-transition` (optional), `slhx-sync` (optional), `slhx-wasm` (optional). No kitchen-sink crate.
|
|
|
|
### 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: 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.
|
|
|
|
### 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.
|
|
|
|
### 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.
|
|
|
|
### 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.
|