diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 09c8a61..9d154d4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,6 +19,23 @@ jobs:
- name: Install
run: npm ci
+ # OCaml toolchain for the reativa (Melange) examples, so the "Reativa"
+ # tab's bundle is compiled and type-checked in CI (not needed for the
+ # local website build, which uses a checked-in placeholder).
+ - name: Set up OCaml
+ uses: ocaml/setup-ocaml@v3
+ with:
+ ocaml-compiler: "5.2"
+ dune-cache: true
+
+ - name: Install Melange toolchain
+ run: opam install -y dune melange mlx
+
+ # Clones reativa (pinned), compiles the .mlx examples inside its dune
+ # project, and bundles the output to website/src/reativa.bundle.js.
+ - name: Build reativa bundle (OCaml → esbuild)
+ run: npm run reativa --workspace xpecs-website
+
# Build the publishable packages (tokens → xote → skill).
- name: Build packages
run: npm run build:packages
diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index 1bb76fa..7eff2a2 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -39,6 +39,24 @@ jobs:
- name: Install
run: npm ci
+ # OCaml toolchain for the reativa (Melange) examples rendered in the
+ # website's "Reativa" tab. Kept out of the local build (opam/melange
+ # aren't needed there), but compiled here so the deployed site ships the
+ # real bundle instead of the checked-in placeholder.
+ - name: Set up OCaml
+ uses: ocaml/setup-ocaml@v3
+ with:
+ ocaml-compiler: "5.2"
+ dune-cache: true
+
+ - name: Install Melange toolchain
+ run: opam install -y dune melange mlx
+
+ # Clones reativa (pinned), compiles the .mlx examples inside its dune
+ # project, and bundles the output to website/src/reativa.bundle.js.
+ - name: Build reativa bundle (OCaml → esbuild)
+ run: npm run reativa --workspace xpecs-website
+
# Build the packages the website consumes (tokens → xote), then the site.
- name: Build packages
run: npm run build:packages
diff --git a/website/package.json b/website/package.json
index b4ed631..10038be 100644
--- a/website/package.json
+++ b/website/package.json
@@ -22,9 +22,7 @@
"res:build": "rescript",
"res:clean": "rescript clean",
"res:dev": "rescript -w",
- "reativa:build": "cd reativa && opam exec -- dune build @melange",
- "reativa:bundle": "esbuild reativa/_build/default/output/xpecs_reativa.js --bundle --format=esm --outfile=src/reativa.bundle.js",
- "reativa": "npm run reativa:build && npm run reativa:bundle",
+ "reativa": "node scripts/build-reativa.mjs",
"dev": "npm run gen && npm run res:build && vite",
"build": "npm run gen && npm run res:build && npm run checks && vite build",
"preview": "vite preview",
diff --git a/website/reativa/.gitignore b/website/reativa/.gitignore
index 69fa449..b474270 100644
--- a/website/reativa/.gitignore
+++ b/website/reativa/.gitignore
@@ -1 +1,3 @@
+# reativa clone + Melange build output, produced by `npm run reativa`.
+.build/
_build/
diff --git a/website/reativa/README.md b/website/reativa/README.md
index 6f1bfe5..2481df3 100644
--- a/website/reativa/README.md
+++ b/website/reativa/README.md
@@ -24,15 +24,23 @@ once and only the reactive regions update in place.
## How it plugs into the (ReScript/Vite) website
-This is a **self-contained Melange workspace**, deliberately separate from the
-ReScript/Vite build so the website keeps compiling without an OCaml toolchain:
-
-1. `dune build @melange` compiles `xpecs_reativa.mlx` to ES modules under
- `_build/default/output/`.
-2. `esbuild` bundles the emitted entry into
+The Melange build is deliberately separate from the ReScript/Vite build, so the
+website keeps compiling without an OCaml toolchain.
+[`../scripts/build-reativa.mjs`](../scripts/build-reativa.mjs) (run by
+`npm run reativa`) does the work:
+
+1. **Clones reativa** (pinned to a commit) into `.build/reativa/`. reativa's core
+ library has no `public_name`, so it is a *private* dune library that can't be
+ consumed as an installed opam package — its own demo builds only because it
+ lives inside the reativa dune project. So we do the same: it copies
+ [`dune`](dune) + [`xpecs_reativa.mlx`](xpecs_reativa.mlx) into a subdirectory
+ of the clone, where the private `reativa` library and the `reativa.mlx_ppx`
+ ppx are in scope.
+2. `dune build @melange` compiles them to ES modules.
+3. `esbuild` bundles the emitted entry into
[`../src/reativa.bundle.js`](../src/reativa.bundle.js) — a single ES module
exporting `mount_example(specId, containerId)`, `example_ids`, and `built`.
-3. The website ([`../src/ReativaExamples.res`](../src/ReativaExamples.res))
+4. The website ([`../src/ReativaExamples.res`](../src/ReativaExamples.res))
imports that bundle and, when the **Reativa** tab is opened, imperatively
mounts the example into a container `
` that Xote renders.
@@ -41,23 +49,25 @@ so the site compiles out of the box and the Reativa tab shows a build hint.
## Building
-Requires the OCaml toolchain. One-time setup:
+Requires the OCaml toolchain (an opam switch on OCaml **5.1+**, since reativa
+needs `melange >= 3`). One-time setup:
```bash
-opam switch create . 5.2.1 --deps-only # or use an existing 5.1+ switch
+opam switch create . 5.2.1 # or reuse an existing 5.1+ switch
opam install dune melange mlx
-opam pin add reativa https://github.com/brnrdog/reativa.git
```
Then, from the `website/` directory:
```bash
-npm run reativa # dune build @melange + esbuild bundle
+npm run reativa # clone reativa → dune build @melange → esbuild bundle
npm run dev # start the site; open any of the specs above → "Reativa"
```
`npm run reativa` overwrites `../src/reativa.bundle.js` with the real compiled
-output (`built = true`). Re-run it after editing `xpecs_reativa.mlx`.
+output (`built = true`). Re-run it after editing `xpecs_reativa.mlx`. To move to
+a newer reativa, bump `REATIVA_REF` in `build-reativa.mjs`.
> Note: `npm run reativa` is intentionally **not** part of `npm run build`/`dev`
-> or CI — it needs opam/melange, which the rest of the website build does not.
+> (it needs opam/melange, which the rest of the website build does not) — but CI
+> runs it, so PRs compile the examples and Pages ships the real bundle.
diff --git a/website/reativa/dune-project b/website/reativa/dune-project
deleted file mode 100644
index 4919557..0000000
--- a/website/reativa/dune-project
+++ /dev/null
@@ -1,17 +0,0 @@
-(lang dune 3.16)
-
-; A self-contained Melange workspace that compiles the reativa (OCaml) Xpecs
-; examples to ES modules. It is intentionally separate from the ReScript/Vite
-; website build: `npm run reativa` (in website/) builds this and bundles the
-; output into website/src/reativa.bundle.js. Requires opam + melange; see
-; website/reativa/README.md.
-
-(using melange 0.1)
-
-(dialect
- (name mlx)
- (implementation
- (extension mlx)
- (merlin_reader mlx)
- (preprocess
- (run mlx-pp %{input-file}))))
diff --git a/website/scripts/build-reativa.mjs b/website/scripts/build-reativa.mjs
new file mode 100644
index 0000000..4c562f4
--- /dev/null
+++ b/website/scripts/build-reativa.mjs
@@ -0,0 +1,82 @@
+// Build the reativa (OCaml + Melange) example bundle consumed by the website's
+// "Reativa" tab (see website/reativa/README.md).
+//
+// reativa's core library is a *private* dune library (no public_name), so it
+// can't be consumed as an installed opam library — its own demo works only
+// because it lives inside the reativa dune project. We do the same: clone
+// reativa (pinned), drop website/reativa/{dune,xpecs_reativa.mlx} into a
+// subdirectory of that project so the private `reativa` library and the
+// `reativa.mlx_ppx` ppx resolve, compile to JS with Melange, then bundle the
+// emitted entry into website/src/reativa.bundle.js with esbuild.
+//
+// Requires: opam switch with `dune`, `melange`, `mlx` installed, plus git and
+// the website's esbuild devDependency. Not part of `npm run build`/CI's website
+// build — invoked explicitly by `npm run reativa`.
+
+import { execFileSync } from "node:child_process";
+import { existsSync, mkdirSync, copyFileSync, readdirSync } from "node:fs";
+import { resolve, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const REATIVA_REPO = "https://github.com/brnrdog/reativa.git";
+// Pin a known-good commit so the build is reproducible. Bump deliberately.
+const REATIVA_REF = "c94697df4bcd16fae59f900a01f7c60964606492";
+
+const websiteDir = resolve(fileURLToPath(import.meta.url), "../..");
+const reativaDir = resolve(websiteDir, "reativa");
+const buildDir = resolve(reativaDir, ".build");
+const clone = resolve(buildDir, "reativa");
+const exDir = resolve(clone, "xpecs_examples");
+
+const sh = (file, args, cwd) =>
+ execFileSync(file, args, { cwd, stdio: "inherit" });
+
+// 1. Clone reativa (or reuse a previous clone) and check out the pinned commit.
+mkdirSync(buildDir, { recursive: true });
+if (!existsSync(resolve(clone, ".git"))) {
+ sh("git", ["clone", REATIVA_REPO, "reativa"], buildDir);
+}
+sh("git", ["fetch", "--quiet", "origin"], clone);
+sh("git", ["checkout", "--quiet", REATIVA_REF], clone);
+
+// 2. Drop the example dune + source into the reativa project (same dune-project
+// ⇒ the private `reativa` library and reativa.mlx_ppx are in scope).
+mkdirSync(exDir, { recursive: true });
+copyFileSync(resolve(reativaDir, "dune"), resolve(exDir, "dune"));
+copyFileSync(
+ resolve(reativaDir, "xpecs_reativa.mlx"),
+ resolve(exDir, "xpecs_reativa.mlx"),
+);
+
+// 3. Compile the examples (and reativa itself) to ES modules via Melange.
+sh("opam", ["exec", "--", "dune", "build", "@melange"], clone);
+
+// 4. Bundle the emitted entry into a single ES module under the website's src.
+// melange.emit mirrors the source path under the target dir, so the entry is
+// normally //.js — but locate it defensively in
+// case the nesting differs across dune/melange versions.
+const outputRoot = resolve(clone, "_build/default/xpecs_examples/output");
+const findEntry = (dir) => {
+ for (const name of readdirSync(dir, { withFileTypes: true })) {
+ const p = join(dir, name.name);
+ if (name.isDirectory()) {
+ const hit = findEntry(p);
+ if (hit) return hit;
+ } else if (name.name === "xpecs_reativa.js") {
+ return p;
+ }
+ }
+ return null;
+};
+const entry = findEntry(outputRoot);
+if (!entry) {
+ throw new Error(`could not find emitted xpecs_reativa.js under ${outputRoot}`);
+}
+const outfile = resolve(websiteDir, "src/reativa.bundle.js");
+sh(
+ "npx",
+ ["--yes", "esbuild", entry, "--bundle", "--format=esm", `--outfile=${outfile}`],
+ websiteDir,
+);
+
+console.log(`\n✓ reativa bundle written to ${outfile}`);
diff --git a/website/src/styles.css b/website/src/styles.css
index e49e1ab..cf02170 100644
--- a/website/src/styles.css
+++ b/website/src/styles.css
@@ -16,8 +16,9 @@
* their Tailwind classes as string literals in this bundle — scan it too so
* any class used only by a reativa example still generates a utility. */
@source "./reativa.bundle.js";
-/* And the reativa source, so utilities exist even before the bundle is built. */
-@source "../reativa/**/*.mlx";
+/* And the reativa source, so utilities exist even before the bundle is built.
+ * Top-level only — avoid recursing into the .build/ reativa clone. */
+@source "../reativa/*.mlx";
html {
scroll-behavior: smooth;