Statically-typed Scalatags bindings for the Datastar hypermedia framework
  • Scala 97.5%
  • Shell 2.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Michal Prihoda 29b65377a4
All checks were successful
Publish / Sonatype Central (push) Has been skipped
Publish / Forgejo Maven registry (push) Successful in 1m5s
CI / build (push) Successful in 1m23s
build: open 0.1.1-SNAPSHOT after the 0.1.0 release
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>
2026-08-12 11:15:47 +02:00
.githooks chore: local git hooks mirroring the CI gates 2026-06-15 12:59:52 +02:00
.github/workflows ci(publish): give the signing key's passphrase to the Central publish 2026-08-12 10:51:49 +02:00
datastar build: adopt strict-warnings posture and OrganizeImports/RemoveUnused 2026-06-15 12:48:37 +02:00
scenarios build: adopt strict-warnings posture and OrganizeImports/RemoveUnused 2026-06-15 12:48:37 +02:00
sse style: scalafmt + scalafix OrganizeImports sweep 2026-06-15 12:48:19 +02:00
tapir style: scalafmt + scalafix OrganizeImports sweep 2026-06-15 12:48:19 +02:00
tapirsse style: scalafmt + scalafix OrganizeImports sweep 2026-06-15 12:48:19 +02:00
.git-blame-ignore-revs build: adopt strict-warnings posture and OrganizeImports/RemoveUnused 2026-06-15 12:48:37 +02:00
.gitignore Phase 0: scaffold scalatags-datastar with core attribute bindings 2026-06-10 09:30:48 +02:00
.mill-version Phase 0: scaffold scalatags-datastar with core attribute bindings 2026-06-10 09:30:48 +02:00
.scalafix.conf build: adopt strict-warnings posture and OrganizeImports/RemoveUnused 2026-06-15 12:48:37 +02:00
.scalafmt.conf Phase 0: scaffold scalatags-datastar with core attribute bindings 2026-06-10 09:30:48 +02:00
build.mill build: open 0.1.1-SNAPSHOT after the 0.1.0 release 2026-08-12 11:15:47 +02:00
DEVELOPMENT.md ci(publish): give the signing key's passphrase to the Central publish 2026-08-12 10:51:49 +02:00
llms.txt docs: llms.txt — orientation index for LLM consumers 2026-06-15 13:07:29 +02:00
mill Phase 0: scaffold scalatags-datastar with core attribute bindings 2026-06-10 09:30:48 +02:00
PLAN.md feat(scenarios): add the animations example — CSS + View-Transitions techniques 2026-06-14 19:02:01 +02:00
README.md docs: DEVELOPMENT.md for the build, checks and coverage workflow 2026-06-15 13:01:36 +02:00

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 initial data-signals JSON 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 readSignals decoding 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 &gt; 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 tripsactive-search, lazy-load, lazy-tabs (a typed Int path action), title-update (a <title> patch by selector), inline-validation, form-data (ActionOptions.form
    • a Tapir formBody).
  • Paginationclick-to-load, infinite-scroll: the offset rides the signal store; append-mode patches and a data-on-intersect.once sentinel.
  • Server feeds over timeprogress-bar, progressive-load, bad-apple, dbmon: the ZStream form of datastarStream streams events on the server's own schedule.
  • Mutable collectionsdelete-row, edit-row, bulk-update, todomvc: an in-memory Repository[Id, T] over a ZIO Ref, exercising @post/@put/@delete, remove/inner/append patching, and granular per-region updates.
  • Single recordsclick-to-edit (the @patch verb, a _fetching indicator), templ-counter (a shared count), svg-morphing (a namespace = svg patch).
  • Client-sidecustom-event, event-bubbling, web-component, on-signal-patch, custom-plugin, sortable: data-on/data-attr/data-bind/data-on-signal-patch driving the browser, including third-party-JS integration points.
  • Animationanimations: four CSS / View-Transitions techniques (a colour throb over a stable id, a useViewTransition swap, fade-out-then-remove, fade-in-on-append) driven only by HTML, CSS and SSE patches — the one example that exercises useViewTransition.

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.