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

NodeBuilderRole
DocPagepage(title)(…)One nav entry; slug from the title
Sectionsection(title)(…)Nested heading + children
Prosemd"…"Markdown → ascent UI at build time
Exampleexample / exampleIOSource string + UI effect
ValueExampleexampleValue / exampleZIOSource string + plain value or effect
FailExampleexpectFail("…")Must-not-compile snippet + diagnostics
CrashExampleexpectCrash { … }Must-fail effect + failure output

Examples carry optional flags:

  • .assert(…): zio-test TestResult (gates CI); UI examples assert on the tree, value examples on A, fail examples on typeCheckErrors, crash examples on Cause[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:

  1. Markdown → UI via commonmark

  2. UI examples → source panel + SSR snapshot (ascent-html)

  3. Value examples → source panel + printed result

  4. Fail examples → source panel + real compiler diagnostics

  5. Crash examples → source panel + pretty-printed failure

  6. Page template + sidebar nav + theme CSS

  7. Optional landing / catalog when SiteModel.home is set

  8. Write metadata.json for 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 → CI
  • SiteBuilder → static site
  • ExampleRegistry → 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)),
)
0

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.