- Scala 97.5%
- Shell 2.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Snapshots from main must not reuse a version that the registries hold as a release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
| .githooks | ||
| .github/workflows | ||
| datastar | ||
| scenarios | ||
| sse | ||
| tapir | ||
| tapirsse | ||
| .git-blame-ignore-revs | ||
| .gitignore | ||
| .mill-version | ||
| .scalafix.conf | ||
| .scalafmt.conf | ||
| build.mill | ||
| DEVELOPMENT.md | ||
| llms.txt | ||
| mill | ||
| PLAN.md | ||
| README.md | ||
scalatags-datastar
Scalatags bindings for the Datastar hypermedia framework.
The goal is more than plain bindings: fully statically-typed Datastar templates,
where signal references, the expression language, and — crucially — the backend
endpoints referenced by @get/@post/… are all checked by the compiler. Backend
endpoints are Tapir endpoints, so a template can
only reference a route that actually exists, and its URL is generated from the endpoint.
See PLAN.md for the design and roadmap.
Status
Early, but the three magic-string kinds Datastar drives reactivity from are now typed:
- Attributes (Phase 1): the full standard
data-*surface with a typed, fluent modifier builder. - Signals + expressions (Phase 2): a typed
Expr[A]DSL, and a case-class signal model (derives Signals) that yields the initialdata-signalsJSON plus field-checked typed handles. - Backend endpoints (Phase 3): actions reverse-routed from typed Tapir endpoints, so
@get/@post/… can only reference routes that exist, with typed action options.
And the server speaks Datastar's wire protocol back:
- Server-side SSE (Phase 4): a native SSE codec (patch-elements / patch-signals / executeScript)
validated against the official Datastar conformance suite, plus
readSignalsdecoding the round-tripped signal store into the same case class that seeds its initial value.
The binding layer is cross-compiled for JVM and JS; the SSE codec is JVM-only. (Datastar Pro attributes are not yet bound.)
import scalatags.Text.all.*
import works.iterative.scalatags.datastar.Datastar.*
import scala.concurrent.duration.*
button(dataOn("click") := "@post('/save')")("Save")
// <button data-on:click="@post('/save')">Save</button>
div(dataSignals := "{count: 0}", dataText := "$count")
// <div data-signals="{count: 0}" data-text="$count"></div>
// Modifiers are typed and chainable; each attribute exposes only the ones Datastar accepts:
input(dataOn("input").debounce(300.millis).once := "@get('/search')")
// <input data-on:input__debounce.300ms__once="@get('/search')">
div(dataOnIntersect.once.threshold(0.5) := "@get('/more')")
// <div data-on-intersect__once__threshold.0.5="@get('/more')"></div>
Typed signals and expressions
The signal store is a case class — the single source of truth for its shape and initial values.
derives Signals gives the initial data-signals JSON; mixing Signals.Handles into the companion
gives field-checked, typed handles that read as stable members.
A single import works.iterative.scalatags.datastar.Datastar.* brings the whole core DSL into scope:
the data-* builders, the Expr DSL (lit and the operators), and Signal/Signals.
import works.iterative.scalatags.datastar.Datastar.*
final case class Counter(count: Int = 0, step: Int = 1) derives Signals
object Counter extends Signals.Handles[Counter]:
val count = signal("count") // Signal[Int] — "count" checked against Counter at compile time
val step = signal("step") // signal("nope") would not compile
div(
dataSignals := Counter(), // data-signals="{count: 0, step: 1}"
input(`type` := "number", dataBind := Counter.step), // data-bind="step" (two-way, bare name)
span(dataShow := Counter.count > lit(0), // data-show="$count > 0" (browser decodes)
dataText := Counter.count) // data-text="$count"
)
dataSignals := Counter() seeds the store straight from the case class; a plain String still binds
through the same := when you need the escape hatch.
Expr operators render with JavaScript precedence, so (Counter.count > lit(5)) && !busy becomes
$count > 5 && !$busy with only the parentheses meaning requires. Equality is ===/!== (Scala
can't override ==); numeric operators are gated by Numeric.
Typed backend actions (Tapir bridge)
Backend actions are reverse-routed from Tapir endpoints, so a template can only reference a route that exists, and the verb and URL are both derived from the endpoint — they cannot drift.
A second import works.iterative.scalatags.datastar.tapir.* adds the endpoint.action extension.
import sttp.tapir.*
import works.iterative.scalatags.datastar.tapir.*
val toggleTodo = endpoint.post.in("todos" / path[Long]("id") / "toggle")
// .action derives the verb (POST) and reverse-routes the URL, both from the endpoint.
button(dataOn("click") := toggleTodo.action(7L))("Toggle")
// <button data-on:click="@post('/todos/7/toggle')">Toggle</button>
endpoint.action(input) is total — String. An input-free endpoint needs no value: increment.action.
The four mutating verbs map directly; an endpoint that fixes no method (or a non-action method such as
HEAD) falls back to @get, matching Tapir's own client interpreter, which realizes a methodless
endpoint as GET. The reverse-routed URL is escaped into the action's string literal, so values can't
break out of the expression.
Datastar's action options are typed via an optional argument. ActionOptions covers contentType
(JSON by default, or form to submit the enclosing form) and request headers; only the fields you
set are rendered, so the default stays a bare @verb('/url').
val save = endpoint.post.in("contacts" / path[Long]("id"))
button(dataOn("click") := save.action(42L, ActionOptions.form.withHeader("X-CSRF-Token", token)))("Save")
// <button data-on:click="@post('/contacts/42', {contentType: 'form', headers: {'X-CSRF-Token': '…'}})">Save</button>
Server-side SSE (scalatags-datastar-sse, JVM)
The server answers actions by streaming Datastar SSE events that patch with the same Scalatags fragments and the same signal case class. The codec renders each event to its exact wire format and is validated against Datastar's official SDK conformance suite.
import works.iterative.scalatags.datastar.sse.*
import works.iterative.scalatags.datastar.Signals
import scalatags.Text.all.*
import zio.json.*
// One case class is the store on both sides of the wire.
final case class Counter(count: Int = 0, step: Int = 1) derives Signals, JsonEncoder, JsonDecoder
// Patch HTML into the DOM — the Frag is rendered and split across data lines.
ServerSentEvents.patchElements(div(id := "count")("5"), selector = Some("#count"), mode = ElementPatchMode.Inner)
// event: datastar-patch-elements
// data: selector #count
// data: mode inner
// data: elements <div id="count">5</div>
// Patch the signal store — the typed model is serialized to compact JSON.
ServerSentEvents.patchSignals(Counter(5, 1))
// event: datastar-patch-signals
// data: signals {"count":5,"step":1}
// Decode the store Datastar round-trips back to the server.
readSignals[Counter]("""{"count":5,"step":1}""") // Right(Counter(5, 1))
Only non-default options emit data lines, so a bare patchElements(frag) is just event: +
data: elements …. The codec is stack-neutral — it produces the SSE strings; the server bridge
below wires them onto a Tapir/ZIO server, and the scenarios app puts the whole round trip together.
Server bridge (Tapir + SSE, JVM)
A third import works.iterative.scalatags.datastar.tapir.sse.* is the server-side counterpart to the
action bridge: it carries the inbound signal store into a typed Tapir input, declares the outbound
text/event-stream, and re-exports the SSE codec — so a handler reaches the whole server side through
one import.
import sttp.tapir.ztapir.*
import works.iterative.scalatags.datastar.tapir.sse.*
import zio.*
// The route the template reverse-routes — empty input, because the signal store is a separate
// channel, never a typed endpoint parameter.
val incrementRoute = endpoint.post.in("increment")
// Its server realization: the round-tripped store decodes from the request, the response streams
// Datastar SSE events.
val increment =
incrementRoute
.in(SignalsInput.body[Counter]) // store from the @post body; a payload that misfits is a 400
.out(datastarEvents) // text/event-stream of Datastar SSE events
val handler = increment.zServerLogic: counter =>
val event = ServerSentEvents.patchSignals(counter.incremented) // re-exported codec
ZIO.succeed(datastarStream(event)) // rendered event strings → the response byte stream
SignalsInput decodes the store in the codec layer, so a payload that does not fit the case
class is a 400 before the handler runs — .body[A] reads a @post JSON body, .query[A] the
datastar query parameter a @get action sends. datastarEvents is the matching
text/event-stream output, and datastarStream(events*) turns the strings the codec rendered into
the response byte stream — no ZStream/Chunk/getBytes plumbing. The import also re-exports
ServerSentEvents, ElementPatchMode and readSignals, so the handler needs nothing further from
the codec module.
Example app (scenarios, JVM)
A runnable dogfood app on the house stack — ZIO + http4s (Blaze) + Tapir — puts every layer together.
It is an examples gallery that reimplements the official
data-star.dev/examples with these typed bindings: a sidebar
navigates between demos, and each demo runs beside the typed Scala that produces it. The source
panels are read at runtime from the very files that compiled — delimited by // snippet: regions — so
a shown excerpt can never drift from the code that runs; they are syntax-highlighted client-side by a
pinned highlight.js build.
27 of the 28 core examples are reimplemented — every htmx-derived and client-side example on
data-star.dev except match_media (which needs the deferred Datastar Pro data-match-media
attribute) — proving the typed surface end to end, alongside a from-scratch counter reference. Each
follows the same store / view / endpoints / handler shape, and a new example is a single registry
entry. By the behaviour they exercise:
- Stateless round trips —
active-search,lazy-load,lazy-tabs(a typedIntpath action),title-update(a<title>patch by selector),inline-validation,form-data(ActionOptions.form- a Tapir
formBody).
- a Tapir
- Pagination —
click-to-load,infinite-scroll: the offset rides the signal store; append-mode patches and adata-on-intersect.oncesentinel. - Server feeds over time —
progress-bar,progressive-load,bad-apple,dbmon: theZStreamform ofdatastarStreamstreams events on the server's own schedule. - Mutable collections —
delete-row,edit-row,bulk-update,todomvc: an in-memoryRepository[Id, T]over a ZIORef, exercising@post/@put/@delete, remove/inner/append patching, and granular per-region updates. - Single records —
click-to-edit(the@patchverb, a_fetchingindicator),templ-counter(a shared count),svg-morphing(anamespace = svgpatch). - Client-side —
custom-event,event-bubbling,web-component,on-signal-patch,custom-plugin,sortable:data-on/data-attr/data-bind/data-on-signal-patchdriving the browser, including third-party-JS integration points. - Animation —
animations: four CSS / View-Transitions techniques (a colour throb over a stable id, auseViewTransitionswap, fade-out-then-remove, fade-in-on-append) driven only by HTML, CSS and SSE patches — the one example that exercisesuseViewTransition.
Together they cover every verb (@get/@post/@put/@patch/@delete), both request channels (the
signal store via the @post/@put body or the @get/@delete datastar query parameter, and the
form-encoded formBody), the typed Expr DSL, and every SSE patch mode.
./mill scenarios.run # serves the gallery; set PORT to override 8080
# http://localhost:8080/ — gallery home (every demo)
# http://localhost:8080/examples/counter — server-driven counter
# http://localhost:8080/examples/todomvc — TodoMVC
# http://localhost:8080/examples/click-to-edit — click to edit … and 24 more
Build
Mill 1.1.2, Scala 3.3.8 (LTS).
./mill datastar.jvm.test # core binding tests
./mill tapir.jvm.test # endpoint bridge tests
./mill sse.test # server SSE codec + conformance suite
./mill tapirsse.test # tapir↔SSE server bridge: input codecs, event stream
./mill scenarios.test # dogfood app: unit + integration + end-to-end
./mill __.compile # cross-compile check (JVM + JS)
./mill __.reformat # format
See DEVELOPMENT.md for formatting, linting, the -Werror
compiler posture, coverage, CI, and the git hooks.
License
MIT.