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 on Compile; thin DocSpecSuite wrappers live on Test. sbt docs/specularSite
forks the builder from the Test classpath (which includes Compile).
| Piece | Typical location |
|---|---|
DocSpec (pages) | docs/src/main/scala/… |
DocSpecSuite wrappers | docs/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 workflow | zipx 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"),
)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)
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:
A Scala.js docs project depending on
specular-core(plus your own JS modules)Either
.interactiveon an ascent example,.liveon an illustration, orexampleDom(key).fromSource(file, marker)for anything elseA
ClientMaincallingSpecularClient.mountAll(SpecularClient.fromPages(pages*) ++ yourMounters)specularSitesplicingspliceFullintoassets/client.js, plusspecularJsProjectsodocs/specularPreviewwatches 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-ci → 0.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:
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 (
stripCi/SPECULAR_STRIP_CI=trueso 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
There are two hub shapes. Pick the one that matches how the sites are published.
| HTTP catalog | Nested monorepo | |
|---|---|---|
| When | Independently released libraries | One repo, one Pages artifact |
| How | Hub fetches each metadata.json over HTTPS | Hub .aggregates member docs projects |
| Rebuild | When the URL allowlist changes | docs/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:
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.
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.
bodyMigration 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.