Preview

ascent-preview is a published JVM library: path-jailed static files plus an SSE endpoint that fires when assets/dev-stamp contents change. The tab reloads. The Preview process does not.

That is the whole contract. HTML and JS may churn on disk; rewrite the stamp to poke the browser. Never watch a task that kills the server (~runReload, ~preview/run).

The command to remember is sbt ~<module>/ascentPreview. ~ is sbt's file watch on a task that keeps Preview up. One-shot (start once, no watch): sbt <module>/ascentPreview.

Install

Library (JVM):

libraryDependencies += "rocks.earlyeffect" %% "ascent-preview" % "<version>"

Plugin (the command):

addSbtPlugin("rocks.earlyeffect" % "sbt-ascent-preview" % "<version>")

Then enablePlugins(AscentPreviewPlugin) on the module you type. Specular docs modules will get that by default once sbt-specular requires this plugin; until then, enable it on docs and set ascentPreviewRoot to the site directory and ascentPreviewRebuild to specularSiteDev.

Scala.js app

lazy val todo = (project in file("example/todo"))
  .enablePlugins(ScalaJSPlugin, AscentPreviewPlugin)
  .settings(
    // JS projects have no PreviewMain on Compile. Point at a JVM ascent-preview classpath:
    ascentPreviewClasspath := (LocalProject("preview") / Compile / fullClasspath).value,
  )
sbt ~todoJS/ascentPreview
# open http://localhost:8765

Default rebuild is ascentPreviewStage: spliceFast (if sbt-splice is on the project), copy index.html, write assets/dev-stamp. Override ascentPreviewBundle for plain fastLinkJS.

Specular docs

Docs are the same command. The rebuild task writes target/site (including the stamp) instead of an example target/preview.

.enablePlugins(SpecularPlugin, AscentPreviewPlugin)
.settings(
  ascentPreviewRoot     := specularSiteDirectory.value,
  ascentPreviewRebuild  := specularSiteDev.value,
  ascentPreviewAutoOpen := true,               // open the tab once Preview binds
  ascentPreviewPort     := AscentPreviewPort("auto"), // first free port >= 8700
)
sbt ~docs/ascentPreview
# open the localhost URL sbt prints (port is auto)

specularServe stays a blocking one-shot of an already-built tree. The edit loop is only ascentPreview.

Compose into zio-http

When a JVM app already serves the staged tree (datastar / hybrid examples), do not start a second PreviewMain. Set ascentPreviewAutoServe := false on the JS module and keep the API server up:

Preview.routes(PreviewConfig(root = previewRoot, port = 8080)) ++ apiRoutes
sbt ~datastarExampleJS/ascentPreview   # stage + stamp only
sbt datastarExampleServer/run          # Preview.routes + API on :8080

The server process stays up. Only the files under target/preview change.

Tab client

From ascent-js (localhost only; inert on GitHub Pages):

DevReload.install()   // EventSource /__ascent/reload → location.reload()

Specular's SpecularClient.mountAll already calls this. Do not also poll assets/dev-stamp.

Without Scala.js, a tiny snippet in index.html:

<script>
if (["localhost","127.0.0.1","[::1]"].includes(location.hostname)) {
  const es = new EventSource("/__ascent/reload");
  const reload = () => location.reload();
  es.addEventListener("reload", reload);
  es.addEventListener("message", reload);
  es.addEventListener("error", () => es.close());
}
</script>

Config and jail

PreviewConfig(root, port = 8765, stamp = assets/dev-stamp, reloadPath = __ascent/reload, cors = false, openBrowser = false). CORS is off by default so same-origin example servers stay strict.

Paths are jailed: .. is rejected, and the resolved file must be a canonical descendant of root. CLI: PreviewMain <port> <siteRoot> [--open] (default 8765 and target/site). --open is what ascentPreviewAutoOpen := true passes so the tab opens once the socket is bound, not on every ~ rebuild.

Module bind: ascentPreviewPort := AscentPreviewPort(8701) or ascentPreviewPort := AscentPreviewPort("auto") (first free port >= 8700). Neotype rejects "noo auto" and privileged ports like 80 at compile time.

Anti-pattern

sbt ~docs/Test/runReload     # restarts the Preview JVM on every compile
sbt ~preview/run             # same: the process is the watch target

Watch the rebuild (ascentPreview / ascentPreviewStage / specularSite), not the server.