Concepts
Specular is intentionally small at the core: a documentation AST plus interpreters that fold it into tests or HTML. Everything else (themes, hubs, sbt wiring) hangs off that.
The DocSpec AST
| Node | Builder | Role |
|---|---|---|
DocPage | page(title)(…) | One nav entry; slug from the title |
Section | section(title)(…) | Nested heading + children |
Prose | md"…" | Markdown → ascent UI at build time |
Example | example / exampleIO | Source string + UI effect |
ValueExample | exampleValue / exampleZIO | Source string + plain value or effect |
FailExample | expectFail("…") | Must-not-compile snippet + diagnostics |
CrashExample | expectCrash { … } | Must-fail effect + failure output |
Examples carry optional flags:
.assert(…): zio-testTestResult(gates CI); UI examples assert on the tree, value examples onA, fail examples ontypeCheckErrors, crash examples onCause[E].interactive: UI examples only; register for client remount after SSR
Ids (<page-slug>-ex-1, …) are assigned when you call page, so SSR wrappers and the JS
registry stay aligned across pages without colliding.
E.p(A.className("note"), "captured source + live UI")captured source + live UI
Interpreters
DocTestInterpreter (specular-zio-test) walks the tree, runs each asserted example's
body, and turns .assert into a named test. Prose and unasserted examples are skipped
in the suite. UI examples go through ExampleRunner; value examples run their URIO under
Scope directly.
SiteBuilder (specular-site) walks the same tree for HTML:
Markdown → UI via commonmark
UI examples → source panel + SSR snapshot (
ascent-html)Value examples → source panel + printed result
Fail examples → source panel + real compiler diagnostics
Crash examples → source panel + pretty-printed failure
Page template + sidebar nav + theme CSS
Optional landing / catalog when
SiteModel.homeis setWrite
metadata.jsonfor hub consumption
One authoring surface; two consumers. That is the product.
E.ul(
E.li(E.code("DocTestInterpreter"), " → CI"),
E.li(E.code("SiteBuilder"), " → static site"),
E.li(E.code("ExampleRegistry"), " → browser mounts"),
)DocTestInterpreter→ CISiteBuilder→ static siteExampleRegistry→ browser mounts
Interactive examples
SSR gives readers a first paint. .interactive examples then remount into #<slug>-ex-N
wrappers via a Scala.js client that shares the DocSpec sources (cross-compiled or
duplicated page list).
In this dogfood site, ExampleRegistry.fromPages(…) collects interactive bodies and
ClientMain mounts each into its SSR node. Prefer that pattern over hand-written IDs.
for n <- sq(0)
yield E.div(
E.button(Events.onClick(_ => n.update(_ + 1)), "tick"),
E.span(" ", n.map(_.toString)),
)SiteModel and ProjectMeta
SiteModel is the site-level config: title, basePath, pages, optional clientScript,
theme hooks, meta, optional logo, nested nav, and pageToc.
The header brand (logo + title) and the sidebar project name both link to index.html.
For guide-scale sidebars, declare a nested product and derives SiteNav (Saferis-style).
Nested case classes become nav groups; leaf DocSpec singletons become pages. Flatten with
nav.pages for routing. Optional @navLabel("…") overrides the humanized type name.
Section headings get stable ids. An on-page TOC appears automatically when a page has two or
more top-level sections (pageToc = Some(true/false) to force).
ProjectMeta is what hubs care about: name, organization, version, Scala version, title,
description, docs URL, page list, and optional displayVersion (install / chrome when it
should differ from the build version). Prefer ProjectMeta.fromSystemProperties so
sbt-specular (or CI) fills fields from sbt keys / env:
-Dspecular.meta.name=…-Dspecular.meta.version=…-Dspecular.meta.displayVersion=…(optional;specularDisplayVersion/SPECULAR_DISPLAY_VERSION)-Dspecular.site.dir=…-Dspecular.site.basePath=…
Every successful buildSite writes metadata.json beside index.html. Org hubs
compose from an allowlist of http(s) URLs only (ProjectCatalog.fromMetadataUrls at
build time, or ProjectCatalog.live + LiveCatalog.bootstrap in the browser); not an open
proxy. Link fields are sanitized with SafeHref.
Themes and full sites
Docs-only mode is enough for a library micro-site: SiteModel(title, pages) plus meta.
Full project / org sites add brand and home (hero, ProjectCatalog, …). Themes ship
as Theme.default or Theme.fromTokens(...). DocsSite.standardLayers uses the stock theme;
DocsSite.themedStack leaves Theme as an environment hole so any theme layer composes in:
override def layers = myTheme >>> DocsSite.themedStack. Early Effect docs use the published
early-effect-docs-theme pack, which pre-composes that as EarlyEffectTheme.layers.
Use micro-sites for versioned library docs; use a hub site when you want one landing page that discovers many libraries via their published manifests. Live catalogs remount through Ascent so a refresh picks up new versions without rebuilding the hub.