Interactive OCaml Tutorials — Implementation Plan#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build a system where tutorial authors write .mld files with tagged code blocks, run dune build @doc, and get HTML pages with interactive, executable OCaml cells.
Architecture: Thin odoc plugin (Approach C) translates {@ocaml ...} tags to <x-ocaml> HTML elements with data attributes. The x-ocaml WebComponent handles all interactive behaviour. Package universes are built from opam switches via the existing jtw tool and discovered at runtime via findlib_index.json.
Tech Stack: OCaml, js_of_ocaml, odoc (fork with extension API), dune-site plugins, x-ocaml WebComponent, js_top_worker
Repo: /home/jons-agent/workspace/mono (monorepo managed by monopam)
Work Streams#
The work is organised into four independent streams. Streams 1-3 can proceed in parallel. Stream 4 (FRP experiments) is exploratory and independent.
Stream 1: odoc-interactive-extension (the thin plugin)#
Stream 2: x-ocaml cell modes (exercise/test/hidden/interactive)#
Stream 3: Universe builder improvements (findlib_index.json)#
Stream 4: Widget/FRP bridge experiments#
Stream 1: odoc-interactive-extension#
Create a new odoc extension plugin that handles {@ocaml ...} code
blocks with interactive attributes and @x-ocaml.* custom tags.
Current state#
- The odoc extension API exists at
odoc/src/extension_api/odoc_extension_api.ml odoc-scrollycode-extension/is a working reference implementation- Code blocks with attributes like
{@ocaml foo=bar [...]}are already parsed by odoc's parser intocode_block_metawithlanguageandtagsfields - The
Code_Block_Extensionmodule type handles code block transformation - The
Extensionmodule type handles custom@tagprocessing - Resources (JS/CSS URLs) are collected and injected into page
<head>
Key files to reference#
odoc-scrollycode-extension/src/scrollycode_extension.ml— reference plugin implementationodoc-scrollycode-extension/src/dune— dune config for plugin registrationodoc/src/extension_api/odoc_extension_api.ml— the API to implementodoc/src/extension_registry/odoc_extension_registry.ml— registry internalsodoc/src/document/comment.ml:257-307— where code block extensions are dispatched
Task 1.1: Scaffold odoc-interactive-extension directory#
Files:
- Create:
odoc-interactive-extension/dune-project - Create:
odoc-interactive-extension/src/dune - Create:
odoc-interactive-extension/src/interactive_extension.ml
Step 1: Create dune-project
(lang dune 3.18)
(using dune_site 0.1)
(name odoc-interactive-extension)
(generate_opam_files true)
(package
(name odoc-interactive-extension)
(synopsis "Interactive OCaml code cells for odoc documentation")
(depends
(ocaml (>= 4.14))
odoc))
Step 2: Create src/dune
Follow the scrollycode pattern exactly:
(library
(public_name odoc-interactive-extension.impl)
(name interactive_extension)
(libraries odoc.extension_api))
(plugin
(name odoc-interactive-extension)
(libraries odoc-interactive-extension.impl)
(site (odoc extensions)))
Step 3: Create minimal interactive_extension.ml
Start with a skeleton that registers both a Code_Block_Extension (for
{@ocaml ...}) and an Extension (for @x-ocaml.* tags):
open Odoc_extension_api
(* Page-level config accumulated during processing *)
let universe_url = ref None
let requires = ref []
let auto_execute = ref true
let merlin_enabled = ref true
module X_ocaml_config : Extension = struct
let prefix = "x-ocaml"
let to_document ~tag content =
(* tag will be "x-ocaml.universe", "x-ocaml.requires", etc. *)
let subtag = match String.split_on_char '.' tag with
| _ :: rest -> String.concat "." rest
| _ -> tag
in
let text = (* extract text from content *) "" in
(match subtag with
| "universe" -> universe_url := Some text
| "requires" -> requires := String.split_on_char ',' text
| "auto-execute" -> auto_execute := (text <> "false")
| "merlin" -> merlin_enabled := (text <> "false")
| _ -> ());
(* Emit nothing visible *)
{ content = []; overrides = []; resources = []; assets = [] }
end
module X_ocaml_code : Code_Block_Extension = struct
let prefix = "ocaml"
let to_document meta code =
let tags = meta.tags in
(* Extract mode: interactive, exercise, test, hidden *)
let mode = (* ... find mode tag ... *) "interactive" in
let id_attr = (* ... find id=xxx binding ... *) None in
let for_attr = (* ... find for=xxx binding ... *) None in
let merlin_attr = (* ... find merlin tag ... *) None in
(* Build data attributes string *)
let attrs = String.concat " "
(List.filter_map Fun.id [
Some (Printf.sprintf "mode=\"%s\"" mode);
Option.map (Printf.sprintf "data-id=\"%s\"") id_attr;
Option.map (Printf.sprintf "data-for=\"%s\"") for_attr;
Option.map (fun _ -> "data-merlin") merlin_attr;
])
in
let html = Printf.sprintf "<x-ocaml %s>\n%s\n</x-ocaml>" attrs code in
Some {
content = [{ attr = []; desc = Raw_markup ("html", html) }];
overrides = [];
resources = [
(* x-ocaml.js — URL will come from universe config *)
Js_url "x-ocaml.js";
];
assets = [];
}
end
let () =
Registry.register (module X_ocaml_config);
Registry.register_code_block (module X_ocaml_code)
Step 4: Build to verify compilation
Run: opam exec -- dune build -p odoc-interactive-extension
Expected: Compiles without errors (may need adjustments to match
exact API signatures — check odoc_extension_api.mli for precise
types)
Step 5: Commit
git add odoc-interactive-extension/
git commit -m "feat: scaffold odoc-interactive-extension plugin"
Task 1.2: Implement @x-ocaml.* config tag handling#
Files:
- Modify:
odoc-interactive-extension/src/interactive_extension.ml
Step 1: Study how scrollycode extracts text from tag content
Read odoc-scrollycode-extension/src/scrollycode_extension.ml to see
how to_document ~tag content extracts text from the
nestable_block_element list. The content is structured AST, not
plain text.
Step 2: Implement text extraction from tag content
Write a helper that extracts plain text from the nestable block element list (the tag payload).
Step 3: Implement meta tag emission
The @x-ocaml.universe tag should emit a <meta> tag in the page.
Since odoc resource injection happens in <head>, this may need to
use Raw_markup in the content, or a Js_inline resource containing
a meta tag injection script. Investigate which approach works.
Step 4: Build and test with a sample .mld file
Create a test .mld file with @x-ocaml.universe and verify the
generated HTML contains the right meta tag.
Step 5: Commit
Task 1.3: Implement code block transformation#
Files:
- Modify:
odoc-interactive-extension/src/interactive_extension.ml
Step 1: Study the Code_Block_Extension API precisely
Read odoc/src/extension_api/odoc_extension_api.ml — look at the
code_block_meta type and code_block_tags to understand how
{@ocaml exercise id=factorial [...]} is parsed. The tags will be a
list of Tag (bare) and Binding (key=value) entries.
Step 2: Implement tag extraction
Write helpers to extract:
- Mode: first bare tag that matches
interactive|exercise|test|hidden id: fromBinding("id", value)for: fromBinding("for", value)merlin: bare tag presenceenv: fromBinding("env", value)
Step 3: Implement HTML emission
Generate <x-ocaml> elements with the appropriate data attributes.
The code content goes as the text content of the element. Make sure
to HTML-escape the code content.
Step 4: Handle the script tag injection
The x-ocaml.js script should be injected once per page via
resources. The URL should reference the universe base URL if
configured, otherwise use a relative path. Investigate whether the
resource URL can be dynamic (based on the @x-ocaml.universe config)
or must be static.
Step 5: Build and test with sample .mld
Create a test .mld file with various cell types:
@x-ocaml.universe ./universe
{@ocaml hidden [let helper x = x + 1]}
{@ocaml interactive [let x = helper 41]}
{@ocaml exercise id=double [
let double x =
(* YOUR CODE HERE *)
failwith "Not implemented"
]}
{@ocaml test for=double [assert (double 5 = 10)]}
Run dune build @doc and inspect the generated HTML to verify:
- Hidden cell emits
<x-ocaml mode="hidden"> - Interactive cell emits
<x-ocaml mode="interactive"> - Exercise cell emits
<x-ocaml mode="exercise" data-id="double"> - Test cell emits
<x-ocaml mode="test" data-for="double"> - Script tag is injected once
- Meta tag for universe URL is present
Step 6: Commit
Task 1.4: Add to monorepo build#
Files:
- Modify:
dune-project(root) — add package - Modify:
sources.toml— if needed for monopam
Step 1: Add the package to the root dune-project
The monorepo's root dune-project needs to know about the new
package. Check how other extension packages are registered.
Step 2: Verify it builds in the monorepo context
Run: opam exec -- dune build
Step 3: Commit
Stream 2: x-ocaml cell modes#
Extend the x-ocaml WebComponent to support mode, data-id,
data-for, data-env, data-merlin, and data-auto-execute
attributes.
Current state#
- x-ocaml currently reads attributes only from the
<script>tag, not from individual<x-ocaml>elements - Per-element, only
run-onis read (click vs load) - Cells form a linked list (
prev/next) with implicit sequential execution - All cells are fully editable with CodeMirror
- No concept of modes, hidden cells, or test cells
- Cell status is:
Not_run | Running | Run_ok | Request_run
Key files#
x-ocaml/src/x_ocaml.ml— main entry, WebComponent registration, global statex-ocaml/src/cell.ml— individual cell UI, editor, executionx-ocaml/src/editor.ml— CodeMirror wrapperx-ocaml/src/webcomponent.ml— custom element definition, shadow DOM, attribute readingx-ocaml/src/backend.ml— worker communication abstraction
Task 2.1: Read mode and data attributes from elements#
Files:
- Modify:
x-ocaml/src/cell.ml - Modify:
x-ocaml/src/x_ocaml.ml
Step 1: Read the current cell initialisation code
Read cell.ml — specifically Cell.init and Cell.start — to
understand how cells are created and what data they hold. Read
x_ocaml.ml to see how the connectedCallback creates cells.
Step 2: Add mode type and reading
Add a mode type to cell.ml:
type mode = Interactive | Exercise | Test | Hidden
Read mode from the element's mode attribute in the
connectedCallback. Default to Interactive if not present.
Step 3: Read data attributes
Read data-id, data-for, data-env, data-merlin from the
element. Store them in the cell record.
Step 4: Build and verify
Run: opam exec -- dune build
Step 5: Commit
Task 2.2: Implement hidden cell behaviour#
Files:
- Modify:
x-ocaml/src/cell.ml
Step 1: Skip rendering for hidden cells
In the cell initialization, if mode = Hidden:
- Don't create the CodeMirror editor
- Don't create the run button
- Don't attach to shadow DOM (or set
display: none) - Still register in the cell linked list for execution ordering
- Still hold the code text from the element's textContent
Step 2: Ensure hidden cells execute
Hidden cells should still execute their code (they provide setup definitions). When a subsequent cell runs and triggers the linked list dependency chain, hidden cells should execute silently.
Step 3: Test manually
Create an HTML file with:
<x-ocaml mode="hidden">let helper x = x + 1</x-ocaml>
<x-ocaml mode="interactive">helper 41</x-ocaml>
Verify the hidden cell is not visible and helper 41 evaluates to
42.
Step 4: Commit
Task 2.3: Implement exercise cell behaviour#
Files:
- Modify:
x-ocaml/src/cell.ml
Step 1: Make exercise cells editable, others read-only
Currently all cells are editable. Change this:
Exercise: editable (keep current behaviour)Interactive: editable but could be made read-only — check design doc. Design says "No" for editable, so set CodeMirror to read-only.Test: read-onlyHidden: no editor
Use CodeMirror's readOnly extension/config to control this.
Step 2: Visual differentiation
Add CSS classes or styles to distinguish exercise cells:
- Exercise cells could have a light background tint or border indicating "edit here"
- Test cells could have a different tint indicating "assertion"
Keep it minimal — just enough to distinguish cell types visually.
Step 3: Test manually
Create an HTML page with exercise + test cells and verify:
- Exercise cell is editable
- Test cell is read-only
- Visual distinction is clear
Step 4: Commit
Task 2.4: Implement test cell linking#
Files:
- Modify:
x-ocaml/src/cell.ml - Modify:
x-ocaml/src/x_ocaml.ml
Step 1: Implement positional linking
After all cells are registered (in the connectedCallback or a
post-registration pass), for each test cell without a data-for
attribute, find the nearest preceding exercise cell and record the
association.
Step 2: Implement explicit linking
For test cells with data-for="name", find the cell with
data-id="name" and record the association.
Step 3: Implement test execution trigger
When an exercise cell is executed (user clicks Run), automatically execute its associated test cells afterwards. Display pass/fail results in the test cell's output area.
Step 4: Test manually
Create an HTML page with the factorial exercise example. Verify:
- Filling in the correct implementation and running it triggers the test cell
- Tests passing shows success
- Tests failing shows the assertion error
Step 5: Commit
Task 2.5: Implement per-cell merlin override#
Files:
- Modify:
x-ocaml/src/cell.ml - Modify:
x-ocaml/src/x_ocaml.ml
Step 1: Read page-level merlin setting
The page-level data-merlin (from <meta> tag or <script> tag
attribute) sets the default. Read this in x_ocaml.ml.
Step 2: Allow per-cell override
If an <x-ocaml> element has data-merlin="true" or
data-merlin="false", override the page default for that cell.
Step 3: Skip merlin initialisation for disabled cells
In cell.ml, when creating the Merlin client, check the cell's
merlin setting. If disabled, don't create the Merlin worker or
register error/completion callbacks.
Step 4: Test and commit
Task 2.6: Implement universe discovery from meta tag#
Files:
- Modify:
x-ocaml/src/x_ocaml.ml
Step 1: Read universe URL from meta tag
Currently x-ocaml reads src-worker from the <script> tag. Add
logic to also check for:
<meta name="x-ocaml-universe" content="https://...">
If present, derive the worker URL from the universe base
({universe}/worker.js).
Step 2: Fallback chain
<meta name="x-ocaml-universe">→ use{url}/worker.jssrc-workerattribute on<script>tag (existing behaviour)- Relative
./universe/worker.js(default)
Step 3: Pass universe URL to worker for findlib_index.json
The worker needs to know where to find findlib_index.json. This
may require extending the init RPC call to accept the
findlib_index URL (currently it uses a relative path).
Step 4: Test and commit
Stream 3: Universe builder improvements#
Improve the jtw opam command to produce the universe layout from
the design doc, using findlib_index.json.
Current state#
jtw opamexists injs_top_worker/bin/jtw.ml(lines 499-535)- It walks an opam switch, copies CMIs, compiles cma.js, generates
findlib_index(not.json) anddynamic_cmis.json - The JSON format currently uses
"metas"key (not"meta_files") findlibish.mlparser accepts both"metas"and"meta_files"- The tool already handles most of what's needed
Key files#
js_top_worker/bin/jtw.ml— the CLI tool (571 lines)js_top_worker/bin/ocamlfind.ml— queries ocamlfind for packagesjs_top_worker/lib/findlibish.ml— findlib_index parser/loader
Task 3.1: Rename findlib_index to findlib_index.json#
Files:
- Modify:
js_top_worker/bin/jtw.ml - Modify:
js_top_worker/lib/findlibish.ml
Step 1: Read the current code
Read jtw.ml to find where findlib_index filenames are generated.
Read findlibish.ml to find where findlib_index is expected.
Step 2: Update output filename
In jtw.ml, change the output filename from findlib_index to
findlib_index.json wherever universes are written.
Step 3: Update default input path
In findlibish.ml (or wherever the default findlib_index path is
set), update to look for findlib_index.json. Keep backward
compatibility: if findlib_index.json doesn't exist, try
findlib_index.
Step 4: Standardise JSON key names
If the generated JSON uses "metas", also emit "meta_files" for
consistency with the design doc. The parser already accepts both.
Step 5: Build and test
Run: opam exec -- dune build
If there are existing tests for jtw, run them.
Step 6: Commit
Task 3.2: Include worker.js and x-ocaml.js in universe output#
Files:
- Modify:
js_top_worker/bin/jtw.ml
Step 1: Study current worker.js generation
The jtw opam command has a --no-worker flag, meaning it can
already include worker.js. Read the code to understand how
Mk_backend.mk works and where the worker.js gets placed.
Step 2: Add x-ocaml.js to output
When generating a universe, also copy x-ocaml.js into the output
directory alongside worker.js. This may require a new flag
(--x-ocaml-js PATH) or auto-detection.
Step 3: Update the design doc if needed
If the implementation differs from the design doc's universe layout, update the design doc.
Step 4: Build and test
Step 5: Commit
Task 3.3: Add @x-ocaml.requires support to worker init#
Files:
- Modify:
js_top_worker/lib/impl.ml
Step 1: Study current requires handling
The init RPC already accepts findlib_requires in init_config
(impl.ml line 655+). Check whether x-ocaml currently passes
requires through.
Step 2: Verify the flow
Trace the path from x-ocaml reading @x-ocaml.requires (from
<meta> tag) through to the worker's init/setup calls. If
x-ocaml already has a way to pass requires, document it. If not,
identify where the integration point is.
Step 3: Implement if needed
If x-ocaml doesn't currently pass requires to the worker init, add this capability.
Step 4: Test and commit
Stream 4: Widget/FRP bridge experiments#
These are exploratory — the goal is to evaluate Lwd and Note for the widget architecture, not to build production code.
Task 4.1: Set up experiment scaffolding#
Files:
- Create:
experiments/widget-bridge/dune-project - Create:
experiments/widget-bridge/README.md
Step 1: Create experiment directory
Set up a minimal dune project outside the monorepo (or in a subdirectory) that can build js_of_ocaml targets and use web workers.
Step 2: Define the serializable view type
Create a simple, serializable view description type:
type event_id = string
type attr =
| Property of string * string
| Style of string * string
| Class of string
| On of string * event_id (* event name, handler id *)
type node =
| Text of string
| Element of { tag : string; attrs : attr list; children : node list }
(* Serialise to JSON for postMessage *)
val to_json : node -> string
val of_json : string -> node
Step 3: Define the event message type
type event_msg = {
handler_id : event_id;
event_type : string;
value : string option; (* for input elements *)
}
Step 4: Commit
Task 4.2: Experiment A — Counter with Lwd#
Files:
- Create:
experiments/widget-bridge/lwd_counter/
Step 1: Install Lwd
Run: opam install lwd
Step 2: Write the worker-side counter
Using Lwd, create a counter:
Lwd.varfor the count- Derived
Lwd.tthat produces anodeview description - On "increment" event, update the var
- On each change, serialize the view and post it
Step 3: Write the main-thread renderer
A small JS/OCaml module that:
- Receives serialized view descriptions
- Diffs against current DOM (or just replaces)
- Attaches event listeners that send messages back
Step 4: Measure and document
- Code size (lines of OCaml)
- Bundle size impact
- Round-trip latency (click → updated DOM)
- Ergonomics notes
Step 5: Commit
Task 4.3: Experiment B — Counter with Note#
Files:
- Create:
experiments/widget-bridge/note_counter/
Same as Task 4.2 but using Note's signals and events. Compare:
Note.S.createfor the count signalNote.E.createfor click eventsNote.S.mapto derive the view
Document comparison with Lwd version.
Task 4.4: Experiment C — Richer widget (two sliders)#
Files:
- Create:
experiments/widget-bridge/sliders/
Using whichever library felt better from A/B, build:
- Two sliders that control x and y
- A computed display showing x * y
- Tests composition and multiple interacting inputs
Task 4.5: Experiment D — TyXML backend#
Files:
- Create:
experiments/widget-bridge/tyxml_backend/
Try instantiating TyXML's Html_f.Make functor with a custom
Xml_sigs.T implementation that produces the serializable node
type. Evaluate whether the type safety is worth the boilerplate.
Dependency graph#
Stream 1 (odoc plugin) ──────────────────────┐
│
Stream 2 (x-ocaml modes) ───────────────────┼──> End-to-end demo
│
Stream 3 (universe builder) ─────────────────┘
Stream 4 (FRP experiments) ──────────────────────> Decision on widget arch
Streams 1, 2, 3 converge in an end-to-end integration test: write
an .mld with exercises, build with dune build @doc, serve the
output alongside a universe, and verify the interactive tutorial
works in a browser.
Stream 4 is independent exploratory work.
End-to-end integration test#
After Streams 1-3 are complete:
- Build a universe from the current opam switch using
jtw opam - Write a sample
.mldtutorial with hidden setup, interactive demos, exercises, and tests - Run
dune build @docwith the interactive extension installed - Serve the generated HTML + universe with a local HTTP server
- Open in browser and verify:
- Hidden cells are invisible but their definitions are available
- Interactive cells display pre-filled code
- Exercise cells are editable
- Running an exercise triggers associated test cells
- Test pass/fail is clearly displayed
- Merlin provides completions in exercise cells