Getting started

This page is itself a DocSpec: the same source runs as a zio-test suite and builds the static site you are reading. Follow the steps below to wire Specular into a Scala 3 / sbt library.

1. Add the artifacts

Publish line is Maven Central under rocks.earlyeffect (see the README badge for the current version). DocSpecs and BuildSite live on Compile. Thin DocSpecSuite wrappers live on Test so sbt test discovers them:

// project/plugins.sbt
addSbtPlugin("rocks.earlyeffect" % "sbt-specular" % "<version>")

// build.sbt (docs project)
enablePlugins(SpecularPlugin)
libraryDependencies ++= Seq(
  "rocks.earlyeffect" %% "specular-core"     % "<version>",
  "rocks.earlyeffect" %% "specular-site"     % "<version>",
  "rocks.earlyeffect" %% "specular-zio-test" % "<version>" % Test,
)
specularBuildMain   := "com.example.docs.BuildSite"
specularMetaProject := Some(LocalProject("root")) // product identity, not the docs module
specularArtifactKind := "library" // or "plugin"

specular-core is also available for Scala.js (%%%) when you ship interactive examples.

2. Author a DocSpec

A page is page / section / md / example. Prose is markdown; UI examples are ascent UI values whose full source span is captured for the site panel. Plain Scala and ZIO use exampleValue / exampleZIO (same ValueExample node: source + printed result). exampleZIO accepts ZIO[Scope, E, A]; E need not be a Throwable. Documented typed failures use exampleError (result is E); defects stay on expectCrash.

E.ul(E.li("a"), E.li("b"), E.li("c"))
  • a
  • b
  • c

Effectful UIs (state with sq, IO) use exampleIO. Mark them .interactive so the Scala.js client remounts them in the browser after SSR.

for count <- sq(0)
yield E.div(
  E.button(Events.onClick(_ => count.update(_ + 1)), "+"),
  E.span(" count: ", count.map(_.toString)),
)
count: 0

For non-UI libraries, assert a value or effect outcome directly:

"specular".length
8

3. Run examples as tests

Only examples with .assert become zio-test cases. Prefer DocSpecSuite so the page is the suite (no separate *Spec.scala):

import specular.*
import specular.ziotest.DocSpecSuite
import zio.test.*

object GettingStarted extends DocSpecSuite:
  def doc = page("Getting started")(
    exampleValue(1 + 1).assert(n => assertTrue(n == 2)),
  )

Put a JVM-only DocSpecSuite under docs/src/test/scala (or keep shared pages as DocSpec on Compile and add thin wrappers; see Library authors). sbt test discovers it like any other zio-test suite. Unasserted snapshots still render on the site; they just do not gate CI.

If you also need a Scala.js client for .interactive examples, keep shared pages as DocSpec on Compile and add thin DocSpecSuite wrappers on the JVM only (see Library authors).

E.ul(
  E.li(E.code(".assert"), " → suite test"),
  E.li(E.code(".interactive"), " → client mount"),
  E.li("plain ", E.code("example"), " → SSR only"),
)
  • .assert → suite test
  • .interactive → client mount
  • plain example → SSR only

4. Build the site

Extend DocsSite with an ordered page list (your site map / nav order):

import specular.site.*

object BuildSite extends DocsSite:
  def pages = Vector(GettingStarted.doc, Concepts.doc)
  // optional: override site / layers / afterBuild

Also under src/main (docs/src/main/scalajvm when the docs module is cross-built). sbt docs/specularSite compiles Test (which includes Compile), splices the JS client (spliceFull), forks that main with product meta from specularMetaProject, and writes HTML plus metadata.json.

Local loop:

sbt docs/specularPreview       # edit loop: spliceFast, Preview stays up (do not ~)
sbt docs/specularPreviewStop   # stop the watch and Preview JVM (sbt prompt)
sbt docs/specularPreviewOnce   # start Preview once, no watch
sbt docs/specularSite          # publish-quality: spliceFull (what Pages deploys)
sbt docs/specularServe         # one-shot preview of an already-built site (do not ~)

A Scala.js client is optional. When you have one, add sbt-splice and wire specularJsLink to spliceFull (copy into assets/client.js), specularJsLinkDev to spliceFast, and specularJsProject := Some(LocalProject("docsJS")) so the preview poller watches that client's Compile sources.

E.div(A.className("demo"), E.p("Hello from Specular"))

Hello from Specular

5. Publish on GitHub Pages

Any static host works; GitHub Pages is the common path. Enable Settings → Pages → Source: GitHub Actions, then deploy specularSite output on v* tags (and optional workflow_dispatch).

On a zipx build, add zipxCapabilities += ZipxDocs.pages() and regenerate CI: that emits a docs job in .github/workflows/ci.yml calling the same reusable workflow. Without zipx, a caller workflow is enough (copy and point sbt-project at your docs module):

# .github/workflows/docs.yml
name: Docs
on:
  push:
    tags: ['v*']
  workflow_dispatch:
permissions:
  contents: read
  pages: write
  id-token: write
jobs:
  deploy:
    uses: early-effect/.github/.github/workflows/specular-docs.yml@main
    with:
      sbt-project: docs

Set SPECULAR_BASE_PATH and SPECULAR_DOCS_URL in CI so nav and metadata.json match the published project-site URL (for example /my-lib under *.github.io).

Next: Concepts for the AST and interpreters, or Library authors for a full cookbook, including HTTP catalog hubs and nested monorepo sub-sites.