From 460a3bd6accb0f9336324ee19721a1a9f4f7eb64 Mon Sep 17 00:00:00 2001 From: Russ White <356303+russwyte@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:37:24 -0500 Subject: [PATCH] Lead the docs site with the evaluate sequence. The front page is state and questions in, structured answers out. Specular 0.20 still writes a summary index, so the build copies that page over index.html. A raw version containing -ci, +, or SNAPSHOT advertises 0.1.0. stripCi is not applied first. Docs use specular 0.20 and mermoid-ascent 0.2.0. Neither is on the published artifact. Specular's heddle 0.9 edge is excluded so the client stays on heddle 0.4. --- README.md | 9 +- build.sbt | 10 +- .../src/test/scala/hexis/docs/BuildSite.scala | 40 +++-- .../test/scala/hexis/docs/DocsVersion.scala | 18 +++ docs/src/test/scala/hexis/docs/Front.scala | 149 ++++++++++++++++++ docs/src/test/scala/hexis/docs/Install.scala | 6 +- project/ZipxVersions.scala | 41 ++++- project/plugins.sbt | 2 +- 8 files changed, 241 insertions(+), 34 deletions(-) create mode 100644 docs/src/test/scala/hexis/docs/DocsVersion.scala create mode 100644 docs/src/test/scala/hexis/docs/Front.scala 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")