Interactive OCaml Demos — End-to-End Setup#
This document explains how to build and serve the interactive OCaml demo
pages that use the odoc-interactive-extension and js_top_worker (jtw)
to create executable code cells inside odoc documentation.
Overview#
The system has three main components:
-
odoc-interactive-extension — an odoc plugin that transforms
{@ocaml[...]}code blocks into<x-ocaml>custom elements and processes@x-ocaml.universetags. -
x-ocaml — a WebComponent (
<x-ocaml>) that creates CodeMirror editors, connects to a Web Worker for OCaml evaluation, and renders output inline. -
jtw (js_top_worker) — builds "universe" directories containing compiled OCaml libraries (
.cma.jsfiles,.cmistubs, findlib metadata) that the worker loads at runtime.
Demo pages#
| Page | Description | Universe |
|---|---|---|
demo1.html |
Basic expressions + Yojson | ./universe (yojson from default switch) |
demo2_v2.html |
Yojson 2.x API | ./universe-v2 (yojson from demo-yojson-v2 switch) |
demo2_v3.html |
Yojson 3.x API | ./universe-v3 (yojson from default switch) |
The v2 and v3 demos show that different library versions can coexist as separate universes, each page loading its own set of compiled libraries.
Prerequisites#
- OCaml 5.4+ with opam
- The monorepo checked out at
/home/jons-agent/workspace/mono(or adjust paths) - Two opam switches:
default— has yojson 3.x (or latest)demo-yojson-v2— has yojson 2.2.2
- Python 3 (for the dev server, or any static file server with CORS)
Creating the yojson v2 switch (one-time)#
opam switch create demo-yojson-v2 ocaml-base-compiler.5.4.1
eval $(opam env --switch demo-yojson-v2 --set-switch)
opam install yojson.2.2.2
Build Steps#
All commands assume you start from the monorepo root.
Step 1: Build and install the toolchain#
The odoc extension must be installed before generating docs, because odoc loads extensions as plugins at doc-generation time.
eval $(opam env --switch default --set-switch)
dune build @install
This builds and installs (locally) odoc, jtw, x-ocaml, and the
odoc-interactive-extension plugin.
Step 2: Generate odoc HTML#
dune build @doc
This generates HTML for all packages. The demo .mld files
(odoc-interactive-extension/doc/demo1.mld, etc.) are compiled as part
of the odoc-interactive-extension package documentation.
The HTML output lands in:
_build/default/_doc/_html/odoc-interactive-extension/
├── demo1.html
├── demo2_v2.html
├── demo2_v3.html
└── index.html
Step 3: Copy x-ocaml assets#
The extension loads x-ocaml.js and worker.js from a local
_x-ocaml/ directory relative to the HTML pages. Copy them from the
dune build output:
HTMLDIR=_build/default/_doc/_html/odoc-interactive-extension
mkdir -p "$HTMLDIR/_x-ocaml"
cp _build/default/x-ocaml/src/x_ocaml.bc.js "$HTMLDIR/_x-ocaml/x-ocaml.js"
cp _build/default/js_top_worker/lib/.worker.eobjs/jsoo/worker.bc.js "$HTMLDIR/_x-ocaml/worker.js"
Step 4: Build universe directories#
Each demo page declares a universe via @x-ocaml.universe ./universe-name
in its .mld source. We use jtw opam to generate each universe
directory.
The jtw binary is at _build/install/default/bin/jtw.
JTW=_build/install/default/bin/jtw
# Universe for demo1 (default switch, yojson latest)
eval $(opam env --switch default --set-switch)
$JTW opam --no-worker --output "$HTMLDIR/universe" yojson
# Universe for demo2_v3 (default switch, yojson latest = 3.x)
$JTW opam --no-worker --output "$HTMLDIR/universe-v3" yojson
# Universe for demo2_v2 (demo-yojson-v2 switch, yojson 2.2.2)
eval $(opam env --switch demo-yojson-v2 --set-switch)
$JTW opam --no-worker --switch demo-yojson-v2 --output "$HTMLDIR/universe-v2" yojson
# Switch back to default
eval $(opam env --switch default --set-switch)
Key flags:
--no-worker— skip building a worker.js (we provide our own via_x-ocaml/)--output DIR— where to write the universe files--switch NAME— which opam switch to read packages fromyojson— positional argument: the library to include (plus its deps)
Each universe directory will contain:
universe/
├── findlib_index.json # lists META file locations
└── lib/
├── ocaml/
│ ├── META
│ ├── dynamic_cmis.json # lists .cmi files to load on demand
│ └── *.cmi # stdlib type interfaces
├── yojson/
│ ├── META
│ ├── *.cmi
│ └── yojson.cma.js # compiled library code
└── seq/ # (dependency, if needed)
└── ...
Step 5: Serve and test#
Start a local HTTP server from the HTML root:
python3 docs/demos/cors_server.py 8080 _build/default/_doc/_html
Then open in a browser:
- http://localhost:8080/odoc-interactive-extension/demo1.html
- http://localhost:8080/odoc-interactive-extension/demo2_v2.html
- http://localhost:8080/odoc-interactive-extension/demo2_v3.html
Each page should:
- Auto-evaluate all code cells on load
- Show output inline below each expression (e.g.,
- : int = 7) - Display stdout output (e.g.,
Hello, World!)
Writing Demo Pages#
Demo pages are standard odoc .mld files with two additions:
Universe tag#
Declares the universe directory (relative to the HTML page):
@x-ocaml.universe ./universe
This makes the extension use the jtw backend (js_top_worker) instead of
the builtin merlin-js backend. The worker loads libraries from the
specified universe directory.
Code cells#
Standard odoc {@ocaml[...]} blocks become interactive cells:
{@ocaml[
1 + 2 * 3
]}
For cells that use #require or reference libraries:
{@ocaml[
#require "yojson"
]}
{@ocaml[
let json = `Assoc [("key", `String "value")]
let () = print_endline (Yojson.Safe.to_string json)
]}
Note: The #require directive must be in a separate cell before the code
that uses the library, because each cell is evaluated independently and
#require loads the library's .cma.js file.
Cell modes (extension syntax)#
For the extended {x@ocaml ... x[...]x} syntax, additional attributes
control cell behaviour:
{x@ocaml hidden x[let helper = 42]x} (* invisible setup cell *)
{x@ocaml exercise id=solve x[...]x} (* editable cell *)
{x@ocaml test for=solve x[assert (...)]x} (* test cell, linked *)
{x@ocaml interactive x[read_only_code]x} (* default: visible, read-only *)
Architecture Notes#
Same-origin constraint#
Web Workers cannot be loaded from a different origin. The extension
therefore always loads x-ocaml.js and worker.js from the local
_x-ocaml/ directory (same origin as the page).
Universe directories can technically be cross-origin for fetching
findlib_index.json and .cmi files (these use fetch()), but
.cma.js files are loaded via importScripts() inside the worker,
which also requires same-origin in modern browsers. For simplicity,
keep universe directories same-origin (relative paths in the
@x-ocaml.universe tag).
How the pieces connect#
Page Load
│
├─ odoc extension emits:
│ <meta name="x-ocaml-universe" content="./universe">
│ <script src="./_x-ocaml/x-ocaml.js" src-worker="./_x-ocaml/worker.js" backend="jtw">
│ <x-ocaml mode="interactive">code...</x-ocaml>
│
├─ x-ocaml.js (WebComponent):
│ 1. Reads <meta> tags for universe URL
│ 2. Creates Web Worker from worker.js
│ 3. Sends init message with findlib_index URL
│ 4. For each <x-ocaml> cell:
│ - Creates CodeMirror editor
│ - Sends eval message to worker
│ - Renders streaming output as decorations
│
└─ worker.js (js_top_worker):
1. Receives init with findlib_index URL
2. Fetches findlib_index.json → learns library locations
3. Fetches dynamic_cmis.json → learns available .cmi files
4. On eval: compiles OCaml code using toplevel
5. On #require: loads .cma.js via importScripts
6. Sends output_at (per-phrase) and output (final) messages
jtw_client.ml — the bridge#
x-ocaml/src/jtw_client.ml bridges x-ocaml's X_protocol with
js_top_worker's message protocol. Key design decisions:
-
Uses
Jtw.eval_stream(notJtw.eval) to capture per-phraseoutput_atmessages, which contain the actual evaluation results (e.g.,val x : int = 3). TheJtw.evalfunction only returns the finaloutputmessage, which often has an emptycaml_ppf. -
Derives
stdlib_dcs(the URL fordynamic_cmis.json) from thefindlib_indexURL, so the worker can locate stdlib CMIs regardless of whether the universe is at a relative or absolute path.
Troubleshooting#
No output after clicking Run / cells don't auto-evaluate
- Check browser console for worker errors
- Verify
_x-ocaml/worker.jsexists and is accessible - Verify the universe's
findlib_index.jsonis accessible
"Toplevel not initialized" error
- The worker.js must be built with
js_of_ocaml-topleveland the flags--toplevel +toplevel.js +dynlink.js
"Unbound module Yojson" after #require
- Check that
#require "yojson"is in a separate earlier cell - Verify the universe directory contains
lib/yojson/yojson.cma.js
Cross-origin errors
- Keep universe directories same-origin (use relative paths in
@x-ocaml.universe) - The
_x-ocaml/directory must be on the same origin as the HTML page
Console shows 404 for .cmi files
- Normal if the code doesn't reference those modules; the worker fetches CMIs on demand and some 404s are expected for unused modules