Hiccup → HTML rendering and the small DSL for Datastar attributes.
Two responsibilities live here, deliberately kept apart from the kernel:
Serialise hiccup to HTML with Chassis.
The kernel works with hiccup data structures all the way through;
they are only stringified at the very edge, just before
patch-elements! writes to the wire. This keeps everything before
the wire pure, diff-able, and REPL-inspectable.
Generate Datastar attribute fragments — on, bind — that
tag a piece of UI with the wiring that lets the client post events
back to the right conversation and instance.
The cid only exists at request time, so the helpers consult a dynamic var bound by the http layer for the duration of a render.
Hiccup → HTML rendering and the small DSL for Datastar attributes. Two responsibilities live here, deliberately kept apart from the kernel: 1. **Serialise hiccup to HTML** with [Chassis](https://github.com/onionpancakes/chassis). The kernel works with hiccup data structures all the way through; they are only stringified at the very edge, just before `patch-elements!` writes to the wire. This keeps everything before the wire pure, diff-able, and REPL-inspectable. 2. **Generate Datastar attribute fragments** — `on`, `bind` — that tag a piece of UI with the wiring that lets the client post events back to the right conversation and instance. The cid only exists at request time, so the helpers consult a dynamic var bound by the http layer for the duration of a render.
URL prefix for the current adapter mount. Standalone stube keeps this
empty, while embedders can bind it to e.g. /widget so generated
Datastar URLs stay inside the host route tree.
URL prefix for the current adapter mount. Standalone stube keeps this empty, while embedders can bind it to e.g. `/widget` so generated Datastar URLs stay inside the host route tree.
The conversation id of the request currently being served. Bound by the http layer around every render so attribute helpers can build URLs pointing at the right SSE endpoint.
The conversation id of the request currently being served. Bound by the http layer around every render so attribute helpers can build URLs pointing at the right SSE endpoint.
The conversation being rendered, bound by frame/render-frame for
the duration of one render call. render-slot consults it to
look up embedded children by id.
Two-way bindings (s/bind) and event hooks (s/on) only need the
cid; only slot rendering needs the conversation, hence the separate
vars.
The conversation being rendered, bound by `frame/render-frame` for the duration of one render call. [[render-slot]] consults it to look up embedded children by id. Two-way bindings (`s/bind`) and event hooks (`s/on`) only need the cid; only slot rendering needs the conversation, hence the separate vars.
Selector targeted by the first frame render. The shell and embedded fragment render a matching element.
Selector targeted by the first frame render. The shell and embedded fragment render a matching element.
(back-button label)(back-button label attrs)Return a small Hiccup button wired to the conversation-level [:back]
effect.
(s/back-button "Back")
This intentionally does not take self: conversation-level history
rewind is a conversation operation, not a component-local event. It
walks :conv/history (not window.history) — the browser's Back
button still works independently. For wizard-style Back buttons that
answer a parent with a sentinel, keep using
(s/on self :click :as ...) from that component.
Return a small Hiccup button wired to the conversation-level `[:back]`
effect.
(s/back-button "Back")
This intentionally does not take `self`: conversation-level history
rewind is a conversation operation, not a component-local event. It
walks `:conv/history` (not `window.history`) — the browser's Back
button still works independently. For wizard-style Back buttons that
answer a parent with a sentinel, keep using
`(s/on self :click :as ...)` from that component.(back-url)URL the browser POSTs to for the conversation-level Back action.
URL the browser POSTs to for the conversation-level Back action.
(behavior self behavior-id)(behavior self behavior-id args)Attach a client-side behavior to this element.
[:div (s/behavior self :notes/cm6-editor {:doc-id (:doc-id self)})]
Renders as data-stube-behavior="notes/cm6-editor" plus one
data-stube-arg-<key> attribute per entry in args.
The behaviors bridge loaded with the shell discovers the attribute
after each Datastar morph, lazy-imports the module at
/<base-path>/behaviors/<ns>/<name>.js, and drives its
mount/patched/unmount lifecycle. Behaviors are the canonical
seam for non-trivial client code: third-party widgets, autocompletes,
drag-and-drop, anything that needs imperative JS keyed to a DOM
element.
args values are stringified (name for keywords, str for
numbers/booleans, pr-str for everything else) and decoded on the
JS side as ctx.args.<camelKey>. Pass small, scalar values that
belong to the server's view of the element — avoid round-tripping
large blobs through DOM attributes.
Behavior ctx shape:
el — the DOM element the behavior is attached to.args — data-stube-arg-* attributes decoded into a camelCased
object.basePath — the kernel base-path so behaviors can build other
stube URLs.signals.get(name) / signals.set(name, v) / signals.patch(map)
— read/write Datastar signals. Aliases ctx.setSignal(name, v)
and ctx.patchSignals(map) exist for the common write paths so
behaviors can drive bound signals directly, retiring hidden-input
shims.fetch(eventUrl, opts?) — POST to a stube event URL the same
shape s/on produces. Pass the URL in via a data-stube-arg-*
built with event-url on the server.Use preserve when the behavior owns DOM children outside the
server's render tree, and on-unmount for one-off teardown that
doesn't need the full behavior contract.
Attach a client-side behavior to this element.
[:div (s/behavior self :notes/cm6-editor {:doc-id (:doc-id self)})]
Renders as `data-stube-behavior="notes/cm6-editor"` plus one
`data-stube-arg-<key>` attribute per entry in `args`.
The behaviors bridge loaded with the shell discovers the attribute
after each Datastar morph, lazy-imports the module at
`/<base-path>/behaviors/<ns>/<name>.js`, and drives its
`mount`/`patched`/`unmount` lifecycle. Behaviors are the canonical
seam for non-trivial client code: third-party widgets, autocompletes,
drag-and-drop, anything that needs imperative JS keyed to a DOM
element.
`args` values are stringified (`name` for keywords, `str` for
numbers/booleans, `pr-str` for everything else) and decoded on the
JS side as `ctx.args.<camelKey>`. Pass small, scalar values that
belong to the server's view of the element — avoid round-tripping
large blobs through DOM attributes.
Behavior `ctx` shape:
* `el` — the DOM element the behavior is attached to.
* `args` — `data-stube-arg-*` attributes decoded into a camelCased
object.
* `basePath` — the kernel base-path so behaviors can build other
stube URLs.
* `signals.get(name)` / `signals.set(name, v)` / `signals.patch(map)`
— read/write Datastar signals. Aliases `ctx.setSignal(name, v)`
and `ctx.patchSignals(map)` exist for the common write paths so
behaviors can drive bound signals directly, retiring hidden-input
shims.
* `fetch(eventUrl, opts?)` — POST to a stube event URL the same
shape `s/on` produces. Pass the URL in via a `data-stube-arg-*`
built with [[event-url]] on the server.
Use [[preserve]] when the behavior owns DOM children outside the
server's render tree, and [[on-unmount]] for one-off teardown that
doesn't need the full behavior contract.(behavior-module-url behavior-id)URL the behaviors bridge imports for behavior behavior-id
(a qualified keyword) in the current mount.
URL the behaviors bridge imports for behavior `behavior-id` (a qualified keyword) in the current mount.
(behaviors-js-url)URL for stube's behaviors bridge script in the current mount.
URL for stube's behaviors bridge script in the current mount.
(bind signal)Return an attribute map that two-way binds the named signal to the current element.
[:input (merge {:name "answer"} (s/bind :answer))]
Datastar's signal-defining attributes use the colon form
(data-bind:foo); the dash form would not be recognised.
Datastar 1.0 camel-cases data-bind:<key> by default. Clojure code
conventionally names signals with kebab-case keywords and reads the
POSTed signals back by the same keyword, so force Datastar's no-op
kebab case modifier to keep the wire key unchanged.
Return an attribute map that two-way binds the named signal to the
current element.
[:input (merge {:name "answer"} (s/bind :answer))]
Datastar's signal-defining attributes use the colon form
(`data-bind:foo`); the dash form would not be recognised.
Datastar 1.0 camel-cases `data-bind:<key>` by default. Clojure code
conventionally names signals with kebab-case keywords and reads the
POSTed signals back by the same keyword, so force Datastar's no-op
kebab case modifier to keep the wire key unchanged.(component-class type-kw)Return the auto-generated CSS class for component id type-kw
(e.g. :notes/shell → "stube-c-notes-shell"). Always present on
every component root so host CSS can hang selectors off it without
the host having to spell out every class manually.
Return the auto-generated CSS class for component id `type-kw` (e.g. `:notes/shell` → `"stube-c-notes-shell"`). Always present on every component root so host CSS can hang selectors off it without the host having to spell out every class manually.
(component-module-url module-id)URL of a JS module by module-id (e.g. "notes/zoom") in the
current mount.
URL of a JS module by `module-id` (e.g. `"notes/zoom"`) in the current mount.
(component-slug type-kw)Return the wire form of component id type-kw — namespace + "/" +
name (e.g. :notes/shell → "notes/shell"). This is the value of
data-stube-component on every component root.
Return the wire form of component id `type-kw` — namespace + "/" + name (e.g. `:notes/shell` → `"notes/shell"`). This is the value of `data-stube-component` on every component root.
(component-style-url type-kw)URL of the stylesheet for component type-kw in the current mount.
URL of the stylesheet for component `type-kw` in the current mount.
(event-url target route-event)URL the browser POSTs to for an event. Public so user code can build custom Datastar expressions that target the same endpoint.
target is either a bare instance-id string or an instance map.
route-event is either a keyword (:save) or a structured event
vector ([:pick-day day]). The path always contains the logical
event name; structured payloads ride in a small EDN query parameter so
the server can reconstruct {:event :pick-day :payload day} without
teaching Datastar about stube metadata.
URL the browser POSTs to for an event. Public so user code can build
custom Datastar expressions that target the same endpoint.
`target` is either a bare instance-id string or an instance map.
`route-event` is either a keyword (`:save`) or a structured event
vector (`[:pick-day day]`). The path always contains the logical
event name; structured payloads ride in a small EDN query parameter so
the server can reconstruct `{:event :pick-day :payload day}` without
teaching Datastar about stube metadata.(halos-js-url)URL for the optional halos script in the current mount.
URL for the optional halos script in the current mount.
(html tree)Render hiccup tree to an HTML string.
Render hiccup `tree` to an HTML string.
(instance-id self)Return the :instance/id carried on self, or throw with a clear
message if the value is missing.
Helpers passing instance ids out into pure rendering code should reach
for this rather than destructuring :instance/id directly — the
framework owns the wire shape, and the public name is the stable seam.
Return the `:instance/id` carried on `self`, or throw with a clear message if the value is missing. Helpers passing instance ids out into pure rendering code should reach for this rather than destructuring `:instance/id` directly — the framework owns the wire shape, and the public name is the stable seam.
(local-bind self signal)Like bind, but scopes logical signal to this component instance.
:keep #{:answer}
[:input (s/local-bind self :answer)]
The browser sends :answer-<iid>; the conversation layer lifts that
value back onto :answer before the handler runs.
Like [[bind]], but scopes logical `signal` to this component instance.
:keep #{:answer}
[:input (s/local-bind self :answer)]
The browser sends `:answer-<iid>`; the conversation layer lifts that
value back onto `:answer` before the handler runs.(on self dom-event)(on self dom-event as-kw route-event)(on self dom-event as-kw route-event modifiers)Return an attribute map that wires a real DOM event on the surrounding element to a server-side stube event.
Two arities:
(on self :submit) ;; DOM `submit` → POST .../submit
(on self :click :as :inc) ;; DOM `click` → POST .../inc
(on self :click :as [:pick item-id])
;; handler sees :event :pick,
;; :payload item-id
The first form is the common case where the DOM event name and the
route name happen to be the same (most often :submit on a form,
:input on a text field). The second form is the right one for any
click-triggered action: a button has no inc event of its own, only
click, so we need to listen on click and route to inc
separately.
Datastar registers listeners under the colon form
(data-on:<event>); the dash form data-on-<event> is reserved for
built-in pseudo-events (data-on-intersect, …) and would be silently
ignored. data-on:submit automatically calls preventDefault, so
forms never trigger a full-page reload.
The instance id and route event live in the URL path itself —
Datastar still ships every other signal as the request body, so
two-way bindings (s/bind) keep working unchanged.
Usage:
[:form (s/on self :submit) …]
[:button (s/on self :click :as :inc) "+"]
[:button (s/on self :click :as :dec) "−"]
[:input (s/on self :input :as :search {:debounce "300ms"})]
The optional 5-arity modifiers map produces Datastar event
modifiers in the attribute name (data-on:input__debounce.300ms).
See on-target for the modifier rules.
Return an attribute map that wires a real DOM event on the
surrounding element to a server-side stube event.
Two arities:
(on self :submit) ;; DOM `submit` → POST .../submit
(on self :click :as :inc) ;; DOM `click` → POST .../inc
(on self :click :as [:pick item-id])
;; handler sees :event :pick,
;; :payload item-id
The first form is the common case where the DOM event name and the
route name happen to be the same (most often `:submit` on a form,
`:input` on a text field). The second form is the right one for any
click-triggered action: a button has no `inc` event of its own, only
`click`, so we need to listen on `click` and route to `inc`
separately.
Datastar registers listeners under the colon form
(`data-on:<event>`); the dash form `data-on-<event>` is reserved for
built-in pseudo-events (`data-on-intersect`, …) and would be silently
ignored. `data-on:submit` automatically calls `preventDefault`, so
forms never trigger a full-page reload.
The instance id and route event live in the URL path itself —
Datastar still ships every other signal as the request body, so
two-way bindings (`s/bind`) keep working unchanged.
Usage:
[:form (s/on self :submit) …]
[:button (s/on self :click :as :inc) "+"]
[:button (s/on self :click :as :dec) "−"]
[:input (s/on self :input :as :search {:debounce "300ms"})]
The optional 5-arity `modifiers` map produces Datastar event
modifiers in the attribute name (`data-on:input__debounce.300ms`).
See [[on-target]] for the modifier rules.(on-mount self label expr)Return a Datastar data-init expression only before self is rendered.
Use this with preserve to construct a third-party widget once, then
let later stube renders update the host element's attributes without
re-running the widget constructor.
Return a Datastar `data-init` expression only before `self` is rendered. Use this with [[preserve]] to construct a third-party widget once, then let later stube renders update the host element's attributes without re-running the widget constructor.
(on-parent self dom-event)(on-parent self dom-event as-kw route-event)(on-parent self dom-event as-kw route-event modifiers)Like on-target, but routes the event to self's parent instance.
[:button (s/on-parent self :click :as [:open note-id]) "Open"]
Equivalent to (on-target (:instance/parent self) …), but the public
name lets pure render helpers ride one stable seam instead of
reaching for the instance-map's keys. Use this for the recurring
pattern of a child rendering controls whose semantics belong to the
parent (close button on a card, link inside a row that opens
something in the owning desk, etc.).
Like [[on-target]], but routes the event to `self`'s parent instance.
[:button (s/on-parent self :click :as [:open note-id]) "Open"]
Equivalent to `(on-target (:instance/parent self) …)`, but the public
name lets pure render helpers ride one stable seam instead of
reaching for the instance-map's keys. Use this for the recurring
pattern of a child rendering controls whose semantics belong to the
parent (close button on a card, link inside a row that opens
something in the owning desk, etc.).(on-target target dom-event)(on-target target dom-event as-kw route-event)(on-target target dom-event as-kw route-event modifiers)Like on, but route the event to an explicit target instance
instead of the component whose hiccup is being rendered.
[:button (s/on-target parent-iid :click :as [:open note-id]) "Open"]
[:button (s/on-target parent-self :click :as :open) "Open"]
[:input (s/on-target target :input :as :search {:debounce "300ms"})]
target may be either a bare instance-id string or an instance map;
the helper coerces it through instance-id.
The optional 5-arity modifiers map produces Datastar event modifiers
in the attribute name (data-on:input__debounce.300ms). Values may be
strings, numbers, keywords, or true for flag-only modifiers
({:stop true :prevent true} → __prevent__stop). Map entries are
sorted by key name for deterministic output; pass a vector of pairs to
preserve caller order.
This is intentionally a narrow escape hatch for cross-instance controls such as links rendered inside one child that should notify a stable parent without answering/removing the child.
Like [[on]], but route the event to an explicit target instance
instead of the component whose hiccup is being rendered.
[:button (s/on-target parent-iid :click :as [:open note-id]) "Open"]
[:button (s/on-target parent-self :click :as :open) "Open"]
[:input (s/on-target target :input :as :search {:debounce "300ms"})]
`target` may be either a bare instance-id string or an instance map;
the helper coerces it through [[instance-id]].
The optional 5-arity `modifiers` map produces Datastar event modifiers
in the attribute name (`data-on:input__debounce.300ms`). Values may be
strings, numbers, keywords, or `true` for flag-only modifiers
(`{:stop true :prevent true}` → `__prevent__stop`). Map entries are
sorted by key name for deterministic output; pass a vector of pairs to
preserve caller order.
This is intentionally a narrow escape hatch for cross-instance controls
such as links rendered inside one child that should notify a stable
parent without answering/removing the child.(on-unmount self label expr)Attach a JS expression that runs once when the host element is detached from the DOM.
Use this alongside preserve / on-mount to dispose third-party
widgets cleanly:
[:div (merge (s/preserve self :editor)
(s/on-mount self :editor "el.cmView = new EditorView({parent:el})")
(s/on-unmount self :editor "el.cmView?.destroy()"))]
The expression runs once, just before the host detaches,
with el bound to the element (mirroring on-mount). The
expression must be synchronous and idempotent; it should not emit
events back to the server. Errors are logged to console and do
not block the morph.
Implemented via a single document-wide MutationObserver installed
by stube/preserve.js.
Attach a JS expression that runs once when the host element is
detached from the DOM.
Use this alongside [[preserve]] / [[on-mount]] to dispose third-party
widgets cleanly:
[:div (merge (s/preserve self :editor)
(s/on-mount self :editor "el.cmView = new EditorView({parent:el})")
(s/on-unmount self :editor "el.cmView?.destroy()"))]
The expression runs **once**, **just before** the host detaches,
with `el` bound to the element (mirroring [[on-mount]]). The
expression must be synchronous and idempotent; it should not emit
events back to the server. Errors are logged to `console` and do
not block the morph.
Implemented via a single document-wide MutationObserver installed
by `stube/preserve.js`.Query-string key used by event-url for structured event payloads.
Query-string key used by [[event-url]] for structured event payloads.
(preserve self label)Return attributes marking an element's children as externally owned.
[:div (merge (s/preserve self :editor)
(s/on-mount self :editor "..."))]
stube's shell loads a small bridge that lets Datastar merge the marked
element's attributes on each morph while skipping its child subtree.
The label only needs to be unique within the
rendered patch; use a stable keyword such as :editor or :chart.
Return attributes marking an element's children as externally owned.
[:div (merge (s/preserve self :editor)
(s/on-mount self :editor "..."))]
stube's shell loads a small bridge that lets Datastar merge the marked
element's attributes on each morph while skipping its child subtree.
The label only needs to be unique within the
rendered patch; use a stable keyword such as `:editor` or `:chart`.(preserve-js-url)URL for stube's preserved-subtree bridge script in the current mount.
URL for stube's preserved-subtree bridge script in the current mount.
(render-slot self slot-key)(render-slot self slot-key lookup!)Inline the hiccup of an embedded child. Inside a parent's :render,
[:section (s/render-slot self :slot/header)]
expands to whatever the :ui/site-header instance currently renders,
and Chassis serialises both layers in one pass. No HTML escaping is
needed because we hand back hiccup, not a pre-rendered string.
The lookup arrow is:
slot-key → (:instance/children self)
→ child instance id
→ (instance *conv* child-iid)
→ child component definition's `:render`
Throws if *conv* is unbound or the slot is unknown. Returns the
default hidden placeholder when the child component has no :render
of its own.
Inline the hiccup of an embedded child. Inside a parent's `:render`,
[:section (s/render-slot self :slot/header)]
expands to whatever the `:ui/site-header` instance currently renders,
and Chassis serialises both layers in one pass. No HTML escaping is
needed because we hand back hiccup, not a pre-rendered string.
The lookup arrow is:
slot-key → (:instance/children self)
→ child instance id
→ (instance *conv* child-iid)
→ child component definition's `:render`
Throws if `*conv*` is unbound or the slot is unknown. Returns the
default hidden placeholder when the child component has no `:render`
of its own.(root-attrs self & attr-maps)Return an attribute map carrying the framework hooks every component root needs, merged with any other attribute maps the caller hands in.
(s/root-attrs self (s/on self :submit) {:class "x"})
Emitted attributes:
:id — self's :instance/id, required by Datastar's morph-by-id.:data-stube-component — the component id in ns/name form, so
CSS selectors and the behaviors bridge can address every component
by its registered keyword.:class — the auto-generated stube-c-<ns>-<name> class
(concatenated with any user-supplied :class).If the caller passes :id, the framework id wins. If :instance/type
is missing (component code exercised through tests with hand-rolled
instance maps), the component-derived attributes are skipped silently
and only :id is enforced.
Return an attribute map carrying the framework hooks every component
root needs, merged with any other attribute maps the caller hands in.
(s/root-attrs self (s/on self :submit) {:class "x"})
Emitted attributes:
* `:id` — `self`'s `:instance/id`, required by Datastar's morph-by-id.
* `:data-stube-component` — the component id in `ns/name` form, so
CSS selectors and the behaviors bridge can address every component
by its registered keyword.
* `:class` — the auto-generated `stube-c-<ns>-<name>` class
(concatenated with any user-supplied `:class`).
If the caller passes `:id`, the framework id wins. If `:instance/type`
is missing (component code exercised through tests with hand-rolled
instance maps), the component-derived attributes are skipped silently
and only `:id` is enforced.(sse-url cid)URL the shell uses to open the Datastar SSE stream for cid.
URL the shell uses to open the Datastar SSE stream for `cid`.
(ui-css-url)URL for the stock stylesheet in the current mount.
URL for the stock stylesheet in the current mount.
(upload-attrs self)Return form attributes for a zero-JS multipart upload.
[:form (s/upload-attrs self)
[:input {:type "file" :name "file"}]
[:button "Upload"]]
(s/upload-frame self)
The hidden iframe target prevents the browser from navigating away from the Datastar shell while the server handles the multipart POST.
Return form attributes for a zero-JS multipart upload.
[:form (s/upload-attrs self)
[:input {:type "file" :name "file"}]
[:button "Upload"]]
(s/upload-frame self)
The hidden iframe target prevents the browser from navigating away from
the Datastar shell while the server handles the multipart POST.(upload-frame self)Hidden iframe target used by upload-attrs.
Hidden iframe target used by [[upload-attrs]].
(upload-target self)Stable hidden iframe target name for upload forms owned by self.
Stable hidden iframe target name for upload forms owned by `self`.
(upload-url self)URL a multipart upload form POSTs to for self.
Uploads intentionally do not use Datastar's signal POST body: browser
file inputs need a normal multipart/form-data request. The HTTP
layer turns that request back into a regular :upload-received event
for this instance and pushes any resulting fragments over the already
open SSE stream.
URL a multipart upload form POSTs to for `self`. Uploads intentionally do not use Datastar's signal POST body: browser file inputs need a normal `multipart/form-data` request. The HTTP layer turns that request back into a regular `:upload-received` event for this instance and pushes any resulting fragments over the already open SSE stream.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |