Library authors
A practical path for documenting a Scala 3 library: module layout, what to assert, interactive optional extras, release cadence, and hub registration.
Recommended module layout
Keep docs next to the library, not in a separate repo. Docs-as-tests means DocSpecs and
DocsSite live under Test; sbt docs/specularSite builds from the Test classpath.
| Piece | Typical location |
|---|---|
DocSpec / DocSpecSuite | docs/src/test/scala/… |
DocsSite (BuildSite) | docs/src/test/scalajvm/… (or src/test/scala) |
ClientMain (optional) | docs/src/main/scalajs/… (linker-only JS project) |
| Shared DocSpecs for JS | same src/test/scala added to JS Compile sources |
| Caller workflow | .github/workflows/docs.yml |
Without interactives, extend DocSpecSuite once per page (page = suite). With interactives,
keep shared pages as DocSpec and add thin JVM DocSpecSuite wrappers so the JS client does
not pull zio-test into the browser bundle.
Depend the docs project on specular-core, specular-zio-test, and specular-site (Test
scope), plus 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 asserts DocSpecs"),
E.li("docs/specularSite SSR from Test CP"),
E.li("docs JS (optional) links client.js"),
)What to put in examples
Prefer examples that exercise the contract readers care about:
Construct a value with the public API, then
.asserta 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."),
)Interactive examples (optional)
Use .interactive when the point is behavior (clicks, state, streaming), not just a
static tree. You will need:
Scala.js docs project depending on
specular-core(and ascent-js as needed)ExampleRegistry.fromPages(…)listing the same pages asSiteModelA
ClientMainthat mounts into#<page-slug>-ex-NspecularSite(or equivalent) linkingmain.jsintoassets/client.js
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.
Set an override so docs show a release (or placeholder) version while the build version stays unchanged:
// empty = use build version (default; also SPECULAR_DISPLAY_VERSION)
specularDisplayVersion := "0.0.6"
// or, with dynver: previousStableVersion.value.getOrElse("<version>")
ProjectMeta.displayVersion / docsVersion feed ArtifactKind.defaultInstall, docs header
and footer, and catalog badges. metadata.json still records both version (build) and
optional displayVersion.
Checklist:
sbt testgreen (includes DocSpecs)Tag
vX.Y.Z→ Central publish and docs deployConfirm your published docs URL and
…/metadata.jsonloadUse a manual docs workflow run when you need a regen without a new tag (set
specularDisplayVersion/SPECULAR_DISPLAY_VERSIONso 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"),
)Optional: compose into a hub
A hub is just another Specular site that composes a ProjectCatalog from published
metadata.json URLs. Your library does not need one; the micro-site stands alone.
If your org (or you) keeps a hub:
Publish the library docs so
metadata.jsonis reachable over HTTPSAdd that URL to the hub's catalog allowlist (often a plain text list of URLs)
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.
Migration from markdown docs
You do not need a big-bang rewrite:
Add Specular alongside existing README / mdoc
Move the highest-churn API examples into DocSpecs first (the ones that rot)
Point the README at the Pages URL for the full tour
Delete fences that now live as asserted examples
Specular complements a short README; it replaces the long “hope the fences still compile” middle.