diff --git a/README.md b/README.md
index 92ed08e..9f0e39e 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,8 @@
# Hexis
-ZIO / Scala 3 SDK for [TypeSafe System One](https://docs.typesafe.ai/introduction) (Jev).
+State and questions go in. Structured answers come out.
-Send a state and typed questions. Get structured answers, probabilities, and confidence.
-Compose those answers in your code.
-
-Cross-built for JVM, Scala.js, and Scala Native. HTTP is [heddle](https://github.com/early-effect/heddle) 0.4.0+.
-
-Install, including the current version: [earlyeffect.rocks/hexis](https://www.earlyeffect.rocks/hexis/install.html).
+Docs: [earlyeffect.rocks/hexis](https://www.earlyeffect.rocks/hexis/)
## Quick start
diff --git a/build.sbt b/build.sbt
index 0c83531..8547c4e 100644
--- a/build.sbt
+++ b/build.sbt
@@ -131,8 +131,14 @@ lazy val docs = project
specularBuildMain := "hexis.docs.BuildSite",
specularMetaProject := Some(LocalProject("hexis")),
specularArtifactKind := "library",
- specularSiteDirectory := (ThisBuild / baseDirectory).value / "target" / "site",
- specularDisplayVersion := stripCi,
+ specularSiteDirectory := (ThisBuild / baseDirectory).value / "target" / "site",
+ // Do not strip -ci first. sbt-dynver-ci names a distance build 0.1.1-ci, and stripping
+ // that suffix yields 0.1.1, which is not the published coordinate. A raw version that
+ // contains -ci, +, or SNAPSHOT advertises the last Central tag.
+ specularDisplayVersion := { raw =>
+ val distance = raw.contains("-ci") || raw.contains("+") || raw.contains("SNAPSHOT")
+ if raw.nonEmpty && !distance then raw else "0.1.0"
+ },
)
addCommandAlias("docsPreview", "~docs/specularPreview")
diff --git a/docs/src/test/scala/hexis/docs/BuildSite.scala b/docs/src/test/scala/hexis/docs/BuildSite.scala
index bd29842..a0175ae 100644
--- a/docs/src/test/scala/hexis/docs/BuildSite.scala
+++ b/docs/src/test/scala/hexis/docs/BuildSite.scala
@@ -4,36 +4,48 @@ import earlyeffect.docs.EarlyEffectTheme
import specular.site.*
import zio.*
-import java.nio.file.Path
+import java.nio.file.{Files, Path, StandardCopyOption}
object BuildSite extends DocsSite:
- def pages =
+ /** The guide. The front page is not in this list, so it is not a second sidebar item. */
+ private val guide: Vector[specular.DocPage] =
Vector(Overview.doc, Install.doc, Quickstart.doc, Transport.doc, Patterns.doc, Errors.doc, Cookbooks.doc)
- override def site: SiteModel =
- val m = meta
- val branded = EarlyEffectTheme.brand(super.site)
+ /** Not a nav item. The sidebar is the guide; this page is the front. */
+ private val frontPage: specular.DocPage = Front.doc
+
+ def pages: Vector[specular.DocPage] = frontPage +: guide
+
+ override def site(settings: DocsSettings): SiteModel =
+ val branded = EarlyEffectTheme.brand(super.site(settings))
branded.copy(
- summaryMarkdown = Some(
- """**Hexis** is a ZIO SDK for TypeSafe System One. Typed questions, structured
-answers, probabilities you can branch on. HTTP is heddle on JVM, JS, and Native.
-"""
- ),
+ nav = Some(NavModel.flat(guide)),
+ pages = pages,
+ summaryMarkdown = None,
+ installSnippets = Vector(CodeSnippet("Install", Install.coordinate)),
brand = Some(
Brand(
- name = m.title.getOrElse("hexis"),
+ name = settings.meta.displayTitle,
links = Vector(EarlyEffectTheme.github("https://github.com/early-effect/hexis")),
)
),
- installSnippets = Vector(CodeSnippet("Install", Install.coordinate)),
)
end site
override def layers: ZLayer[Any, Nothing, SiteBuilder] =
EarlyEffectTheme.layers
- override def afterBuild(out: Path, result: SiteOutput): Task[Unit] =
+ override def afterBuild(out: Path, result: SiteOutput): IO[SiteError, Unit] =
val _ = result
- EarlyEffectTheme.writeLogo(out)
+ // Specular always writes index.html as a summary plus a second copy of the nav.
+ // The front DocPage is the index. Copy it over that file. Asset paths stay site-relative.
+ val front = out.resolve(s"${frontPage.slug}.html")
+ val index = out.resolve("index.html")
+ val promote =
+ ZIO.attemptBlockingIO(Files.copy(front, index, StandardCopyOption.REPLACE_EXISTING)).mapError { err =>
+ SiteError.WriteFailed(index, err)
+ }
+ promote *> EarlyEffectTheme.writeLogo(out)
+ end afterBuild
end BuildSite
diff --git a/docs/src/test/scala/hexis/docs/DocsVersion.scala b/docs/src/test/scala/hexis/docs/DocsVersion.scala
new file mode 100644
index 0000000..59bfd39
--- /dev/null
+++ b/docs/src/test/scala/hexis/docs/DocsVersion.scala
@@ -0,0 +1,18 @@
+package hexis.docs
+
+/** Install coordinate. Judge the raw build version. Do not trust a version after `-ci` was stripped. */
+object DocsVersion:
+
+ /** Last published tag. */
+ val published = "0.1.0"
+
+ /** A dynver distance (`-ci`, `+`, `SNAPSHOT`) is the next line. Anything else is advertised as written. */
+ def advertise(raw: String): String =
+ val trimmed = raw.trim
+ val distance = trimmed.contains("-ci") || trimmed.contains("+") || trimmed.contains("SNAPSHOT")
+ if trimmed.nonEmpty && !distance then trimmed else published
+
+ /** `specular.meta.version` is the raw build version. Absent in unit tests, so those use [[published]]. */
+ def version: String =
+ sys.props.get("specular.meta.version").map(advertise).getOrElse(published)
+end DocsVersion
diff --git a/docs/src/test/scala/hexis/docs/Front.scala b/docs/src/test/scala/hexis/docs/Front.scala
new file mode 100644
index 0000000..c34b95f
--- /dev/null
+++ b/docs/src/test/scala/hexis/docs/Front.scala
@@ -0,0 +1,149 @@
+package hexis.docs
+
+import hexis.*
+import mermoid.ascent.MermoidAscent
+import mermoid.{
+ ContainerFit,
+ Diagram,
+ LayoutConfig,
+ Mermaid,
+ RenderConfig,
+ ResponsiveConfig,
+ SequenceConfig,
+ SequenceModel,
+ Viewport,
+}
+import specular.*
+import specular.ziotest.DocSpecSuite
+import zio.test.*
+
+/** The site front. Specular still emits a summary index; [[BuildSite]] copies this page over it.
+ *
+ * Not a sidebar item.
+ */
+object Front extends DocSpecSuite:
+
+ /** Caller, SystemOne, Transport, then System One. Transport is the client dependency. The remote is
+ * `POST /v1/systemone`. There is no broker.
+ */
+ private val evaluate =
+ Mermaid("""sequenceDiagram
+ | participant Caller
+ | participant SystemOne
+ | participant Transport
+ | participant API as System One
+ | Caller->>SystemOne: state,
questions
+ | SystemOne->>Transport: POST
/v1/systemone
+ | Transport->>API: Http
Request
+ | API-->>Transport: answers
+ | Transport-->>SystemOne: Http
Response
+ | SystemOne-->>Caller: structured
answers
+ |""".stripMargin)
+
+ /** Phone column is about 390px. Fit scales down to 0.72, then the column scrolls. It does not crop the scene. */
+ private val shown =
+ RenderConfig(
+ layout = LayoutConfig(padding = 8, fontSize = 15, edgeLabelFontSize = 13, lineHeight = 18),
+ sequence = SequenceConfig(
+ actorMinWidth = 48,
+ actorPadH = 6,
+ actorHeight = 32,
+ columnGap = 12,
+ rowPitch = 34,
+ headerGap = 8,
+ footerGap = 10,
+ ),
+ responsive = ResponsiveConfig(fit = ContainerFit.ToWidth(0.72)),
+ )
+
+ private val phone = Some(Viewport(390))
+
+ def doc = page("State and questions go in. Structured answers come out.")(
+ illustration(MermoidAscent.diagram(evaluate, shown, phone)).assert { ui =>
+ val text = ui.toString
+ assertTrue(
+ text.contains("mermoid-root"),
+ text.contains("mermoid-fit"),
+ text.contains("Caller"),
+ text.contains("SystemOne"),
+ text.contains("Transport"),
+ text.contains("System One"),
+ text.contains("state,"),
+ text.contains("questions"),
+ text.contains("/v1/systemone"),
+ text.contains("Http"),
+ text.contains("Request"),
+ text.contains("Response"),
+ text.contains("structured"),
+ text.contains("answers"),
+ )
+ },
+ section("Install")(
+ md"""
+```scala
+${Install.coordinate}
+```
+"""
+ ),
+ section("SystemOne.evaluate")(
+ md"""
+`SystemOne.evaluate` is the client. The state and the questions go in. A structured answer comes out.
+""",
+ cite(
+ SystemOne.evaluate(_: String, _: Question[Boolean], _: Option[Model])(using
+ _: ToState[String],
+ _: Ask.Aux[Question[Boolean], Answer[Boolean]],
+ )
+ ),
+ ),
+ )
+
+ override def spec =
+ suite("Front")(
+ super.spec,
+ test("install coordinate is 0.1.0") {
+ val version = DocsVersion.version
+ assertTrue(
+ version == DocsVersion.published,
+ Install.coordinate.contains(version),
+ Install.coordinate.contains("%% \"hexis\""),
+ Install.coordinate.contains("%%% \"hexis\""),
+ !Install.coordinate.contains("0.1.1"),
+ !Install.coordinate.contains("-ci"),
+ !Install.coordinate.contains("SNAPSHOT"),
+ !Install.coordinate.contains(""),
+ DocsVersion.advertise("0.1.1-ci") == DocsVersion.published,
+ DocsVersion.advertise("0.1.1-SNAPSHOT") == DocsVersion.published,
+ DocsVersion.advertise("0.1.0+4-abcdef") == DocsVersion.published,
+ DocsVersion.advertise("0.1.0") == "0.1.0",
+ )
+ },
+ test("the figure is evaluate, and fit keeps it inside a phone column") {
+ val order = evaluate.diagram match
+ case Diagram.Sequence(stmts) => SequenceModel.participantOrder(stmts).map(_.value)
+ case _ => Nil
+ val ui = MermoidAscent.diagram(evaluate, shown, phone)
+ val width = sceneWidth(ui)
+ // 0.72 is the fit floor. A 390px phone has room for about 360px of figure after page padding.
+ val fits = width.exists(w => w > 0 && w * 0.72 <= 360)
+ assertTrue(
+ order == List("Caller", "SystemOne", "Transport", "API"),
+ evaluate.source.contains("sequenceDiagram"),
+ !evaluate.source.contains("broker"),
+ fits,
+ ).label(s"scene width $width")
+ },
+ )
+
+ private def sceneWidth(ui: ascent.ast.UI[Any]): Option[Double] =
+ ui match
+ case element: ascent.ast.UI.Element[?] =>
+ val here = element.attrs.collectFirst {
+ case ascent.ast.Attr.StaticAttr("data-mermoid-width", ascent.domtypes.AttrValue.Str(raw)) =>
+ raw.toDoubleOption
+ }.flatten
+ here.orElse(element.children.iterator.map(sceneWidth).collectFirst { case Some(w) => w })
+ case ascent.ast.UI.Fragment(children) =>
+ children.iterator.map(sceneWidth).collectFirst { case Some(w) => w }
+ case _ => None
+end Front
diff --git a/docs/src/test/scala/hexis/docs/Install.scala b/docs/src/test/scala/hexis/docs/Install.scala
index 540aba6..a7e0c95 100644
--- a/docs/src/test/scala/hexis/docs/Install.scala
+++ b/docs/src/test/scala/hexis/docs/Install.scala
@@ -1,15 +1,13 @@
package hexis.docs
import specular.*
-import specular.site.ProjectMeta
import specular.ziotest.DocSpecSuite
import zio.test.*
object Install extends DocSpecSuite:
- /** Site builds pass `-Dspecular.meta.*`. Doc tests load this page without them. */
- def docsVersion: String =
- ProjectMeta.fromSystemProperties.map(_.docsVersion).getOrElse("0.0.0")
+ /** Site builds pass `-Dspecular.meta.version`. Doc tests load this page without it. */
+ def docsVersion: String = DocsVersion.version
def coordinate: String =
s"""libraryDependencies += "rocks.earlyeffect" %% "hexis" % "$docsVersion"
diff --git a/project/ZipxVersions.scala b/project/ZipxVersions.scala
index 5394012..3f656ef 100644
--- a/project/ZipxVersions.scala
+++ b/project/ZipxVersions.scala
@@ -21,21 +21,50 @@ object MyVersions extends ZipxVersions:
val scalaJavaTime = Lib("io.github.cquiroz", "scala-java-time", "2.7.0")
val scalaJavaTimeTzdb = scalaJavaTime.mod("scala-java-time-tzdb")
- val specular = Lib("rocks.earlyeffect", "specular-core", "0.17.0")
+ val specular = Lib("rocks.earlyeffect", "specular-core", "0.20.0")
val specularZioTest = specular.mod("specular-zio-test").test
- val specularTheme = specular.mod("early-effect-docs-theme").test
- val ascentJs = Lib("rocks.earlyeffect", "ascent-js", "0.7.4")
- val ascentCss = ascentJs.mod("ascent-css")
+
+ /** Specular 0.20 depends on heddle 0.9. The published client stays on 0.4, so docs do not take that edge. */
+ val specularSite = specular
+ .mod("specular-site")
+ .excluding(
+ ZipxExclude.org("rocks.earlyeffect", "heddle_3"),
+ ZipxExclude.org("rocks.earlyeffect", "ascent-preview_3"),
+ )
+ .test
+ val specularTheme = specular
+ .mod("early-effect-docs-theme")
+ .excluding(
+ ZipxExclude.org("rocks.earlyeffect", "heddle_3"),
+ ZipxExclude.org("rocks.earlyeffect", "ascent-preview_3"),
+ )
+ .test
+
+ /** Docs figures only. The published hexis artifact does not select this. */
+ val mermoidAscent = Lib("rocks.earlyeffect", "mermoid-ascent", "0.2.0")
+
+ /** What mermoid-ascent 0.2.0 and specular 0.20 resolve. No docs client, so ascent-js stays off this classpath. */
+ val ascentCore = Lib("rocks.earlyeffect", "ascent-core", "0.10.1")
+ val ascentCss = ascentCore.mod("ascent-css")
+ val ascentHtml = Lib("rocks.earlyeffect", "ascent-html", "0.10.0")
val scalafmt = Plugin("org.scalameta", "sbt-scalafmt", "2.6.2")
val scalajs = Plugin("org.scala-js", "sbt-scalajs", "1.22.0")
val scalaNative = Plugin("org.scala-native", "sbt-scala-native", "0.5.12")
- val specularPlugin = Plugin("rocks.earlyeffect", "sbt-specular", "0.17.0")
+ val specularPlugin = Plugin("rocks.earlyeffect", "sbt-specular", "0.20.0")
val sbtReload = Plugin("com.jamesward", "sbt-reload", "0.0.7")
def coreLib = library(zio, zioStreams, zioJson, heddle)
def coreTest = library(zioTest, zioTestSbt)
- def docsTest = library(specularZioTest, specularTheme, ascentCss)
+ def docsTest = library(
+ specularZioTest,
+ specularSite,
+ specularTheme,
+ mermoidAscent.test,
+ ascentCore,
+ ascentCss,
+ ascentHtml,
+ )
def javaTime = library(scalaJavaTime, scalaJavaTimeTzdb)
def jsRuntime = javaTime
diff --git a/project/plugins.sbt b/project/plugins.sbt
index be11db6..ca94c45 100644
--- a/project/plugins.sbt
+++ b/project/plugins.sbt
@@ -3,5 +3,5 @@ addSbtPlugin("rocks.earlyeffect" % "sbt-zipx" % "0.18.0")
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.6.2")
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.22.0")
addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.12")
-addSbtPlugin("rocks.earlyeffect" % "sbt-specular" % "0.17.0")
+addSbtPlugin("rocks.earlyeffect" % "sbt-specular" % "0.20.0")
addSbtPlugin("com.jamesward" % "sbt-reload" % "0.0.7")