Library authors

A practical path for documenting a Scala 3 library: module layout, what to assert, interactive optional extras, release cadence, and hub registration.

Keep docs next to the library, not in a separate repo. Docs-as-tests means DocSpecs and DocsSite live on Compile; thin DocSpecSuite wrappers live on Test. sbt docs/specularSite forks the builder from the Test classpath (which includes Compile).

PieceTypical location
DocSpec (pages)docs/src/main/scala/…
DocSpecSuite wrappersdocs/src/test/scalajvm/… (or src/test/scala)
DocsSite (BuildSite)docs/src/main/scalajvm/… (or src/main/scala)
ClientMain (optional)docs/src/main/scalajs/… (linker-only JS project)
Caller workflowzipx ZipxDocs.pages() (or a docs.yml caller of specular-docs.yml)

Without interactives, a single DocSpecSuite under Test still works (page = suite). With interactives, keep shared pages as DocSpec on Compile and add thin JVM DocSpecSuite wrappers.

Depend the docs project on specular-core and specular-site (Compile) plus specular-zio-test (Test scope), and on your library modules so examples import the real public API.

Early Effect libraries should also take early-effect-docs-theme for hub-matched colors and the shared logo. Branding is three one-liners on the DocsSite: EarlyEffectTheme.brand(super.site), override def layers = EarlyEffectTheme.layers, and EarlyEffectTheme.writeLogo(out) in afterBuild.

E.ol(
  E.li("docs Test discovers DocSpecSuites"),
  E.li("docs/specularSite SSR (Test CP includes Compile)"),
  E.li("docs JS (optional) splices client.js"),
)
  1. docs Test discovers DocSpecSuites
  2. docs/specularSite SSR (Test CP includes Compile)
  3. docs JS (optional) splices client.js

What to put in examples

Prefer examples that exercise the contract readers care about:

  • Construct a value with the public API, then .assert a property (shape, equality, effect outcome).

  • For ascent UIs, assert non-null trees or structural checks you already use in unit tests.

  • Leave decorative layouts unasserted if they only illustrate CSS (still fine as SSR snapshots).

Avoid:

  • assertTrue(true) as the long-term habit (acceptable while scaffolding; replace with real checks)

  • Pasting internal / package-private helpers readers cannot call

  • Giant apps in one example: split sections so failures point at one idea

E.div(
  E.p("Readers copy from the source panel."),
  E.p("CI copies the assertion."),
  E.p("Keep both aimed at the public API."),
)

Readers copy from the source panel.

CI copies the assertion.

Keep both aimed at the public API.

Interactive examples (optional)

Reach for an interactive example when the point is behavior (clicks, state, streaming) rather than a static tree. Your library does not have to be an ascent library: an interactive example is a keyed DOM mount, so anything that writes into an element qualifies. Interactive examples is the full guide; the setup is:

  1. A Scala.js docs project depending on specular-core (plus your own JS modules)

  2. Either .interactive on an ascent example, .live on an illustration, or exampleDom(key).fromSource(file, marker) for anything else

  3. A ClientMain calling SpecularClient.mountAll(SpecularClient.fromPages(pages*) ++ yourMounters)

  4. specularSite splicing spliceFull into assets/client.js, plus specularJsProject so docs/specularPreview watches that client's Compile sources

Use illustration / illustrationIO when the region is the document (a poster, a host switcher), not a copy-paste sample. fromPages registers every .interactive ascent example and every .live illustration; exampleDom keys are yours to bind, since specular cannot import your client code. Guard the two against drift with SpecularClient.requiredKeys(pages*).

If your library is JVM-only and examples are pure values, skip the JS client entirely.

Release and Pages

Ship docs on the same v* tag as the Maven release when you can. That keeps metadata.json version aligned with Central.

When you need a docs-only Pages deploy (for example workflow_dispatch without a tag), sbt-dynver / sbt-dynver-ci may produce a build version like 0.0.7-ci. That is fine for jars and cache epochs, but install snippets and header chrome should not advertise it as a Central coordinate.

Map the build version so docs show a release (or placeholder) while metadata.json version stays the real coordinate. The default is identity. stripCi drops a trailing -ci only (0.2.2-ci0.2.2; RC and SNAPSHOT are left alone). SPECULAR_STRIP_CI=true selects stripCi and wins over the setting. The mapped value is passed as -Dspecular.meta.displayVersion only when it differs from the build version.

specularDisplayVersion := stripCi
// or pin: specularDisplayVersion := (_ => "0.0.6")

ProjectMeta.displayVersion / docsVersion feed ArtifactKind.defaultInstall, docs header and footer, and catalog badges. metadata.json still records both version (build) and optional displayVersion.

Checklist:

  1. sbt test green (includes DocSpecs)

  2. Tag vX.Y.Z → Central publish and docs deploy

  3. Confirm your published docs URL and …/metadata.json load

  4. Use a manual docs workflow run when you need a regen without a new tag (stripCi / SPECULAR_STRIP_CI=true so install copy stays honest)

Enable GitHub Pages (Actions source) before the first tag deploy if that is your host.

E.ul(
  E.li(E.code("v*"), " tag → jars + docs"),
  E.li(E.code("workflow_dispatch"), " → docs only"),
  E.li(E.code("specularDisplayVersion"), " → install / chrome version"),
  E.li(E.code("metadata.json"), " → hub input"),
)
  • v* tag → jars + docs
  • workflow_dispatch → docs only
  • specularDisplayVersion → install / chrome version
  • metadata.json → hub input

Optional: compose into a hub

There are two hub shapes. Pick the one that matches how the sites are published.

HTTP catalogNested monorepo
WhenIndependently released librariesOne repo, one Pages artifact
HowHub fetches each metadata.json over HTTPSHub .aggregates member docs projects
RebuildWhen the URL allowlist changesdocs/specularSite (already what zipx runs)

A library micro-site does not need either. The HTTP catalog is this section; nested sites are the next.

If your org (or you) keeps a catalog hub:

  1. Publish the library docs so metadata.json is reachable over HTTPS

  2. Add that URL to the hub's catalog allowlist (often a plain text list of URLs)

  3. Rebuild the hub once so the allowlist (and optional Scala.js client) is deployed

For a live hub, use ProjectCatalog.live(urls) (optionally with SSR fallback cards) and ship a small Ascent ClientMain that calls LiveCatalog.bootstrap. The browser re-fetches allowlisted manifests on each visit, so library version bumps show up on refresh. Rebuild the hub when the URL allowlist changes, not on every library tag.

Cards render remote strings as text nodes and links through SafeHref (no javascript: / data: hrefs).

Early Effect's hub at earlyeffect.rocks is built this way: published library metadata.json URLs feed a Specular catalog site.

Optional: nest member sites in a monorepo

A monorepo that wants one static artifact (hub at the site root, member sites in subdirectories) is a plugin setting. Membership is sbt .aggregate. Do not keep a parallel nest list, and do not steal specularJsLink to run a member build.

The hub is a SpecularPlugin project with specularHub := true that aggregates member docs JVM ids (not a product umbrella). Each member sets specularSiteSegment. Members keep their own site directory and relative specularBasePath ("."). docs/specularSite builds those members, copies them under hubDir/<segment>/, then writes the hub. ZipxDocs.pages already runs that task.

lazy val docs = (project in file("docs"))
  .enablePlugins(SpecularPlugin)
  .aggregate(LocalProject("paymentsDocs"))
  .settings(
    specularHub := true,
    specularBuildMain := "com.example.docs.BuildSite",
    specularMetaProject := Some(LocalProject("root")),
  )

lazy val paymentsDocs = (project in file("payments/docs"))
  .enablePlugins(SpecularPlugin)
  .settings(
    specularSiteSegment := "payments",
    specularBuildMain := "com.example.payments.docs.BuildSite",
    specularMetaProject := Some(LocalProject("payments")),
  )

docs/specularPreview stays hub-only: it does not rebuild members. A full local tree is docs/specularSite then preview. Member-only edits stay paymentsDocs/specularPreview. Sub-site chrome gets -Dspecular.site.parentHref=../index.html unless you set specularParentHref. Segments matching assets or images are reserved. Nested hubs and aggregated projects that do not enable SpecularPlugin fail the hub build.

body
Set(assets, images)

Migration from markdown docs

You do not need a big-bang rewrite:

  1. Add Specular alongside existing README / mdoc

  2. Move the highest-churn API examples into DocSpecs first (the ones that rot)

  3. Point the README at the Pages URL for the full tour

  4. Delete fences that now live as asserted examples

Specular complements a short README; it replaces the long “hope the fences still compile” middle.